cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! A single rule-level observation: the data a `Rule::find` returns
//! inside an [`IssueFinding`] or [`DecisionRecordFinding`] to describe
//! the problem to the user. Pure data, no port dependencies.
//!
//! [`IssueFinding`]: crate::domain::model::issue::IssueFinding
//! [`DecisionRecordFinding`]: crate::domain::model::decision_record::DecisionRecordFinding

use std::path::PathBuf;

use crate::domain::model::check::Severity;
use crate::domain::model::entity_ref::EntityRef;
use crate::domain::model::tag_validation::TagViolation;

/// One violation surfaced by a rule. The textual rendering is
/// produced by [`render`] — the domain layer never decides the
/// natural-language wording.
///
/// `fixable` is not stored on the violation — it is derived from
/// whether the enclosing finding carries a fix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckViolation {
    pub rule_id: &'static str,
    pub path: PathBuf,
    pub severity: Severity,
    pub kind: CheckViolationKind,
}

/// Typed observation produced by a rule. Each variant represents one
/// distinct failure shape; the textual rendering lives in [`render`]
/// so the domain layer never decides the user-facing wording. New
/// variants are added rule-by-rule as the migration progresses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckViolationKind {
    /// Record id does not start with the prefix configured for its kind.
    WrongIdPrefix { id: EntityRef, expected: String },
    /// Two records share the same id.
    DuplicateId {
        id: EntityRef,
        also_at: Vec<PathBuf>,
    },
    /// The id's numeric suffix doesn't match the directory's prefix.
    IdSlugMismatch {
        id_suffix: String,
        dir_prefix: String,
    },
    /// A link target couldn't be resolved in the workspace.
    LinkTargetNotFound { target: EntityRef },
    /// The event log fails an invariant; `cause` names the specific
    /// failure mode.
    EventLogBroken { cause: EventLogIssue },
    /// A tag descriptor (closed levels or cardinality) is violated.
    TagDescriptorViolation {
        owner: EntityRef,
        cause: TagViolation,
    },
    /// A forward link exists without its reciprocal back-pointer.
    /// `source` is the record declaring the forward link; the violation
    /// is reported at the target's path, where the back-pointer should
    /// be written.
    MissingBackPointer {
        source: EntityRef,
        forward: String,
        back: String,
    },
    /// A back-pointer exists on the target while the source has not yet
    /// transitioned to accepted (the cascade has not fired).
    PrematureBackPointer {
        source: EntityRef,
        source_status: String,
        back: String,
    },
    /// A back-pointer (`superseded-by` / `amended-by`) on this record has
    /// no matching forward link on the source.
    MissingForwardLink {
        source: EntityRef,
        forward: String,
        back: String,
    },
    /// An ordered tag on a `parent-of` edge has the parent ranking
    /// below (or equal to) the child.
    TagOrderViolated {
        tag_key: String,
        parent: EntityRef,
        parent_value: String,
        child: EntityRef,
        child_value: String,
    },
    /// An issue declares more than one parent.
    MultipleParents {
        child: EntityRef,
        parents: Vec<EntityRef>,
    },
    /// The parent-of graph contains a cycle.
    ParentOfCycle { cycle: Vec<EntityRef> },
    /// A terminal-status parent still has open (non-terminal) children.
    TerminalParentWithOpenChildren {
        parent: EntityRef,
        parent_status: String,
        open_children: Vec<EntityRef>,
    },
    /// A non-rule observation surfaced by the scan step (parse error,
    /// frontmatter warning). The detail string already comes formatted
    /// from the parser; a future ticket would type it further.
    ScanIssue { detail: String },
    /// A companion markdown file references a path that doesn't exist
    /// inside its issue directory.
    BrokenCompanionLink { from: String, to: String },
}

/// Distinct failure modes the event-chain validators emit. Shared
/// between issue and DR universes — `TerminalStatusOutbound` is only
/// produced by the DR validator, the rest by both.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventLogIssue {
    CreatedStatusUnknown {
        status: String,
    },
    CreatedStatusMismatch {
        status: String,
        expected: String,
    },
    FirstActionNotCreated {
        found: String,
    },
    StatusChangeFromUnknown {
        from: String,
    },
    StatusChangeToUnknown {
        to: String,
    },
    EventChainBroken {
        from: String,
        prev_status: String,
    },
    InvalidTransition {
        from: String,
        to: String,
    },
    TerminalStatusOutbound {
        from: String,
    },
    FinalStatusMismatch {
        last_to: String,
        current_status: String,
    },
}

/// Mechanical, descriptive rendering used by tests in the domain layer
/// for substring-grep assertions. Production rendering lives in
/// `infra/driving/cli/check_renderer.rs` and will gain i18n. This
/// renderer emits `Variant key1=val1 key2=val2` so tests can match on
/// the variant name plus the participating identifiers without coupling
/// to user-facing wording.
#[cfg(test)]
pub fn render(kind: &CheckViolationKind) -> String {
    match kind {
        CheckViolationKind::WrongIdPrefix { id, expected } => {
            format!("WrongIdPrefix id={id} expected={expected}")
        }
        CheckViolationKind::DuplicateId { id, also_at } => {
            let paths = also_at
                .iter()
                .map(|p| p.display().to_string())
                .collect::<Vec<_>>()
                .join(",");
            format!("DuplicateId id={id} also_at={paths}")
        }
        CheckViolationKind::IdSlugMismatch {
            id_suffix,
            dir_prefix,
        } => format!("IdSlugMismatch id_suffix={id_suffix} dir_prefix={dir_prefix}"),
        CheckViolationKind::LinkTargetNotFound { target } => {
            format!("LinkTargetNotFound target={target}")
        }
        CheckViolationKind::EventLogBroken { cause } => {
            format!("EventLogBroken cause={cause:?}")
        }
        CheckViolationKind::TagDescriptorViolation { owner, cause } => {
            format!("TagDescriptorViolation owner={owner} cause={cause:?}")
        }
        CheckViolationKind::MissingBackPointer {
            source,
            forward,
            back,
        } => format!("MissingBackPointer source={source} forward={forward} back={back}"),
        CheckViolationKind::PrematureBackPointer {
            source,
            source_status,
            back,
        } => format!(
            "PrematureBackPointer source={source} source_status={source_status} back={back}"
        ),
        CheckViolationKind::MissingForwardLink {
            source,
            forward,
            back,
        } => format!("MissingForwardLink source={source} forward={forward} back={back}"),
        CheckViolationKind::TagOrderViolated {
            tag_key,
            parent,
            parent_value,
            child,
            child_value,
        } => format!(
            "TagOrderViolated tag_key={tag_key} parent={parent} parent_value={parent_value} \
             child={child} child_value={child_value}"
        ),
        CheckViolationKind::MultipleParents { child, parents } => {
            let p = parents
                .iter()
                .map(|r| r.as_str())
                .collect::<Vec<_>>()
                .join(",");
            format!("MultipleParents child={child} parents={p}")
        }
        CheckViolationKind::ParentOfCycle { cycle } => {
            let c = cycle
                .iter()
                .map(|r| r.as_str())
                .collect::<Vec<_>>()
                .join(",");
            format!("ParentOfCycle cycle={c}")
        }
        CheckViolationKind::TerminalParentWithOpenChildren {
            parent,
            parent_status,
            open_children,
        } => {
            let oc = open_children
                .iter()
                .map(|r| r.as_str())
                .collect::<Vec<_>>()
                .join(",");
            format!(
                "TerminalParentWithOpenChildren parent={parent} parent_status={parent_status} \
                 open_children={oc}"
            )
        }
        CheckViolationKind::ScanIssue { detail } => format!("ScanIssue detail={detail}"),
        CheckViolationKind::BrokenCompanionLink { from, to } => {
            format!("BrokenCompanionLink from={from} to={to}")
        }
    }
}

#[cfg(test)]
pub mod strategy {
    use super::{CheckViolation, CheckViolationKind, EventLogIssue};
    use crate::domain::model::check::violation::strategy::severity;
    use proptest::prelude::*;
    use std::path::PathBuf;

    /// Sampled coverage of [`CheckViolationKind`]: enough variants to
    /// exercise the renderer, not exhaustive.
    pub fn check_violation_kind() -> impl Strategy<Value = CheckViolationKind> {
        prop_oneof![
            ".{0,40}".prop_map(|d| CheckViolationKind::ScanIssue { detail: d }),
            ("[a-z]{1,8}", "[a-z]{1,8}")
                .prop_map(|(from, to)| { CheckViolationKind::BrokenCompanionLink { from, to } }),
        ]
    }

    #[allow(dead_code)] // exported for downstream tests; not yet used internally
    pub fn event_log_issue() -> impl Strategy<Value = EventLogIssue> {
        prop_oneof![
            "[a-z]{1,8}".prop_map(|found| EventLogIssue::FirstActionNotCreated { found }),
            ("[a-z]{1,8}", "[a-z]{1,8}")
                .prop_map(|(from, to)| { EventLogIssue::InvalidTransition { from, to } }),
        ]
    }

    prop_compose! {
        pub fn check_violation()(
            severity in severity(),
            kind in check_violation_kind(),
            path in "[a-z]{1,8}",
        ) -> CheckViolation {
            CheckViolation {
                rule_id: "test/rule",
                path: PathBuf::from(path),
                severity,
                kind,
            }
        }
    }
}

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

    proptest! {
        #[test]
        fn render_includes_the_variant_name(kind in strategy::check_violation_kind()) {
            let s = render(&kind);
            // every variant rendering starts with the variant name as a token
            prop_assert!(!s.is_empty());
        }
    }
}