pub mod corpus;
pub mod legacy;
mod steps;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
pub use corpus::{DryRunCorpus, FakeMigrationCorpus, MigrationCorpus};
pub const CURRENT_SCHEMA_VERSION: u32 = 8;
pub struct MigrationCtx<'a> {
pub root_dir: &'a Path,
pub corpus: &'a dyn MigrationCorpus,
pub paths: Vec<PathBuf>,
pub dr_dirs: Vec<PathBuf>,
pub id_map: HashMap<String, String>,
pub tsid_factory: &'a dyn Fn(i64) -> crate::domain::usecases::migrate::legacy::tsid::Tsid,
pub ulid_factory: &'a dyn Fn(i64) -> crate::domain::model::ulid::Ulid,
pub dry_run: bool,
}
impl<'a> MigrationCtx<'a> {
pub fn dr_paths(&self) -> Vec<PathBuf> {
self.paths
.iter()
.filter(|p| self.dr_dirs.iter().any(|d| p.starts_with(d)))
.cloned()
.collect()
}
}
#[derive(Default)]
pub struct StepOutcome {
pub files_changed: usize,
pub details: Vec<Detail>,
}
impl StepOutcome {
pub fn record(&mut self, detail: Detail) {
self.files_changed += 1;
self.details.push(detail);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detail {
pub past: &'static str,
pub infinitive: &'static str,
pub subject: String,
}
impl Detail {
pub fn migrate(subject: impl Into<String>) -> Self {
Self {
past: "migrated",
infinitive: "migrate",
subject: subject.into(),
}
}
pub fn rewrite_links_in(subject: impl Into<String>) -> Self {
Self {
past: "rewrote links in",
infinitive: "rewrite links in",
subject: subject.into(),
}
}
pub fn rename(from: &Path, to: &Path) -> Self {
Self {
past: "renamed",
infinitive: "rename",
subject: format!("{} → {}", from.display(), to.display()),
}
}
pub fn backfill(relationship: &str, source_id: &str, target: &Path) -> Self {
Self {
past: "backfilled",
infinitive: "backfill",
subject: format!("{relationship} → {source_id} into {}", target.display()),
}
}
}
pub trait MigrationStep {
fn id(&self) -> &'static str;
fn source_version(&self) -> u32;
fn description(&self) -> &'static str;
fn run(&self, ctx: &mut MigrationCtx) -> anyhow::Result<StepOutcome>;
}
pub struct StepSummary {
pub id: &'static str,
pub source_version: u32,
pub description: &'static str,
pub files_changed: usize,
pub details: Vec<Detail>,
}
pub struct MigrationReport {
pub visited: usize,
pub steps: Vec<StepSummary>,
pub toml_bumped: bool,
pub toml_already_current: bool,
}
impl MigrationReport {
pub fn total_changed(&self) -> usize {
self.steps.iter().map(|s| s.files_changed).sum()
}
}
pub fn run(
corpus: &dyn MigrationCorpus,
root_dir: &Path,
record_dirs: &[PathBuf],
dr_dirs: Vec<PathBuf>,
tsid_factory: &dyn Fn(i64) -> crate::domain::usecases::migrate::legacy::tsid::Tsid,
ulid_factory: &dyn Fn(i64) -> crate::domain::model::ulid::Ulid,
dry_run: bool,
) -> anyhow::Result<MigrationReport> {
let initial_paths = corpus.collect_index_paths(record_dirs);
let visited = initial_paths.len();
let mut ctx = MigrationCtx {
root_dir,
corpus,
paths: initial_paths,
dr_dirs,
id_map: HashMap::new(),
tsid_factory,
ulid_factory,
dry_run,
};
let mut summaries = Vec::new();
for step in steps::all() {
let outcome = step
.run(&mut ctx)
.with_context(|| format!("step {}", step.id()))?;
summaries.push(StepSummary {
id: step.id(),
source_version: step.source_version(),
description: step.description(),
files_changed: outcome.files_changed,
details: outcome.details,
});
}
let toml_path = root_dir.join("cartulary.toml");
let _ = strip_toml_dr_workflow_keys(corpus, &toml_path)?;
let toml_bumped = bump_schema_version(corpus, &toml_path)?;
let toml_already_current = !toml_bumped;
Ok(MigrationReport {
visited,
steps: summaries,
toml_bumped,
toml_already_current,
})
}
pub fn strip_toml_dr_workflow_keys(
corpus: &dyn MigrationCorpus,
toml_path: &Path,
) -> anyhow::Result<bool> {
let Ok(content) = corpus.read(toml_path) else {
return Ok(false);
};
let stripped = strip_dr_workflow_keys_v5_to_v6(&content);
if stripped == content {
return Ok(false);
}
corpus.write(toml_path, &stripped)?;
Ok(true)
}
pub fn bump_schema_version(corpus: &dyn MigrationCorpus, toml_path: &Path) -> anyhow::Result<bool> {
let Ok(content) = corpus.read(toml_path) else {
return Ok(false);
};
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let mut changed = false;
let mut had_version_line = false;
for line in lines.iter_mut() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("version") {
if rest.trim_start().strip_prefix('=').is_some() {
had_version_line = true;
let new_line = format!("version = {CURRENT_SCHEMA_VERSION}");
if line.trim() != new_line {
*line = new_line;
changed = true;
}
break;
}
}
}
if !had_version_line {
lines.insert(0, format!("version = {CURRENT_SCHEMA_VERSION}"));
changed = true;
}
if changed {
let mut out = lines.join("\n");
if content.ends_with('\n') {
out.push('\n');
}
corpus.write(toml_path, &out)?;
}
Ok(changed)
}
pub fn strip_dr_workflow_keys_v5_to_v6(toml: &str) -> String {
let lines: Vec<&str> = toml.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut i = 0;
let mut in_decision_kind = false;
let mut changed = false;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let header = trimmed.trim_matches(|c| c == '[' || c == ']');
in_decision_kind = is_decision_kind_header(header);
if header.starts_with("decisions.")
&& (header.contains(".statuses") || header.ends_with(".statuses"))
{
changed = true;
i += 1;
while i < lines.len() {
let next = lines[i];
let next_trimmed = next.trim();
if next_trimmed.starts_with('[') && next_trimmed.ends_with(']') {
break;
}
i += 1;
}
continue;
}
}
if in_decision_kind && trimmed.starts_with("preset") && trimmed.contains('=') {
changed = true;
i += 1;
continue;
}
out.push(line.to_string());
i += 1;
}
if !changed {
return toml.to_string();
}
let mut joined = out.join("\n");
if toml.ends_with('\n') {
joined.push('\n');
}
joined
}
fn is_decision_kind_header(header: &str) -> bool {
let Some(rest) = header.strip_prefix("decisions.") else {
return false;
};
!rest.is_empty() && !rest.contains('.')
}
pub(crate) fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
let after = source.strip_prefix("---\n")?;
let end = after.find("\n---")?;
let fm = &after[..end];
let body = after[end + 4..].trim_start_matches('\n');
Some((fm, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v5_to_v6_strips_decisions_kind_statuses_subtable() {
let toml = indoc::indoc! {r#"
[decisions]
types = ["adr"]
[decisions.adr]
dir = "docs/adr"
[decisions.adr.statuses]
draft = { next = ["proposed"], active = true }
proposed = { next = ["accepted"], active = true }
[issues]
dir = "docs/issues"
"#};
let stripped = strip_dr_workflow_keys_v5_to_v6(toml);
assert!(!stripped.contains("[decisions.adr.statuses]"));
assert!(!stripped.contains("draft = "));
assert!(stripped.contains("[issues]"));
assert!(stripped.contains("dir = \"docs/adr\""));
}
#[test]
fn v5_to_v6_strips_decisions_kind_preset_line() {
let toml = indoc::indoc! {r#"
[decisions]
types = ["adr"]
[decisions.adr]
dir = "docs/adr"
preset = "extended"
"#};
let stripped = strip_dr_workflow_keys_v5_to_v6(toml);
assert!(!stripped.contains("preset"));
assert!(stripped.contains("dir = \"docs/adr\""));
}
#[test]
fn v5_to_v6_leaves_issue_preset_alone() {
let toml = indoc::indoc! {r#"
[issues]
preset = "scrum"
"#};
assert_eq!(strip_dr_workflow_keys_v5_to_v6(toml), toml);
}
#[test]
fn v5_to_v6_toml_strip_is_idempotent_on_clean_input() {
let toml = indoc::indoc! {r#"
[decisions]
types = ["adr"]
[decisions.adr]
dir = "docs/adr"
[issues]
dir = "docs/issues"
"#};
assert_eq!(strip_dr_workflow_keys_v5_to_v6(toml), toml);
}
#[test]
fn detail_migrate_uses_migrated_past_form() {
let d = Detail::migrate("foo");
assert_eq!(d.past, "migrated");
assert_eq!(d.infinitive, "migrate");
assert_eq!(d.subject, "foo");
}
#[test]
fn detail_rename_formats_arrow_subject() {
let d = Detail::rename(Path::new("a"), Path::new("b"));
assert_eq!(d.subject, "a → b");
}
}