cordance-core 0.1.1

Cordance core types, schemas, and ports. No I/O.
Documentation
//! Evidence map: maps emitted rules and outputs back to their source anchors.
//!
//! Doctrine alignment:
//! - `single-source-of-truth.md`: every generated rule cites the doctrine,
//!   ADR, schema, or scan finding that produced it.
//! - `event-contracts.md`: this struct is the on-disk shape behind
//!   `.cordance/evidence-map.json` (schema `cordance-evidence-map.v1`).
//!
//! Consumed by `cordance explain <rule>`: the CLI reads the map, filters
//! entries by rule ID or summary substring, and renders the cited sources.

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

/// On-disk `schema` tag for the evidence map.
pub const SCHEMA: &str = "cordance-evidence-map.v1";

/// Top-level evidence map written to `.cordance/evidence-map.json`.
///
/// Round-4 bughunt #1: the `generated_at: DateTime<Utc>` field was the fifth
/// `Utc::now()` site that broke `pack.json` byte-determinism across runs.
/// Removed — see the `pack.rs` module doc for the full rationale.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EvidenceMap {
    pub schema: String,
    pub pack_id: String,
    pub rules: Vec<EvidenceEntry>,
    pub outputs: Vec<OutputEvidence>,
}

/// A single rule or generated output identifier and the sources backing it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EvidenceEntry {
    /// Rule ID or output identifier.
    pub rule_id: String,
    /// Human-readable rule text or output description.
    pub text: String,
    /// Source anchors backing this rule/output.
    pub sources: Vec<EvidenceSource>,
}

/// A pointer at the byte range or file that justifies an `EvidenceEntry`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EvidenceSource {
    /// Either a project source path or a doctrine file path.
    pub path: Utf8PathBuf,
    /// Optional line range (`start_line`, `end_line`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line_range: Option<(u32, u32)>,
    /// What kind of source: `"doctrine"`, `"adr"`, `"schema"`, `"scan"`, etc.
    pub kind: String,
}

/// Per-generated-output provenance: the file written, its sha256, and the
/// source IDs that contributed to its content.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OutputEvidence {
    /// Output path (e.g. `AGENTS.md`).
    pub path: Utf8PathBuf,
    pub sha256: String,
    /// Source IDs that contributed to this output.
    pub source_ids: Vec<String>,
}

impl EvidenceMap {
    /// Construct an empty evidence map for `pack_id`. Useful for tests and
    /// as a deterministic starting point for emitters.
    #[must_use]
    pub fn empty(pack_id: String) -> Self {
        Self {
            schema: SCHEMA.into(),
            pack_id,
            rules: vec![],
            outputs: vec![],
        }
    }
}

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

    #[test]
    fn empty_map_has_v1_schema() {
        let m = EvidenceMap::empty("abc".into());
        assert_eq!(m.schema, SCHEMA);
        assert_eq!(m.pack_id, "abc");
        assert!(m.rules.is_empty());
        assert!(m.outputs.is_empty());
    }

    #[test]
    fn evidence_map_round_trips() {
        let m = EvidenceMap {
            schema: SCHEMA.into(),
            pack_id: "pid".into(),
            rules: vec![EvidenceEntry {
                rule_id: "R-1".into(),
                text: "rule text".into(),
                sources: vec![EvidenceSource {
                    path: "doctrine/principles/build.md".into(),
                    line_range: Some((10, 20)),
                    kind: "doctrine".into(),
                }],
            }],
            outputs: vec![OutputEvidence {
                path: "AGENTS.md".into(),
                sha256: "deadbeef".into(),
                source_ids: vec!["project_readme:README.md".into()],
            }],
        };
        let s = serde_json::to_string(&m).expect("ser");
        let back: EvidenceMap = serde_json::from_str(&s).expect("de");
        assert_eq!(back.pack_id, "pid");
        assert_eq!(back.rules.len(), 1);
        assert_eq!(back.outputs.len(), 1);
    }

    #[test]
    fn line_range_skipped_when_none() {
        let src = EvidenceSource {
            path: "doctrine/x.md".into(),
            line_range: None,
            kind: "doctrine".into(),
        };
        let s = serde_json::to_string(&src).expect("ser");
        assert!(!s.contains("line_range"));
    }
}