cordance-cli 0.1.1

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

/// Paths whose events we always ignore to avoid feedback loops from cordance's
/// own output or tooling artefacts.
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))
}

/// 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,
    }
}

/// 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"
        );
    }
}