cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! v5 → v6 — backfill the inverse pointer (`superseded-by` / `amended-by`)
//! into every target whose source carries the forward link. The codebase
//! did not previously store both sides; this one-shot repair establishes
//! the v6 invariant. DDR-018QWJVHRH35B.
//!
//! Two passes: index every DR record by id and collect
//! `(source_id, target_id, inverse_rel)` triples; then write the inverse
//! pointer into each target. Idempotent: targets that already carry the
//! back-pointer are not re-written.

use std::collections::HashMap;
use std::path::PathBuf;

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

pub struct V05ToV06BackPointers;

impl MigrationStep for V05ToV06BackPointers {
    fn id(&self) -> &'static str {
        "v05-to-v06/back-pointers"
    }

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

    fn description(&self) -> &'static str {
        "backfill superseded-by / amended-by inverses on DR targets"
    }

    fn run(&self, ctx: &mut MigrationCtx) -> anyhow::Result<StepOutcome> {
        let mut out = StepOutcome::default();
        let dr_paths = ctx.dr_paths();

        // Pass 1: index by id, collect (source_id, target_id, inverse_rel).
        let mut path_by_id: HashMap<String, PathBuf> = HashMap::new();
        let mut forward: Vec<(String, String, &'static str)> = Vec::new();
        for path in &dr_paths {
            let Ok(content) = ctx.corpus.read(path) else {
                continue;
            };
            let Some((fm, _)) = split_frontmatter(&content) else {
                continue;
            };
            let Some(source_id) = extract_id_field(fm) else {
                continue;
            };
            path_by_id.insert(source_id.clone(), path.clone());
            for (target_id, rel) in extract_typed_links(fm) {
                let inverse = match rel.as_str() {
                    "supersedes" => Some("superseded-by"),
                    "amends" => Some("amended-by"),
                    _ => None,
                };
                if let Some(inverse) = inverse {
                    forward.push((source_id.clone(), target_id, inverse));
                }
            }
        }

        // Pass 2: write inverse pointers into each target.
        for (source_id, target_id, inverse) in forward {
            let Some(target_path) = path_by_id.get(&target_id) else {
                continue;
            };
            let Ok(content) = ctx.corpus.read(target_path) else {
                continue;
            };
            let Some((fm, body)) = split_frontmatter(&content) else {
                continue;
            };
            let migrated = ensure_back_pointer_v5_to_v6(fm, &source_id, inverse);
            if migrated == fm {
                continue;
            }
            out.record(Detail::backfill(inverse, &source_id, target_path));
            let new_content = format!("---\n{migrated}\n---\n{body}");
            ctx.corpus.write(target_path, &new_content)?;
        }
        Ok(out)
    }
}

/// Pure transform: add a back-pointer link to a target's `links:` block,
/// idempotently.
///
/// If the link already exists, the input is returned unchanged. If the
/// `links:` block is missing, one is created before the `events:` block
/// (or appended at the end).
pub fn ensure_back_pointer_v5_to_v6(
    frontmatter: &str,
    source_id: &str,
    relationship: &str,
) -> String {
    let lines: Vec<&str> = frontmatter.lines().collect();

    if has_link(&lines, source_id, relationship) {
        return frontmatter.to_string();
    }

    let new_entry = format!("  - id: {source_id}\n    relationship: {relationship}");

    let links_idx = lines.iter().position(|l| l.trim() == "links:");
    let mut out: Vec<String>;
    match links_idx {
        Some(idx) => {
            let mut end = idx + 1;
            while end < lines.len() {
                let l = lines[end];
                if !l.is_empty() && !l.starts_with(' ') && !l.starts_with('\t') {
                    break;
                }
                end += 1;
            }
            out = lines.iter().take(end).map(|s| s.to_string()).collect();
            for new_line in new_entry.lines() {
                out.push(new_line.to_string());
            }
            for l in lines.iter().skip(end) {
                out.push(l.to_string());
            }
        }
        None => {
            let insert_at = lines
                .iter()
                .position(|l| l.starts_with("events:"))
                .unwrap_or(lines.len());
            out = lines
                .iter()
                .take(insert_at)
                .map(|s| s.to_string())
                .collect();
            out.push("links:".to_string());
            for new_line in new_entry.lines() {
                out.push(new_line.to_string());
            }
            for l in lines.iter().skip(insert_at) {
                out.push(l.to_string());
            }
        }
    }

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

fn has_link(lines: &[&str], source_id: &str, relationship: &str) -> bool {
    let Some(start) = lines.iter().position(|l| l.trim() == "links:") else {
        return false;
    };
    let mut i = start + 1;
    while i < lines.len() {
        let l = lines[i];
        if !l.is_empty() && !l.starts_with(' ') && !l.starts_with('\t') {
            break;
        }
        if i + 1 < lines.len() {
            let a = l.trim_start().trim_start_matches('-').trim();
            let b = lines[i + 1].trim_start().trim_start_matches('-').trim();
            let (id_str, rel_str) = if a.starts_with("id:") { (a, b) } else { (b, a) };
            let id_value = id_str.strip_prefix("id:").map(str::trim).unwrap_or("");
            let rel_value = rel_str
                .strip_prefix("relationship:")
                .map(str::trim)
                .unwrap_or("");
            if id_value == source_id && rel_value == relationship {
                return true;
            }
        }
        i += 2;
    }
    false
}

fn extract_id_field(fm: &str) -> Option<String> {
    for line in fm.lines() {
        if let Some(rest) = line.strip_prefix("id:") {
            return Some(rest.trim().to_string());
        }
    }
    None
}

fn extract_typed_links(fm: &str) -> Vec<(String, String)> {
    let lines: Vec<&str> = fm.lines().collect();
    let Some(start) = lines.iter().position(|l| l.trim() == "links:") else {
        return Vec::new();
    };
    let mut out = Vec::new();
    let mut i = start + 1;
    while i + 1 < lines.len() {
        let a = lines[i];
        if !a.starts_with(' ') && !a.starts_with('\t') && !a.is_empty() {
            break;
        }
        let b = lines[i + 1];
        let a_trim = a.trim_start().trim_start_matches('-').trim();
        let b_trim = b.trim_start().trim_start_matches('-').trim();
        let (id_str, rel_str) = if a_trim.starts_with("id:") {
            (a_trim, b_trim)
        } else {
            (b_trim, a_trim)
        };
        let id_value = id_str.strip_prefix("id:").map(str::trim).unwrap_or("");
        let rel_value = rel_str
            .strip_prefix("relationship:")
            .map(str::trim)
            .unwrap_or("");
        if !id_value.is_empty() && !rel_value.is_empty() {
            out.push((id_value.to_string(), rel_value.to_string()));
        }
        i += 2;
    }
    out
}

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

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

    #[test]
    fn inserts_into_existing_links_block() {
        let fm = "id: ADR-0001\nstatus: superseded\nlinks:\n  - id: ADR-0099\n    relationship: depends\n";
        let out = ensure_back_pointer_v5_to_v6(fm, "ADR-0002", "superseded-by");
        assert!(out.contains("- id: ADR-0002") && out.contains("relationship: superseded-by"));
        assert!(out.contains("- id: ADR-0099"));
    }

    #[test]
    fn creates_links_block_when_absent() {
        let fm = "id: ADR-0001\nstatus: accepted\nevents:\n- timestamp: t\n";
        let out = ensure_back_pointer_v5_to_v6(fm, "ADR-0002", "amended-by");
        assert!(out.contains("links:"));
        assert!(out.contains("- id: ADR-0002"));
        assert!(out.contains("relationship: amended-by"));
        let links_idx = out.find("links:").unwrap();
        let events_idx = out.find("events:").unwrap();
        assert!(links_idx < events_idx);
    }

    #[test]
    fn is_idempotent_when_link_already_present() {
        let fm = "id: ADR-0001\nlinks:\n  - id: ADR-0002\n    relationship: superseded-by\n";
        assert_eq!(
            ensure_back_pointer_v5_to_v6(fm, "ADR-0002", "superseded-by"),
            fm
        );
    }

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

    #[test]
    fn step_backfills_supersedes_inverse_into_target() {
        let source = "---\nid: ADR-0002\nstatus: accepted\nlinks:\n  - id: ADR-0001\n    relationship: supersedes\n---\nbody\n";
        let target = "---\nid: ADR-0001\nstatus: superseded\n---\nbody\n";
        let corpus = FakeMigrationCorpus::with(&[
            ("docs/adr/0001-a/index.md", target),
            ("docs/adr/0002-b/index.md", source),
        ]);
        let mut ctx = MigrationCtx {
            root_dir: std::path::Path::new("."),
            corpus: &corpus,
            paths: vec![
                PathBuf::from("docs/adr/0001-a/index.md"),
                PathBuf::from("docs/adr/0002-b/index.md"),
            ],
            dr_dirs: vec![PathBuf::from("docs/adr")],
            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 = V05ToV06BackPointers.run(&mut ctx).unwrap();
        assert_eq!(outcome.files_changed, 1);
        let after = corpus.snapshot("docs/adr/0001-a/index.md");
        assert!(after.contains("- id: ADR-0002"));
        assert!(after.contains("relationship: superseded-by"));
    }
}