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;
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
struct MigrationStep {
from: u32,
to: u32,
name: &'static str,
plan_fn: fn(&Config) -> DiagnosticResult<Vec<FileOp>>,
}
const MIGRATIONS: &[MigrationStep] = &[MigrationStep {
from: 1,
to: 2,
name: "structured wire format and schema headers",
plan_fn: plan_v1_to_v2,
}];
pub fn migrate(config: &Config, op: WriteOp) -> DiagnosticResult<Diagnostics> {
crate::load::reject_legacy_json_storage(config)?;
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![])
}
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)
}
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,
})
}
fn plan_v1_to_v2(config: &Config) -> DiagnosticResult<Vec<FileOp>> {
let mut ops = Vec::new();
let mut skip_releases = false;
if let Some(release_ops) = plan_release_upgrade(config)? {
ops.extend(release_ops);
skip_releases = true;
}
let rewrite_ops = plan_toml_rewrites(config, skip_releases)?;
ops.extend(rewrite_ops);
Ok(ops)
}