use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticResult, Diagnostics};
use crate::schema::ARTIFACT_SCHEMA_TEMPLATES;
use crate::ui;
use crate::write::{WriteOp, with_file_transaction, write_file};
use std::fs;
mod ops;
use ops::{FileOp, execute_ops, preview_ops};
pub const CURRENT_SCHEMA_VERSION: u32 = 4;
pub const MIN_SUPPORTED_SCHEMA_VERSION: u32 = 3;
pub(crate) fn validate_supported_schema_version(
version: u32,
location: impl Into<String>,
) -> DiagnosticResult<()> {
if version < MIN_SUPPORTED_SCHEMA_VERSION {
return Err(Diagnostic::new(
crate::diagnostic::DiagnosticCode::E0505MigrationRequired,
format!(
"Project schema version {version} is unsupported (minimum: {MIN_SUPPORTED_SCHEMA_VERSION}). Migrate this repository with a compatible earlier govctl version before upgrading."
),
location,
));
}
if version > CURRENT_SCHEMA_VERSION {
return Err(Diagnostic::new(
crate::diagnostic::DiagnosticCode::E0505MigrationRequired,
format!(
"Project schema version {version} is newer than this govctl supports (latest: {CURRENT_SCHEMA_VERSION}). Upgrade govctl before using this repository."
),
location,
));
}
Ok(())
}
struct MigrationStep {
from: u32,
to: u32,
name: &'static str,
plan_fn: fn(&Config) -> DiagnosticResult<Vec<FileOp>>,
}
const MIGRATIONS: &[MigrationStep] = &[MigrationStep {
from: 3,
to: 4,
name: "enable Conformance Case resources",
plan_fn: plan_v3_to_v4,
}];
fn plan_v3_to_v4(config: &Config) -> DiagnosticResult<Vec<FileOp>> {
validate_prospective_conformance_cases(config)?;
Ok(vec![])
}
fn validate_prospective_conformance_cases(config: &Config) -> DiagnosticResult<()> {
let cases = crate::parse::load_conformance_cases(config)?;
if cases.is_empty() {
return Ok(());
}
crate::validate::conformance::validate_cases(config, &cases)
.into_iter()
.next()
.map_or(Ok(()), Err)
}
pub fn migrate(config: &Config, op: WriteOp) -> DiagnosticResult<Diagnostics> {
validate_supported_schema_version(
config.schema.version,
config
.display_path(&config.gov_root.join("config.toml"))
.display()
.to_string(),
)?;
crate::load::reject_legacy_json_storage(config)?;
let mut support_paths = support_paths_to_sync(config)?;
if crate::cmd::project_support::local_state_gitignore_needs_sync(config)? {
support_paths.push(config.project_root().join(".gitignore"));
}
let support_path_refs = support_paths
.iter()
.map(std::path::PathBuf::as_path)
.collect::<Vec<_>>();
let schema_dir = config.schema_dir();
let schema_dir_existed = schema_dir.exists();
let result = with_file_transaction(&support_path_refs, op, || migrate_inner(config, op));
if result.is_err() && !schema_dir_existed && schema_dir.is_dir() {
let _ = std::fs::remove_dir(&schema_dir);
}
result
}
fn support_paths_to_sync(config: &Config) -> DiagnosticResult<Vec<std::path::PathBuf>> {
let mut paths = Vec::new();
for template in ARTIFACT_SCHEMA_TEMPLATES {
let path = config.schema_dir().join(template.filename);
match fs::read_to_string(&path) {
Ok(existing) if existing == template.content => {}
Ok(_) => paths.push(path),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => paths.push(path),
Err(err) => {
return Err(Diagnostic::io_error(
"read schema file",
err,
config.display_path(&path).display().to_string(),
));
}
}
}
Ok(paths)
}
fn migrate_inner(config: &Config, op: WriteOp) -> DiagnosticResult<Diagnostics> {
let current = config.schema.version;
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 schemas_synced = sync_schemas(config, op)?;
let gitignore_entries_synced =
crate::cmd::project_support::ensure_local_state_gitignore_entries(config, op)?;
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 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,
})
}