use std::sync::mpsc;
use std::time::Duration;
use anyhow::{anyhow, Result};
use camino::Utf8PathBuf;
use cordance_core::{pack::PackTargets, paths::segment_matches_any_ascii_case_insensitive};
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_SEGMENTS: &[&str] = &[
".cordance",
".git",
".codex",
"target",
"node_modules",
"dist",
"build",
"coverage",
".pytest_cache",
"__pycache__",
".idea",
".vscode",
];
fn is_ignored(path: &std::path::Path) -> bool {
let normalised = path.to_string_lossy().replace('\\', "/");
normalised
.split('/')
.any(|seg| segment_matches_any_ascii_case_insensitive(seg, IGNORED_SEGMENTS))
}
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,
from_cortex_push: false,
cortex_receipt_requested_explicitly: 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"
);
}
#[test]
fn is_ignored_skips_target_directory() {
assert!(
is_ignored(std::path::Path::new("target/release/foo")),
"target/ exhaust must be skipped"
);
}
#[test]
fn is_ignored_matches_segments_ascii_case_insensitively() {
assert!(
is_ignored(std::path::Path::new("Target/release/foo")),
"Target/ exhaust must be skipped on NTFS-like filesystems"
);
assert!(
is_ignored(std::path::Path::new(".Cordance/pack.json")),
".Cordance/ exhaust must be skipped on NTFS-like filesystems"
);
assert!(
!is_ignored(std::path::Path::new("myTarget/inside.md")),
"myTarget/ is a user-created directory and must not match the `target` rule"
);
assert!(
!is_ignored(std::path::Path::new(".Cordance-cache/pack.json")),
".Cordance-cache/ must not match the `.cordance` rule"
);
}
#[test]
fn is_ignored_does_not_skip_mytarget_directory() {
assert!(
!is_ignored(std::path::Path::new("mytarget/inside.md")),
"mytarget/ is a user-created directory and must not match the `target` rule"
);
}
#[test]
fn is_ignored_does_not_skip_files_containing_target() {
assert!(
!is_ignored(std::path::Path::new("src/target_helper.rs")),
"files merely containing the substring `target` must not be skipped"
);
}
#[test]
fn is_ignored_skips_nested_cordance() {
assert!(
is_ignored(std::path::Path::new("subproject/.cordance/foo.json")),
"nested .cordance/ exhaust must be skipped even when not at root"
);
}
#[test]
fn is_ignored_normalises_windows_backslashes() {
assert!(
is_ignored(std::path::Path::new(r"target\release\foo")),
"Windows-shaped `target\\release\\foo` must be ignored after normalisation"
);
assert!(
!is_ignored(std::path::Path::new(r"mytarget\inside.md")),
"Windows-shaped `mytarget\\inside.md` must NOT match the `target` rule"
);
}
}