cordance-core 0.1.1

Cordance core types, schemas, and ports. No I/O.
Documentation
//! The `CordancePack` IR — the canonical in-repo description of a target project.
//!
//! A pack is produced by `cordance pack` and is the deterministic compiled view
//! of: project identity + classified sources + selected doctrine entries +
//! generated outputs + lock. All emitters read this.
//!
//! ## Determinism
//!
//! Two `cordance pack` runs against the same commit on the same machine must
//! produce byte-identical `pack.json` / `sources.lock`. Round-4 bughunt #1
//! discovered five `Utc::now()` sites that broke this — `CordancePack`,
//! `SourceLock`, `AdviseReport`, `EvidenceMap` all carried a `generated_at`
//! timestamp that drifted by milliseconds run-to-run. All four fields are now
//! removed from the on-disk shape. If a wall-clock is ever needed for audit,
//! it must be threaded through from a deterministic source (commit date,
//! operator-supplied flag, etc.), not pulled from the system clock at
//! serialisation time.

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};

use crate::advise::AdviseReport;
use crate::lock::SourceLock;
use crate::source::SourceRecord;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CordancePack {
    /// `cordance-pack.v1`.
    pub schema: String,

    pub project: ProjectIdentity,
    pub sources: Vec<SourceRecord>,
    pub doctrine_pins: Vec<DoctrinePin>,
    pub targets: PackTargets,

    pub outputs: Vec<PackOutput>,
    pub source_lock: SourceLock,
    pub advise: AdviseReport,

    /// Records of all decisions made during compilation. Never empty.
    pub residual_risk: Vec<String>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProjectIdentity {
    pub name: String,
    pub repo_root: Utf8PathBuf,
    /// Free-form classifier ("rust-workspace", "ts-bun", "polyglot"…). Set by
    /// the scanner, not the spec.
    pub kind: String,
    /// Operating-system host that produced the pack ("windows", "linux", "macos").
    pub host_os: String,
    /// Axiom algorithm pin (e.g. `"v3.1.1-axiom"`), if known. `None` when
    /// axiom is unconfigured or the `LATEST` file isn't readable.
    ///
    /// `#[serde(default)]` keeps backward compatibility with pack.json files
    /// produced before this field existed.
    #[serde(default)]
    pub axiom_pin: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DoctrinePin {
    /// `0ryant/engineering-doctrine` (or fork).
    pub repo: String,
    pub commit: String,
    pub source_path: Utf8PathBuf,
}

#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct PackTargets {
    pub claude_code: bool,
    pub cursor: bool,
    pub codex: bool,
    pub axiom_harness_target: bool,
    pub cortex_receipt: bool,
}

/// Errors from [`PackTargets::from_csv`].
#[derive(Debug, thiserror::Error)]
pub enum ParseTargetsError {
    /// A token was not one of the recognised target names.
    ///
    /// Round-4 codereview #4 / bughunt #9: the legacy `s.contains("cursor")`
    /// parser silently accepted `no-cursor`, `supercursor`, `--targets foo`,
    /// and any other string containing one of the target substrings. This
    /// error surfaces the user's mistake instead of guessing.
    #[error(
        "unknown pack target {0:?} — expected one of \
             claude-code, cursor, codex, axiom-harness-target, cortex-receipt"
    )]
    UnknownTarget(String),
}

impl PackTargets {
    /// Enable every target. Used as the default when no `--targets` argument is
    /// supplied on the CLI or in an MCP tool call.
    #[must_use]
    pub const fn all() -> Self {
        Self {
            claude_code: true,
            cursor: true,
            codex: true,
            axiom_harness_target: true,
            cortex_receipt: true,
        }
    }

    /// Parse a comma-separated list of target names into a [`PackTargets`].
    ///
    /// Each token is trimmed and exact-matched against the closed set of
    /// known targets. Unknown tokens return [`ParseTargetsError::UnknownTarget`]
    /// — the previous substring scan happily accepted `no-cursor` as "cursor".
    /// Passing `None` (no `--targets` argument supplied at all) or an empty
    /// / whitespace-only string defaults to [`Self::all`] for backward
    /// compatibility with the historical CLI default.
    ///
    /// # Errors
    ///
    /// Returns [`ParseTargetsError::UnknownTarget`] for any token that is not
    /// one of: `"claude-code"`, `"cursor"`, `"codex"`, `"axiom-harness-target"`,
    /// `"cortex-receipt"`.
    pub fn from_csv(s: Option<&str>) -> Result<Self, ParseTargetsError> {
        let Some(s) = s else {
            return Ok(Self::all());
        };
        if s.trim().is_empty() {
            return Ok(Self::all());
        }

        let mut targets = Self::default();
        let mut saw_non_empty_token = false;
        for raw in s.split(',') {
            let token = raw.trim();
            if token.is_empty() {
                continue;
            }
            saw_non_empty_token = true;
            match token {
                "claude-code" => targets.claude_code = true,
                "cursor" => targets.cursor = true,
                "codex" => targets.codex = true,
                "axiom-harness-target" => targets.axiom_harness_target = true,
                "cortex-receipt" => targets.cortex_receipt = true,
                other => return Err(ParseTargetsError::UnknownTarget(other.into())),
            }
        }
        // Round-5 bughunt #2 (R5-bughunt-2): inputs that are only commas /
        // whitespace ("`,`", "` , , `") pass the upper `is_empty` guard but
        // contribute zero recognised tokens, leaving `targets` as the
        // derived `Default` (all-FALSE). The empty-string contract above is
        // "no signal → enable every target"; comma-only soup is the same
        // shape of "no signal" and must follow the same rule rather than
        // silently dropping every emitter. The `saw_non_empty_token` guard
        // distinguishes this from a successful parse that legitimately
        // enables no fields — which is *impossible* today (every recognised
        // token sets exactly one bool to `true`), so the only path that
        // reaches `!saw_non_empty_token` is comma/whitespace-only input.
        if !saw_non_empty_token {
            return Ok(Self::all());
        }
        Ok(targets)
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackOutput {
    pub path: Utf8PathBuf,
    pub target: String,
    pub sha256: String,
    pub bytes: u64,
    pub managed: bool,
    /// IDs of sources this output cites.
    pub source_anchors: Vec<String>,
}

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

    #[test]
    fn empty_pack_serialises() {
        let pack = CordancePack {
            schema: schema::CORDANCE_PACK_V1.into(),
            project: ProjectIdentity {
                name: "fixture".into(),
                repo_root: ".".into(),
                kind: "rust-workspace".into(),
                host_os: "linux".into(),
                axiom_pin: None,
            },
            sources: vec![],
            doctrine_pins: vec![],
            targets: PackTargets::default(),
            outputs: vec![],
            source_lock: SourceLock::empty(),
            advise: AdviseReport::empty(),
            residual_risk: vec!["v0 pack — claim_ceiling=candidate".into()],
        };
        let s = serde_json::to_string(&pack).expect("ser");
        assert!(s.contains("cordance-pack.v1"));
    }

    /// Round-4 bughunt #1: the on-disk shape must not embed wall-clock time.
    /// Two byte-identical packs must serialise to the same bytes regardless of
    /// when they were constructed. The previous `generated_at: DateTime<Utc>`
    /// field made this impossible.
    #[test]
    fn pack_json_contains_no_generated_at_field() {
        let pack = CordancePack {
            schema: schema::CORDANCE_PACK_V1.into(),
            project: ProjectIdentity::default(),
            sources: vec![],
            doctrine_pins: vec![],
            targets: PackTargets::default(),
            outputs: vec![],
            source_lock: SourceLock::empty(),
            advise: AdviseReport::empty(),
            residual_risk: vec![],
        };
        let s = serde_json::to_string(&pack).expect("ser");
        assert!(
            !s.contains("generated_at"),
            "pack.json must not embed a wall-clock timestamp: {s}"
        );
    }

    #[test]
    fn parse_targets_default_when_none() {
        let t = PackTargets::from_csv(None).expect("ok");
        assert!(t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt);
    }

    #[test]
    fn parse_targets_default_when_empty() {
        let t = PackTargets::from_csv(Some("")).expect("ok");
        assert!(t.claude_code && t.cursor);
    }

    #[test]
    fn parse_targets_single_token() {
        let t = PackTargets::from_csv(Some("cursor")).expect("ok");
        assert!(t.cursor);
        assert!(!t.claude_code && !t.codex && !t.axiom_harness_target && !t.cortex_receipt);
    }

    #[test]
    fn parse_targets_multiple_tokens() {
        let t = PackTargets::from_csv(Some("claude-code,codex")).expect("ok");
        assert!(t.claude_code && t.codex);
        assert!(!t.cursor && !t.axiom_harness_target);
    }

    #[test]
    fn parse_targets_trims_whitespace() {
        let t = PackTargets::from_csv(Some("  claude-code , cursor  ")).expect("ok");
        assert!(t.claude_code && t.cursor);
    }

    /// Round-4 codereview #4 / bughunt #9: the legacy substring scan accepted
    /// `no-cursor` as enabling `cursor`. `from_csv` must reject unknown tokens
    /// with a typed error so the user sees what they actually asked for.
    #[test]
    fn parse_targets_rejects_unknown_token() {
        let err = PackTargets::from_csv(Some("no-cursor")).expect_err("unknown token must fail");
        match err {
            ParseTargetsError::UnknownTarget(got) => assert_eq!(got, "no-cursor"),
        }
    }

    /// The substring scan also accepted `--targets supercursor` as enabling
    /// `cursor`. Same expectation: typed error.
    #[test]
    fn parse_targets_rejects_super_prefix() {
        let err = PackTargets::from_csv(Some("supercursor")).expect_err("supercursor must fail");
        assert!(matches!(err, ParseTargetsError::UnknownTarget(_)));
    }

    #[test]
    fn parse_targets_skips_empty_tokens() {
        // Trailing commas and double commas must not produce empty-token
        // errors — they're a benign formatting variant.
        let t = PackTargets::from_csv(Some("claude-code,,cursor,")).expect("ok");
        assert!(t.claude_code && t.cursor);
    }

    /// Round-5 bughunt #2 (R5-bughunt-2): a single comma is "no signal" the
    /// same way an empty string is, so the parser must default to enabling
    /// every target — not silently fall through to the all-FALSE
    /// `Self::default()` shape that would suppress every emitter except the
    /// always-on `pack_json`. A scripted `--targets "$selection"` invocation
    /// with an empty `$selection` after filtering is the realistic case.
    #[test]
    fn parse_targets_comma_only_defaults_to_all() {
        let t = PackTargets::from_csv(Some(",")).expect("ok");
        assert!(
            t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
            "comma-only input must default to all targets enabled (got {t:?})"
        );
    }

    /// Round-5 bughunt #2 (R5-bughunt-2): whitespace-and-comma soup follows
    /// the same rule. The previous shape returned the all-FALSE `Default`
    /// because every token trimmed to empty and was silently skipped, leaving
    /// the operator with one-output packs and no error to chase.
    #[test]
    fn parse_targets_whitespace_and_commas_defaults_to_all() {
        let t = PackTargets::from_csv(Some(" , , ")).expect("ok");
        assert!(
            t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
            "whitespace+comma soup must default to all targets enabled (got {t:?})"
        );
    }
}