cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::path::PathBuf;

use similar::TextDiff;

/// One record's current content paired with what the repository would write
/// canonically. Built by the orchestration layer (CLI) by reading each file
/// and asking the matching repository for the canonical bytes.
pub struct FormatEntry {
    pub path: PathBuf,
    pub current: String,
    pub canonical: String,
}

/// A record whose current bytes differ from canonical.
pub struct FmtChange {
    pub path: PathBuf,
    /// Unified diff of the frontmatter blocks (between the `---` delimiters).
    /// The body is excluded — fmt only changes frontmatter shape, and
    /// trailing-newline normalization is handled by `save()`.
    pub diff: String,
    /// Full canonical file content, ready to be written back.
    pub canonical: String,
}

#[derive(Default)]
pub struct FmtReport {
    pub changes: Vec<FmtChange>,
    pub unchanged: usize,
}

impl FmtReport {
    pub fn has_changes(&self) -> bool {
        !self.changes.is_empty()
    }
}

pub fn fmt(entries: Vec<FormatEntry>) -> FmtReport {
    let mut report = FmtReport::default();
    for entry in entries {
        if entry.current == entry.canonical {
            report.unchanged += 1;
            continue;
        }
        let cur_fm = extract_frontmatter(&entry.current).unwrap_or(entry.current.as_str());
        let can_fm = extract_frontmatter(&entry.canonical).unwrap_or(entry.canonical.as_str());
        let diff = TextDiff::from_lines(cur_fm, can_fm)
            .unified_diff()
            .header("current", "canonical")
            .to_string();
        report.changes.push(FmtChange {
            path: entry.path,
            diff,
            canonical: entry.canonical,
        });
    }
    report
}

/// Return the frontmatter slice (between the two `---` delimiters), or `None`
/// if the source is missing them. Body is excluded.
fn extract_frontmatter(source: &str) -> Option<&str> {
    let after_open = source.strip_prefix("---\n")?;
    let end = after_open.find("\n---")?;
    Some(&after_open[..end])
}

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

    fn entry(path: &str, current: &str, canonical: &str) -> FormatEntry {
        FormatEntry {
            path: PathBuf::from(path),
            current: current.to_string(),
            canonical: canonical.to_string(),
        }
    }

    #[test]
    fn identical_entries_are_counted_as_unchanged() {
        let report = fmt(vec![entry(
            "a.md",
            "---\nid: X\n---\n\nbody\n",
            "---\nid: X\n---\n\nbody\n",
        )]);
        assert_eq!(report.unchanged, 1);
        assert!(report.changes.is_empty());
        assert!(!report.has_changes());
    }

    #[test]
    fn differing_entries_produce_a_change_with_canonical_payload() {
        let current = "---\nid: X\npriority: high\n---\n\nbody\n";
        let canonical = "---\nid: X\n---\n\nbody\n";
        let report = fmt(vec![entry("a.md", current, canonical)]);
        assert_eq!(report.unchanged, 0);
        assert_eq!(report.changes.len(), 1);
        let change = &report.changes[0];
        assert_eq!(change.path, PathBuf::from("a.md"));
        assert_eq!(change.canonical, canonical);
        assert!(
            change.diff.contains("-priority: high"),
            "diff should show removed line, got:\n{}",
            change.diff
        );
    }

    #[test]
    fn diff_excludes_body_only_changes_when_frontmatter_matches() {
        let current = "---\nid: X\n---\n\nold body\n";
        let canonical = "---\nid: X\n---\n\nnew body\n";
        let report = fmt(vec![entry("a.md", current, canonical)]);
        assert_eq!(report.changes.len(), 1);
        let change = &report.changes[0];
        assert!(
            change.diff.is_empty() || !change.diff.contains("body"),
            "frontmatter diff should not mention body, got:\n{}",
            change.diff
        );
    }

    #[test]
    fn missing_frontmatter_falls_back_to_full_content_for_diff() {
        let report = fmt(vec![entry("a.md", "raw text\n", "raw text canonical\n")]);
        assert_eq!(report.changes.len(), 1);
        assert!(!report.changes[0].diff.is_empty());
    }

    #[test]
    fn mixed_entries_split_correctly() {
        let report = fmt(vec![
            entry(
                "same.md",
                "---\nid: A\n---\n\nbody\n",
                "---\nid: A\n---\n\nbody\n",
            ),
            entry(
                "diff.md",
                "---\nid: B\nx: y\n---\n\nbody\n",
                "---\nid: B\n---\n\nbody\n",
            ),
        ]);
        assert_eq!(report.unchanged, 1);
        assert_eq!(report.changes.len(), 1);
        assert_eq!(report.changes[0].path, PathBuf::from("diff.md"));
    }
}