cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! Pack tier: `cordance_pack_dry_run`.
//!
//! Runs the same compile pipeline as `cordance pack --output-mode dry-run`
//! but never touches the filesystem and never returns generated bytes — only
//! the list of planned outputs (path, byte count, target name, sha256). This
//! is the supervised-tier surface for "what would change if I ran pack
//! today?" without giving the agent write authority.

use camino::Utf8PathBuf;
use cordance_core::pack::{CordancePack, PackTargets};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};
use crate::pack_cmd::{self, OutputMode, PackConfig};

#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct PackDryRunParams {
    /// Target repo root. Defaults to the server's working directory.
    #[serde(default)]
    pub target: Option<String>,
    /// Comma-separated subset of `{claude-code, cursor, codex,
    /// axiom-harness-target, cortex-receipt}`. When omitted, all targets
    /// are planned.
    #[serde(default)]
    pub targets: Option<String>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct PackDryRunOutput {
    pub schema: String,
    pub project_name: String,
    pub planned_outputs: Vec<PlannedOutput>,
    pub source_count: usize,
    pub doctrine_commit: Option<String>,
    /// Audit notes the pipeline attached during dry-run (e.g. LLM
    /// unavailability warnings). Always non-empty in production.
    pub residual_risk: Vec<String>,
}

/// Metadata for a single file the writer *would* produce. Never carries the
/// generated bytes; the consumer must run `cordance pack` to materialise them.
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct PlannedOutput {
    #[schemars(with = "String")]
    pub path: Utf8PathBuf,
    pub target: String,
    pub sha256: String,
    pub bytes: u64,
    pub managed: bool,
    pub source_anchors: Vec<String>,
}

/// Run a dry-run pack against `target`. `target` must already be
/// canonicalised by [`crate::mcp::validation::validate_target`].
pub fn dry_run(
    target: &Utf8PathBuf,
    cfg: &Config,
    targets: Option<&str>,
) -> McpToolResult<PackDryRunOutput> {
    // Round-7 redteam #5 (R7-redteam-5): on the MCP wire boundary an empty,
    // whitespace-only, or comma-only `targets` string would otherwise widen
    // to `PackTargets::all()` via `PackTargets::from_csv`'s lenient default
    // (kept for CLI ergonomics). A hostile MCP peer that passes
    // `targets=""` or `targets=","` then drives every emitter — including
    // the harness target authority claim, the cortex receipt builder, and
    // the AGENTS.md write path — even though the wire never explicitly
    // requested any of those targets. The MCP boundary's "untrusted input
    // never silently widens the selection" principle requires an explicit
    // refusal. The CLI keeps the lenient default; only this MCP entry
    // gates on empty input.
    if let Some(raw) = targets {
        if raw.split(',').all(|tok| tok.trim().is_empty()) {
            return Err(McpToolError::InvalidArgument(
                "targets: empty or whitespace-only — supply at least one target name (e.g. \"claude-code,cursor\")".into(),
            ));
        }
    }
    let selected = PackTargets::from_csv(targets)
        .map_err(|e| McpToolError::InvalidArgument(format!("targets: {e}")))?;
    let config = PackConfig {
        target: target.clone(),
        output_mode: OutputMode::DryRun,
        selected_targets: selected,
        doctrine_root: Some(cfg.doctrine_root(target)),
        llm_provider: Some("none".to_string()),
        ollama_model: None,
        // MCP stdout is reserved for JSON-RPC frames.
        quiet: true,
        from_cortex_push: false,
        cortex_receipt_requested_explicitly: targets
            .is_some_and(|raw| raw.split(',').any(|tok| tok.trim() == "cortex-receipt")),
    };
    let pack = pack_cmd::run(&config).map_err(McpToolError::from_anyhow)?;
    let planned_outputs = pack
        .outputs
        .iter()
        .map(|o| PlannedOutput {
            path: o.path.clone(),
            target: o.target.clone(),
            sha256: o.sha256.clone(),
            bytes: o.bytes,
            managed: o.managed,
            source_anchors: o.source_anchors.clone(),
        })
        .collect();

    Ok(PackDryRunOutput {
        schema: "cordance-pack-dry-run.v1".to_string(),
        project_name: pack.project.name.clone(),
        planned_outputs,
        source_count: pack.sources.len(),
        doctrine_commit: pack.doctrine_pins.first().map(|p| p.commit.clone()),
        residual_risk: pack.residual_risk.clone(),
    })
}

/// Helper shared by the other tool modules. Builds a `CordancePack` in
/// dry-run mode with all targets selected, so context/advise/check tools see
/// the same shape they would in a real run.
///
/// LLM enrichment is force-disabled here regardless of project config to
/// keep tool calls deterministic and offline.
pub fn build_pack(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<CordancePack> {
    build_pack_with_cortex_receipt_context(target, cfg, false)
}

/// Build the pack used as input to the MCP cortex receipt tool.
///
/// The selected targets intentionally include `cortex_receipt` so the receipt
/// represents the cortex submission surface, but this path is the receipt
/// producer itself. Suppress the direct-`pack` no-op audit note so it cannot
/// be copied into the receipt's allowed claim language.
pub fn build_pack_for_cortex_receipt(
    target: &Utf8PathBuf,
    cfg: &Config,
) -> McpToolResult<CordancePack> {
    build_pack_with_cortex_receipt_context(target, cfg, true)
}

fn build_pack_with_cortex_receipt_context(
    target: &Utf8PathBuf,
    cfg: &Config,
    from_cortex_push: bool,
) -> McpToolResult<CordancePack> {
    let config = PackConfig {
        target: target.clone(),
        output_mode: OutputMode::DryRun,
        selected_targets: PackTargets {
            claude_code: true,
            cursor: true,
            codex: true,
            axiom_harness_target: true,
            cortex_receipt: true,
        },
        doctrine_root: Some(cfg.doctrine_root(target)),
        llm_provider: Some("none".to_string()),
        ollama_model: None,
        // MCP stdout is reserved for JSON-RPC frames.
        quiet: true,
        from_cortex_push,
        cortex_receipt_requested_explicitly: from_cortex_push,
    };
    pack_cmd::run(&config).map_err(McpToolError::from_anyhow)
}

// Round-4 codereview #4 / bughunt #9 consolidated the substring scanner into
// `cordance_core::pack::PackTargets::from_csv` (typed errors, exact-match
// against the closed set of target names). Both the CLI dispatcher and this
// MCP tool route through it now; the divergence between the two parsers is
// gone.

#[cfg(test)]
mod tests {
    use super::*;

    /// Round-7 redteam #5 (R7-redteam-5): the MCP `cordance_pack_dry_run`
    /// entry must REFUSE empty / whitespace-only / comma-only `targets`
    /// rather than silently widening to every emitter. The CLI keeps the
    /// lenient `from_csv` default for ergonomics — we pin BOTH halves of
    /// the contract here so a future refactor that re-routes the MCP path
    /// through the lenient form trips the test.
    #[test]
    fn dry_run_rejects_empty_string_targets() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
        let cfg = Config::default();

        let result = dry_run(&target, &cfg, Some(""));
        let Err(err) = result else {
            panic!("expected InvalidArgument on empty targets; got Ok");
        };
        match err {
            McpToolError::InvalidArgument(msg) => {
                assert!(
                    msg.contains("empty or whitespace-only"),
                    "error message must explain the cause; got: {msg}"
                );
            }
            other => panic!("expected InvalidArgument; got {other:?}"),
        }
    }

    /// Comma-only soup like `","` must hit the same gate as `""` —
    /// `PackTargets::from_csv` treats both as "no signal → default-to-all",
    /// which is fine for the CLI but a widening surface on the wire.
    #[test]
    fn dry_run_rejects_comma_only_targets() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
        let cfg = Config::default();

        for sample in &[",", " , , ", "   ", ", ,"] {
            let result = dry_run(&target, &cfg, Some(sample));
            assert!(
                matches!(result, Err(McpToolError::InvalidArgument(_))),
                "expected InvalidArgument for targets={sample:?}; got {result:?}"
            );
        }
    }

    /// The CLI must keep the round-4 lenient default for ergonomics. This
    /// test pins that `PackTargets::from_csv(Some(""))` returns `Ok(all)`
    /// — the gate is MCP-side only; the CLI parser is unchanged.
    #[test]
    fn cli_from_csv_empty_string_still_defaults_to_all() {
        let parsed = PackTargets::from_csv(Some(""))
            .expect("CLI parser must keep its lenient default for empty input");
        assert!(
            parsed.claude_code
                && parsed.cursor
                && parsed.codex
                && parsed.axiom_harness_target
                && parsed.cortex_receipt,
            "PackTargets::from_csv(Some(\"\")) must default to all-targets; got {parsed:?}"
        );
    }

    #[test]
    fn dry_run_default_targets_omits_cortex_receipt_noop_warning() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
        let doctrine = tempfile::tempdir().expect("doctrine tempdir");
        let cfg = Config {
            doctrine: crate::config::DoctrineConfig {
                source: doctrine.path().to_string_lossy().into_owned(),
                fallback_repo: "https://nonexistent.example.invalid/doctrine".into(),
                pin_commit: "auto".into(),
            },
            ..Default::default()
        };

        let output = dry_run(&target, &cfg, None).expect("MCP dry-run should build");
        let needle = "cortex-receipt requested via --targets";

        assert!(
            !output
                .residual_risk
                .iter()
                .any(|risk| risk.contains(needle)),
            "omitted MCP targets default to all but must not pretend cortex-receipt was explicit: {:?}",
            output.residual_risk
        );
    }
}