cordance-core 0.1.1

Cordance core types, schemas, and ports. No I/O.
Documentation
//! Source records: every byte cordance consumes is one of these.

use camino::Utf8PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Classification of a single source file by the role it plays.
///
/// This taxonomy is **content-agnostic by design**: classification comes from
/// the directory and filename a file lives at, never from prose content. If a
/// rule depends on file content, that's an `AdviseFinding`, not a class.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceClass {
    EngineeringDoctrinePrinciple,
    EngineeringDoctrinePattern,
    EngineeringDoctrineChecklist,
    EngineeringDoctrineTooling,
    EngineeringDoctrineGlossary,
    EngineeringDoctrineEvolution,
    ProjectDoctrine,
    ProjectAdr,
    ProjectSchema,
    ProjectContract,
    ProjectTest,
    ProjectCi,
    ProjectReleaseGate,
    ProjectSourceCode,
    ProjectReadme,
    ProjectAgentFile,
    AxiomAlgorithm,
    AxiomPolicy,
    AxiomSchema,
    AxiomTool,
    AxiomTemplate,
    AxiomWorkflow,
    AxiomSkill,
    CortexReceipt,
    CortexFixture,
    GeneratedManaged,
    BlockedSurface,
    Unclassified,
}

/// Where a source class fits in the six-bucket authority-surfaces taxonomy
/// used by `pai-axiom-project-harness-target.v1`. Multiple `SourceClass`
/// values map to the same `SurfaceCategory`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SurfaceCategory {
    ProductSpec,
    Adrs,
    Doctrine,
    TestsOrEvals,
    RuntimeRoots,
    ReleaseGates,
}

#[allow(clippy::use_self)]
impl SourceClass {
    /// Map a source class to its harness-target bucket.
    #[must_use]
    pub const fn surface_category(self) -> Option<SurfaceCategory> {
        use SourceClass as S;
        use SurfaceCategory as C;
        match self {
            S::ProjectReadme | S::ProjectDoctrine => Some(C::ProductSpec),
            S::ProjectAdr => Some(C::Adrs),
            S::EngineeringDoctrinePrinciple
            | S::EngineeringDoctrinePattern
            | S::EngineeringDoctrineChecklist
            | S::EngineeringDoctrineTooling
            | S::EngineeringDoctrineGlossary
            | S::EngineeringDoctrineEvolution => Some(C::Doctrine),
            S::ProjectTest | S::ProjectSchema | S::ProjectContract => Some(C::TestsOrEvals),
            S::ProjectCi | S::ProjectReleaseGate => Some(C::ReleaseGates),
            _ => None,
        }
    }
}

/// One scanned source file.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SourceRecord {
    /// Stable identifier (`{class}:{path}` with `/` separators).
    pub id: String,
    /// Repo-relative path, forward-slashed.
    pub path: Utf8PathBuf,
    pub class: SourceClass,
    /// sha256 of the file bytes, hex-encoded lowercase.
    pub sha256: String,
    /// Size in bytes.
    pub size_bytes: u64,
    /// Last-modified time if the filesystem reports one.
    ///
    /// Round-4 bughunt #10: the OS-reported mtime is per-machine and
    /// per-checkout. Embedding it in `pack.json` makes the on-disk shape
    /// non-deterministic across operators with the same commit. `#[serde(skip)]`
    /// keeps the field in memory (the scanner still populates it for in-process
    /// tools that want to inspect freshness) but excludes it from
    /// serialisation. Deserialisation defaults to `None`. The deterministic
    /// truth lives in `sha256` + the git commit log.
    #[serde(skip)]
    pub modified: Option<DateTime<Utc>>,
    /// Was this file ignored (matched a blocked-surface rule)?
    #[serde(default)]
    pub blocked: bool,
    /// Reason for blocking, if `blocked`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blocked_reason: Option<String>,
}

impl SourceRecord {
    #[must_use]
    pub fn stable_id(class: SourceClass, path: &Utf8PathBuf) -> String {
        let class_str =
            serde_json::to_string(&class).unwrap_or_else(|_| "\"unclassified\"".to_string());
        format!("{}:{}", class_str.trim_matches('"'), path.as_str())
    }
}

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

    #[test]
    fn doctrine_classes_map_to_doctrine_bucket() {
        assert_eq!(
            SourceClass::EngineeringDoctrinePrinciple.surface_category(),
            Some(SurfaceCategory::Doctrine)
        );
        assert_eq!(
            SourceClass::EngineeringDoctrineChecklist.surface_category(),
            Some(SurfaceCategory::Doctrine)
        );
    }

    #[test]
    fn adr_maps_to_adrs() {
        assert_eq!(
            SourceClass::ProjectAdr.surface_category(),
            Some(SurfaceCategory::Adrs)
        );
    }

    #[test]
    fn source_record_roundtrips() {
        let r = SourceRecord {
            id: "project_adr:docs/adr/0001.md".into(),
            path: "docs/adr/0001.md".into(),
            class: SourceClass::ProjectAdr,
            sha256: "deadbeef".repeat(8),
            size_bytes: 1234,
            modified: None,
            blocked: false,
            blocked_reason: None,
        };
        let s = serde_json::to_string(&r).expect("serialize");
        let back: SourceRecord = serde_json::from_str(&s).expect("deserialize");
        assert_eq!(back.path, r.path);
    }
}