ricecoder_specs/
steering.rs

1//! Steering file loading and management
2
3use crate::error::{Severity, SpecError, ValidationError};
4use crate::models::{Standard, Steering, SteeringRule, TemplateRef};
5use std::fs;
6use std::path::Path;
7
8/// Loads and merges steering documents
9pub struct SteeringLoader;
10
11impl SteeringLoader {
12    /// Load steering from a directory
13    ///
14    /// Searches for YAML and Markdown files in the directory and loads them.
15    /// Returns a merged Steering document with all rules, standards, and templates.
16    ///
17    /// # Arguments
18    /// * `path` - Directory path to search for steering files
19    ///
20    /// # Returns
21    /// * `Ok(Steering)` - Merged steering document
22    /// * `Err(SpecError)` - If loading or parsing fails
23    pub fn load(path: &Path) -> Result<Steering, SpecError> {
24        if !path.exists() {
25            return Ok(Steering {
26                rules: vec![],
27                standards: vec![],
28                templates: vec![],
29            });
30        }
31
32        if !path.is_dir() {
33            return Err(SpecError::InvalidFormat(format!(
34                "Steering path is not a directory: {}",
35                path.display()
36            )));
37        }
38
39        let mut all_rules = vec![];
40        let mut all_standards = vec![];
41        let mut all_templates = vec![];
42
43        // Recursively search for steering files
44        Self::load_from_directory(path, &mut all_rules, &mut all_standards, &mut all_templates)?;
45
46        Ok(Steering {
47            rules: all_rules,
48            standards: all_standards,
49            templates: all_templates,
50        })
51    }
52
53    /// Load steering files from a directory recursively
54    fn load_from_directory(
55        dir: &Path,
56        rules: &mut Vec<SteeringRule>,
57        standards: &mut Vec<Standard>,
58        templates: &mut Vec<TemplateRef>,
59    ) -> Result<(), SpecError> {
60        let entries = fs::read_dir(dir).map_err(SpecError::IoError)?;
61
62        for entry in entries {
63            let entry = entry.map_err(SpecError::IoError)?;
64            let path = entry.path();
65
66            if path.is_dir() {
67                // Recursively load from subdirectories
68                Self::load_from_directory(&path, rules, standards, templates)?;
69            } else if path.is_file() {
70                let file_name = path.file_name().unwrap_or_default().to_string_lossy();
71
72                // Check if it's a YAML or Markdown file
73                if file_name.ends_with(".yaml") || file_name.ends_with(".yml") {
74                    let content = fs::read_to_string(&path).map_err(SpecError::IoError)?;
75                    let steering = Self::parse_yaml(&content, &path)?;
76                    rules.extend(steering.rules);
77                    standards.extend(steering.standards);
78                    templates.extend(steering.templates);
79                } else if file_name.ends_with(".md") {
80                    let content = fs::read_to_string(&path).map_err(SpecError::IoError)?;
81                    let steering = Self::parse_markdown(&content, &path)?;
82                    rules.extend(steering.rules);
83                    standards.extend(steering.standards);
84                    templates.extend(steering.templates);
85                }
86            }
87        }
88
89        Ok(())
90    }
91
92    /// Parse YAML steering file
93    fn parse_yaml(content: &str, path: &Path) -> Result<Steering, SpecError> {
94        serde_yaml::from_str(content).map_err(|e| SpecError::ParseError {
95            path: path.display().to_string(),
96            line: e.location().map(|l| l.line()).unwrap_or(0),
97            message: e.to_string(),
98        })
99    }
100
101    /// Parse Markdown steering file
102    fn parse_markdown(content: &str, path: &Path) -> Result<Steering, SpecError> {
103        // For now, try to parse as YAML embedded in markdown
104        // In a full implementation, this would extract YAML code blocks
105        // or parse markdown-specific steering format
106
107        // Try to find YAML code block
108        if let Some(start) = content.find("```yaml") {
109            let after_start = &content[start + 7..];
110            if let Some(end) = after_start.find("```") {
111                let yaml_content = &after_start[..end];
112                return serde_yaml::from_str(yaml_content).map_err(|e| SpecError::ParseError {
113                    path: path.display().to_string(),
114                    line: e.location().map(|l| l.line()).unwrap_or(0),
115                    message: e.to_string(),
116                });
117            }
118        }
119
120        // If no YAML block found, return empty steering
121        Ok(Steering {
122            rules: vec![],
123            standards: vec![],
124            templates: vec![],
125        })
126    }
127
128    /// Merge global and project steering
129    ///
130    /// Project steering takes precedence over global steering.
131    /// Rules, standards, and templates are merged with project items overriding global items
132    /// when they have the same ID.
133    ///
134    /// # Arguments
135    /// * `global` - Global steering document
136    /// * `project` - Project steering document
137    ///
138    /// # Returns
139    /// * `Ok(Steering)` - Merged steering document with project taking precedence
140    /// * `Err(SpecError)` - If merge fails
141    pub fn merge(global: &Steering, project: &Steering) -> Result<Steering, SpecError> {
142        // Merge rules: project overrides global
143        let mut merged_rules = global.rules.clone();
144        for project_rule in &project.rules {
145            // Remove any global rule with the same ID
146            merged_rules.retain(|r| r.id != project_rule.id);
147            // Add the project rule
148            merged_rules.push(project_rule.clone());
149        }
150
151        // Merge standards: project overrides global
152        let mut merged_standards = global.standards.clone();
153        for project_standard in &project.standards {
154            // Remove any global standard with the same ID
155            merged_standards.retain(|s| s.id != project_standard.id);
156            // Add the project standard
157            merged_standards.push(project_standard.clone());
158        }
159
160        // Merge templates: project overrides global
161        let mut merged_templates = global.templates.clone();
162        for project_template in &project.templates {
163            // Remove any global template with the same ID
164            merged_templates.retain(|t| t.id != project_template.id);
165            // Add the project template
166            merged_templates.push(project_template.clone());
167        }
168
169        Ok(Steering {
170            rules: merged_rules,
171            standards: merged_standards,
172            templates: merged_templates,
173        })
174    }
175
176    /// Validate steering syntax and semantics
177    ///
178    /// Checks that:
179    /// - All rule IDs are unique
180    /// - All standard IDs are unique
181    /// - All template IDs are unique
182    /// - Rules have non-empty descriptions and patterns
183    /// - Standards have non-empty descriptions
184    /// - Templates have non-empty paths
185    ///
186    /// # Arguments
187    /// * `steering` - Steering document to validate
188    ///
189    /// # Returns
190    /// * `Ok(())` - If steering is valid
191    /// * `Err(SpecError)` - If validation fails
192    pub fn validate(steering: &Steering) -> Result<(), SpecError> {
193        let mut errors = vec![];
194
195        // Check for duplicate rule IDs
196        let mut rule_ids = std::collections::HashSet::new();
197        for (idx, rule) in steering.rules.iter().enumerate() {
198            if !rule_ids.insert(&rule.id) {
199                errors.push(ValidationError {
200                    path: "steering".to_string(),
201                    line: idx + 1,
202                    column: 0,
203                    message: format!("Duplicate rule ID: {}", rule.id),
204                    severity: Severity::Error,
205                });
206            }
207
208            // Check rule has description
209            if rule.description.is_empty() {
210                errors.push(ValidationError {
211                    path: "steering".to_string(),
212                    line: idx + 1,
213                    column: 0,
214                    message: format!("Rule {} has empty description", rule.id),
215                    severity: Severity::Warning,
216                });
217            }
218
219            // Check rule has pattern
220            if rule.pattern.is_empty() {
221                errors.push(ValidationError {
222                    path: "steering".to_string(),
223                    line: idx + 1,
224                    column: 0,
225                    message: format!("Rule {} has empty pattern", rule.id),
226                    severity: Severity::Warning,
227                });
228            }
229        }
230
231        // Check for duplicate standard IDs
232        let mut standard_ids = std::collections::HashSet::new();
233        for (idx, standard) in steering.standards.iter().enumerate() {
234            if !standard_ids.insert(&standard.id) {
235                errors.push(ValidationError {
236                    path: "steering".to_string(),
237                    line: idx + 1,
238                    column: 0,
239                    message: format!("Duplicate standard ID: {}", standard.id),
240                    severity: Severity::Error,
241                });
242            }
243
244            // Check standard has description
245            if standard.description.is_empty() {
246                errors.push(ValidationError {
247                    path: "steering".to_string(),
248                    line: idx + 1,
249                    column: 0,
250                    message: format!("Standard {} has empty description", standard.id),
251                    severity: Severity::Warning,
252                });
253            }
254        }
255
256        // Check for duplicate template IDs
257        let mut template_ids = std::collections::HashSet::new();
258        for (idx, template) in steering.templates.iter().enumerate() {
259            if !template_ids.insert(&template.id) {
260                errors.push(ValidationError {
261                    path: "steering".to_string(),
262                    line: idx + 1,
263                    column: 0,
264                    message: format!("Duplicate template ID: {}", template.id),
265                    severity: Severity::Error,
266                });
267            }
268
269            // Check template has path
270            if template.path.is_empty() {
271                errors.push(ValidationError {
272                    path: "steering".to_string(),
273                    line: idx + 1,
274                    column: 0,
275                    message: format!("Template {} has empty path", template.id),
276                    severity: Severity::Warning,
277                });
278            }
279        }
280
281        // If there are any errors, return them
282        if !errors.is_empty() {
283            let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
284            if has_errors {
285                return Err(SpecError::ValidationFailed(errors));
286            }
287        }
288
289        Ok(())
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use std::fs;
297    use tempfile::TempDir;
298
299    #[test]
300    fn test_steering_loader_empty_directory() {
301        let temp_dir = TempDir::new().unwrap();
302        let result = SteeringLoader::load(temp_dir.path());
303        assert!(result.is_ok());
304        let steering = result.unwrap();
305        assert!(steering.rules.is_empty());
306        assert!(steering.standards.is_empty());
307        assert!(steering.templates.is_empty());
308    }
309
310    #[test]
311    fn test_steering_loader_nonexistent_directory() {
312        let path = Path::new("/nonexistent/steering/path");
313        let result = SteeringLoader::load(path);
314        assert!(result.is_ok());
315        let steering = result.unwrap();
316        assert!(steering.rules.is_empty());
317    }
318
319    #[test]
320    fn test_steering_loader_yaml_file() {
321        let temp_dir = TempDir::new().unwrap();
322        let steering_file = temp_dir.path().join("steering.yaml");
323
324        let yaml_content = r#"
325rules:
326  - id: rule-1
327    description: Use snake_case for variables
328    pattern: "^[a-z_]+$"
329    action: enforce
330standards:
331  - id: std-1
332    description: All public APIs must have tests
333templates:
334  - id: tpl-1
335    path: templates/entity.rs
336"#;
337
338        fs::write(&steering_file, yaml_content).unwrap();
339
340        let result = SteeringLoader::load(temp_dir.path());
341        assert!(result.is_ok());
342        let steering = result.unwrap();
343        assert_eq!(steering.rules.len(), 1);
344        assert_eq!(steering.standards.len(), 1);
345        assert_eq!(steering.templates.len(), 1);
346        assert_eq!(steering.rules[0].id, "rule-1");
347        assert_eq!(steering.standards[0].id, "std-1");
348        assert_eq!(steering.templates[0].id, "tpl-1");
349    }
350
351    #[test]
352    fn test_steering_merge_project_overrides_global() {
353        let global = Steering {
354            rules: vec![
355                SteeringRule {
356                    id: "rule-1".to_string(),
357                    description: "Global rule".to_string(),
358                    pattern: "global".to_string(),
359                    action: "enforce".to_string(),
360                },
361                SteeringRule {
362                    id: "rule-2".to_string(),
363                    description: "Only in global".to_string(),
364                    pattern: "pattern".to_string(),
365                    action: "enforce".to_string(),
366                },
367            ],
368            standards: vec![],
369            templates: vec![],
370        };
371
372        let project = Steering {
373            rules: vec![
374                SteeringRule {
375                    id: "rule-1".to_string(),
376                    description: "Project rule".to_string(),
377                    pattern: "project".to_string(),
378                    action: "enforce".to_string(),
379                },
380                SteeringRule {
381                    id: "rule-3".to_string(),
382                    description: "Only in project".to_string(),
383                    pattern: "pattern".to_string(),
384                    action: "enforce".to_string(),
385                },
386            ],
387            standards: vec![],
388            templates: vec![],
389        };
390
391        let result = SteeringLoader::merge(&global, &project);
392        assert!(result.is_ok());
393        let merged = result.unwrap();
394
395        // Should have 3 rules: rule-1 (project version), rule-2 (global), rule-3 (project)
396        assert_eq!(merged.rules.len(), 3);
397
398        // Find rule-1 and verify it's the project version
399        let rule_1 = merged.rules.iter().find(|r| r.id == "rule-1").unwrap();
400        assert_eq!(rule_1.description, "Project rule");
401        assert_eq!(rule_1.pattern, "project");
402    }
403
404    #[test]
405    fn test_steering_merge_empty_global() {
406        let global = Steering {
407            rules: vec![],
408            standards: vec![],
409            templates: vec![],
410        };
411
412        let project = Steering {
413            rules: vec![SteeringRule {
414                id: "rule-1".to_string(),
415                description: "Project rule".to_string(),
416                pattern: "pattern".to_string(),
417                action: "enforce".to_string(),
418            }],
419            standards: vec![],
420            templates: vec![],
421        };
422
423        let result = SteeringLoader::merge(&global, &project);
424        assert!(result.is_ok());
425        let merged = result.unwrap();
426        assert_eq!(merged.rules.len(), 1);
427        assert_eq!(merged.rules[0].id, "rule-1");
428    }
429
430    #[test]
431    fn test_steering_merge_empty_project() {
432        let global = Steering {
433            rules: vec![SteeringRule {
434                id: "rule-1".to_string(),
435                description: "Global rule".to_string(),
436                pattern: "pattern".to_string(),
437                action: "enforce".to_string(),
438            }],
439            standards: vec![],
440            templates: vec![],
441        };
442
443        let project = Steering {
444            rules: vec![],
445            standards: vec![],
446            templates: vec![],
447        };
448
449        let result = SteeringLoader::merge(&global, &project);
450        assert!(result.is_ok());
451        let merged = result.unwrap();
452        assert_eq!(merged.rules.len(), 1);
453        assert_eq!(merged.rules[0].id, "rule-1");
454    }
455
456    #[test]
457    fn test_steering_validate_valid() {
458        let steering = Steering {
459            rules: vec![SteeringRule {
460                id: "rule-1".to_string(),
461                description: "Valid rule".to_string(),
462                pattern: "pattern".to_string(),
463                action: "enforce".to_string(),
464            }],
465            standards: vec![Standard {
466                id: "std-1".to_string(),
467                description: "Valid standard".to_string(),
468            }],
469            templates: vec![TemplateRef {
470                id: "tpl-1".to_string(),
471                path: "templates/entity.rs".to_string(),
472            }],
473        };
474
475        let result = SteeringLoader::validate(&steering);
476        assert!(result.is_ok());
477    }
478
479    #[test]
480    fn test_steering_validate_duplicate_rule_ids() {
481        let steering = Steering {
482            rules: vec![
483                SteeringRule {
484                    id: "rule-1".to_string(),
485                    description: "First rule".to_string(),
486                    pattern: "pattern".to_string(),
487                    action: "enforce".to_string(),
488                },
489                SteeringRule {
490                    id: "rule-1".to_string(),
491                    description: "Duplicate rule".to_string(),
492                    pattern: "pattern".to_string(),
493                    action: "enforce".to_string(),
494                },
495            ],
496            standards: vec![],
497            templates: vec![],
498        };
499
500        let result = SteeringLoader::validate(&steering);
501        assert!(result.is_err());
502    }
503
504    #[test]
505    fn test_steering_validate_empty_rule_description() {
506        let steering = Steering {
507            rules: vec![SteeringRule {
508                id: "rule-1".to_string(),
509                description: String::new(),
510                pattern: "pattern".to_string(),
511                action: "enforce".to_string(),
512            }],
513            standards: vec![],
514            templates: vec![],
515        };
516
517        let result = SteeringLoader::validate(&steering);
518        // Should succeed but with warnings
519        assert!(result.is_ok());
520    }
521
522    #[test]
523    fn test_steering_validate_empty_template_path() {
524        let steering = Steering {
525            rules: vec![],
526            standards: vec![],
527            templates: vec![TemplateRef {
528                id: "tpl-1".to_string(),
529                path: String::new(),
530            }],
531        };
532
533        let result = SteeringLoader::validate(&steering);
534        // Should succeed but with warnings
535        assert!(result.is_ok());
536    }
537}