govctl 0.9.4

Project governance CLI for RFC, ADR, and Work Item management
//! Versioned migration pipeline for governance artifact storage.
//!
//! Each migration is a step from schema version N to N+1.
//! The current version is tracked in `gov/config.toml` under `[schema] version`.

use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticResult, Diagnostics};
use crate::schema::ARTIFACT_SCHEMA_TEMPLATES;
use crate::ui;
use crate::write::{WriteOp, write_file};
use std::fs;

mod ops;
mod releases;
mod rewrite;

use ops::{FileOp, execute_ops, preview_ops};
use releases::plan_release_upgrade;
use rewrite::plan_toml_rewrites;

/// Latest schema version. Bump when adding a new migration step.
pub const CURRENT_SCHEMA_VERSION: u32 = 2;

/// A versioned migration step.
struct MigrationStep {
    from: u32,
    to: u32,
    name: &'static str,
    plan_fn: fn(&Config) -> DiagnosticResult<Vec<FileOp>>,
}

/// All registered migrations, ordered by version.
const MIGRATIONS: &[MigrationStep] = &[MigrationStep {
    from: 1,
    to: 2,
    name: "structured wire format and schema headers",
    plan_fn: plan_v1_to_v2,
}];

// =============================================================================
// Public API
// =============================================================================

pub fn migrate(config: &Config, op: WriteOp) -> DiagnosticResult<Diagnostics> {
    crate::load::reject_legacy_json_storage(config)?;

    // Always sync bundled JSON Schemas regardless of schema version. [[ADR-0035]]
    let schemas_synced = sync_schemas(config, op)?;
    let gitignore_entries_synced =
        crate::cmd::project_support::ensure_local_state_gitignore_entries(op)?;

    let current = config.schema.version;
    if current >= CURRENT_SCHEMA_VERSION {
        if schemas_synced > 0 || gitignore_entries_synced > 0 {
            let mut parts = Vec::new();
            if schemas_synced > 0 {
                parts.push(format!("{schemas_synced} schema file(s)"));
            }
            if gitignore_entries_synced > 0 {
                let label = if gitignore_entries_synced == 1 {
                    "gitignore entry"
                } else {
                    "gitignore entries"
                };
                parts.push(format!("{gitignore_entries_synced} {label}"));
            }
            let message = if op.is_preview() {
                format!(
                    "Would sync {}; already at schema version {CURRENT_SCHEMA_VERSION}",
                    parts.join(", ")
                )
            } else {
                format!(
                    "Synced {}; already at schema version {CURRENT_SCHEMA_VERSION}",
                    parts.join(", ")
                )
            };
            if op.is_preview() {
                ui::info(message);
            } else {
                ui::success(message);
            }
        } else {
            ui::info(format!(
                "Repository already at schema version {CURRENT_SCHEMA_VERSION}"
            ));
        }
        return Ok(vec![]);
    }

    let pending: Vec<&MigrationStep> = MIGRATIONS
        .iter()
        .filter(|s| s.from >= current && s.to <= CURRENT_SCHEMA_VERSION)
        .collect();

    let mut all_ops = Vec::new();
    let mut step_names = Vec::new();
    for step in &pending {
        let ops = (step.plan_fn)(config)?;
        step_names.push(format!("v{} -> v{}: {}", step.from, step.to, step.name));
        all_ops.extend(ops);
    }
    let config_path = config.gov_root.join("config.toml");
    all_ops.push(plan_config_version_bump(config, CURRENT_SCHEMA_VERSION)?);

    if op.is_preview() {
        preview_ops(config, &all_ops);
    } else {
        execute_ops(config, &all_ops)?;
        for name in &step_names {
            ui::sub_info(name);
        }
        let writes = all_ops
            .iter()
            .filter(|o| matches!(o, FileOp::Write { path, .. } if path != &config_path))
            .count();
        let deletes = all_ops
            .iter()
            .filter(|o| matches!(o, FileOp::Delete { .. }))
            .count();
        if writes > 0 || deletes > 0 {
            let mut parts = vec![format!("{writes} file(s) written")];
            if deletes > 0 {
                parts.push(format!("{deletes} file(s) deleted"));
            }
            ui::success(format!("Migrated: {}", parts.join(", ")));
        } else {
            ui::success(format!("Schema version bumped to {CURRENT_SCHEMA_VERSION}"));
        }
    }

    Ok(vec![])
}

/// Overwrite bundled JSON Schema files into `gov/schema/`. [[ADR-0035]]
/// Returns the number of schema files that were created or updated.
fn sync_schemas(config: &Config, op: WriteOp) -> DiagnosticResult<usize> {
    let schema_dir = config.schema_dir();
    if !schema_dir.exists() {
        crate::write::create_dir_all(&schema_dir, op, Some(&config.display_path(&schema_dir)))?;
    }
    let mut count = 0;
    for template in ARTIFACT_SCHEMA_TEMPLATES {
        let path = schema_dir.join(template.filename);
        if path.exists()
            && let Ok(existing) = fs::read_to_string(&path)
            && existing == template.content
        {
            continue;
        }
        let display = config.display_path(&path);
        write_file(&path, template.content, op, Some(&display))?;
        count += 1;
    }
    Ok(count)
}

/// Plan a `[schema] version` bump in config.toml preserving the rest of the file.
fn plan_config_version_bump(config: &Config, new_version: u32) -> DiagnosticResult<FileOp> {
    let path = config.gov_root.join("config.toml");
    let display_path = config.display_path(&path).display().to_string();
    let content = fs::read_to_string(&path)
        .map_err(|err| Diagnostic::io_error("read config for migration", err, &display_path))?;

    let mut lines: Vec<String> = content.lines().map(String::from).collect();
    let mut in_schema = false;
    let mut found = false;

    for line in &mut lines {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_schema = trimmed == "[schema]";
        }
        if in_schema && trimmed.starts_with("version") && trimmed.contains('=') {
            *line = format!("version = {new_version}");
            found = true;
            break;
        }
    }

    if !found {
        lines.push(String::new());
        lines.push("[schema]".to_string());
        lines.push(format!("version = {new_version}"));
    }

    let mut output = lines.join("\n");
    if !output.ends_with('\n') {
        output.push('\n');
    }
    Ok(FileOp::Write {
        path,
        content: output,
    })
}

// =============================================================================
// v1 -> v2: structured wire format + schema headers
// =============================================================================

fn plan_v1_to_v2(config: &Config) -> DiagnosticResult<Vec<FileOp>> {
    let mut ops = Vec::new();

    // 1. Release metadata normalization
    let mut skip_releases = false;
    if let Some(release_ops) = plan_release_upgrade(config)? {
        ops.extend(release_ops);
        skip_releases = true;
    }

    // 2. Rewrite all TOML artifacts: add #:schema headers + strip govctl.schema
    let rewrite_ops = plan_toml_rewrites(config, skip_releases)?;
    ops.extend(rewrite_ops);

    Ok(ops)
}