ggen_cli_validation/
noun_verb_validator.rs

1//! Noun-verb command structure validation
2//!
3//! Validates clap-noun-verb command sequences for:
4//! - Circular dependencies
5//! - Valid command chains
6//! - Audit trail generation
7
8use crate::error::{Result, ValidationError};
9use std::collections::{HashMap, HashSet};
10
11/// Validator for noun-verb command structures
12#[derive(Debug, Default)]
13pub struct NounVerbValidator {
14    /// Command dependency graph
15    dependencies: HashMap<String, Vec<String>>,
16    /// Execution history for audit trail
17    audit_trail: Vec<AuditEntry>,
18}
19
20/// Audit trail entry
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct AuditEntry {
23    /// Command executed
24    pub command: String,
25    /// Timestamp
26    pub timestamp: String,
27    /// Dependencies resolved
28    pub dependencies: Vec<String>,
29    /// Success status
30    pub success: bool,
31}
32
33impl NounVerbValidator {
34    /// Create a new validator
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            dependencies: HashMap::new(),
39            audit_trail: Vec::new(),
40        }
41    }
42
43    /// Register a command with its dependencies
44    pub fn register_command(&mut self, command: String, deps: Vec<String>) {
45        self.dependencies.insert(command, deps);
46    }
47
48    /// Validate a command sequence for circular dependencies
49    pub fn validate_sequence(&self, commands: &[String]) -> Result<()> {
50        for command in commands {
51            self.check_circular_dependencies(command, &mut HashSet::new())?;
52        }
53        Ok(())
54    }
55
56    /// Check for circular dependencies recursively
57    fn check_circular_dependencies(
58        &self, command: &str, visited: &mut HashSet<String>,
59    ) -> Result<()> {
60        if visited.contains(command) {
61            return Err(ValidationError::CircularDependency);
62        }
63
64        visited.insert(command.to_string());
65
66        if let Some(deps) = self.dependencies.get(command) {
67            for dep in deps {
68                self.check_circular_dependencies(dep, visited)?;
69            }
70        }
71
72        visited.remove(command);
73        Ok(())
74    }
75
76    /// Validate command structure
77    pub fn validate_command_structure(&self, noun: &str, verb: &str) -> Result<()> {
78        // Check noun is not empty
79        if noun.is_empty() {
80            return Err(ValidationError::InvalidCommandStructure {
81                reason: "Noun cannot be empty".to_string(),
82            });
83        }
84
85        // Check verb is not empty
86        if verb.is_empty() {
87            return Err(ValidationError::InvalidCommandStructure {
88                reason: "Verb cannot be empty".to_string(),
89            });
90        }
91
92        // Check for valid characters (alphanumeric, dash, underscore)
93        let valid_chars = |s: &str| {
94            s.chars()
95                .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
96        };
97
98        if !valid_chars(noun) {
99            return Err(ValidationError::InvalidCommandStructure {
100                reason: format!("Noun '{noun}' contains invalid characters"),
101            });
102        }
103
104        if !valid_chars(verb) {
105            return Err(ValidationError::InvalidCommandStructure {
106                reason: format!("Verb '{verb}' contains invalid characters"),
107            });
108        }
109
110        Ok(())
111    }
112
113    /// Record command execution in audit trail
114    pub fn record_execution(&mut self, command: String, dependencies: Vec<String>, success: bool) {
115        let entry = AuditEntry {
116            command,
117            timestamp: chrono::Utc::now().to_rfc3339(),
118            dependencies,
119            success,
120        };
121        self.audit_trail.push(entry);
122    }
123
124    /// Get audit trail
125    #[must_use]
126    pub fn get_audit_trail(&self) -> &[AuditEntry] {
127        &self.audit_trail
128    }
129
130    /// Export audit trail as JSON
131    ///
132    /// # Errors
133    /// Returns error if serialization fails
134    pub fn export_audit_trail(&self) -> Result<String> {
135        serde_json::to_string_pretty(&self.audit_trail).map_err(|e| {
136            ValidationError::InvalidCommandStructure {
137                reason: format!("Failed to serialize audit trail: {e}"),
138            }
139        })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_circular_dependency_detection() {
149        let mut validator = NounVerbValidator::new();
150
151        // Create circular dependency: A -> B -> C -> A
152        validator.register_command("A".to_string(), vec!["B".to_string()]);
153        validator.register_command("B".to_string(), vec!["C".to_string()]);
154        validator.register_command("C".to_string(), vec!["A".to_string()]);
155
156        let result = validator.validate_sequence(&["A".to_string()]);
157        assert!(result.is_err());
158        assert!(matches!(result, Err(ValidationError::CircularDependency)));
159    }
160
161    #[test]
162    fn test_valid_command_structure() {
163        let validator = NounVerbValidator::new();
164
165        assert!(validator
166            .validate_command_structure("template", "generate")
167            .is_ok());
168        assert!(validator
169            .validate_command_structure("ontology", "extract")
170            .is_ok());
171        assert!(validator
172            .validate_command_structure("hook", "create")
173            .is_ok());
174    }
175
176    #[test]
177    fn test_invalid_command_structure() {
178        let validator = NounVerbValidator::new();
179
180        // Empty noun/verb
181        assert!(validator.validate_command_structure("", "verb").is_err());
182        assert!(validator.validate_command_structure("noun", "").is_err());
183
184        // Invalid characters
185        assert!(validator
186            .validate_command_structure("noun!", "verb")
187            .is_err());
188        assert!(validator
189            .validate_command_structure("noun", "verb*")
190            .is_err());
191    }
192
193    #[allow(clippy::expect_used)]
194    #[test]
195    fn test_audit_trail_recording() {
196        let mut validator = NounVerbValidator::new();
197
198        validator.record_execution(
199            "template generate".to_string(),
200            vec!["template load".to_string()],
201            true,
202        );
203
204        let trail = validator.get_audit_trail();
205        assert_eq!(trail.len(), 1);
206        assert_eq!(trail[0].command, "template generate");
207        assert!(trail[0].success);
208    }
209
210    #[allow(clippy::expect_used)]
211    #[test]
212    fn test_audit_trail_export() {
213        let mut validator = NounVerbValidator::new();
214
215        validator.record_execution("test command".to_string(), vec![], true);
216
217        let json = validator.export_audit_trail();
218        assert!(json.is_ok());
219        assert!(json.expect("JSON should be valid").contains("test command"));
220    }
221
222    #[test]
223    fn test_linear_dependency_chain() {
224        let mut validator = NounVerbValidator::new();
225
226        // Linear chain: A -> B -> C (no cycles)
227        validator.register_command("A".to_string(), vec!["B".to_string()]);
228        validator.register_command("B".to_string(), vec!["C".to_string()]);
229        validator.register_command("C".to_string(), vec![]);
230
231        let result = validator.validate_sequence(&["A".to_string()]);
232        assert!(result.is_ok());
233    }
234}