smelt_validator/semantic/
intent.rs1use crate::config::IntentConfig;
4use crate::rules::ValidationRule;
5use crate::validator::{ValidationSeverity, Violation};
6use smelt_core::{IntentRecord, SemanticDelta};
7
8pub struct IntentValidator {
10 config: IntentConfig,
11}
12
13impl IntentValidator {
14 pub fn new(config: IntentConfig) -> Self {
16 Self { config }
17 }
18
19 fn check_scope(&self, delta: &SemanticDelta, intent: &IntentRecord) -> Vec<Violation> {
21 let mut violations = Vec::new();
22
23 if self.config.require_rationale_for_large_changes
25 && delta.impact_summary.files_affected >= self.config.large_change_threshold
26 && intent.rationale.is_none()
27 {
28 violations.push(Violation {
29 rule: "large-change-rationale".to_string(),
30 severity: ValidationSeverity::Warning,
31 message: format!(
32 "Large change ({} files affected) without rationale",
33 delta.impact_summary.files_affected
34 ),
35 location: None,
36 suggestion: Some(format!(
37 "Provide rationale for changes affecting {} or more files",
38 self.config.large_change_threshold
39 )),
40 });
41 }
42
43 for constraint in &intent.constraints {
45 if constraint.required {
46 tracing::debug!(
49 "Checking constraint: {} = {}",
50 constraint.name,
51 constraint.value
52 );
53 }
54 }
55
56 violations
57 }
58
59 fn check_breaking_allowed(
61 &self,
62 delta: &SemanticDelta,
63 intent: &IntentRecord,
64 ) -> Vec<Violation> {
65 let mut violations = Vec::new();
66
67 let breaking_allowed = intent
69 .constraints
70 .iter()
71 .any(|c| c.name == "allow_breaking_changes" && c.value.to_lowercase() == "true");
72
73 if delta.impact_summary.breaking_changes > 0 && !breaking_allowed {
74 violations.push(Violation {
75 rule: "breaking-not-allowed".to_string(),
76 severity: ValidationSeverity::Error,
77 message: format!(
78 "{} breaking change(s) detected but intent does not allow breaking changes",
79 delta.impact_summary.breaking_changes
80 ),
81 location: None,
82 suggestion: Some(
83 "Add constraint 'allow_breaking_changes: true' to intent if intentional"
84 .to_string(),
85 ),
86 });
87 }
88
89 violations
90 }
91}
92
93impl ValidationRule for IntentValidator {
94 fn name(&self) -> &'static str {
95 "intent"
96 }
97
98 fn validate(&self, delta: &SemanticDelta, intent: Option<&IntentRecord>) -> Vec<Violation> {
99 let Some(intent) = intent else {
100 return Vec::new();
101 };
102
103 if !self.config.validate_scope {
104 return Vec::new();
105 }
106
107 let mut violations = Vec::new();
108 violations.extend(self.check_scope(delta, intent));
109 violations.extend(self.check_breaking_allowed(delta, intent));
110
111 violations
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use chrono::Utc;
119 use smelt_core::{Author, AuthorType, Constraint, ContextLinks, ImpactSummary, IntentStatus};
120 use uuid::Uuid;
121
122 fn make_intent(rationale: Option<String>, constraints: Vec<Constraint>) -> IntentRecord {
123 IntentRecord {
124 id: Uuid::new_v4(),
125 created_at: Utc::now(),
126 author: Author {
127 name: "Test".to_string(),
128 email: "test@test.com".to_string(),
129 author_type: AuthorType::Human,
130 },
131 goal: "Test goal".to_string(),
132 rationale,
133 constraints,
134 context_links: ContextLinks::default(),
135 status: IntentStatus::InProgress,
136 baseline_snapshot_id: None,
137 }
138 }
139
140 fn make_delta(files_affected: usize, breaking_changes: usize) -> SemanticDelta {
141 SemanticDelta {
142 id: Uuid::new_v4(),
143 intent_id: Uuid::new_v4(),
144 timestamp: Utc::now(),
145 from_snapshot: Uuid::new_v4(),
146 to_snapshot: Uuid::new_v4(),
147 changes: Vec::new(),
148 impact_summary: ImpactSummary {
149 files_affected,
150 breaking_changes,
151 ..Default::default()
152 },
153 }
154 }
155
156 #[test]
157 fn test_large_change_with_rationale_ok() {
158 let config = IntentConfig {
159 require_rationale_for_large_changes: true,
160 large_change_threshold: 5,
161 ..Default::default()
162 };
163
164 let validator = IntentValidator::new(config);
165 let intent = make_intent(Some("Major refactoring".to_string()), vec![]);
166 let delta = make_delta(10, 0);
167
168 let violations = validator.validate(&delta, Some(&intent));
169 assert!(violations.is_empty());
170 }
171
172 #[test]
173 fn test_large_change_without_rationale() {
174 let config = IntentConfig {
175 require_rationale_for_large_changes: true,
176 large_change_threshold: 5,
177 ..Default::default()
178 };
179
180 let validator = IntentValidator::new(config);
181 let intent = make_intent(None, vec![]);
182 let delta = make_delta(10, 0);
183
184 let violations = validator.validate(&delta, Some(&intent));
185 assert_eq!(violations.len(), 1);
186 assert_eq!(violations[0].rule, "large-change-rationale");
187 }
188
189 #[test]
190 fn test_breaking_changes_not_allowed() {
191 let config = IntentConfig::default();
192
193 let validator = IntentValidator::new(config);
194 let intent = make_intent(None, vec![]);
195 let delta = make_delta(1, 2);
196
197 let violations = validator.validate(&delta, Some(&intent));
198 assert_eq!(violations.len(), 1);
199 assert_eq!(violations[0].rule, "breaking-not-allowed");
200 }
201
202 #[test]
203 fn test_breaking_changes_allowed_by_constraint() {
204 let config = IntentConfig::default();
205
206 let validator = IntentValidator::new(config);
207 let intent = make_intent(
208 None,
209 vec![Constraint {
210 name: "allow_breaking_changes".to_string(),
211 value: "true".to_string(),
212 required: false,
213 }],
214 );
215 let delta = make_delta(1, 2);
216
217 let violations = validator.validate(&delta, Some(&intent));
218 assert!(violations.is_empty());
219 }
220}