Skip to main content

spec_drift/analyzers/
constraint.rs

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
8/// ConstraintAnalyzer — enforces `constraint_violation` (heuristic, Warning).
9///
10/// Strategy: the user declares invariants in `spec-drift.toml` as
11/// `[[rules.constraint_violation]]` entries. The only shape supported today
12/// is a return-type constraint:
13///
14/// ```toml
15/// [[rules.constraint_violation]]
16/// name = "handlers_return_api_result"
17/// glob = "src/handlers/**"
18/// return_type = "Result<_, ApiError>"
19/// ```
20///
21/// For every `fn` under `glob`, the analyzer compares the function's return
22/// type to `return_type`. Matching is syntactic: whitespace is normalized and
23/// `_` in the pattern matches *any* single tokenized segment. This keeps the
24/// rule deterministic without requiring full type resolution.
25pub 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
130/// Compare two type strings after whitespace normalization, treating `_` in
131/// the expected pattern as a match-any single-segment wildcard.
132fn 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    // Fast path: no wildcard to worry about.
139    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
149/// Compare two normalized type strings where `_` in `pattern` matches a single
150/// top-level type argument. Nested angle brackets and parens are tracked so
151/// `Result<_, ApiError>` matches `Result<Vec<User>, ApiError>`.
152fn 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            // Match any non-empty segment that ends at a top-level `,`, `>` or
161            // `)`. A "top-level" boundary is one where our depth counters are
162            // back at zero relative to the start of this wildcard match.
163            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}