ggen_cli_lib/cmds/project/
test.rs

1//! Golden file snapshot testing for templates.
2//!
3//! This module provides snapshot testing capabilities to verify that template
4//! generation produces expected output. It implements Pattern 009: PROJECT PLAN
5//! by enabling regression testing through golden file comparisons.
6//!
7//! # Examples
8//!
9//! ```bash
10//! ggen project test "template.tmpl" --golden expected/
11//! ggen project test "rust-cli.tmpl" --golden golden/ --update
12//! ggen project test "*.tmpl" --golden snapshots/ --json
13//! ```
14//!
15//! # Errors
16//!
17//! Returns errors if template rendering fails, golden files are missing,
18//! or snapshot comparisons detect unexpected differences.
19
20use clap::Args;
21use ggen_utils::error::Result;
22use std::fs;
23use std::path::{Component, Path, PathBuf};
24
25#[derive(Args, Debug)]
26pub struct TestArgs {
27    /// Template reference (e.g., "template.tmpl" or "gpack:path/to.tmpl")
28    pub template_ref: String,
29
30    /// Directory containing golden/expected files
31    #[arg(long, short = 'g')]
32    pub golden: PathBuf,
33
34    /// Variables to pass to the template (key=value format)
35    #[arg(short = 'v', long = "var")]
36    pub vars: Vec<String>,
37
38    /// Update golden files instead of comparing
39    #[arg(long, short = 'u')]
40    pub update: bool,
41
42    /// Show detailed diff when tests fail
43    #[arg(long)]
44    pub verbose: bool,
45
46    /// Output results in JSON format
47    #[arg(long)]
48    pub json: bool,
49
50    /// Perform dry-run without writing updates
51    #[arg(long)]
52    pub dry_run: bool,
53}
54
55/// Validate path to prevent directory traversal attacks
56fn validate_path(path: &Path) -> Result<()> {
57    if path.components().any(|c| matches!(c, Component::ParentDir)) {
58        return Err(ggen_utils::error::Error::new(
59            "Path traversal detected: paths containing '..' are not allowed",
60        ));
61    }
62    Ok(())
63}
64
65/// Parse key=value pairs into HashMap
66fn parse_vars(vars: &[String]) -> Result<std::collections::HashMap<String, String>> {
67    let mut map = std::collections::HashMap::new();
68    for var in vars {
69        let parts: Vec<&str> = var.splitn(2, '=').collect();
70        if parts.len() != 2 {
71            return Err(ggen_utils::error::Error::new_fmt(format_args!(
72                "Invalid variable format: '{}'. Expected 'key=value'",
73                var
74            )));
75        }
76        map.insert(parts[0].to_string(), parts[1].to_string());
77    }
78    Ok(map)
79}
80
81/// Main entry point for `ggen project test`
82pub async fn run(args: &TestArgs) -> Result<()> {
83    // Validate inputs
84    if args.template_ref.is_empty() {
85        return Err(ggen_utils::error::Error::new(
86            "Template reference cannot be empty",
87        ));
88    }
89
90    validate_path(&args.golden)?;
91    let _vars = parse_vars(&args.vars)?;
92
93    println!("๐Ÿงช Running golden file snapshot tests...");
94
95    if args.update {
96        println!("๐Ÿ“ Update mode: Golden files will be updated");
97    } else {
98        println!("๐Ÿ” Compare mode: Checking against golden files");
99    }
100
101    // Ensure golden directory exists
102    if !args.golden.exists() && !args.update {
103        return Err(ggen_utils::error::Error::new_fmt(format_args!(
104            "Golden directory not found: {}",
105            args.golden.display()
106        )));
107    }
108
109    if args.update && !args.dry_run {
110        fs::create_dir_all(&args.golden).map_err(ggen_utils::error::Error::from)?;
111    }
112
113    // Build command for cargo make test
114    let mut cmd = std::process::Command::new("cargo");
115    cmd.args(["make", "project-test"]);
116    cmd.arg("--template").arg(&args.template_ref);
117    cmd.arg("--golden").arg(&args.golden);
118
119    for var in &args.vars {
120        cmd.arg("--var").arg(var);
121    }
122
123    if args.update {
124        cmd.arg("--update");
125    }
126
127    if args.verbose {
128        cmd.arg("--verbose");
129    }
130
131    if args.json {
132        cmd.arg("--json");
133    }
134
135    if args.dry_run {
136        cmd.arg("--dry-run");
137    }
138
139    let output = cmd.output().map_err(ggen_utils::error::Error::from)?;
140
141    if !output.status.success() {
142        let stderr = String::from_utf8_lossy(&output.stderr);
143        return Err(ggen_utils::error::Error::new_fmt(format_args!(
144            "Snapshot test failed: {}",
145            stderr
146        )));
147    }
148
149    let stdout = String::from_utf8_lossy(&output.stdout);
150
151    if args.json {
152        println!("{}", stdout);
153    } else {
154        println!("{}", stdout);
155        if args.update {
156            println!("โœ… Golden files updated successfully");
157        } else {
158            println!("โœ… All snapshot tests passed");
159        }
160    }
161
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_parse_vars_valid() {
171        let vars = vec!["name=test".to_string(), "version=1.0".to_string()];
172        let result = parse_vars(&vars).unwrap();
173
174        assert_eq!(result.get("name"), Some(&"test".to_string()));
175        assert_eq!(result.get("version"), Some(&"1.0".to_string()));
176    }
177
178    #[test]
179    fn test_parse_vars_invalid() {
180        let vars = vec!["invalid".to_string()];
181        let result = parse_vars(&vars);
182
183        assert!(result.is_err());
184    }
185
186    #[test]
187    fn test_validate_path_safe() {
188        let path = Path::new("golden/snapshots");
189        assert!(validate_path(path).is_ok());
190    }
191
192    #[test]
193    fn test_validate_path_traversal() {
194        let path = Path::new("../etc/passwd");
195        assert!(validate_path(path).is_err());
196    }
197
198    #[tokio::test]
199    async fn test_test_args_validation() {
200        let args = TestArgs {
201            template_ref: "".to_string(),
202            golden: PathBuf::from("golden"),
203            vars: vec![],
204            update: false,
205            verbose: false,
206            json: false,
207            dry_run: false,
208        };
209
210        let result = run(&args).await;
211        assert!(result.is_err());
212        assert!(result
213            .unwrap_err()
214            .to_string()
215            .contains("Template reference cannot be empty"));
216    }
217}