Skip to main content

batuta/comply/
rule.rs

1//! Stack Compliance Rule trait
2//!
3//! Defines the interface for compliance rules that can be checked
4//! across the Sovereign AI Stack projects.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::Path;
9
10/// Result of a compliance rule check
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RuleResult {
13    /// Whether the rule passed
14    pub passed: bool,
15    /// List of violations found
16    pub violations: Vec<RuleViolation>,
17    /// Suggestions for improvement (not violations)
18    pub suggestions: Vec<Suggestion>,
19    /// Additional context/metadata
20    pub context: Option<String>,
21}
22
23impl RuleResult {
24    /// Create a passing result
25    pub fn pass() -> Self {
26        Self { passed: true, violations: Vec::new(), suggestions: Vec::new(), context: None }
27    }
28
29    /// Create a passing result with suggestions
30    pub fn pass_with_suggestions(suggestions: Vec<Suggestion>) -> Self {
31        Self { passed: true, violations: Vec::new(), suggestions, context: None }
32    }
33
34    /// Create a failing result with violations
35    pub fn fail(violations: Vec<RuleViolation>) -> Self {
36        Self { passed: false, violations, suggestions: Vec::new(), context: None }
37    }
38
39    /// Add context to the result
40    pub fn with_context(mut self, context: impl Into<String>) -> Self {
41        self.context = Some(context.into());
42        self
43    }
44}
45
46/// A specific violation of a compliance rule
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct RuleViolation {
49    /// Violation code (e.g., "MK-001")
50    pub code: String,
51    /// Human-readable message
52    pub message: String,
53    /// Severity of the violation
54    pub severity: ViolationLevel,
55    /// File or location where violation was found
56    pub location: Option<String>,
57    /// Line number (if applicable)
58    pub line: Option<usize>,
59    /// Expected value/content
60    pub expected: Option<String>,
61    /// Actual value/content found
62    pub actual: Option<String>,
63    /// Whether this violation is auto-fixable
64    pub fixable: bool,
65}
66
67impl RuleViolation {
68    /// Create a new violation
69    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
70        Self {
71            code: code.into(),
72            message: message.into(),
73            severity: ViolationLevel::Error,
74            location: None,
75            line: None,
76            expected: None,
77            actual: None,
78            fixable: false,
79        }
80    }
81
82    /// Set the severity
83    pub fn with_severity(mut self, severity: ViolationLevel) -> Self {
84        self.severity = severity;
85        self
86    }
87
88    /// Set the location
89    pub fn with_location(mut self, location: impl Into<String>) -> Self {
90        self.location = Some(location.into());
91        self
92    }
93
94    /// Set the line number
95    pub fn with_line(mut self, line: usize) -> Self {
96        self.line = Some(line);
97        self
98    }
99
100    /// Set expected/actual values
101    pub fn with_diff(mut self, expected: impl Into<String>, actual: impl Into<String>) -> Self {
102        self.expected = Some(expected.into());
103        self.actual = Some(actual.into());
104        self
105    }
106
107    /// Mark as fixable
108    pub fn fixable(mut self) -> Self {
109        self.fixable = true;
110        self
111    }
112}
113
114/// Severity level of a violation
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum ViolationLevel {
117    /// Informational - not a real violation
118    Info,
119    /// Warning - should be fixed but not blocking
120    Warning,
121    /// Error - must be fixed
122    Error,
123    /// Critical - blocks releases
124    Critical,
125}
126
127impl fmt::Display for ViolationLevel {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            ViolationLevel::Info => write!(f, "INFO"),
131            ViolationLevel::Warning => write!(f, "WARN"),
132            ViolationLevel::Error => write!(f, "ERROR"),
133            ViolationLevel::Critical => write!(f, "CRITICAL"),
134        }
135    }
136}
137
138/// A suggestion for improvement (not a violation)
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct Suggestion {
141    /// Suggestion message
142    pub message: String,
143    /// Location (if applicable)
144    pub location: Option<String>,
145    /// Suggested fix
146    pub fix: Option<String>,
147}
148
149impl Suggestion {
150    /// Create a new suggestion
151    pub fn new(message: impl Into<String>) -> Self {
152        Self { message: message.into(), location: None, fix: None }
153    }
154
155    /// Add location
156    pub fn with_location(mut self, location: impl Into<String>) -> Self {
157        self.location = Some(location.into());
158        self
159    }
160
161    /// Add suggested fix
162    pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
163        self.fix = Some(fix.into());
164        self
165    }
166}
167
168/// Result of attempting to fix violations
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct FixResult {
171    /// Whether all fixes were applied successfully
172    pub success: bool,
173    /// Number of violations fixed
174    pub fixed_count: usize,
175    /// Number of violations that couldn't be fixed
176    pub failed_count: usize,
177    /// Details about each fix attempt
178    pub details: Vec<FixDetail>,
179}
180
181impl FixResult {
182    /// Create a successful fix result
183    pub fn success(fixed: usize) -> Self {
184        Self { success: true, fixed_count: fixed, failed_count: 0, details: Vec::new() }
185    }
186
187    /// Create a partial fix result
188    pub fn partial(fixed: usize, failed: usize, details: Vec<FixDetail>) -> Self {
189        Self { success: false, fixed_count: fixed, failed_count: failed, details }
190    }
191
192    /// Create a failed fix result
193    pub fn failure(error: impl Into<String>) -> Self {
194        Self {
195            success: false,
196            fixed_count: 0,
197            failed_count: 0,
198            details: vec![FixDetail::Error(error.into())],
199        }
200    }
201
202    /// Add a detail
203    pub fn with_detail(mut self, detail: FixDetail) -> Self {
204        self.details.push(detail);
205        self
206    }
207}
208
209/// Detail about a specific fix attempt
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub enum FixDetail {
212    /// Successfully applied fix
213    Fixed { code: String, description: String },
214    /// Failed to apply fix
215    FailedToFix { code: String, reason: String },
216    /// General error
217    Error(String),
218}
219
220/// Trait for stack compliance rules
221///
222/// Implement this trait to create custom compliance rules.
223pub trait StackComplianceRule: Send + Sync + std::fmt::Debug {
224    /// Unique identifier for this rule (e.g., "makefile-targets")
225    fn id(&self) -> &str;
226
227    /// Human-readable description
228    fn description(&self) -> &str;
229
230    /// Detailed help text (optional)
231    fn help(&self) -> Option<&str> {
232        None
233    }
234
235    /// Check a project for compliance
236    fn check(&self, project_path: &Path) -> anyhow::Result<RuleResult>;
237
238    /// Whether this rule can auto-fix violations
239    fn can_fix(&self) -> bool {
240        false
241    }
242
243    /// Attempt to fix violations (if supported)
244    fn fix(&self, project_path: &Path) -> anyhow::Result<FixResult> {
245        let _ = project_path;
246        Ok(FixResult::failure("Auto-fix not supported for this rule"))
247    }
248
249    /// Category of this rule
250    fn category(&self) -> RuleCategory {
251        RuleCategory::General
252    }
253}
254
255/// Category of compliance rules
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257pub enum RuleCategory {
258    /// General project structure
259    General,
260    /// Build system (Makefile, Cargo)
261    Build,
262    /// CI/CD workflows
263    Ci,
264    /// Code quality
265    Code,
266    /// Documentation
267    Docs,
268}
269
270impl fmt::Display for RuleCategory {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            RuleCategory::General => write!(f, "General"),
274            RuleCategory::Build => write!(f, "Build"),
275            RuleCategory::Ci => write!(f, "CI"),
276            RuleCategory::Code => write!(f, "Code"),
277            RuleCategory::Docs => write!(f, "Docs"),
278        }
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_rule_result_pass() {
288        let result = RuleResult::pass();
289        assert!(result.passed);
290        assert!(result.violations.is_empty());
291    }
292
293    #[test]
294    fn test_rule_result_fail() {
295        let violations = vec![RuleViolation::new("TEST-001", "Test violation")];
296        let result = RuleResult::fail(violations);
297        assert!(!result.passed);
298        assert_eq!(result.violations.len(), 1);
299    }
300
301    #[test]
302    fn test_violation_builder() {
303        let violation = RuleViolation::new("MK-001", "Missing target")
304            .with_severity(ViolationLevel::Error)
305            .with_location("Makefile")
306            .with_line(10)
307            .with_diff("test-fast", "test")
308            .fixable();
309
310        assert_eq!(violation.code, "MK-001");
311        assert_eq!(violation.severity, ViolationLevel::Error);
312        assert_eq!(violation.location, Some("Makefile".to_string()));
313        assert_eq!(violation.line, Some(10));
314        assert!(violation.fixable);
315    }
316
317    #[test]
318    fn test_fix_result() {
319        let result = FixResult::success(5);
320        assert!(result.success);
321        assert_eq!(result.fixed_count, 5);
322    }
323
324    #[test]
325    fn test_violation_level_display() {
326        assert_eq!(format!("{}", ViolationLevel::Info), "INFO");
327        assert_eq!(format!("{}", ViolationLevel::Warning), "WARN");
328        assert_eq!(format!("{}", ViolationLevel::Error), "ERROR");
329        assert_eq!(format!("{}", ViolationLevel::Critical), "CRITICAL");
330    }
331
332    #[test]
333    fn test_rule_result_pass_with_suggestions() {
334        let suggestions =
335            vec![Suggestion::new("Consider adding tests"), Suggestion::new("Could improve docs")];
336        let result = RuleResult::pass_with_suggestions(suggestions);
337        assert!(result.passed);
338        assert!(result.violations.is_empty());
339        assert_eq!(result.suggestions.len(), 2);
340    }
341
342    #[test]
343    fn test_rule_result_with_context() {
344        let result = RuleResult::pass().with_context("Checked 10 files");
345        assert_eq!(result.context, Some("Checked 10 files".to_string()));
346    }
347
348    #[test]
349    fn test_suggestion_new() {
350        let suggestion = Suggestion::new("Add more tests");
351        assert_eq!(suggestion.message, "Add more tests");
352        assert!(suggestion.location.is_none());
353        assert!(suggestion.fix.is_none());
354    }
355
356    #[test]
357    fn test_suggestion_with_location() {
358        let suggestion = Suggestion::new("Fix formatting").with_location("src/lib.rs");
359        assert_eq!(suggestion.location, Some("src/lib.rs".to_string()));
360    }
361
362    #[test]
363    fn test_suggestion_with_fix() {
364        let suggestion = Suggestion::new("Add license").with_fix("Add MIT license file");
365        assert_eq!(suggestion.fix, Some("Add MIT license file".to_string()));
366    }
367
368    #[test]
369    fn test_suggestion_builder_chain() {
370        let suggestion = Suggestion::new("Update config")
371            .with_location("Cargo.toml")
372            .with_fix("Add edition = \"2024\"");
373        assert_eq!(suggestion.message, "Update config");
374        assert!(suggestion.location.is_some());
375        assert!(suggestion.fix.is_some());
376    }
377
378    #[test]
379    fn test_fix_result_partial() {
380        let details = vec![
381            FixDetail::Fixed {
382                code: "MK-001".to_string(),
383                description: "Added target".to_string(),
384            },
385            FixDetail::FailedToFix {
386                code: "MK-002".to_string(),
387                reason: "File not writable".to_string(),
388            },
389        ];
390        let result = FixResult::partial(1, 1, details);
391        assert!(!result.success);
392        assert_eq!(result.fixed_count, 1);
393        assert_eq!(result.failed_count, 1);
394        assert_eq!(result.details.len(), 2);
395    }
396
397    #[test]
398    fn test_fix_result_failure() {
399        let result = FixResult::failure("Cannot write to disk");
400        assert!(!result.success);
401        assert_eq!(result.fixed_count, 0);
402        assert_eq!(result.failed_count, 0);
403        assert_eq!(result.details.len(), 1);
404    }
405
406    #[test]
407    fn test_fix_result_with_detail() {
408        let result = FixResult::success(1).with_detail(FixDetail::Fixed {
409            code: "TEST".to_string(),
410            description: "Test fix".to_string(),
411        });
412        assert_eq!(result.details.len(), 1);
413    }
414
415    #[test]
416    fn test_fix_detail_error_variant() {
417        let detail = FixDetail::Error("Something went wrong".to_string());
418        match detail {
419            FixDetail::Error(msg) => assert_eq!(msg, "Something went wrong"),
420            _ => panic!("Expected Error variant"),
421        }
422    }
423
424    #[test]
425    fn test_rule_category_display() {
426        assert_eq!(format!("{}", RuleCategory::General), "General");
427        assert_eq!(format!("{}", RuleCategory::Build), "Build");
428        assert_eq!(format!("{}", RuleCategory::Ci), "CI");
429        assert_eq!(format!("{}", RuleCategory::Code), "Code");
430        assert_eq!(format!("{}", RuleCategory::Docs), "Docs");
431    }
432
433    #[test]
434    fn test_violation_default_values() {
435        let violation = RuleViolation::new("TEST", "Test message");
436        assert_eq!(violation.severity, ViolationLevel::Error);
437        assert!(violation.location.is_none());
438        assert!(violation.line.is_none());
439        assert!(violation.expected.is_none());
440        assert!(violation.actual.is_none());
441        assert!(!violation.fixable);
442    }
443
444    #[test]
445    fn test_violation_with_severity_warning() {
446        let violation =
447            RuleViolation::new("WARN-001", "Warning").with_severity(ViolationLevel::Warning);
448        assert_eq!(violation.severity, ViolationLevel::Warning);
449    }
450
451    #[test]
452    fn test_violation_with_severity_critical() {
453        let violation = RuleViolation::new("CRIT-001", "Critical issue")
454            .with_severity(ViolationLevel::Critical);
455        assert_eq!(violation.severity, ViolationLevel::Critical);
456    }
457
458    #[test]
459    fn test_violation_with_severity_info() {
460        let violation =
461            RuleViolation::new("INFO-001", "Info message").with_severity(ViolationLevel::Info);
462        assert_eq!(violation.severity, ViolationLevel::Info);
463    }
464
465    #[test]
466    fn test_rule_category_equality() {
467        assert_eq!(RuleCategory::General, RuleCategory::General);
468        assert_eq!(RuleCategory::Build, RuleCategory::Build);
469        assert_eq!(RuleCategory::Ci, RuleCategory::Ci);
470        assert_ne!(RuleCategory::General, RuleCategory::Build);
471    }
472
473    #[test]
474    fn test_violation_level_equality() {
475        assert_eq!(ViolationLevel::Info, ViolationLevel::Info);
476        assert_eq!(ViolationLevel::Warning, ViolationLevel::Warning);
477        assert_ne!(ViolationLevel::Info, ViolationLevel::Warning);
478    }
479
480    #[test]
481    fn test_fix_detail_fixed_variant() {
482        let detail = FixDetail::Fixed {
483            code: "MK-001".to_string(),
484            description: "Added test target".to_string(),
485        };
486        match detail {
487            FixDetail::Fixed { code, description } => {
488                assert_eq!(code, "MK-001");
489                assert_eq!(description, "Added test target");
490            }
491            _ => panic!("Expected Fixed variant"),
492        }
493    }
494
495    #[test]
496    fn test_fix_detail_failed_variant() {
497        let detail = FixDetail::FailedToFix {
498            code: "MK-002".to_string(),
499            reason: "Permission denied".to_string(),
500        };
501        match detail {
502            FixDetail::FailedToFix { code, reason } => {
503                assert_eq!(code, "MK-002");
504                assert_eq!(reason, "Permission denied");
505            }
506            _ => panic!("Expected FailedToFix variant"),
507        }
508    }
509
510    #[test]
511    fn test_rule_result_fields() {
512        let result = RuleResult::fail(vec![RuleViolation::new("A", "B")]);
513        assert!(!result.passed);
514        assert_eq!(result.violations.len(), 1);
515        assert!(result.suggestions.is_empty());
516        assert!(result.context.is_none());
517    }
518
519    #[test]
520    fn test_violation_full_chain() {
521        let violation = RuleViolation::new("FULL-001", "Full test")
522            .with_severity(ViolationLevel::Warning)
523            .with_location("test.rs")
524            .with_line(42)
525            .with_diff("expected", "actual")
526            .fixable();
527
528        assert_eq!(violation.code, "FULL-001");
529        assert_eq!(violation.message, "Full test");
530        assert_eq!(violation.severity, ViolationLevel::Warning);
531        assert_eq!(violation.location, Some("test.rs".to_string()));
532        assert_eq!(violation.line, Some(42));
533        assert_eq!(violation.expected, Some("expected".to_string()));
534        assert_eq!(violation.actual, Some("actual".to_string()));
535        assert!(violation.fixable);
536    }
537
538    #[test]
539    fn test_rule_result_serialization() {
540        let result = RuleResult::pass().with_context("test context");
541        let json = serde_json::to_string(&result).expect("json serialize failed");
542        assert!(json.contains("\"passed\":true"));
543        assert!(json.contains("test context"));
544    }
545
546    #[test]
547    fn test_violation_serialization() {
548        let violation = RuleViolation::new("SER-001", "Serialize test");
549        let json = serde_json::to_string(&violation).expect("json serialize failed");
550        assert!(json.contains("SER-001"));
551        assert!(json.contains("Serialize test"));
552    }
553
554    #[test]
555    fn test_suggestion_serialization() {
556        let suggestion = Suggestion::new("Test suggestion").with_location("file.rs");
557        let json = serde_json::to_string(&suggestion).expect("json serialize failed");
558        assert!(json.contains("Test suggestion"));
559        assert!(json.contains("file.rs"));
560    }
561
562    #[test]
563    fn test_fix_result_serialization() {
564        let result = FixResult::success(3);
565        let json = serde_json::to_string(&result).expect("json serialize failed");
566        assert!(json.contains("\"success\":true"));
567        assert!(json.contains("\"fixed_count\":3"));
568    }
569
570    #[test]
571    fn test_rule_category_serialization() {
572        let category = RuleCategory::Build;
573        let json = serde_json::to_string(&category).expect("json serialize failed");
574        assert!(json.contains("Build"));
575    }
576
577    #[test]
578    fn test_violation_level_serialization() {
579        let level = ViolationLevel::Critical;
580        let json = serde_json::to_string(&level).expect("json serialize failed");
581        assert!(json.contains("Critical"));
582    }
583}