diff --git a/AGENTS.md b/AGENTS.md index 5b89372..24f92a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,10 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition ## Agent rules +- Before starting work, create `dist/NOREBUILD` (creating `dist/` if needed) to + pause automatic rebuilds. Remove it only after all work, validation and commits + are complete. If it already exists, leave it in place; it may belong to another + active agent or the user. - Never push changes unless explicitly told to. - After changing files, run `cargo fmt --all`. Do not override project formatting configuration or invoke a formatter directly on individual files. diff --git a/CHANGELOG.md b/CHANGELOG.md index a372a0c..f201b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period, + with automatic rebuilds paused while `dist/NOREBUILD` exists. - Separate gameplay floor and structure layers, with buildable/removable floors and windows. Marked painted Tiled tiles become stateful gameplay entities; removing floors exposes the map's configurable default terrain. diff --git a/README.md b/README.md index bdb72a7..813cfdf 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,19 @@ and the changelog. Unpack it and run the binaries from that directory. The unpac staging directory is kept next to the archive. Options: `--format zip|tar.gz`, `--out-dir DIR`, `--debug`. +Run `cargo xtask watch` to rebuild and pack automatically after source changes. +It accepts the same options as `pack`, for example `cargo xtask watch --debug`. +The watcher waits for 10 seconds without further changes; each change resets +that timer. It watches Git-tracked and non-ignored untracked files, including +assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata +and the selected output directory. Git must be installed. No build runs on startup. + +Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while +the file exists; deleting it starts a fresh 10-second wait for pending changes. +An already running build finishes, and subsequent builds never overlap. Build +failures are reported without stopping the watcher; a later source change retries. +Press Ctrl-C to stop. Run this from the source checkout, not a packaged archive. + The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows (zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1f18495..e892169 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,8 +2,11 @@ //! //! ```text //! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] +//! cargo xtask watch [pack options] //! ``` +mod watch; + use std::{ env, error::Error, @@ -25,6 +28,7 @@ fn main() -> ExitCode { let mut args = env::args().skip(1); let result = match args.next().as_deref() { Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), + Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)), Some("-h" | "--help") | None => { print_help(); return ExitCode::SUCCESS; @@ -44,8 +48,9 @@ fn print_help() { println!( "usage: cargo xtask \n\n\ commands:\n \ - pack build the client and server and pack them with their assets\n\n\ - pack options:\n \ + pack build the client and server and pack them with their assets\n \ + watch pack after sources are unchanged for 10 seconds; pause with dist/NOREBUILD\n\n\ + pack/watch options:\n \ --target TRIPLE cross-compile for TRIPLE (default: host)\n \ --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ --out-dir DIR output directory (default: dist)\n \ diff --git a/xtask/src/watch.rs b/xtask/src/watch.rs new file mode 100644 index 0000000..cf34dba --- /dev/null +++ b/xtask/src/watch.rs @@ -0,0 +1,291 @@ +use std::{ + collections::{BTreeMap, hash_map::DefaultHasher}, + fs, + hash::{Hash, Hasher}, + io, + path::{Path, PathBuf}, + process::{Child, Command}, + thread, + time::{Duration, Instant}, +}; + +use crate::{PackOptions, Result, cargo, workspace_root}; + +const DEBOUNCE: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +#[derive(Default)] +struct PendingBuild { + last_change: Option, + paused: bool, +} + +impl PendingBuild { + fn observe(&mut self, changed: bool, paused: bool, now: Instant) { + if changed || (self.paused && !paused && self.last_change.is_some()) { + self.last_change = Some(now); + } + self.paused = paused; + } + + fn ready(&self, now: Instant) -> bool { + !self.paused + && self + .last_change + .is_some_and(|last| now.duration_since(last) >= DEBOUNCE) + } +} + +pub fn run(options: &PackOptions) -> Result<()> { + let root = workspace_root().canonicalize()?; + let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist"))); + fs::create_dir_all(&output)?; + let output = output.canonicalize()?; + if root.starts_with(&output) { + return Err("watch output directory must not contain the workspace".into()); + } + let excluded = [root.join("dist"), root.join("target"), output]; + let guard = root.join("dist/NOREBUILD"); + let mut previous = snapshot(&root, &excluded)?; + let mut pending = PendingBuild::default(); + let mut build: Option = None; + println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop."); + loop { + let current = snapshot(&root, &excluded)?; + let changed = current != previous; + previous = current; + let paused = guard.try_exists()?; + if paused != pending.paused { + if paused { + println!("Packing paused: {} exists.", guard.display()); + } else { + println!("Packing resumed; pending changes wait another 10 seconds."); + } + } + pending.observe(changed, paused, Instant::now()); + if changed { + println!("Source changes detected; resetting the 10-second timer."); + } + + if let Some(child) = &mut build + && let Some(status) = child.try_wait()? + { + if status.success() { + println!("Build and pack completed."); + } else { + eprintln!("Build and pack failed ({status}); watching for further changes."); + } + build = None; + } + if build.is_none() && pending.ready(Instant::now()) && !guard.try_exists()? { + println!("Sources settled; rebuilding and packing."); + // Recompile xtask too, so edits to packaging code take effect without restarting the watcher. + build = Some(pack_command(&root, options).spawn()?); + pending.last_change = None; + } + thread::sleep(POLL_INTERVAL); + } +} + +fn pack_command(root: &Path, options: &PackOptions) -> Command { + let mut command = cargo(); + command + .current_dir(root) + .args(["run", "--package", "xtask", "--", "pack"]); + if let Some(target) = &options.target { + command.args(["--target", target]); + } + if let Some(format) = options.format { + command.args(["--format", format.extension()]); + } + if let Some(output) = &options.out_dir { + command.arg("--out-dir").arg(output); + } + if options.debug { + command.arg("--debug"); + } + command +} + +fn snapshot(root: &Path, excluded: &[PathBuf]) -> Result> { + let files = Command::new("git") + .current_dir(root) + .args([ + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]) + .output()?; + if !files.status.success() { + return Err(format!( + "could not list watched sources: {}", + String::from_utf8_lossy(&files.stderr).trim() + ) + .into()); + } + let mut snapshot = BTreeMap::new(); + for name in files + .stdout + .split(|byte| *byte == 0) + .filter(|name| !name.is_empty()) + { + let path = root.join(std::str::from_utf8(name)?); + if excluded.iter().any(|output| path.starts_with(output)) { + continue; + } + let read = || -> io::Result> { + let metadata = fs::symlink_metadata(&path)?; + let mut hash = DefaultHasher::new(); + if metadata.file_type().is_symlink() { + fs::read_link(&path)?.hash(&mut hash); + } else if metadata.is_file() { + fs::read(&path)?.hash(&mut hash); + } else { + return Ok(None); + } + Ok(Some(hash.finish())) + }; + match read() { + Ok(Some(hash)) => { + snapshot.insert(path, hash); + } + Ok(None) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("could not watch {}: {error}", path.display()).into()), + } + } + Ok(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debounce_waits_ten_seconds_and_each_change_resets_it() { + let start = Instant::now(); + let mut pending = PendingBuild::default(); + assert!(!pending.ready(start + DEBOUNCE)); + pending.observe(true, false, start); + assert!(!pending.ready(start + DEBOUNCE - Duration::from_nanos(1))); + assert!(pending.ready(start + DEBOUNCE)); + pending.observe(true, false, start + Duration::from_secs(9)); + assert!(!pending.ready(start + Duration::from_secs(18))); + assert!(pending.ready(start + Duration::from_secs(19))); + } + + #[test] + fn guard_keeps_changes_pending_and_removal_restarts_debounce() { + let start = Instant::now(); + let mut pending = PendingBuild::default(); + pending.observe(true, false, start); + pending.observe(false, true, start + Duration::from_secs(9)); + assert!(!pending.ready(start + Duration::from_secs(100))); + pending.observe(true, true, start + Duration::from_secs(100)); + pending.observe(false, false, start + Duration::from_secs(200)); + assert!(!pending.ready(start + Duration::from_secs(209))); + assert!(pending.ready(start + Duration::from_secs(210))); + pending.last_change = None; + pending.observe(false, true, start + Duration::from_secs(211)); + pending.observe(false, false, start + Duration::from_secs(212)); + assert!(!pending.ready(start + Duration::from_secs(222))); + } + + #[test] + fn edits_during_a_build_remain_queued_for_the_next_build() { + let start = Instant::now(); + let mut pending = PendingBuild::default(); + pending.observe(true, false, start); + assert!(pending.ready(start + DEBOUNCE)); + pending.last_change = None; + pending.observe(true, false, start + Duration::from_secs(12)); + pending.observe(true, false, start + Duration::from_secs(18)); + assert!(!pending.ready(start + Duration::from_secs(27))); + assert!(pending.ready(start + Duration::from_secs(28))); + assert!(pending.ready(start + Duration::from_secs(40))); + } + + #[test] + fn forwards_pack_options() { + let options = PackOptions::parse( + [ + "--target", + "x86_64-pc-windows-gnu", + "--format", + "zip", + "--out-dir", + "custom output", + "--debug", + ] + .into_iter() + .map(str::to_owned), + ) + .unwrap(); + let command = pack_command(Path::new("."), &options); + assert_eq!( + command.get_args().collect::>(), + [ + "run", + "--package", + "xtask", + "--", + "pack", + "--target", + "x86_64-pc-windows-gnu", + "--format", + "zip", + "--out-dir", + "custom output", + "--debug", + ] + ); + } + + #[test] + fn watches_edits_additions_deletions_and_ignores_git_and_outputs() { + let root = std::env::temp_dir().join(format!( + "station-watch-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let git = |args: &[&str]| { + let status = Command::new("git") + .current_dir(&root) + .args(args) + .status() + .unwrap(); + assert!(status.success()); + }; + git(&["init", "--quiet"]); + fs::write(root.join(".gitignore"), "target/\ndist/\nignored/\n").unwrap(); + fs::write(root.join("source.rs"), "one").unwrap(); + git(&["add", ".gitignore", "source.rs"]); + let excluded = [ + root.join("dist"), + root.join("target"), + root.join("custom output"), + ]; + let initial = snapshot(&root, &excluded).unwrap(); + fs::write(root.join(".git/test-output"), "metadata").unwrap(); + for directory in ["dist", "target", "ignored", "custom output"] { + fs::create_dir(root.join(directory)).unwrap(); + fs::write(root.join(directory).join("generated"), "output").unwrap(); + } + assert_eq!(snapshot(&root, &excluded).unwrap(), initial); + fs::write(root.join("source.rs"), "two").unwrap(); + let edited = snapshot(&root, &excluded).unwrap(); + assert_ne!(edited, initial); + fs::write(root.join("new.rs"), "new").unwrap(); + let added = snapshot(&root, &excluded).unwrap(); + assert_ne!(added, edited); + fs::remove_file(root.join("source.rs")).unwrap(); + assert_ne!(snapshot(&root, &excluded).unwrap(), added); + fs::remove_dir_all(root).unwrap(); + } +}