diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0fdff0e..207bd97 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,10 +1,13 @@ {"id":"devloop-0nx","title":"Define project docs, AGENTS, and roadmap","description":"Add README, PLAN, and repository-specific AGENTS guidance for the standalone tool.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T01:27:27Z","closed_at":"2026-03-24T01:27:27Z","close_reason":"Closed"} {"id":"devloop-8km","title":"Implement config and workflow engine","description":"Load config, watch paths, classify events, and execute ordered workflows against named processes and hooks.","status":"closed","priority":2,"issue_type":"feature","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed","dependencies":[{"issue_id":"devloop-8km","depends_on_id":"devloop-0nx","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} +{"id":"devloop-c6l","title":"Polish 0.8.0 release docs and test ergonomics","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T07:12:58Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T07:20:27Z","closed_at":"2026-04-08T07:20:27Z","close_reason":"Closed"} {"id":"devloop-d81","title":"Move real client config out of devloop examples","description":"Keep only generic examples in devloop and move the working blog config into the client repository root.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:31:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:39:07Z","closed_at":"2026-03-24T02:39:07Z","close_reason":"Kept only generic examples in devloop and moved the working blog config into the client repo root."} {"id":"devloop-dzy","title":"Make client hook paths repo-relative","description":"Resolve devloop config command paths relative to the client repo or config, not the tool checkout.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:16:34Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:22:17Z","closed_at":"2026-03-24T02:22:17Z","close_reason":"Closed"} {"id":"devloop-mml","title":"Address roborev findings on state ownership and client-specific URL composition","status":"closed","priority":1,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:44:51Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:53:31Z","closed_at":"2026-03-24T02:53:31Z","close_reason":"Shared session state is now owned in memory, generic state templating replaced blog-specific derivation, redundant writes are skipped, and the review job was addressed."} {"id":"devloop-nmu","title":"Add blog client example and verification","description":"Create example config/hooks for the blog repo and verify the tool runs against it.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:15:00Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed","dependencies":[{"issue_id":"devloop-nmu","depends_on_id":"devloop-8km","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} {"id":"devloop-s2h","title":"Bootstrap configurable dev-loop engine MVP","description":"Create a standalone Rust CLI in /tmp/devloop with config-driven file watching, process supervision, workflows, hooks, and documentation. Use the blog repo as the first client.","status":"open","priority":2,"issue_type":"epic","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:14:54Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T01:14:54Z","dependencies":[{"issue_id":"devloop-s2h","depends_on_id":"devloop-nmu","type":"blocks","created_at":"2026-03-24T12:15:07Z","created_by":"Daniel Vianna","metadata":"{}"}]} {"id":"devloop-ufx","title":"Add client adapter for dynamic tunnel url consumption","description":"The blog app still treats SITE_URL as startup-only state. Add a client-side integration pattern that reads devloop state dynamically so tunnel restarts affect rendered metadata.","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:27:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed"} +{"id":"devloop-uog","title":"Add watcher regression smoke test and poll backend fallback","status":"closed","priority":2,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T05:45:33Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T06:46:22Z","closed_at":"2026-04-08T06:46:22Z","close_reason":"Closed"} {"id":"devloop-vxg","title":"Support process-emitted state updates","description":"Long-running processes such as cloudflared need a first-class way to publish readiness and state into the engine instead of relying on wrapper scripts mutating the session file.","status":"closed","priority":2,"issue_type":"feature","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T01:27:27Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T02:09:33Z","closed_at":"2026-03-24T02:09:33Z","close_reason":"Closed"} {"id":"devloop-w34","title":"Support workflow composition to reduce repeated setup steps","description":"Add a generic way for one workflow to reuse another so client configs can avoid duplicating repeated step sequences such as wait-for-tunnel plus templated write_state composition.","status":"closed","priority":3,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-03-24T02:53:31Z","created_by":"Daniel Vianna","updated_at":"2026-03-24T03:01:13Z","closed_at":"2026-03-24T03:01:13Z","close_reason":"Added reusable run_workflow steps, validated nested workflow recursion and missing references, and updated the generic example."} +{"id":"devloop-y2l","title":"Fix repeated literal file edit watch flakiness","description":"A Rust integration smoke test now reproduces missed workflow triggers during repeated edits to the same watched file. Fix the watch pipeline so the smoke test passes deterministically without sleeps.","status":"closed","priority":1,"issue_type":"task","owner":"1708810+dmvianna@users.noreply.github.com","created_at":"2026-04-08T06:23:10Z","created_by":"Daniel Vianna","updated_at":"2026-04-08T06:46:22Z","closed_at":"2026-04-08T06:46:22Z","close_reason":"Closed"} diff --git a/AGENTS.md b/AGENTS.md index aba1b79..8d2ada4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,13 @@ without hard-coding knowledge of any one repository. avoid baking it into the core. - Do not push unless explicitly asked. This repository may not even have a remote during early development. +- Do not use `sleep` to resolve races. Races must be resolved with + deterministic logic, explicit readiness signals, or ordered state + transitions. +- When tests must mutate process-global state such as environment + variables, isolate the unsafe operation in a small helper, serialize + access with a lock, and document the safety rationale instead of + scattering raw unsafe calls through test bodies. - Run quality gates for code changes: `cargo fmt`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7397310..497a40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.8.0] - 2026-04-08 + +### Added +- Added a configurable watcher backend with non-breaking `native` + default behavior plus a `poll` fallback mode for environments where + native filesystem notifications are unreliable. +- Added a Rust repeated-edit watch flake smoke test that can be run + locally with `DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 cargo test --test + watch_flake_smoke -- --nocapture`. +- Added explicit trailing-slash syntax for literal directory watch + targets, for example `content/`, so recursive directory intent is + preserved even when the directory does not yet exist at startup. +- Added a development guide under [`docs/development.md`](docs/development.md) + and exposed it in the CLI as `devloop docs development`. + +### Changed +- `devloop` now derives concrete watch targets from configured watch + patterns and asks the backend to watch only those files or + directories instead of always watching the whole repository root. +- The watch flake smoke test is now opt-in instead of running during + every default `cargo test` or CI run. The existing runtime smoke test + remains in CI. + +### Fixed +- Native watch registration now resolves file and directory targets at + runtime, so startup no longer depends on those paths already existing + when config is parsed. +- Fixed a real watch flake where the debounce batch could be dropped if + another `tokio::select!` branch won the race while filesystem events + were already buffered. +- Test-only environment mutation now lives behind locked helpers with + documented safety rationale instead of scattered raw unsafe blocks. + ## [0.7.0] - 2026-03-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index ba8d79d..86a1baf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "axum", @@ -248,6 +248,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tempfile", "tokio", "tokio-stream", "toml", @@ -283,6 +284,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -742,6 +749,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1098,6 +1111,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1325,6 +1351,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Cargo.toml b/Cargo.toml index ee8fea7..eeba7c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.7.0" +version = "0.8.0" edition = "2024" [dependencies] @@ -21,3 +21,6 @@ toml = "0.8.22" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "fmt"] } unicode-width = "0.2" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/README.md b/README.md index d28b3c6..2e3a46a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Built-in reference docs are also available from the CLI: ```bash devloop docs config devloop docs behavior +devloop docs development devloop docs security ``` @@ -186,6 +187,9 @@ For the runtime behavior reference, see For the full configuration reference, see [`docs/configuration.md`](docs/configuration.md). +For local contributor workflow details, including the opt-in watch +flake smoke test, see [`docs/development.md`](docs/development.md). + For the external-event trust model and push-versus-polling tradeoffs, see [`docs/security.md`](docs/security.md). diff --git a/docs/README.md b/docs/README.md index 99f9df9..5e618e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ - [Behavior Reference](behavior.md) - [Configuration Reference](configuration.md) +- [Development Guide](development.md) - [Security Notes](security.md) This directory holds detailed reference material for `devloop`. diff --git a/docs/behavior.md b/docs/behavior.md index cc0857d..41f57b9 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -49,13 +49,20 @@ merged back into the live session. ## Watching and debounce -`devloop` watches the configured `root` recursively. +`devloop` derives concrete filesystem watch targets from the configured +watch-group patterns and watches only those files or directories. - Only relevant file-system events are considered. - Events are batched for `debounce_ms`. - Matching changes are grouped by workflow name before execution. - Each workflow receives the set of changed relative paths that matched it during the debounce window. +- The default backend uses native filesystem notifications. A polling + backend can be selected in config as a fallback for environments + where native events are unreliable. +- Literal file targets are watched as narrowly as the backend allows. + Use a trailing `/` in the config when you mean an explicit directory + target that should be watched recursively. If multiple watch groups map to the same workflow, their matched paths are merged for that workflow run. diff --git a/docs/configuration.md b/docs/configuration.md index 9e0b1c3..4986bca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,21 @@ startup_workflows = ["startup"] - `startup_workflows`: workflows to run after autostart processes have been started. +Optional watcher backend config: + +```toml +[watcher] +kind = "native" +poll_interval_ms = 250 +``` + +- `watcher.kind`: watcher backend to use. `native` is the default and + uses the platform-recommended `notify` backend. `poll` uses + `notify`'s polling watcher as a fallback for environments where + native filesystem events are unreliable. +- `watcher.poll_interval_ms`: polling interval used when + `watcher.kind = "poll"`. Default: `250`. + Optional browser reload server config: ```toml @@ -41,16 +56,26 @@ bind = "127.0.0.1:0" Watch groups map file patterns to workflows. ```toml -[watch.rust] -paths = ["src/**/*.rs", "Cargo.toml"] -workflow = "rust" +[watch.content] +paths = ["content/", "templates/**/*.html"] +workflow = "content" ``` - Table name: the watch-group name. - `paths`: glob patterns evaluated relative to `root`. + Use a trailing `/` for a literal directory target that should be + watched recursively, including when the directory may not exist yet + at startup. Without the trailing slash, a literal path is treated as + a file target. - `workflow`: workflow to run when a matching file changes. If omitted, the watch-group name is used as the workflow name. +`devloop` derives concrete watch targets from these patterns and asks +the backend to watch only those literal files or directories instead of +always watching the whole repository root recursively. `native` remains +the default backend; `poll` exists as a fallback for environments where +filesystem notifications are unreliable. + ## Processes Processes are long-running commands supervised by `devloop`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ac064c1 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,46 @@ +# Development Guide + +This guide covers local development workflows for `devloop` itself. + +## Quality gates + +Run the standard checks before committing: + +```bash +cargo fmt +cargo test +cargo clippy --all-targets --all-features -- -D warnings +./scripts/ci-smoke.sh +``` + +`./scripts/ci-smoke.sh` is the fast runtime smoke test used in CI. It +checks that `devloop run` can start, begin watching, react to one file +change, and shut down cleanly. + +## Opt-in watch flake smoke + +The repeated-edit watch flake smoke test is intentionally opt-in. It is +useful when changing watch registration, debounce logic, or event +delivery, but it is slower and more environment-sensitive than the +standard test suite. + +Run it locally with: + +```bash +DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 cargo test --test watch_flake_smoke -- --nocapture +``` + +Without that environment variable, the test exits early so normal +`cargo test` and CI runs stay fast. + +## Test policy + +When a test must mutate process-global state such as environment +variables: + +- serialize access with a test-local lock +- keep `unsafe` in a narrow helper +- document the safety rationale at the helper + +Do not scatter raw `unsafe { std::env::set_var(...) }` calls across test +bodies. diff --git a/examples/blog/devloop.toml b/examples/blog/devloop.toml index 23a615a..546af6c 100644 --- a/examples/blog/devloop.toml +++ b/examples/blog/devloop.toml @@ -6,6 +6,9 @@ debounce_ms = 300 state_file = "./.devloop/state.json" startup_workflows = ["startup"] +[watcher] +kind = "native" + [watch.rust] paths = ["src/**/*.rs", "Cargo.toml", "content/layout.html", "content/banner.html", "content/site.toml"] workflow = "rust" diff --git a/src/browser_reload.rs b/src/browser_reload.rs index 35364f6..1aa6202 100644 --- a/src/browser_reload.rs +++ b/src/browser_reload.rs @@ -135,6 +135,7 @@ mod tests { Config { root: ".".into(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some("./state.json".into()), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/config.rs b/src/config.rs index f8e47e7..07d05a1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,8 @@ pub struct Config { pub root: PathBuf, #[serde(default = "default_debounce_ms")] pub debounce_ms: u64, + #[serde(default)] + pub watcher: WatcherConfig, pub state_file: Option, #[serde(default)] pub startup_workflows: Vec, @@ -92,6 +94,7 @@ impl Config { } self.event_server.validate()?; self.browser_reload_server.validate()?; + self.watcher.validate()?; for (name, event) in &self.event { event .validate() @@ -129,6 +132,20 @@ impl Config { .collect() } + pub fn compiled_watch_targets(&self) -> Vec { + let mut targets = BTreeMap::::new(); + for group in self.watch.values() { + for pattern in &group.paths { + let target = CompiledWatchTarget::from_pattern(&self.root, pattern); + targets + .entry(target.path) + .and_modify(|recursive| *recursive |= target.recursive) + .or_insert(target.recursive); + } + } + targets.into_iter().map(CompiledWatchTarget::from).collect() + } + pub fn has_external_events(&self) -> bool { !self.event.is_empty() } @@ -213,6 +230,102 @@ impl CompiledWatchGroup { } } +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WatcherKind { + #[default] + Native, + Poll, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WatcherConfig { + #[serde(default)] + pub kind: WatcherKind, + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u64, +} + +impl Default for WatcherConfig { + fn default() -> Self { + Self { + kind: WatcherKind::Native, + poll_interval_ms: default_poll_interval_ms(), + } + } +} + +impl WatcherConfig { + fn validate(&self) -> Result<()> { + if self.poll_interval_ms == 0 { + return Err(anyhow!( + "watcher poll_interval_ms must be greater than zero" + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CompiledWatchTarget { + pub path: PathBuf, + pub recursive: bool, +} + +impl From<(PathBuf, bool)> for CompiledWatchTarget { + fn from((path, recursive): (PathBuf, bool)) -> Self { + Self { path, recursive } + } +} + +impl CompiledWatchTarget { + fn from_pattern(root: &Path, pattern: &str) -> Self { + let mut prefix = PathBuf::new(); + let mut recursive = false; + for segment in pattern.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == "**" { + recursive = true; + break; + } + if segment_has_glob_magic(segment) { + recursive = true; + break; + } + prefix.push(segment); + } + + if prefix.as_os_str().is_empty() { + return Self { + path: root.to_path_buf(), + recursive: true, + }; + } + + if pattern_is_literal(pattern) { + return Self { + path: root.join(prefix), + recursive: pattern.ends_with('/'), + }; + } + + Self { + path: root.join(prefix), + recursive, + } + } +} + +fn pattern_is_literal(pattern: &str) -> bool { + !pattern.split('/').any(segment_has_glob_magic) && !pattern.contains("**") +} + +fn segment_has_glob_magic(segment: &str) -> bool { + segment.contains('*') || segment.contains('?') || segment.contains('[') || segment.contains('{') +} + #[derive(Debug, Clone, Deserialize)] pub struct ProcessSpec { pub command: Vec, @@ -702,11 +815,11 @@ pub enum LogStyle { } pub fn absolutize(base: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { + normalize_path_buf(if path.is_absolute() { path.to_path_buf() } else { base.join(path) - } + }) } fn default_debounce_ms() -> u64 { @@ -721,6 +834,10 @@ fn default_timeout_ms() -> u64 { 15_000 } +fn default_poll_interval_ms() -> u64 { + 250 +} + fn default_true() -> bool { true } @@ -752,6 +869,10 @@ fn default_browser_reload_server_bind() -> String { "127.0.0.1:0".to_string() } +fn normalize_path_buf(path: PathBuf) -> PathBuf { + path.components().collect() +} + #[cfg(test)] mod tests { use super::*; @@ -760,6 +881,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1186,6 +1308,81 @@ mod tests { assert_eq!(config.bind, "127.0.0.1:0"); } + #[test] + fn watcher_defaults_to_native_with_poll_fallback_interval() { + let watcher: WatcherConfig = toml::from_str("").expect("parse watcher config"); + + assert_eq!(watcher.kind, WatcherKind::Native); + assert_eq!(watcher.poll_interval_ms, 250); + } + + #[test] + fn compiled_watch_targets_reduce_patterns_to_concrete_paths() { + let mut config = base_config(); + config.root = PathBuf::from("/tmp/example"); + config.watch.insert( + "rust".into(), + WatchGroup { + paths: vec![ + "src/**/*.rs".into(), + "Cargo.toml".into(), + "content/banner.html".into(), + ], + workflow: Some("rust".into()), + }, + ); + config.watch.insert( + "content".into(), + WatchGroup { + paths: vec!["content/**/*.md".into(), "content/**/*.html".into()], + workflow: Some("content".into()), + }, + ); + + assert_eq!( + config.compiled_watch_targets(), + vec![ + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/Cargo.toml"), + recursive: false, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content"), + recursive: true, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content/banner.html"), + recursive: false, + }, + CompiledWatchTarget { + path: PathBuf::from("/tmp/example/src"), + recursive: true, + }, + ] + ); + } + + #[test] + fn compiled_watch_targets_keep_literal_directory_targets_recursive() { + let mut config = base_config(); + config.root = PathBuf::from("/tmp/example"); + config.watch.insert( + "content".into(), + WatchGroup { + paths: vec!["content/".into()], + workflow: Some("content".into()), + }, + ); + + assert_eq!( + config.compiled_watch_targets(), + vec![CompiledWatchTarget { + path: PathBuf::from("/tmp/example/content"), + recursive: true, + }] + ); + } + #[test] fn config_detects_notify_reload_in_nested_workflow() { let mut config = base_config(); diff --git a/src/core.rs b/src/core.rs index cdac1a0..24340fc 100644 --- a/src/core.rs +++ b/src/core.rs @@ -612,6 +612,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/engine.rs b/src/engine.rs index 547e5e7..c2b4406 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2,12 +2,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; use std::time::Duration; use anyhow::{Result, anyhow}; use notify::{ - Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, + Config as NotifyConfig, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, + Watcher, event::{AccessKind, AccessMode}, }; use serde_json::{Map, Value}; @@ -17,7 +17,7 @@ use tracing::{error, info}; use unicode_width::UnicodeWidthStr; use crate::browser_reload::{BrowserReloadSender, BrowserReloadServer, notify_browser_reload}; -use crate::config::{CompiledWatchGroup, Config, LogStyle}; +use crate::config::{CompiledWatchGroup, CompiledWatchTarget, Config, LogStyle, WatcherKind}; use crate::core::{RuntimeEffect, RuntimeEvent, RuntimeMachine, WorkflowEffect, WorkflowMachine}; use crate::external_events::{ExternalEventMessage, ExternalEventServer}; use crate::processes::ProcessManager; @@ -69,8 +69,10 @@ struct LiveRuntimeAdapter<'a, 'b> { config: &'a Config, processes: &'a mut ProcessManager<'b>, state: &'a SessionState, - watcher: &'a mut RecommendedWatcher, + watcher: &'a mut Box, watcher_shutdown: Arc, + watched_targets: Vec, + active_watch_targets: Vec, external_event_tx: tokio::sync::mpsc::UnboundedSender, external_event_server: Option, browser_reload_server: Option, @@ -90,17 +92,15 @@ impl Engine { )?; let mut processes = ProcessManager::new(&self.config); let watch_groups = self.config.compiled_watchers()?; - let (tx, rx) = mpsc::channel(); + let watched_targets = self.config.compiled_watch_targets(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let (external_event_tx, mut external_event_rx) = tokio::sync::mpsc::unbounded_channel(); let tx_watcher = tx.clone(); let watcher_shutdown = Arc::new(AtomicBool::new(false)); let watcher_shutdown_callback = watcher_shutdown.clone(); - let mut watcher = RecommendedWatcher::new( - move |result| { - forward_watcher_event(&tx_watcher, &watcher_shutdown_callback, result); - }, - NotifyConfig::default(), - )?; + let mut watcher = create_watcher(&self.config, move |result| { + forward_watcher_event(&tx_watcher, &watcher_shutdown_callback, result); + })?; let mut maintain_tick = tokio::time::interval(Duration::from_secs(1)); let mut runtime = RuntimeMachine::new(&self.config); let runtime_start = Instant::now(); @@ -114,11 +114,15 @@ impl Engine { state: &state, watcher: &mut watcher, watcher_shutdown, + watched_targets, + active_watch_targets: Vec::new(), external_event_tx, external_event_server: None, browser_reload_server: None, }; execute_runtime_effects(&mut runtime, &mut adapter).await?; + let mut pending_watch_events = Vec::new(); + let mut watch_deadline = None; loop { tokio::select! { @@ -138,9 +142,26 @@ impl Engine { return Ok(()); } } - batch = next_batch(&rx, self.config.debounce()) => { - let events = batch?; - let workflows = classify_events(&self.config.root, &watch_groups, &events); + event = rx.recv() => { + match event { + Some(result) => { + pending_watch_events.push(result?); + if watch_deadline.is_none() { + watch_deadline = Some(Instant::now() + self.config.debounce()); + } + } + None => return Err(anyhow!("watcher event channel disconnected")), + } + } + _ = async { + if let Some(deadline) = watch_deadline { + tokio::time::sleep_until(deadline).await; + } + }, if watch_deadline.is_some() => { + let workflows = + classify_events(&self.config.root, &watch_groups, &pending_watch_events); + pending_watch_events.clear(); + watch_deadline = None; if !workflows.is_empty() { runtime.handle_event(RuntimeEvent::WatchChanges { workflows }); if execute_runtime_effects(&mut runtime, &mut adapter).await? { @@ -273,9 +294,38 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { } async fn start_watching(&mut self) -> Result<()> { - self.watcher - .watch(&self.config.root, RecursiveMode::Recursive)?; - info!("watching {}", self.config.root.display()); + self.active_watch_targets.clear(); + let mut registrations = BTreeMap::::new(); + for target in &self.watched_targets { + for registration in resolve_watch_registrations(target, self.config.watcher.kind)? { + registrations + .entry(registration.path) + .and_modify(|recursive| *recursive |= registration.recursive) + .or_insert(registration.recursive); + } + } + + for (path, recursive) in registrations { + let registration = CompiledWatchTarget { path, recursive }; + self.watcher.watch( + ®istration.path, + if registration.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }, + )?; + self.active_watch_targets.push(registration.clone()); + info!( + "watching {}{}", + registration.path.display(), + if registration.recursive { + " (recursive)" + } else { + "" + } + ); + } Ok(()) } @@ -296,7 +346,10 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { async fn stop_watching(&mut self) -> Result<()> { self.watcher_shutdown.store(true, Ordering::Relaxed); - self.watcher.unwatch(&self.config.root)?; + for target in &self.active_watch_targets { + self.watcher.unwatch(&target.path)?; + } + self.active_watch_targets.clear(); Ok(()) } @@ -305,6 +358,24 @@ impl RuntimeEffectAdapter for LiveRuntimeAdapter<'_, '_> { } } +fn create_watcher(config: &Config, handler: F) -> Result> +where + F: FnMut(notify::Result) + Send + 'static, +{ + let notify_config = NotifyConfig::default(); + match config.watcher.kind { + WatcherKind::Native => { + Ok(Box::new(RecommendedWatcher::new(handler, notify_config)?) as Box<_>) + } + WatcherKind::Poll => Ok(Box::new(PollWatcher::new( + handler, + notify_config + .with_compare_contents(true) + .with_poll_interval(Duration::from_millis(config.watcher.poll_interval_ms)), + )?) as Box<_>), + } +} + async fn execute_runtime_effects( runtime: &mut RuntimeMachine, adapter: &mut A, @@ -352,7 +423,7 @@ async fn execute_runtime_effects( } fn forward_watcher_event( - tx: &mpsc::Sender>, + tx: &tokio::sync::mpsc::UnboundedSender>, shutting_down: &AtomicBool, result: notify::Result, ) { @@ -462,24 +533,67 @@ fn boxed_banner_lines(message: &str) -> [String; 3] { [border.clone(), line, border] } -async fn next_batch( - rx: &mpsc::Receiver>, - debounce: Duration, -) -> Result> { - let first = match rx.recv() { - Ok(result) => result?, - Err(_) => return Err(anyhow!("watcher event channel disconnected")), - }; - let start = Instant::now(); - let mut events = vec![first]; - while start.elapsed() < debounce { - match rx.try_recv() { - Ok(result) => events.push(result?), - Err(mpsc::TryRecvError::Empty) => sleep(Duration::from_millis(25)).await, - Err(mpsc::TryRecvError::Disconnected) => break, +fn resolve_watch_registrations( + target: &CompiledWatchTarget, + watcher_kind: WatcherKind, +) -> Result> { + if target.recursive { + return Ok(vec![CompiledWatchTarget { + path: closest_existing_ancestor(&target.path)?, + recursive: true, + }]); + } + + if target.path.exists() { + if target.path.is_dir() { + return Ok(vec![CompiledWatchTarget { + path: target.path.clone(), + recursive: true, + }]); } + + return Ok(match watcher_kind { + WatcherKind::Native => { + let mut registrations = vec![target.clone()]; + if let Some(parent) = target.path.parent() { + registrations.push(CompiledWatchTarget { + path: parent.to_path_buf(), + recursive: false, + }); + } + registrations + } + WatcherKind::Poll => vec![target.clone()], + }); + } + + let immediate_parent = target + .path + .parent() + .ok_or_else(|| anyhow!("watch target '{}' has no parent", target.path.display()))?; + if immediate_parent.exists() { + return Ok(vec![CompiledWatchTarget { + path: immediate_parent.to_path_buf(), + recursive: target.recursive, + }]); + } + + Ok(vec![CompiledWatchTarget { + path: closest_existing_ancestor(immediate_parent)?, + recursive: true, + }]) +} + +fn closest_existing_ancestor(path: &Path) -> Result { + let mut candidate = path; + loop { + if candidate.exists() { + return Ok(candidate.to_path_buf()); + } + candidate = candidate + .parent() + .ok_or_else(|| anyhow!("watch target '{}' has no existing ancestor", path.display()))?; } - Ok(events) } fn classify_events( @@ -554,6 +668,7 @@ mod tests { use std::collections::VecDeque; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; + use tempfile::tempdir; fn unique_state_path() -> PathBuf { let unique = SystemTime::now() @@ -587,19 +702,126 @@ mod tests { assert_eq!(grouped["content"], vec!["content/posts/example.md"]); } - #[tokio::test] - async fn next_batch_errors_when_watcher_channel_disconnects() { - let (_tx, rx) = mpsc::channel(); - drop(_tx); + #[test] + fn resolve_watch_registration_keeps_existing_poll_file_exact() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + std::fs::write(&file, "hello\n").expect("write watched file"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file.clone(), + recursive: false, + }, + WatcherKind::Poll, + ) + .expect("resolve watch registration"); - let error = next_batch(&rx, Duration::from_millis(10)) - .await - .expect_err("channel disconnect should error"); + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: file, + recursive: false, + }] + ); + } - assert!( - error - .to_string() - .contains("watcher event channel disconnected") + #[test] + fn resolve_watch_registration_keeps_existing_native_file_and_parent() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + std::fs::write(&file, "hello\n").expect("write watched file"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file.clone(), + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![ + CompiledWatchTarget { + path: file, + recursive: false, + }, + CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: false, + }, + ] + ); + } + + #[test] + fn resolve_watch_registration_falls_back_to_existing_parent_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let file = dir.path().join("watched.txt"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: file, + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: false, + }] + ); + } + + #[test] + fn resolve_watch_registration_uses_recursive_parent_for_missing_explicit_directory_target() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("content"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: target, + recursive: true, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: true, + }] + ); + } + + #[test] + fn resolve_watch_registration_climbs_recursively_when_parent_is_missing() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("nested").join("watched.txt"); + + let registrations = resolve_watch_registrations( + &CompiledWatchTarget { + path: target, + recursive: false, + }, + WatcherKind::Native, + ) + .expect("resolve watch registration"); + + assert_eq!( + registrations, + vec![CompiledWatchTarget { + path: dir.path().to_path_buf(), + recursive: true, + }] ); } @@ -683,6 +905,7 @@ mod tests { let mut config = Config { root: root.clone(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -738,6 +961,7 @@ mod tests { let mut config = Config { root: root.clone(), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -806,6 +1030,7 @@ mod tests { let mut config = Config { root, debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(state_path.clone()), startup_workflows: vec![], watch: BTreeMap::new(), @@ -939,6 +1164,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -996,6 +1222,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1046,6 +1273,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1123,6 +1351,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1182,6 +1411,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1321,6 +1551,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1402,7 +1633,7 @@ mod tests { #[test] fn forward_watcher_event_ignores_send_failures_after_shutdown() { - let (tx, rx) = mpsc::channel(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let shutdown = AtomicBool::new(true); drop(rx); @@ -1422,6 +1653,7 @@ mod tests { let mut config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1487,6 +1719,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), @@ -1538,6 +1771,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec!["startup".into()], watch: BTreeMap::new(), @@ -1572,6 +1806,7 @@ mod tests { let config = Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/external_events.rs b/src/external_events.rs index 86dbf5e..5b8f54e 100644 --- a/src/external_events.rs +++ b/src/external_events.rs @@ -240,6 +240,7 @@ mod tests { Config { root: PathBuf::from("."), debounce_ms: 100, + watcher: crate::config::WatcherConfig::default(), state_file: Some(PathBuf::from("./state.json")), startup_workflows: vec![], watch: BTreeMap::new(), diff --git a/src/main.rs b/src/main.rs index 966351c..87cb103 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod external_events; mod output; mod processes; mod state; +#[cfg(test)] +mod test_support; use std::path::PathBuf; @@ -62,6 +64,7 @@ enum Command { enum DocsTopic { Config, Behavior, + Development, Security, } @@ -151,6 +154,7 @@ fn docs_text(topic: DocsTopic) -> &'static str { match topic { DocsTopic::Config => include_str!("../docs/configuration.md"), DocsTopic::Behavior => include_str!("../docs/behavior.md"), + DocsTopic::Development => include_str!("../docs/development.md"), DocsTopic::Security => include_str!("../docs/security.md"), } } @@ -344,33 +348,19 @@ mod tests { use crate::output::{ format_output_prefix, normalize_internal_log_label, normalize_source_label, }; + use crate::test_support::RustLogGuard; use clap::Parser; - use std::sync::{Mutex, OnceLock}; - - fn rust_log_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } #[test] fn default_rust_log_uses_info_when_unset() { - let _guard = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - unsafe { - std::env::remove_var("RUST_LOG"); - } + let _guard = RustLogGuard::set(None); assert_eq!(default_rust_log(), "info"); } #[test] fn default_rust_log_respects_environment_override() { - let _guard = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - unsafe { - std::env::set_var("RUST_LOG", "debug,devloop=trace"); - } + let _guard = RustLogGuard::set(Some("debug,devloop=trace")); assert_eq!(default_rust_log(), "debug,devloop=trace"); - unsafe { - std::env::remove_var("RUST_LOG"); - } } #[test] @@ -418,6 +408,14 @@ mod tests { assert!(rendered.contains("startup_workflows")); } + #[test] + fn docs_text_uses_embedded_development_reference() { + let rendered = docs_text(DocsTopic::Development); + + assert!(rendered.starts_with("# Development Guide")); + assert!(rendered.contains("DEVLOOP_RUN_WATCH_FLAKE_SMOKE")); + } + #[test] fn rendered_docs_drop_markdown_heading_markers() { let rendered = render_docs_text(DocsTopic::Config); @@ -460,4 +458,14 @@ mod tests { _ => panic!("expected docs subcommand"), } } + + #[test] + fn cli_parses_development_docs_subcommand() { + let cli = Cli::try_parse_from(["devloop", "docs", "development"]).expect("parse cli"); + + match cli.command { + super::Command::Docs { topic } => assert!(matches!(topic, DocsTopic::Development)), + _ => panic!("expected docs subcommand"), + } + } } diff --git a/src/processes.rs b/src/processes.rs index c09887f..2c81327 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -984,6 +984,7 @@ fn timeout_error(name: &str, probe: &ProbeSpec) -> anyhow::Error { mod tests { use super::*; use crate::config::{OutputExtract, ProbeSpec}; + use crate::test_support::RustLogGuard; use serde_json::Value; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -1323,10 +1324,7 @@ mod tests { #[test] fn configure_command_inherits_parent_rust_log_by_default() { - let original = std::env::var_os("RUST_LOG"); - unsafe { - std::env::set_var("RUST_LOG", "debug"); - } + let _guard = RustLogGuard::set(Some("debug")); let command = configure_command( &["cargo".into(), "run".into()], @@ -1352,16 +1350,11 @@ mod tests { rust_log.is_none(), "RUST_LOG should not be overridden in child env" ); - - restore_rust_log(original); } #[test] fn configure_command_keeps_explicit_rust_log_override() { - let original = std::env::var_os("RUST_LOG"); - unsafe { - std::env::set_var("RUST_LOG", "debug"); - } + let _guard = RustLogGuard::set(Some("debug")); let mut env = BTreeMap::new(); env.insert("RUST_LOG".into(), "info,gcp_rust_blog=debug".into()); @@ -1389,8 +1382,6 @@ mod tests { .expect("explicit RUST_LOG should be preserved"); assert_eq!(rust_log, "info,gcp_rust_blog=debug"); - - restore_rust_log(original); } #[test] @@ -1463,17 +1454,6 @@ mod tests { })); } - fn restore_rust_log(original: Option) { - match original { - Some(value) => unsafe { - std::env::set_var("RUST_LOG", value); - }, - None => unsafe { - std::env::remove_var("RUST_LOG"); - }, - } - } - #[test] fn output_color_code_is_stable_for_same_process() { assert_eq!( diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..80751b5 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,54 @@ +use std::ffi::{OsStr, OsString}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +fn rust_log_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +pub(crate) struct RustLogGuard { + _lock: MutexGuard<'static, ()>, + original: Option, +} + +impl RustLogGuard { + pub(crate) fn set(value: Option<&str>) -> Self { + let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); + let original = std::env::var_os("RUST_LOG"); + match value { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + Self { + _lock: lock, + original, + } + } +} + +impl Drop for RustLogGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => set_test_env_var("RUST_LOG", value), + None => remove_test_env_var("RUST_LOG"), + } + } +} + +fn set_test_env_var(key: &str, value: impl AsRef) { + // SAFETY: all test-time RUST_LOG mutation goes through the shared + // `rust_log_lock`, so no concurrent unit test can race on this + // process-global environment state. + unsafe { + std::env::set_var(key, value); + } +} + +fn remove_test_env_var(key: &str) { + // SAFETY: all test-time RUST_LOG mutation goes through the shared + // `rust_log_lock`, so removing the variable cannot race with another + // unit test in this process. + unsafe { + std::env::remove_var(key); + } +} diff --git a/tests/watch_flake_smoke.rs b/tests/watch_flake_smoke.rs new file mode 100644 index 0000000..e865b20 --- /dev/null +++ b/tests/watch_flake_smoke.rs @@ -0,0 +1,159 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; + +#[test] +fn repeated_literal_file_edits_keep_triggering_native_watch_workflow() { + if std::env::var_os("DEVLOOP_RUN_WATCH_FLAKE_SMOKE").is_none() { + eprintln!("skipping watch flake smoke; set DEVLOOP_RUN_WATCH_FLAKE_SMOKE=1 to run it"); + return; + } + + let fixture = WatchFixture::new(); + let mut child = DevloopChild::spawn(&fixture); + + child.wait_for_log_line("startup value: initial", Duration::from_secs(10)); + child.wait_for_log_line("watching ", Duration::from_secs(10)); + + for write_index in 1..=10 { + let value = format!("native-trial-{}", "x".repeat(write_index)); + fixture.write_value(&value); + child.wait_for_log_line(&format!("changed value: {value}"), Duration::from_secs(10)); + } +} + +struct WatchFixture { + dir: TempDir, +} + +impl WatchFixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("create tempdir"); + let fixture = Self { dir }; + fixture.write("watched.txt", "initial\n"); + fixture.write( + "devloop.toml", + r#"root = "." +debounce_ms = 300 +state_file = "./.devloop/state.json" +startup_workflows = ["startup"] + +[watch.content] +paths = ["watched.txt"] +workflow = "content" + +[hook.current_value] +command = ["sed", "-n", "1p", "watched.txt"] +cwd = "." +capture = "text" +state_key = "current_value" +output = { inherit = false } + +[workflow.startup] +steps = [ + { action = "run_hook", hook = "current_value" }, + { action = "log", message = "startup value: {{current_value}}" }, +] + +[workflow.content] +steps = [ + { action = "run_hook", hook = "current_value" }, + { action = "log", message = "changed value: {{current_value}}" }, +] +"#, + ); + fixture + } + + fn config_path(&self) -> std::path::PathBuf { + self.dir.path().join("devloop.toml") + } + + fn write_value(&self, value: &str) { + self.write("watched.txt", &format!("{value}\n")); + } + + fn write(&self, relative_path: &str, contents: &str) { + let path = self.dir.path().join(relative_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create fixture parent directories"); + } + std::fs::write(path, contents).expect("write fixture file"); + } +} + +struct DevloopChild { + child: Child, + lines: Receiver, + history: Arc>>, +} + +impl DevloopChild { + fn spawn(fixture: &WatchFixture) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_devloop")); + command + .arg("run") + .arg("--config") + .arg(fixture.config_path()) + .current_dir(fixture.dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn devloop"); + let stderr = child.stderr.take().expect("take child stderr"); + let (tx, rx) = mpsc::channel(); + let history = Arc::new(Mutex::new(Vec::new())); + let history_writer = Arc::clone(&history); + thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(line) => { + history_writer + .lock() + .expect("lock log history") + .push(line.clone()); + if tx.send(line).is_err() { + return; + } + } + Err(_) => return, + } + } + }); + Self { + child, + lines: rx, + history, + } + } + + fn wait_for_log_line(&mut self, needle: &str, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_else(|| Duration::from_secs(0)); + let line = self.lines.recv_timeout(remaining).unwrap_or_else(|_| { + let history = self.history.lock().expect("lock log history"); + panic!( + "timed out waiting for log line containing '{needle}'. recent logs: {history:?}" + ); + }); + if line.contains(needle) { + return; + } + } + } +} + +impl Drop for DevloopChild { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +}