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