cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu migrate` — thin CLI wrapper around
//! [`domain::usecases::migrate::run`]. Parses `--dry-run`, wires the
//! filesystem adapter (or its `DryRunCorpus` wrapper), prints the audit
//! header/details/summary.
//!
//! The actual migration logic lives in `domain/usecases/migrate/`.

use std::path::{Path, PathBuf};

use clap::{Arg, ArgMatches, Command};

use crate::domain::usecases::migrate::{
    self, Detail, DryRunCorpus, MigrationCorpus, MigrationReport, StepSummary,
};
use crate::infra::driven::fs::config::CURRENT_SCHEMA_VERSION;
use crate::infra::driven::fs::migration_corpus::FsMigrationCorpus;
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::Context;

pub(in super::super) fn subcommand() -> Command {
    Command::new("migrate")
        .about(format!(
            "Bring all frontmatters to the current schema version (v{CURRENT_SCHEMA_VERSION})"
        ))
        .arg(
            Arg::new("dry-run")
                .long("dry-run")
                .action(clap::ArgAction::SetTrue)
                .help("Print what would change without writing"),
        )
}

pub(in super::super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let dry_run = sub.get_flag("dry-run");
    let config = ctx.config();

    // Migrate runs on the raw toml; reject content that is not even
    // TOML grammar (so prepending `version = N` would only corrupt
    // it further). A missing file is fine — nothing to migrate.
    let toml_path = ctx.root_dir.join("cartulary.toml");
    if let Ok(text) = std::fs::read_to_string(&toml_path) {
        if toml::from_str::<toml::Value>(&text).is_err() {
            die1(
                CliError::new(format!(
                    "cartulary.toml at {} is not valid TOML; \
                     fix it manually before re-running migrate",
                    toml_path.display()
                ))
                .kind("migrate"),
                ctx.output_fmt,
            );
        }
    }

    let record_dirs: Vec<PathBuf> = std::iter::once(config.issues_dir.clone())
        .chain(config.decision_kinds.iter().map(|k| k.dir.clone()))
        .collect();
    let dr_dirs: Vec<PathBuf> = config
        .decision_kinds
        .iter()
        .map(|k| k.dir.clone())
        .collect();

    let report = if dry_run {
        let corpus = DryRunCorpus::new(FsMigrationCorpus);
        run(&corpus, ctx.root_dir.as_path(), &record_dirs, dr_dirs, true)
    } else {
        run(
            &FsMigrationCorpus,
            ctx.root_dir.as_path(),
            &record_dirs,
            dr_dirs,
            false,
        )
    };

    let report = match report {
        Ok(r) => r,
        Err(e) => die1(
            CliError::new(format!("migrate failed: {e:#}")).kind("migrate"),
            ctx.output_fmt,
        ),
    };

    render_report(&report, dry_run);
}

fn run(
    corpus: &dyn MigrationCorpus,
    root_dir: &Path,
    record_dirs: &[PathBuf],
    dr_dirs: Vec<PathBuf>,
    dry_run: bool,
) -> anyhow::Result<MigrationReport> {
    let tsid_factory = |ms: i64| crate::infra::driven::fs::id_generator::tsid_from_millis(ms);
    let ulid_factory = |ms: i64| crate::infra::driven::fs::id_generator::ulid_from_millis(ms);
    migrate::run(
        corpus,
        root_dir,
        record_dirs,
        dr_dirs,
        &tsid_factory,
        &ulid_factory,
        dry_run,
    )
}

fn render_report(report: &MigrationReport, dry_run: bool) {
    for step in &report.steps {
        render_step(step, dry_run);
    }
    render_summary(report, dry_run);
}

fn render_step(step: &StepSummary, dry_run: bool) {
    let short = step.id.rsplit('/').next().unwrap_or(step.id);
    println!(
        "==> v{:02} → v{:02}  {short}{}",
        step.source_version,
        step.source_version + 1,
        step.description,
    );
    for d in &step.details {
        println!("    {}", conjugate(d, dry_run));
    }
}

fn conjugate(d: &Detail, dry_run: bool) -> String {
    if dry_run {
        format!("would {} {}", d.infinitive, d.subject)
    } else {
        format!("{} {}", d.past, d.subject)
    }
}

fn render_summary(report: &MigrationReport, dry_run: bool) {
    let label_width = report
        .steps
        .iter()
        .map(|s| s.id.len())
        .max()
        .unwrap_or(0)
        .max(14);

    println!();
    println!("Migration summary");
    println!("{:<label_width$}  files", "step");
    for step in &report.steps {
        println!("{:<label_width$}  {}", step.id, step.files_changed);
    }
    let toml_status = if report.toml_already_current {
        format!("already at version = {CURRENT_SCHEMA_VERSION}")
    } else if dry_run {
        format!("would bump to version = {CURRENT_SCHEMA_VERSION}")
    } else {
        format!("bumped to version = {CURRENT_SCHEMA_VERSION}")
    };
    println!("{:<label_width$}  {toml_status}", "cartulary.toml");

    let verb = if dry_run { "would migrate" } else { "migrated" };
    println!(
        "{verb} {} file(s) of {}",
        report.total_changed(),
        report.visited
    );
}