1use crate::error::{Severity, SpecError, ValidationError};
4use crate::models::{Standard, Steering, SteeringRule, TemplateRef};
5use std::fs;
6use std::path::Path;
7
8pub struct SteeringLoader;
10
11impl SteeringLoader {
12 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 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 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 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 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 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 fn parse_markdown(content: &str, path: &Path) -> Result<Steering, SpecError> {
103 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 Ok(Steering {
122 rules: vec![],
123 standards: vec![],
124 templates: vec![],
125 })
126 }
127
128 pub fn merge(global: &Steering, project: &Steering) -> Result<Steering, SpecError> {
142 let mut merged_rules = global.rules.clone();
144 for project_rule in &project.rules {
145 merged_rules.retain(|r| r.id != project_rule.id);
147 merged_rules.push(project_rule.clone());
149 }
150
151 let mut merged_standards = global.standards.clone();
153 for project_standard in &project.standards {
154 merged_standards.retain(|s| s.id != project_standard.id);
156 merged_standards.push(project_standard.clone());
158 }
159
160 let mut merged_templates = global.templates.clone();
162 for project_template in &project.templates {
163 merged_templates.retain(|t| t.id != project_template.id);
165 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 pub fn validate(steering: &Steering) -> Result<(), SpecError> {
193 let mut errors = vec![];
194
195 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 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 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 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 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 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 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 !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 assert_eq!(merged.rules.len(), 3);
397
398 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 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 assert!(result.is_ok());
536 }
537}