use ggen_utils::error::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Component, Path};
#[derive(Debug, Clone, Default)]
pub struct ApplyInput {
pub plan_file: String,
pub dry_run: bool,
pub auto_confirm: bool,
}
#[derive(Debug, Clone)]
pub struct ApplicationResult {
pub operations_count: usize,
pub plan_file: String,
}
#[derive(Serialize, Deserialize, Debug)]
struct GenerationPlan {
template_ref: String,
variables: HashMap<String, String>,
timestamp: chrono::DateTime<chrono::Utc>,
format: String,
}
fn validate_path(path: &Path) -> Result<()> {
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(ggen_utils::error::Error::new(
"Path traversal detected: paths containing '..' are not allowed",
));
}
Ok(())
}
pub fn apply_plan(args: &ApplyInput) -> Result<ApplicationResult> {
let plan_path = Path::new(&args.plan_file);
validate_path(plan_path)?;
if !plan_path.exists() {
return Err(ggen_utils::error::Error::new_fmt(format_args!(
"Plan file not found: {}",
args.plan_file
)));
}
let content = fs::read_to_string(plan_path).map_err(ggen_utils::error::Error::from)?;
let plan: GenerationPlan = match plan_path.extension().and_then(|ext| ext.to_str()) {
Some("json") => serde_json::from_str(&content).map_err(ggen_utils::error::Error::from)?,
Some("yaml") | Some("yml") => {
serde_yaml::from_str(&content).map_err(ggen_utils::error::Error::from)?
}
Some("toml") => toml::from_str(&content).map_err(ggen_utils::error::Error::from)?,
_ => {
return Err(ggen_utils::error::Error::new_fmt(format_args!(
"Unsupported plan file format. Supported: .json, .yaml, .yml, .toml"
)))
}
};
ggen_utils::alert_info!("📋 Plan Summary:");
ggen_utils::alert_info!(" Template: {}", plan.template_ref);
ggen_utils::alert_info!(" Variables: {}", plan.variables.len());
ggen_utils::alert_info!(
" Created: {}",
plan.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
);
if args.dry_run {
ggen_utils::alert_info!("🔍 Dry run mode - no changes will be applied");
return Ok(ApplicationResult {
operations_count: plan.variables.len(),
plan_file: args.plan_file.clone(),
});
}
if !args.auto_confirm {
ggen_utils::alert_warning!("\nThis will apply the generation plan to your project.");
ggen_utils::alert_info!("Continue? [y/N]: ");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(ggen_utils::error::Error::from)?;
if !input.trim().to_lowercase().starts_with('y') {
ggen_utils::alert_info!("Plan application cancelled by user");
return Ok(ApplicationResult {
operations_count: 0,
plan_file: args.plan_file.clone(),
});
}
}
let operations_count = plan.variables.len();
Ok(ApplicationResult {
operations_count,
plan_file: args.plan_file.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_validate_path_safe() {
let path = Path::new("safe/path/plan.json");
assert!(validate_path(path).is_ok());
}
#[test]
fn test_validate_path_traversal() {
let path = Path::new("../../../etc/passwd");
let result = validate_path(path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Path traversal"));
}
#[test]
fn test_apply_plan_file_not_found() {
let args = ApplyInput {
plan_file: "nonexistent.json".to_string(),
auto_confirm: true,
dry_run: false,
};
let result = apply_plan(&args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_apply_plan_dry_run() -> Result<()> {
let temp_dir = tempdir().unwrap();
let plan_path = temp_dir.path().join("test-plan.json");
let plan = GenerationPlan {
template_ref: "test.tmpl".to_string(),
variables: [("key".to_string(), "value".to_string())]
.iter()
.cloned()
.collect(),
timestamp: chrono::Utc::now(),
format: "json".to_string(),
};
let plan_content = serde_json::to_string_pretty(&plan).unwrap();
fs::write(&plan_path, plan_content).unwrap();
let args = ApplyInput {
plan_file: plan_path.to_string_lossy().to_string(),
auto_confirm: true,
dry_run: true,
};
let result = apply_plan(&args)?;
assert_eq!(result.operations_count, 1);
Ok(())
}
}