cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! v4 → v5 — extract every `relationship: relates` entry from `links:`
//! into a top-level `relates:` list of bare entity refs.
//! ISSUE-018P03NSC7VNQ / DDR-018QWJVHRH35B.
//!
//! Idempotent: a v5 file (no `relationship: relates` in `links:`) passes
//! through unchanged.

use crate::domain::usecases::migrate::{
    split_frontmatter, Detail, MigrationCtx, MigrationStep, StepOutcome,
};

pub struct V04ToV05ExtractRelates;

impl MigrationStep for V04ToV05ExtractRelates {
    fn id(&self) -> &'static str {
        "v04-to-v05/extract-relates"
    }

    fn source_version(&self) -> u32 {
        4
    }

    fn description(&self) -> &'static str {
        "extract relationship:relates entries into a top-level relates: block"
    }

    fn run(&self, ctx: &mut MigrationCtx) -> anyhow::Result<StepOutcome> {
        let mut out = StepOutcome::default();
        for path in ctx.paths.clone() {
            let Ok(content) = ctx.corpus.read(&path) else {
                continue;
            };
            let Some((fm, body)) = split_frontmatter(&content) else {
                continue;
            };
            let migrated = migrate_relates_v4_to_v5(fm);
            if migrated == fm {
                continue;
            }
            out.record(Detail::migrate(path.display().to_string()));
            let new_content = format!("---\n{migrated}\n---\n{body}");
            ctx.corpus.write(&path, &new_content)?;
        }
        Ok(out)
    }
}

/// Pure transform: a v4 frontmatter is rewritten into v5 by extracting
/// every `relationship: relates` entry from `links:` into a top-level
/// `relates:` list of bare entity refs.
///
/// Existing entries in `relates:` are preserved; new targets are appended
/// (deduplicated against any pre-existing targets in that block).
///
/// If stripping `relates` entries leaves `links:` empty, the `links:`
/// header is removed too.
pub fn migrate_relates_v4_to_v5(frontmatter: &str) -> String {
    let lines: Vec<&str> = frontmatter.lines().collect();

    let Some(links_start) = lines.iter().position(|l| l.trim() == "links:") else {
        return frontmatter.to_string();
    };
    let mut links_end = lines.len();
    for (i, line) in lines.iter().enumerate().skip(links_start + 1) {
        if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
            links_end = i;
            break;
        }
    }

    let entries: &[&str] = &lines[links_start + 1..links_end];
    if entries.is_empty() {
        return frontmatter.to_string();
    }

    let mut kept_links: Vec<&str> = Vec::new();
    let mut new_relates: Vec<String> = Vec::new();
    let mut idx = 0;
    let mut found_relates = false;
    while idx + 1 < entries.len() {
        let a = entries[idx];
        let b = entries[idx + 1];
        let (id_line, rel_line) = if a.trim_start().starts_with("- id:") {
            (a, b)
        } else {
            (b, a)
        };
        let id_value = id_line.trim_start().trim_start_matches('-').trim();
        let id_value = id_value.strip_prefix("id:").map(str::trim).unwrap_or("");
        let rel_value = rel_line
            .trim_start()
            .trim_start_matches('-')
            .trim()
            .strip_prefix("relationship:")
            .map(str::trim)
            .unwrap_or("");
        if rel_value == "relates" {
            found_relates = true;
            new_relates.push(id_value.to_string());
        } else {
            kept_links.push(a);
            kept_links.push(b);
        }
        idx += 2;
    }

    if !found_relates {
        return frontmatter.to_string();
    }

    let mut out: Vec<String> = lines[..links_start].iter().map(|s| s.to_string()).collect();

    if !kept_links.is_empty() {
        out.push("links:".to_string());
        for line in &kept_links {
            out.push((*line).to_string());
        }
    }

    let existing_relates = collect_existing_relates(&lines);
    let mut all_relates: Vec<String> = existing_relates;
    for r in new_relates {
        if !all_relates.contains(&r) {
            all_relates.push(r);
        }
    }
    if !all_relates.is_empty() {
        out.push("relates:".to_string());
        for r in &all_relates {
            out.push(format!("  - {r}"));
        }
    }

    append_post_links_skipping_relates(&mut out, &lines, links_end);

    let mut joined = out.join("\n");
    if frontmatter.ends_with('\n') {
        joined.push('\n');
    }
    joined
}

fn collect_existing_relates(lines: &[&str]) -> Vec<String> {
    let mut out = Vec::new();
    let Some(start) = lines.iter().position(|l| l.trim() == "relates:") else {
        return out;
    };
    for line in lines.iter().skip(start + 1) {
        if line.is_empty() || (!line.starts_with(' ') && !line.starts_with('\t')) {
            break;
        }
        let trimmed = line.trim_start().trim_start_matches('-').trim();
        if !trimmed.is_empty() {
            out.push(trimmed.to_string());
        }
    }
    out
}

fn append_post_links_skipping_relates(out: &mut Vec<String>, lines: &[&str], links_end: usize) {
    let mut i = links_end;
    while i < lines.len() {
        let line = lines[i];
        if line.trim() == "relates:" {
            i += 1;
            while i < lines.len() {
                let l = lines[i];
                if l.is_empty() || (!l.starts_with(' ') && !l.starts_with('\t')) {
                    break;
                }
                i += 1;
            }
            continue;
        }
        out.push(line.to_string());
        i += 1;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::usecases::migrate::FakeMigrationCorpus;
    use std::path::PathBuf;

    // ── pure transform tests ────────────────────────────────────────────

    #[test]
    fn extracts_a_single_relates_entry() {
        let v4 = "id: ISSUE-0001\nlinks:\n  - id: ISSUE-0042\n    relationship: relates\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert!(!v5.contains("relationship: relates"));
        assert!(v5.contains("relates:\n  - ISSUE-0042"));
        assert!(!v5.contains("links:"));
    }

    #[test]
    fn preserves_other_link_relationships() {
        let v4 = "id: ADR-0001\nlinks:\n  - id: ADR-0002\n    relationship: supersedes\n  - id: ADR-0003\n    relationship: relates\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert!(v5.contains("- id: ADR-0002"));
        assert!(v5.contains("relationship: supersedes"));
        assert!(v5.contains("relates:\n  - ADR-0003"));
    }

    #[test]
    fn is_idempotent_when_no_relates() {
        let v5 = "id: ADR-0001\nlinks:\n  - id: ADR-0002\n    relationship: supersedes\n";
        assert_eq!(migrate_relates_v4_to_v5(v5), v5);
    }

    #[test]
    fn is_idempotent_when_no_links_block() {
        let no_links = "id: ADR-0001\ntitle: Use Rust\nstatus: accepted\ndate: 2026-01-01";
        assert_eq!(migrate_relates_v4_to_v5(no_links), no_links);
    }

    #[test]
    fn merges_with_pre_existing_relates_block() {
        let v4 = "id: ISSUE-0001\nlinks:\n  - id: ISSUE-0042\n    relationship: relates\nrelates:\n  - ISSUE-0099\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert!(v5.contains("- ISSUE-0099"));
        assert!(v5.contains("- ISSUE-0042"));
        assert_eq!(v5.matches("relates:").count(), 1);
    }

    #[test]
    fn dedups_against_existing_relates() {
        let v4 = "id: A\nlinks:\n  - id: ISSUE-0042\n    relationship: relates\nrelates:\n  - ISSUE-0042\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert_eq!(v5.matches("- ISSUE-0042").count(), 1);
    }

    #[test]
    fn handles_relationship_first_form() {
        let v4 = "id: A\nlinks:\n  - relationship: relates\n    id: ISSUE-0042\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert!(v5.contains("relates:\n  - ISSUE-0042"));
    }

    #[test]
    fn preserves_trailing_newline() {
        let v4 = "id: A\nlinks:\n  - id: B\n    relationship: relates\nevents: []\n";
        let v5 = migrate_relates_v4_to_v5(v4);
        assert!(v5.ends_with('\n'));
    }

    // ── step orchestration test ────────────────────────────────────────

    #[test]
    fn step_extracts_relates_via_corpus() {
        let v4 = "---\nid: ISSUE-0001\nlinks:\n  - id: ISSUE-0042\n    relationship: relates\n---\nbody\n";
        let corpus = FakeMigrationCorpus::with(&[("docs/issues/0001-foo/index.md", v4)]);
        let mut ctx = MigrationCtx {
            root_dir: std::path::Path::new("."),
            corpus: &corpus,
            paths: vec![PathBuf::from("docs/issues/0001-foo/index.md")],
            dr_dirs: vec![],
            id_map: Default::default(),
            tsid_factory: &|ms| {
                crate::domain::usecases::migrate::legacy::tsid::Tsid::from_millis_with_random(ms, 0)
            },
            ulid_factory: &|ms| crate::domain::model::ulid::Ulid::from_millis_with_random(ms, 0),
            dry_run: false,
        };
        let outcome = V04ToV05ExtractRelates.run(&mut ctx).unwrap();
        assert_eq!(outcome.files_changed, 1);
        let after = corpus.snapshot("docs/issues/0001-foo/index.md");
        assert!(after.contains("relates:\n  - ISSUE-0042"));
        assert!(!after.contains("relationship: relates"));
    }
}