use std::sync::mpsc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use camino::Utf8PathBuf;
use cordance_core::pack::PackTargets;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult};
use tracing::warn;
use crate::pack_cmd::{self, OutputMode, PackConfig};
fn watch_target_missing(target: &Utf8PathBuf) -> bool {
!target.as_std_path().exists()
}
const IGNORED_PREFIXES: &[&str] = &[".cordance/", "target/", ".git/", "node_modules/"];
fn is_ignored(path: &std::path::Path) -> bool {
let path_str = path.to_string_lossy();
IGNORED_PREFIXES
.iter()
.any(|prefix| path_str.contains(prefix))
}
fn watch_pack_config(target: &Utf8PathBuf) -> PackConfig {
PackConfig {
target: target.clone(),
output_mode: OutputMode::DryRun,
selected_targets: PackTargets::all(),
doctrine_root: None,
llm_provider: None,
ollama_model: None,
quiet: false,
}
}
pub fn run(target: &Utf8PathBuf, debounce_ms: u64) -> Result<()> {
println!(
"cordance watch: watching {target} (debounce {debounce_ms}ms) \u{2014} Ctrl+C to stop"
);
let (tx, rx) = mpsc::channel::<DebounceEventResult>();
let mut debouncer = new_debouncer(Duration::from_millis(debounce_ms), move |res| {
let _ = tx.send(res);
})?;
debouncer.watcher().watch(target.as_std_path(), RecursiveMode::Recursive)?;
loop {
let Ok(batch) = rx.recv() else {
tracing::info!("watcher channel closed; cordance watch exiting");
return Ok(());
};
match batch {
Ok(events) => {
let has_relevant = events.iter().any(|ev| !is_ignored(&ev.path));
if !has_relevant {
continue;
}
if watch_target_missing(target) {
tracing::error!(
target = %target,
"watch target was deleted; exiting"
);
drop(debouncer);
return Err(anyhow!(
"watch target was deleted: {target}"
));
}
println!(" \u{2192} change detected, re-running pack...");
let config = watch_pack_config(target);
match pack_cmd::run(&config) {
Ok(pack) => {
let n = pack.outputs.len();
println!(" \u{2713} pack dry-run: {n} outputs planned");
}
Err(e) => {
println!(" \u{2717} pack error: {e}");
}
}
}
Err(e) => {
warn!("cordance watch: notify error: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn watch_pack_config_selects_all_targets() {
let cfg = watch_pack_config(&Utf8PathBuf::from("."));
let expected = PackTargets::all();
assert!(
cfg.selected_targets.claude_code,
"claude_code must be enabled"
);
assert!(cfg.selected_targets.cursor, "cursor must be enabled");
assert!(cfg.selected_targets.codex, "codex must be enabled");
assert!(
cfg.selected_targets.axiom_harness_target,
"axiom_harness_target must be enabled"
);
assert!(
cfg.selected_targets.cortex_receipt,
"cortex_receipt must be enabled"
);
assert_eq!(cfg.selected_targets.claude_code, expected.claude_code);
assert_eq!(cfg.selected_targets.cursor, expected.cursor);
assert_eq!(cfg.selected_targets.codex, expected.codex);
assert_eq!(
cfg.selected_targets.axiom_harness_target,
expected.axiom_harness_target
);
assert_eq!(cfg.selected_targets.cortex_receipt, expected.cortex_receipt);
}
#[test]
fn watch_target_missing_after_directory_removed() {
let tmp = tempfile::tempdir().expect("tempdir");
let target_path: Utf8PathBuf = tmp
.path()
.to_path_buf()
.try_into()
.expect("tempdir path is utf-8");
assert!(
!watch_target_missing(&target_path),
"extant target must not be reported missing"
);
tmp.close().expect("tempdir close");
assert!(
watch_target_missing(&target_path),
"removed target must be reported missing so watch loop exits"
);
}
}