cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `cordance watch` — re-runs pack on filesystem changes. LANE-V1-WATCH.
//!
//! # Testing note
//!
//! The `run` function contains a blocking I/O loop that waits indefinitely
//! for filesystem events and only terminates on Ctrl+C (SIGINT). Unit-testing
//! that loop without a dedicated thread-cancellation mechanism would require
//! either a timeout or synthetic signal injection, both of which are out of
//! scope for v0. Integration-level coverage can be added later using a
//! dedicated watcher harness that injects events through the mpsc channel.
//!
//! Round-5 bughunt #4 (R5-bughunt-4) added a pinned unit test for the
//! `PackTargets` selection used per debounced re-pack — exercised through the
//! pure `watch_pack_config` helper to avoid the blocking loop.

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};

/// Predicate: should the watch loop exit because its target was removed?
///
/// Extracted from the loop body so the round-6 bughunt #4 (R6-bughunt-4)
/// regression has a pure, testable surface. The watcher itself is a
/// real-time, OS-event-driven loop that races with tempdir teardown — a
/// unit test against the loop would be flaky. The exit *decision* is
/// pure: `target.exists() == false` means we must exit. The unit test
/// below pins exactly that predicate.
fn watch_target_missing(target: &Utf8PathBuf) -> bool {
    !target.as_std_path().exists()
}

/// Single-component directory names whose events we always ignore to avoid
/// feedback loops from cordance's own output or build / tooling artefacts.
///
/// Round-8 bughunt #3 (R8-bughunt-3, three-round carryover from round-1 R10
/// through round-7 R7-bughunt-5): the previous shape was a substring-contains
/// match against `.cordance/`, `target/`, `.git/`, `node_modules/`. That
/// silently dropped any user-created path that shared one of those substrings
/// — `mytarget/foo.md` matched `target/`, `.cordance.bak/notes.md` matched
/// `.cordance/`, `legacy_target/build.md` matched `target/`. The operator saw
/// edits land on disk and watch-driven re-packs never fire, with no log line
/// to chase. Segment-exact matching catches the canonical exhaust paths and
/// leaves all user-named lookalikes alone.
///
/// The name set is the segment-shaped subset of
/// `cordance-scan::blocked::PATH_SUBSTRINGS` — every entry there that is a
/// single path segment (e.g. `.cordance/` -> `.cordance`, `.codex/cache/` is
/// rooted under `.codex` so we ignore `.codex` wholesale at the watch layer
/// since the watch loop only cares about whether we should re-pack and any
/// edit under `.codex` is either runtime exhaust or an `.codex/AGENTS.md`
/// emitter writeback we want to swallow).
const IGNORED_SEGMENTS: &[&str] = &[
    ".cordance",
    ".git",
    ".codex",
    "target",
    "node_modules",
    "dist",
    "build",
    "coverage",
    ".pytest_cache",
    "__pycache__",
    ".idea",
    ".vscode",
];

/// True if any path segment exactly matches one of `IGNORED_SEGMENTS`.
///
/// Normalises Windows backslashes to forward slashes before splitting, so a
/// `notify` event arriving as `target\release\foo` on Windows is still
/// classified correctly. Splitting on `/` rather than walking
/// `Path::components()` avoids the `Prefix(...)` / `RootDir` shape on absolute
/// Windows paths — we only want to compare normal name components.
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))
}

/// Build the [`PackConfig`] used for each watch-driven re-pack.
///
/// Extracted so the parallel-lane regression for round-5 bughunt #4 can pin
/// `selected_targets == PackTargets::all()` without invoking the blocking
/// watch loop. The watch loop must always opt in to every emitter so a debounce
/// rebuild surfaces the same eleven outputs an interactive `cordance pack`
/// would produce; using `PackTargets::default()` silently dropped to one
/// output (the always-on `pack_json` post-emitter) and made the "outputs
/// planned" counter actively misleading.
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,
    }
}

/// Watch `target` for filesystem changes and re-run a dry-run pack on every
/// batch of events. Runs until the process is interrupted (Ctrl+C).
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>();

    // `debouncer` must be kept alive for the duration of the loop; dropping
    // it stops the watcher and unregisters the OS inotify/FSEvents handle.
    let mut debouncer = new_debouncer(Duration::from_millis(debounce_ms), move |res| {
        // Errors on send mean the receiver has gone away; nothing useful to do.
        let _ = tx.send(res);
    })?;

    debouncer
        .watcher()
        .watch(target.as_std_path(), RecursiveMode::Recursive)?;

    loop {
        // Blocking receive; exits cleanly when the channel closes (e.g. on
        // Ctrl+C, when the debouncer is dropped). A panic here would mask the
        // operator's interrupt with a stack trace, so handle the disconnect
        // explicitly with `let ... else`.
        let Ok(batch) = rx.recv() else {
            tracing::info!("watcher channel closed; cordance watch exiting");
            return Ok(());
        };

        match batch {
            Ok(events) => {
                // Skip batches where every event is from an ignored path.
                let has_relevant = events.iter().any(|ev| !is_ignored(&ev.path));
                if !has_relevant {
                    continue;
                }

                // Round-6 bughunt #4 (R6-bughunt-4): if the operator
                // `rm -rf`'d the target between debounce ticks, the
                // pre-existing code rolled into `pack_cmd::run`, hit an
                // empty/missing-cordance.toml error, recorded it as a
                // `scan_error`, shipped an empty pack, and looped — the
                // watcher spins silently while the operator sees nothing
                // useful. The fix: detect the disappearance up-front,
                // log loudly, drop the debouncer (Drop unregisters the
                // OS watch handle), and return a propagated error so the
                // CLI exits non-zero. Round-4 raised this at MEDIUM and
                // round-5 missed it despite landing other watch_cmd
                // changes; round-6 escalated to HIGH.
                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...");

                // Round-5 bughunt #4 (R5-bughunt-4): `PackTargets::default()`
                // is the all-FALSE shape, so the watch loop used to silently
                // suppress every emitter except the always-on `pack_json`
                // post-emitter. The shared `watch_pack_config` helper now
                // pins `selected_targets = PackTargets::all()` and is covered
                // by a unit test below.
                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::*;

    /// Round-5 bughunt #4 (R5-bughunt-4): the watch loop must dispatch every
    /// emitter on each re-pack. The previous code used
    /// `PackTargets::default()` — which is all-false on a struct of bool —
    /// silently dropping output count from eleven to one. Pin the helper's
    /// shape so a future refactor that flips the default cannot regress
    /// `cordance watch` without tripping this test.
    #[test]
    fn watch_pack_config_selects_all_targets() {
        let cfg = watch_pack_config(&Utf8PathBuf::from("."));
        let expected = PackTargets::all();
        // Field-by-field compare so a future field addition fails compilation
        // here rather than silently matching all-true under a `==` impl.
        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"
        );
        // Cross-check against the canonical `all()` constructor in case a
        // future contributor extends `PackTargets` without updating this test.
        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);
    }

    /// Round-6 bughunt #4 (R6-bughunt-4): the loop must observe target
    /// deletion and return an error so the CLI exits non-zero. The
    /// real-time `notify` debouncer races tempdir teardown badly enough
    /// that a full loop-level test is flaky on CI. The PURE
    /// `watch_target_missing` predicate, however, is deterministic — and
    /// it is the load-bearing decision inside the loop. Pinning it here
    /// guarantees the regression cannot reintroduce silently: if a future
    /// refactor accidentally returns `true` for an extant target (or
    /// `false` for an absent one) the loop's exit semantics flip.
    #[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");

        // Sanity: directory exists, predicate must say "not missing".
        assert!(
            !watch_target_missing(&target_path),
            "extant target must not be reported missing"
        );

        // Drop the tempdir to remove it from disk. `tempdir::close` is
        // the explicit equivalent and surfaces I/O errors; we use it so
        // the test fails loudly if the OS leaves the directory in a
        // half-removed state rather than silently letting the predicate
        // misfire.
        tmp.close().expect("tempdir close");

        assert!(
            watch_target_missing(&target_path),
            "removed target must be reported missing so watch loop exits"
        );
    }

    /// Round-8 bughunt #3 (R8-bughunt-3): the canonical positive case. A
    /// release-binary path under `target/` is exhaust the watch loop must
    /// suppress; otherwise the post-build artefact churn re-triggers
    /// `cordance pack` in an infinite loop. The segment match catches this
    /// just as the previous substring rule did — pinning here so a future
    /// refactor that drops `target` from `IGNORED_SEGMENTS` regresses
    /// loudly.
    #[test]
    fn is_ignored_skips_target_directory() {
        assert!(
            is_ignored(std::path::Path::new("target/release/foo")),
            "target/ exhaust must be skipped"
        );
    }

    /// R9 path-policy blocker: Windows / NTFS-like filesystems resolve
    /// ASCII-case variants of ignored exhaust directories to the same
    /// surface. Watch must suppress those variants while keeping segment
    /// boundaries exact for user-created lookalikes.
    #[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"
        );
    }

    /// Round-8 bughunt #3 (R8-bughunt-3): the substring-misfire case. The
    /// round-1 through round-7 carryover bug: a user-created directory
    /// named `mytarget/` was silently filtered because `"target/"` is a
    /// substring of `"mytarget/"`. With segment-exact matching, `mytarget`
    /// is its own segment that does NOT equal `target`, so the path is
    /// classified as a real user edit and the watch loop runs the re-pack.
    #[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"
        );
    }

    /// Round-8 bughunt #3 (R8-bughunt-3): companion case. A file whose NAME
    /// contains `target` (e.g. `src/target_helper.rs`) must not be
    /// classified as exhaust. Segment matching only compares whole
    /// components; `target_helper.rs` is a single segment that does not
    /// equal `target`.
    #[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"
        );
    }

    /// Round-8 bughunt #3 (R8-bughunt-3): exhaust under a nested
    /// subproject. A subproject's `.cordance/` directory holds the same
    /// pack-state exhaust as the top-level `.cordance/`; events there
    /// must still be filtered so a nested pack doesn't drive an infinite
    /// re-pack loop in the parent watcher.
    #[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"
        );
    }

    /// Round-8 bughunt #3 (R8-bughunt-3): Windows backslash normalisation.
    /// `notify` events on Windows arrive with backslashes
    /// (`target\release\foo`). The `is_ignored` predicate must normalise
    /// before splitting; without the `replace('\\', "/")` step, the entire
    /// string is one segment and never matches `target`.
    #[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"
        );
    }
}