1use super::DriftAnalyzer;
2use crate::config::ConstraintRule;
3use crate::context::ProjectContext;
4use crate::domain::{Divergence, Location, RuleId, Severity};
5use quote::ToTokens;
6use std::path::Path;
7
8pub struct ConstraintAnalyzer {
26 rules: Vec<ConstraintRule>,
27}
28
29impl ConstraintAnalyzer {
30 pub fn new(rules: Vec<ConstraintRule>) -> Self {
31 Self { rules }
32 }
33}
34
35impl DriftAnalyzer for ConstraintAnalyzer {
36 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
37 if self.rules.is_empty() {
38 return Vec::new();
39 }
40
41 let mut out = Vec::new();
42 for rule in &self.rules {
43 for rs in &ctx.rust_files {
44 let rel = ctx.rel(rs);
45 if !rule.glob.is_match(rel) {
46 continue;
47 }
48 check_file(rs, rule, &mut out);
49 }
50 }
51 out
52 }
53}
54
55fn check_file(path: &Path, rule: &ConstraintRule, out: &mut Vec<Divergence>) {
56 let Ok(source) = std::fs::read_to_string(path) else {
57 return;
58 };
59 let Ok(file) = syn::parse_file(&source) else {
60 return;
61 };
62
63 for item in &file.items {
64 inspect_item(item, path, rule, out);
65 }
66}
67
68fn inspect_item(item: &syn::Item, path: &Path, rule: &ConstraintRule, out: &mut Vec<Divergence>) {
69 match item {
70 syn::Item::Fn(f) => {
71 compare_return_type(&f.sig, &f.sig.ident.to_string(), path, rule, out);
72 }
73 syn::Item::Impl(i) => {
74 for impl_item in &i.items {
75 if let syn::ImplItem::Fn(f) = impl_item {
76 compare_return_type(&f.sig, &f.sig.ident.to_string(), path, rule, out);
77 }
78 }
79 }
80 syn::Item::Mod(m) => {
81 if let Some((_, items)) = &m.content {
82 for item in items {
83 inspect_item(item, path, rule, out);
84 }
85 }
86 }
87 _ => {}
88 }
89}
90
91fn compare_return_type(
92 sig: &syn::Signature,
93 fn_name: &str,
94 path: &Path,
95 rule: &ConstraintRule,
96 out: &mut Vec<Divergence>,
97) {
98 let Some(expected) = &rule.return_type else {
99 return;
100 };
101
102 let actual = match &sig.output {
103 syn::ReturnType::Default => "()".to_string(),
104 syn::ReturnType::Type(_, ty) => ty.to_token_stream().to_string(),
105 };
106
107 if matches_with_wildcard(expected, &actual) {
108 return;
109 }
110
111 let line = ident_line(&sig.ident);
112 out.push(Divergence {
113 rule: RuleId::ConstraintViolation,
114 severity: Severity::Warning,
115 location: Location::new(path, line),
116 stated: format!(
117 "constraint `{}`: `{}` should return `{}`",
118 rule.name, fn_name, expected
119 ),
120 reality: format!("`{}` returns `{}`", fn_name, normalize(&actual)),
121 risk: "Functions under this glob have drifted from the stated contract.".to_string(),
122 attribution: None,
123 });
124}
125
126fn ident_line(ident: &syn::Ident) -> u32 {
127 ident.span().start().line as u32
128}
129
130fn matches_with_wildcard(expected: &str, actual: &str) -> bool {
133 let e = normalize(expected);
134 let a = normalize(actual);
135 if e == a {
136 return true;
137 }
138 if !e.contains('_') {
140 return false;
141 }
142 wildcard_eq(&e, &a)
143}
144
145fn normalize(s: &str) -> String {
146 s.chars().filter(|c| !c.is_whitespace()).collect()
147}
148
149fn wildcard_eq(pattern: &str, actual: &str) -> bool {
153 let mut p_idx = 0;
154 let mut a_idx = 0;
155 let p = pattern.as_bytes();
156 let a = actual.as_bytes();
157
158 while p_idx < p.len() && a_idx < a.len() {
159 if p[p_idx] == b'_' {
160 let mut depth_angle: i32 = 0;
164 let mut depth_paren: i32 = 0;
165 let mut matched = 0;
166 while a_idx + matched < a.len() {
167 let c = a[a_idx + matched];
168 if depth_angle == 0 && depth_paren == 0 && (c == b',' || c == b'>' || c == b')') {
169 break;
170 }
171 match c {
172 b'<' => depth_angle += 1,
173 b'>' => depth_angle -= 1,
174 b'(' => depth_paren += 1,
175 b')' => depth_paren -= 1,
176 _ => {}
177 }
178 matched += 1;
179 }
180 if matched == 0 {
181 return false;
182 }
183 a_idx += matched;
184 p_idx += 1;
185 } else if p[p_idx] == a[a_idx] {
186 p_idx += 1;
187 a_idx += 1;
188 } else {
189 return false;
190 }
191 }
192
193 p_idx == p.len() && a_idx == a.len()
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use globset::Glob;
200
201 fn rule_for(glob: &str, return_type: &str) -> ConstraintRule {
202 ConstraintRule {
203 name: "test_rule".to_string(),
204 glob: Glob::new(glob).unwrap().compile_matcher(),
205 return_type: Some(return_type.to_string()),
206 }
207 }
208
209 fn run(src: &str, rel_path: &str, rule: ConstraintRule) -> Vec<Divergence> {
210 let tmp = tempfile::tempdir().unwrap();
211 let path = tmp.path().join(rel_path);
212 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
213 std::fs::write(&path, src).unwrap();
214
215 let mut ctx = ProjectContext::new(tmp.path());
216 ctx.rust_files.push(path);
217
218 ConstraintAnalyzer::new(vec![rule]).analyze(&ctx)
219 }
220
221 #[test]
222 fn flags_wrong_return_type() {
223 let src = r#"
224 pub fn list_users() -> Option<Vec<User>> { None }
225 "#;
226 let rule = rule_for("src/handlers/**", "Result<_, ApiError>");
227 let divs = run(src, "src/handlers/users.rs", rule);
228 assert_eq!(divs.len(), 1);
229 assert_eq!(divs[0].rule, RuleId::ConstraintViolation);
230 assert_eq!(divs[0].severity, Severity::Warning);
231 }
232
233 #[test]
234 fn accepts_matching_return_type_with_wildcard() {
235 let src = r#"
236 pub fn list_users() -> Result<Vec<User>, ApiError> { todo!() }
237 "#;
238 let rule = rule_for("src/handlers/**", "Result<_, ApiError>");
239 let divs = run(src, "src/handlers/users.rs", rule);
240 assert!(divs.is_empty());
241 }
242
243 #[test]
244 fn ignores_files_outside_glob() {
245 let src = r#"
246 pub fn helper() -> i32 { 1 }
247 "#;
248 let rule = rule_for("src/handlers/**", "Result<_, ApiError>");
249 let divs = run(src, "src/utils/math.rs", rule);
250 assert!(divs.is_empty());
251 }
252
253 #[test]
254 fn checks_impl_methods() {
255 let src = r#"
256 pub struct Foo;
257 impl Foo {
258 pub fn not_a_result(&self) -> bool { true }
259 }
260 "#;
261 let rule = rule_for("src/handlers/**", "Result<_, ApiError>");
262 let divs = run(src, "src/handlers/foo.rs", rule);
263 assert_eq!(divs.len(), 1);
264 }
265
266 #[test]
267 fn wildcard_matches_any_single_segment() {
268 assert!(matches_with_wildcard(
269 "Result<_, ApiError>",
270 "Result<Vec<User>, ApiError>"
271 ));
272 assert!(matches_with_wildcard(
273 "Result<_, _>",
274 "Result<User, MyError>"
275 ));
276 assert!(!matches_with_wildcard(
277 "Result<_, ApiError>",
278 "Result<User, DbError>"
279 ));
280 }
281}