cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Production renderer for `CheckViolationKind`. The English wording
//! lives here, on the infra side of the domain/infra boundary — the
//! natural seam where i18n (ADR-pending) will hook in.
//!
//! A near-identical `#[cfg(test)]` renderer sits inside the domain
//! (`domain/model/check/check_violation.rs`); it exists for test
//! convenience only and is allowed to drift from this version once
//! i18n lands, since tests assert on the typed kind, not on a
//! particular natural language.

use crate::domain::model::check::{CheckViolation, CheckViolationKind, EventLogIssue, Severity};
use crate::domain::model::entity_ref::EntityRef;

/// Human-path renderer: appends an actionable recovery hint when the
/// violation is one a user can act on directly. The structured output
/// path stays on the plain `render` so machine consumers don't receive
/// English prose mixed into their `message` field.
pub fn render_with_hint(violation: &CheckViolation) -> String {
    let body = render(&violation.kind);
    if matches!(
        (&violation.kind, violation.severity),
        (CheckViolationKind::ScanIssue { .. }, Severity::Error)
    ) {
        format!(
            "{body}\n  hint: edit the file to repair the frontmatter; \
             `cartu fmt` only handles syntactically-valid YAML."
        )
    } else {
        body
    }
}

pub fn render(kind: &CheckViolationKind) -> String {
    match kind {
        CheckViolationKind::WrongIdPrefix { id, expected } => {
            format!("id '{id}' does not start with configured prefix '{expected}'")
        }
        CheckViolationKind::DuplicateId { id, also_at } => {
            let paths = also_at
                .iter()
                .map(|p| p.display().to_string())
                .collect::<Vec<_>>()
                .join(", ");
            format!("duplicate id {id}: also found at {paths}")
        }
        CheckViolationKind::IdSlugMismatch {
            id_suffix,
            dir_prefix,
        } => format!("id suffix ({id_suffix}) does not match directory prefix ({dir_prefix})"),
        CheckViolationKind::LinkTargetNotFound { target } => {
            format!("link target '{target}' not found in workspace")
        }
        CheckViolationKind::EventLogBroken { cause } => render_event_log_issue(cause),
        CheckViolationKind::TagDescriptorViolation { owner, cause } => {
            format!("{owner}: {}", cause.message())
        }
        CheckViolationKind::MissingBackPointer {
            source,
            forward,
            back,
        } => {
            format!("missing back-pointer '{back}' → {source} (reciprocal of '{forward}')")
        }
        CheckViolationKind::PrematureBackPointer {
            source,
            source_status,
            back,
        } => format!(
            "premature back-pointer '{back}' → {source}: {source} is still '{source_status}'"
        ),
        CheckViolationKind::MissingForwardLink {
            source,
            forward,
            back,
        } => {
            format!("missing forward link '{forward}' → {source} (reciprocal of '{back}')")
        }
        CheckViolationKind::TagOrderViolated {
            tag_key,
            parent,
            parent_value,
            child,
            child_value,
        } => format!(
            "ordered rule violated for '{tag_key}': parent {parent} ({tag_key}={parent_value}) \
             must rank above child {child} ({tag_key}={child_value})"
        ),
        CheckViolationKind::MultipleParents { child, parents } => format!(
            "{child} has multiple parents ({}); a child has at most one parent",
            join_refs(parents)
        ),
        CheckViolationKind::ParentOfCycle { cycle } => {
            format!("cycle detected: {}", join_refs_with(cycle, ""))
        }
        CheckViolationKind::TerminalParentWithOpenChildren {
            parent,
            parent_status,
            open_children,
        } => format!(
            "{parent} is {parent_status} but has open children ({}); reopen the parent or close / re-parent the children",
            join_refs(open_children)
        ),
        CheckViolationKind::ScanIssue { detail } => detail.clone(),
        CheckViolationKind::BrokenCompanionLink { from, to } => {
            format!("broken link in {from}: '{to}' does not exist")
        }
    }
}

fn join_refs(refs: &[EntityRef]) -> String {
    join_refs_with(refs, ", ")
}

fn join_refs_with(refs: &[EntityRef], sep: &str) -> String {
    refs.iter()
        .map(|r| r.as_str())
        .collect::<Vec<_>>()
        .join(sep)
}

fn render_event_log_issue(issue: &EventLogIssue) -> String {
    match issue {
        EventLogIssue::CreatedStatusUnknown { status } => {
            format!("'created' event status '{status}' is not a known status")
        }
        EventLogIssue::CreatedStatusMismatch { status, expected } => format!(
            "'created' event status '{status}' does not match the configured initial status '{expected}'"
        ),
        EventLogIssue::FirstActionNotCreated { found } => {
            format!("first event action must be 'created', got '{found}'")
        }
        EventLogIssue::StatusChangeFromUnknown { from } => {
            format!("'from' value '{from}' is not a known status")
        }
        EventLogIssue::StatusChangeToUnknown { to } => {
            format!("'to' value '{to}' is not a known status")
        }
        EventLogIssue::EventChainBroken { from, prev_status } => format!(
            "event chain broken: 'from' is '{from}' but previous status was '{prev_status}'"
        ),
        EventLogIssue::InvalidTransition { from, to } => {
            format!("invalid transition '{from}' → '{to}'")
        }
        EventLogIssue::TerminalStatusOutbound { from } => {
            format!("status '{from}' is terminal — no outgoing transitions are allowed")
        }
        EventLogIssue::FinalStatusMismatch {
            last_to,
            current_status,
        } => format!(
            "last 'status_changed' to is '{last_to}' but current status is '{current_status}'"
        ),
    }
}