ggen_cli_lib/cmds/project/
validate.rs

1//! Validate plans and generated output.
2//!
3//! This module provides validation capabilities for generation plans and
4//! output files, ensuring they meet schema requirements, contain valid
5//! syntax, and follow project conventions.
6//!
7//! # Examples
8//!
9//! ```bash
10//! ggen project validate plan.json
11//! ggen project validate generated/ --recursive
12//! ggen project validate output.rs --schema rust-file.schema.json
13//! ggen project validate plan.yaml --verbose --json
14//! ```
15//!
16//! # Errors
17//!
18//! Returns errors if validation fails, files are malformed, or schema
19//! constraints are violated.
20
21use clap::Args;
22use ggen_utils::error::Result;
23use std::path::{Component, Path, PathBuf};
24
25#[derive(Args, Debug)]
26pub struct ValidateArgs {
27    /// Path to plan file or directory to validate
28    pub path: PathBuf,
29
30    /// Schema file for validation (optional)
31    #[arg(long, short = 's')]
32    pub schema: Option<PathBuf>,
33
34    /// Validate directories recursively
35    #[arg(long, short = 'r')]
36    pub recursive: bool,
37
38    /// File pattern for recursive validation (e.g., "*.rs")
39    #[arg(long, default_value = "*")]
40    pub pattern: String,
41
42    /// Strict validation mode (fail on warnings)
43    #[arg(long)]
44    pub strict: bool,
45
46    /// Show verbose output
47    #[arg(long)]
48    pub verbose: bool,
49
50    /// Output results in JSON format
51    #[arg(long)]
52    pub json: 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/// Detect file type from extension
66fn detect_validation_type(path: &Path) -> Result<String> {
67    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
68
69    let validation_type = match extension {
70        "json" => "json-plan",
71        "yaml" | "yml" => "yaml-plan",
72        "toml" => "toml-plan",
73        "rs" => "rust-file",
74        "ts" | "tsx" => "typescript-file",
75        "js" | "jsx" => "javascript-file",
76        "py" => "python-file",
77        _ => "generic",
78    };
79
80    Ok(validation_type.to_string())
81}
82
83/// Main entry point for `ggen project validate`
84pub async fn run(args: &ValidateArgs) -> Result<()> {
85    // Validate inputs
86    validate_path(&args.path)?;
87
88    if let Some(schema_path) = &args.schema {
89        validate_path(schema_path)?;
90        if !schema_path.exists() {
91            return Err(ggen_utils::error::Error::new_fmt(format_args!(
92                "Schema file not found: {}",
93                schema_path.display()
94            )));
95        }
96    }
97
98    println!("✅ Validating files...");
99
100    // Check if path exists
101    if !args.path.exists() {
102        return Err(ggen_utils::error::Error::new_fmt(format_args!(
103            "Path not found: {}",
104            args.path.display()
105        )));
106    }
107
108    // Detect validation type
109    let validation_type = detect_validation_type(&args.path)?;
110
111    if args.verbose {
112        println!("📋 Validation type: {}", validation_type);
113    }
114
115    // Build command for cargo make validate
116    let mut cmd = std::process::Command::new("cargo");
117    cmd.args(["make", "project-validate"]);
118    cmd.arg("--path").arg(&args.path);
119    cmd.arg("--type").arg(&validation_type);
120
121    if let Some(schema_path) = &args.schema {
122        cmd.arg("--schema").arg(schema_path);
123    }
124
125    if args.recursive {
126        cmd.arg("--recursive");
127    }
128
129    if !args.pattern.is_empty() && args.pattern != "*" {
130        cmd.arg("--pattern").arg(&args.pattern);
131    }
132
133    if args.strict {
134        cmd.arg("--strict");
135    }
136
137    if args.verbose {
138        cmd.arg("--verbose");
139    }
140
141    if args.json {
142        cmd.arg("--json");
143    }
144
145    let output = cmd.output().map_err(ggen_utils::error::Error::from)?;
146
147    if !output.status.success() {
148        let stderr = String::from_utf8_lossy(&output.stderr);
149        return Err(ggen_utils::error::Error::new_fmt(format_args!(
150            "Validation failed: {}",
151            stderr
152        )));
153    }
154
155    let stdout = String::from_utf8_lossy(&output.stdout);
156
157    if args.json {
158        println!("{}", stdout);
159    } else {
160        println!("{}", stdout);
161        println!("✅ Validation completed successfully");
162    }
163
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_detect_validation_type_json() {
173        let path = Path::new("plan.json");
174        let result = detect_validation_type(path).unwrap();
175        assert_eq!(result, "json-plan");
176    }
177
178    #[test]
179    fn test_detect_validation_type_yaml() {
180        let path = Path::new("config.yaml");
181        let result = detect_validation_type(path).unwrap();
182        assert_eq!(result, "yaml-plan");
183    }
184
185    #[test]
186    fn test_detect_validation_type_rust() {
187        let path = Path::new("main.rs");
188        let result = detect_validation_type(path).unwrap();
189        assert_eq!(result, "rust-file");
190    }
191
192    #[test]
193    fn test_detect_validation_type_generic() {
194        let path = Path::new("README.md");
195        let result = detect_validation_type(path).unwrap();
196        assert_eq!(result, "generic");
197    }
198
199    #[test]
200    fn test_validate_path_safe() {
201        let path = Path::new("src/main.rs");
202        assert!(validate_path(path).is_ok());
203    }
204
205    #[test]
206    fn test_validate_path_traversal() {
207        let path = Path::new("../etc/passwd");
208        assert!(validate_path(path).is_err());
209    }
210
211    #[tokio::test]
212    async fn test_validate_args_path_not_found() {
213        let args = ValidateArgs {
214            path: PathBuf::from("nonexistent.json"),
215            schema: None,
216            recursive: false,
217            pattern: "*".to_string(),
218            strict: false,
219            verbose: false,
220            json: false,
221        };
222
223        let result = run(&args).await;
224        assert!(result.is_err());
225        assert!(result.unwrap_err().to_string().contains("Path not found"));
226    }
227}