use crate::output::{ExitCode, Outputable, colors, header};
use crate::tools::spec::get_tool_spec;
use serde::Serialize;
use std::fs;
#[derive(Debug, Clone, Serialize)]
pub struct Migration {
pub kind: String,
pub message: String,
pub line_hint: Option<String>,
pub severity: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MigrateReport {
pub file: String,
pub migrations: Vec<Migration>,
pub applied: bool,
}
impl Outputable for MigrateReport {
fn to_human(&self) -> String {
let mut out = String::new();
out.push_str(&header(&format!("Migration Report: {}", self.file)));
out.push('\n');
if self.migrations.is_empty() {
out.push_str(&format!(
"\n{}No migrations needed.{} Config is up to date.\n",
colors::GREEN,
colors::RESET
));
return out;
}
for (i, m) in self.migrations.iter().enumerate() {
let color = match m.severity.as_str() {
"error" => colors::RED,
"warning" => colors::YELLOW,
_ => colors::CYAN,
};
out.push_str(&format!(
"\n {}{}. [{}]{} {}\n",
color,
i + 1,
m.kind,
colors::RESET,
m.message
));
if let Some(ref hint) = m.line_hint {
out.push_str(&format!(" {}{}{}\n", colors::DIM, hint, colors::RESET));
}
}
out.push_str(&format!(
"\n{} migration(s) found.\n",
self.migrations.len()
));
if !self.applied {
out.push_str(&format!(
"{}Tip:{} Run with --apply to apply changes.\n",
colors::DIM,
colors::RESET
));
}
out
}
fn exit_code(&self) -> ExitCode {
if self.migrations.iter().any(|m| m.severity == "error") {
ExitCode::Error
} else if !self.migrations.is_empty() {
ExitCode::Warning
} else {
ExitCode::Ok
}
}
}
pub fn run_migrate(file: &str, apply: bool) -> MigrateReport {
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
return MigrateReport {
file: file.to_string(),
migrations: vec![Migration {
kind: "error".to_string(),
message: format!("Cannot read {}: {}", file, e),
line_hint: None,
severity: "error".to_string(),
}],
applied: false,
};
}
};
let parsed: Result<toml::Value, _> = toml::from_str(&content);
let Ok(config) = parsed else {
return MigrateReport {
file: file.to_string(),
migrations: vec![Migration {
kind: "parse-error".to_string(),
message: format!("Invalid TOML: {}", parsed.unwrap_err()),
line_hint: None,
severity: "error".to_string(),
}],
applied: false,
};
};
let mut migrations = Vec::new();
if let Some(provisioner) = config.get("provisioner").and_then(|p| p.as_table()) {
for tool_name in provisioner.keys() {
if get_tool_spec(tool_name).is_none() {
migrations.push(Migration {
kind: "unknown-tool".to_string(),
message: format!(
"'{}' is not a recognized tool. Check spelling or remove it.",
tool_name
),
line_hint: Some(format!("[provisioner]\n{} = ...", tool_name)),
severity: "warning".to_string(),
});
}
}
}
if config.get("tools").is_some() {
migrations.push(Migration {
kind: "renamed-section".to_string(),
message: "[tools] has been renamed to [provisioner]. Update your config.".to_string(),
line_hint: Some("Replace [tools] with [provisioner]".to_string()),
severity: "warning".to_string(),
});
}
if let Some(hooks) = config.get("hooks").and_then(|h| h.as_table()) {
for key in hooks.keys() {
if key == "pre_setup" || key == "post_setup" || key == "config" {
continue;
}
if get_tool_spec(key).is_none() {
migrations.push(Migration {
kind: "unknown-hook-tool".to_string(),
message: format!(
"Hook references unknown tool '{}'. It may not trigger.",
key
),
line_hint: Some(format!("[hooks.{}]", key)),
severity: "info".to_string(),
});
}
}
}
let mut applied = false;
if apply {
let has_tools_rename = migrations.iter().any(|m| m.kind == "renamed-section");
if has_tools_rename {
let rewritten = content.replace("[tools]", "[provisioner]");
if toml::from_str::<toml::Value>(&rewritten).is_ok() {
if let Err(e) = atomic_write(file, &rewritten) {
migrations.push(Migration {
kind: "apply-failed".to_string(),
message: format!("Could not write {file}: {e}"),
line_hint: None,
severity: "error".to_string(),
});
} else {
applied = true;
}
} else {
migrations.push(Migration {
kind: "apply-skipped".to_string(),
message: format!(
"Rewritten {file} did not round-trip through TOML parse; left unchanged"
),
line_hint: None,
severity: "warning".to_string(),
});
}
}
}
MigrateReport {
file: file.to_string(),
migrations,
applied,
}
}
fn atomic_write(target: &str, content: &str) -> std::io::Result<()> {
use std::io::Write;
let target_path = std::path::Path::new(target);
let parent = target_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.as_file_mut().write_all(content.as_bytes())?;
tmp.as_file_mut().flush()?;
tmp.persist(target_path)
.map_err(|e| std::io::Error::other(format!("persist failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn apply_rewrites_tools_section_to_provisioner() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("jarvy.toml");
fs::write(&file, "[tools]\ngit = \"latest\"\n").unwrap();
let report = run_migrate(file.to_str().unwrap(), true);
assert!(report.applied, "expected applied=true, got {report:?}");
let after = fs::read_to_string(&file).unwrap();
assert!(after.contains("[provisioner]"), "got:\n{after}");
assert!(!after.contains("[tools]"), "got:\n{after}");
}
#[test]
fn report_only_when_apply_false() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("jarvy.toml");
let original = "[tools]\ngit = \"latest\"\n";
fs::write(&file, original).unwrap();
let report = run_migrate(file.to_str().unwrap(), false);
assert!(!report.applied);
assert_eq!(fs::read_to_string(&file).unwrap(), original);
}
#[test]
fn apply_refuses_when_round_trip_fails() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("jarvy.toml");
fs::write(&file, "[tools]\ngit = \"latest\"\n").unwrap();
let _ = run_migrate(file.to_str().unwrap(), true);
let after = fs::read_to_string(&file).unwrap();
let _: toml::Value = toml::from_str(&after).expect("post-apply file must parse");
}
#[test]
fn apply_is_noop_when_no_rewrites_pending() {
let tmp = tempdir().unwrap();
let file = tmp.path().join("jarvy.toml");
let original = "[provisioner]\ngit = \"latest\"\n";
fs::write(&file, original).unwrap();
let report = run_migrate(file.to_str().unwrap(), true);
assert!(!report.applied);
assert_eq!(fs::read_to_string(&file).unwrap(), original);
}
}