cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Version-aware reference parsers for frontmatter.
//!
//! The repository layer holds the workspace's `schema_version`; canonical
//! `id:` and `links: -> id:` targets must match that version's strict shape.
//! Cross-version inputs surface a clear error pointing at `cartu migrate`,
//! rather than being tolerated.
//!
//! `aliases:` are deliberately not routed through these helpers — alias
//! entries are free-form forwarding strings (a v4 record's aliases will
//! contain v3-shape `ISSUE-0042`) and can stay tolerant.

use crate::domain::model::entity_ref::EntityRef;
use crate::domain::model::record_ref::{DecisionRecordRef, IssueRef};

fn cross_version_hint(schema_version: u32, raw: &str) -> anyhow::Error {
    anyhow::anyhow!(
        "id '{raw}' does not match the v{schema_version} schema shape; \
         run 'cartu migrate' to bring the workspace to the current version"
    )
}

/// Parse an `EntityRef` using the dispatch policy for `schema_version`.
///
/// Policies:
/// - **v3 and below**: strict v3 (`<UPPER>+-<NNNN>`). A v4-shape id surfaces
///   an error with a `cartu migrate` hint — a v3 workspace has no business
///   carrying TSIDs.
/// - **v4**: prefer the strict v4 shape. The fallback to v3 stays as a
///   read-only tolerance: phase 4 (ADR-0022) closed the migration window
///   on the *write* path (the allocator emits TSIDs only and `cartu migrate`
///   rewrites every canonical id), but legacy fixtures and hand-written
///   v3-shape ids at canonical positions in a v4 workspace stay legible
///   so that test suites and partially-migrated checkouts don't break
///   silently. The fallback can be removed once the test fixtures move
///   to TSID-shape ids end-to-end.
/// - **Unknown future versions**: tolerant (`parse_any`) for forward
///   compatibility with workspaces ahead of this binary.
pub fn parse_entity_ref_for_schema(raw: &str, schema_version: u32) -> anyhow::Result<EntityRef> {
    match schema_version {
        0..=3 => EntityRef::parse_v3(raw).map_err(|_| cross_version_hint(schema_version, raw)),
        4 => EntityRef::parse_v4(raw).or_else(|_| EntityRef::parse_v3(raw)),
        _ => EntityRef::parse_any(raw),
    }
}

/// Parse an `IssueRef` using the dispatch policy for `schema_version`.
/// See [`parse_entity_ref_for_schema`] for policy details.
pub fn parse_issue_ref_for_schema(raw: &str, schema_version: u32) -> anyhow::Result<IssueRef> {
    match schema_version {
        0..=3 => IssueRef::parse_v3(raw).map_err(|_| cross_version_hint(schema_version, raw)),
        4 => IssueRef::parse_v4(raw).or_else(|_| IssueRef::parse_v3(raw)),
        _ => IssueRef::parse_any(raw),
    }
}

/// Parse a `DecisionRecordRef` using the dispatch policy for `schema_version`.
/// See [`parse_entity_ref_for_schema`] for policy details.
pub fn parse_decision_record_ref_for_schema(
    raw: &str,
    schema_version: u32,
) -> anyhow::Result<DecisionRecordRef> {
    match schema_version {
        0..=3 => {
            DecisionRecordRef::parse_v3(raw).map_err(|_| cross_version_hint(schema_version, raw))
        }
        4 => DecisionRecordRef::parse_v4(raw).or_else(|_| DecisionRecordRef::parse_v3(raw)),
        _ => DecisionRecordRef::parse_any(raw),
    }
}

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

    #[test]
    fn v3_workspace_accepts_legacy_id() {
        assert!(parse_entity_ref_for_schema("ISSUE-0042", 3).is_ok());
    }

    #[test]
    fn v3_workspace_refuses_tsid_id_with_migrate_hint() {
        let err = parse_entity_ref_for_schema("ISSUE-0DCT3MKW5T2K0", 3).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("v3"), "got: {msg}");
        assert!(msg.contains("cartu migrate"), "got: {msg}");
    }

    #[test]
    fn v4_workspace_accepts_tsid_id() {
        assert!(parse_entity_ref_for_schema("ISSUE-0DCT3MKW5T2K0", 4).is_ok());
    }

    #[test]
    fn v4_workspace_tolerates_legacy_id_at_canonical_position() {
        // Phase 4 (ADR-0022) closes the migration window on the *write*
        // path; the read path still tolerates legacy v3-shape ids so test
        // fixtures and partially-migrated checkouts stay legible.
        assert!(parse_entity_ref_for_schema("ISSUE-0042", 4).is_ok());
    }

    #[test]
    fn issue_ref_parser_routes_by_version() {
        assert!(parse_issue_ref_for_schema("ISSUE-0042", 3).is_ok());
        assert!(parse_issue_ref_for_schema("ISSUE-0DCT3MKW5T2K0", 4).is_ok());
        // Cross-version refusal: a v4-shape id under a v3 workspace is wrong.
        assert!(parse_issue_ref_for_schema("ISSUE-0DCT3MKW5T2K0", 3).is_err());
    }

    #[test]
    fn decision_record_ref_parser_routes_by_version() {
        assert!(parse_decision_record_ref_for_schema("ADR-0001", 3).is_ok());
        assert!(parse_decision_record_ref_for_schema("ADR-0DCT3MKW5T2K0", 4).is_ok());
        // Cross-version refusal: a v4-shape id under a v3 workspace is wrong.
        assert!(parse_decision_record_ref_for_schema("ADR-0DCT3MKW5T2K0", 3).is_err());
    }

    #[test]
    fn unknown_future_version_falls_back_to_tolerant() {
        assert!(parse_entity_ref_for_schema("ISSUE-0042", 99).is_ok());
        assert!(parse_entity_ref_for_schema("ISSUE-0DCT3MKW5T2K0", 99).is_ok());
    }
}