use crate::error::{ForgeError, ForgeResult};
use colored::Colorize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
pub fn needs_schema_upgrade(file: &Path) -> ForgeResult<Option<String>> {
let content = fs::read_to_string(file)
.map_err(|e| ForgeError::IO(format!("Failed to read {}: {}", file.display(), e)))?;
let is_multi_doc = content.lines().skip(1).any(|line| line.trim() == "---");
if is_multi_doc {
return Ok(None);
}
let yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content)
.map_err(|e| ForgeError::Parse(format!("Failed to parse {}: {}", file.display(), e)))?;
let version = yaml
.get("_forge_version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0");
if version == "5.0.0" {
Ok(None)
} else {
Ok(Some(version.to_string()))
}
}
pub fn auto_upgrade_schema(file: &Path, verbose: bool) -> ForgeResult<()> {
let mut upgraded_files = HashSet::new();
upgrade_file_recursive(file, "5.0.0", false, verbose, &mut upgraded_files)?;
Ok(())
}
pub fn upgrade(file: &Path, dry_run: bool, target_version: &str, verbose: bool) -> ForgeResult<()> {
println!("{}", "🔥 Forge - Schema Upgrade".bold().green());
println!();
println!(" File: {}", file.display());
println!(" Target: v{target_version}");
if dry_run {
println!(" Mode: {} (no files modified)", "DRY RUN".yellow());
}
println!();
let mut upgraded_files: HashSet<PathBuf> = HashSet::new();
let changes =
upgrade_file_recursive(file, target_version, dry_run, verbose, &mut upgraded_files)?;
println!();
println!("{}", "═".repeat(70));
println!();
if dry_run {
println!(
"{} {} file(s) would be upgraded",
"DRY RUN:".yellow().bold(),
changes
);
println!();
println!(" Run without --dry-run to apply changes.");
} else {
println!(
"{} {} file(s) upgraded to v{}",
"✅".green(),
changes,
target_version
);
}
println!();
Ok(())
}
pub fn upgrade_file_recursive(
file: &Path,
target_version: &str,
dry_run: bool,
verbose: bool,
upgraded_files: &mut HashSet<PathBuf>,
) -> ForgeResult<usize> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if upgraded_files.contains(&canonical) {
if verbose {
println!(
" {} {} (already processed)",
"⏭️".dimmed(),
file.display()
);
}
return Ok(0);
}
upgraded_files.insert(canonical);
let content = fs::read_to_string(file)
.map_err(|e| ForgeError::IO(format!("Failed to read {}: {}", file.display(), e)))?;
let mut yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content)
.map_err(|e| ForgeError::Parse(format!("Failed to parse {}: {}", file.display(), e)))?;
let mut changes = 0;
if let Some(serde_yaml_ng::Value::Sequence(include_list)) = yaml.get("_includes").cloned() {
let parent_dir = file.parent().unwrap_or_else(|| Path::new("."));
for include in include_list {
if let Some(include_file) = include.get("file").and_then(|f| f.as_str()) {
let include_path = parent_dir.join(include_file);
if include_path.exists() {
changes += upgrade_file_recursive(
&include_path,
target_version,
dry_run,
verbose,
upgraded_files,
)?;
}
}
}
}
let current_version = yaml
.get("_forge_version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0");
if current_version == target_version {
if verbose {
println!(
" {} {} (already v{})",
"✓".green(),
file.display(),
target_version
);
}
return Ok(changes);
}
println!(
" {} {} (v{} → v{})",
if dry_run {
"→".yellow()
} else {
"↑".cyan()
},
file.display(),
current_version,
target_version
);
let yaml_map = yaml
.as_mapping_mut()
.ok_or_else(|| ForgeError::Parse("Root must be a YAML mapping".to_string()))?;
yaml_map.insert(
serde_yaml_ng::Value::String("_forge_version".to_string()),
serde_yaml_ng::Value::String(target_version.to_string()),
);
if target_version == "5.0.0" {
split_scalars_to_inputs_outputs(yaml_map, verbose);
}
if !dry_run {
let backup_path = file.with_extension("yaml.bak");
fs::copy(file, &backup_path)
.map_err(|e| ForgeError::IO(format!("Failed to create backup: {e}")))?;
if verbose {
println!(" {} Backup: {}", "📋".dimmed(), backup_path.display());
}
let upgraded_content = serde_yaml_ng::to_string(&yaml)
.map_err(|e| ForgeError::IO(format!("Failed to serialize YAML: {e}")))?;
let final_content =
format!("# Upgraded to Forge v{target_version} by 'forge upgrade'\n{upgraded_content}");
fs::write(file, final_content)
.map_err(|e| ForgeError::IO(format!("Failed to write {}: {}", file.display(), e)))?;
}
Ok(changes + 1)
}
pub fn split_scalars_to_inputs_outputs(yaml_map: &mut serde_yaml_ng::Mapping, verbose: bool) {
let mut inputs: serde_yaml_ng::Mapping = serde_yaml_ng::Mapping::new();
let mut outputs: serde_yaml_ng::Mapping = serde_yaml_ng::Mapping::new();
let mut keys_to_remove: Vec<serde_yaml_ng::Value> = Vec::new();
if let Some(existing_inputs) = yaml_map.get(serde_yaml_ng::Value::String("inputs".to_string()))
{
if let Some(map) = existing_inputs.as_mapping() {
inputs = map.clone();
}
}
if let Some(existing_outputs) =
yaml_map.get(serde_yaml_ng::Value::String("outputs".to_string()))
{
if let Some(map) = existing_outputs.as_mapping() {
outputs = map.clone();
}
}
for (key, value) in yaml_map.iter() {
let key_str = key.as_str().unwrap_or("");
if key_str.starts_with('_')
|| key_str == "inputs"
|| key_str == "outputs"
|| key_str == "scenarios"
{
continue;
}
if let Some(mapping) = value.as_mapping() {
let value_key = serde_yaml_ng::Value::String("value".to_string());
let formula_key = serde_yaml_ng::Value::String("formula".to_string());
if mapping.contains_key(&value_key) {
let has_formula = mapping.contains_key(&formula_key)
&& mapping
.get(&formula_key)
.is_some_and(|f| !f.is_null() && f.as_str().is_some_and(|s| !s.is_empty()));
if has_formula {
outputs.insert(key.clone(), value.clone());
if verbose {
println!(
" {} {} → outputs (has formula)",
"📤".dimmed(),
key_str
);
}
} else {
inputs.insert(key.clone(), value.clone());
if verbose {
println!(" {} {} → inputs (value only)", "📥".dimmed(), key_str);
}
}
keys_to_remove.push(key.clone());
}
}
}
for key in keys_to_remove {
yaml_map.remove(&key);
}
if !inputs.is_empty() {
yaml_map.insert(
serde_yaml_ng::Value::String("inputs".to_string()),
serde_yaml_ng::Value::Mapping(inputs),
);
}
if !outputs.is_empty() {
yaml_map.insert(
serde_yaml_ng::Value::String("outputs".to_string()),
serde_yaml_ng::Value::Mapping(outputs),
);
}
}
#[cfg(test)]
mod auto_upgrade_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_needs_schema_upgrade_old_version() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("old.yaml");
std::fs::write(
&yaml_path,
r#"_forge_version: "1.0.0"
x:
value: 10
formula: null
"#,
)
.unwrap();
let result = needs_schema_upgrade(&yaml_path).unwrap();
assert_eq!(result, Some("1.0.0".to_string()));
}
#[test]
fn test_needs_schema_upgrade_v4_version() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("v4.yaml");
std::fs::write(
&yaml_path,
r#"_forge_version: "4.0.0"
x:
value: 10
formula: null
"#,
)
.unwrap();
let result = needs_schema_upgrade(&yaml_path).unwrap();
assert_eq!(result, Some("4.0.0".to_string()));
}
#[test]
fn test_needs_schema_upgrade_current_version() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("current.yaml");
std::fs::write(
&yaml_path,
r#"_forge_version: "5.0.0"
x:
value: 10
formula: null
"#,
)
.unwrap();
let result = needs_schema_upgrade(&yaml_path).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_needs_schema_upgrade_skips_multi_doc() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("multi.yaml");
std::fs::write(
&yaml_path,
r#"---
_forge_version: "1.0.0"
x:
value: 10
---
_forge_version: "1.0.0"
y:
value: 20
"#,
)
.unwrap();
let result = needs_schema_upgrade(&yaml_path).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_needs_schema_upgrade_no_version() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("noversion.yaml");
std::fs::write(
&yaml_path,
r"x:
value: 10
formula: null
",
)
.unwrap();
let result = needs_schema_upgrade(&yaml_path).unwrap();
assert_eq!(result, Some("1.0.0".to_string()));
}
#[test]
fn test_auto_upgrade_schema_upgrades_file() {
let dir = TempDir::new().unwrap();
let yaml_path = dir.path().join("upgrade_me.yaml");
std::fs::write(
&yaml_path,
r#"_forge_version: "1.0.0"
x:
value: 10
formula: null
"#,
)
.unwrap();
let result = auto_upgrade_schema(&yaml_path, false);
assert!(result.is_ok());
let content = std::fs::read_to_string(&yaml_path).unwrap();
assert!(content.contains("5.0.0"));
}
}