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)
}
}
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;
#[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'));
}
#[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"));
}
}