Skip to main content

scute_core/code_complexity/
check.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use super::score;
6use crate::files;
7use crate::{Evaluation, Evidence, ExecutionError, Expected, Thresholds};
8
9pub const CHECK_NAME: &str = "code-complexity";
10
11/// Configuration for the code complexity check.
12///
13/// All fields are optional and fall back to sensible defaults when absent.
14///
15/// ```
16/// use scute_core::code_complexity::Definition;
17///
18/// // Zero-config: warn at 5, fail at 10
19/// let default = Definition::default();
20/// ```
21#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields, rename_all = "kebab-case")]
23pub struct Definition {
24    /// Warn/fail boundaries for per-function complexity scores.
25    /// Defaults to warn: 5, fail: 10.
26    pub thresholds: Option<Thresholds>,
27    /// Glob patterns for files to exclude from scanning.
28    pub exclude: Option<Vec<String>>,
29}
30
31impl Definition {
32    fn thresholds(&self) -> Thresholds {
33        self.thresholds.clone().unwrap_or(Thresholds {
34            warn: Some(5),
35            fail: Some(10),
36        })
37    }
38}
39
40/// Score cognitive complexity for every function in the given paths.
41///
42/// Accepts a mix of files and directories. Directories are walked to
43/// discover supported files (respecting `exclude` patterns).
44///
45/// Returns one [`Evaluation`] per function found. When no supported files
46/// exist, returns a single passing evaluation.
47///
48/// ```no_run
49/// use std::path::PathBuf;
50/// use scute_core::code_complexity::{self, Definition};
51///
52/// let evals = code_complexity::check(
53///     &[PathBuf::from("src/")],
54///     &Definition::default(),
55/// ).unwrap();
56/// for eval in &evals {
57///     if eval.is_fail() {
58///         eprintln!("complex function: {}", eval.target);
59///     }
60/// }
61/// ```
62///
63/// # Errors
64///
65/// Returns `ExecutionError` if any path is invalid.
66pub fn check(
67    paths: &[PathBuf],
68    definition: &Definition,
69) -> Result<Vec<Evaluation>, ExecutionError> {
70    let thresholds = definition.thresholds();
71    let exclude = definition.exclude.as_deref().unwrap_or_default();
72
73    let files = files::resolve_paths(paths, &["rs"], exclude).map_err(|e| ExecutionError {
74        code: "invalid_target".into(),
75        message: e.to_string(),
76        recovery: "check that the path exists and is readable".into(),
77    })?;
78
79    let language: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into();
80    let mut evaluations = Vec::new();
81
82    for path in &files {
83        let Ok(source) = std::fs::read_to_string(path) else {
84            continue;
85        };
86        evaluations.extend(score_file(path, &source, &language, &thresholds));
87    }
88
89    if evaluations.is_empty() {
90        let label = paths
91            .first()
92            .map_or_else(|| ".".into(), |p| p.display().to_string());
93        evaluations.push(Evaluation::completed(label, 0, thresholds, vec![]));
94    }
95
96    Ok(evaluations)
97}
98
99fn score_file(
100    path: &Path,
101    source: &str,
102    language: &tree_sitter::Language,
103    thresholds: &Thresholds,
104) -> Vec<Evaluation> {
105    score::score_functions(source, language)
106        .into_iter()
107        .map(|func| {
108            let target = format!("{}:{}:{}", path.display(), func.line, func.name);
109            let evidence = func
110                .contributors
111                .iter()
112                .map(|c| format_evidence(c, path))
113                .collect();
114            Evaluation::completed(target, func.score, thresholds.clone(), evidence)
115        })
116        .collect()
117}
118
119fn format_nesting_chain(chain: &[score::Construct]) -> String {
120    chain
121        .iter()
122        .map(|c| c.label())
123        .collect::<Vec<_>>()
124        .join(" > ")
125}
126
127fn pluralize_levels(n: u64) -> &'static str {
128    if n == 1 { "level" } else { "levels" }
129}
130
131fn format_ops(operators: &[String]) -> String {
132    let mut unique: Vec<&str> = operators.iter().map(String::as_str).collect();
133    unique.dedup();
134    let quoted: Vec<String> = unique.iter().map(|o| format!("'{o}'")).collect();
135    let prefix = if unique.len() > 1 { "mixed " } else { "" };
136    format!("{prefix}{}", quoted.join(" and "))
137}
138
139fn format_evidence(c: &score::Contributor, path: &Path) -> Evidence {
140    let location = Some(format!("{}:{}", path.display(), c.line));
141    let text = |s: &str| Some(Expected::Text(s.into()));
142
143    let (rule, found, expected) = match &c.kind {
144        score::ContributorKind::FlowBreak { construct } => (
145            "flow break",
146            format!(
147                "'{}' {} (+{})",
148                construct.label(),
149                construct.flow_break_label(),
150                c.increment
151            ),
152            None,
153        ),
154        score::ContributorKind::Nesting {
155            construct,
156            depth,
157            chain,
158        } => {
159            let name = construct.label();
160            let chain = format_nesting_chain(chain);
161            let levels = pluralize_levels(*depth);
162            (
163                "nesting",
164                format!(
165                    "'{name}' nested {depth} {levels}: '{chain}' (+{})",
166                    c.increment
167                ),
168                text("extract inner block into a function"),
169            )
170        }
171        score::ContributorKind::Else => (
172            "else",
173            format!("'else' branch (+{})", c.increment),
174            text("use a guard clause or early return"),
175        ),
176        score::ContributorKind::Logical { operators } => (
177            "boolean logic",
178            format!("{} operators (+{})", format_ops(operators), c.increment),
179            text("extract into a named boolean"),
180        ),
181        score::ContributorKind::Recursion { fn_name } => (
182            "recursion",
183            format!("recursive call to '{fn_name}' (+{})", c.increment),
184            text("consider iterative approach"),
185        ),
186        score::ContributorKind::Jump { keyword, label } => (
187            "jump",
188            format!("'{}' to label {label} (+{})", keyword.label(), c.increment),
189            text("restructure to avoid labeled jump"),
190        ),
191    };
192
193    Evidence {
194        rule: Some(rule.to_string()),
195        location,
196        found,
197        expected,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205    use test_case::test_case;
206
207    fn check_dir(dir: &Path) -> Vec<Evaluation> {
208        check(&[dir.to_path_buf()], &Definition::default()).unwrap()
209    }
210
211    #[test]
212    fn returns_one_evaluation_per_function() {
213        let dir = tempfile::tempdir().unwrap();
214        fs::write(
215            dir.path().join("two.rs"),
216            "fn a() {} fn b(x: i32) -> i32 { if x > 0 { 1 } else { -1 } }",
217        )
218        .unwrap();
219
220        let evals = check_dir(dir.path());
221
222        assert_eq!(evals.len(), 2);
223        assert!(evals[0].target.contains('a'));
224        assert!(evals[1].target.contains('b'));
225    }
226
227    #[test]
228    fn returns_single_pass_for_empty_directory() {
229        let dir = tempfile::tempdir().unwrap();
230
231        let evals = check_dir(dir.path());
232
233        assert_eq!(evals.len(), 1);
234        assert!(evals[0].is_pass());
235    }
236
237    #[test]
238    fn scores_only_functions_in_specified_file() {
239        let dir = tempfile::tempdir().unwrap();
240        let target = dir.path().join("target.rs");
241        fs::write(&target, "fn focused() { if true {} }").unwrap();
242        fs::write(dir.path().join("other.rs"), "fn ignored() { if true {} }").unwrap();
243
244        let evals = check(&[target], &Definition::default()).unwrap();
245
246        assert!(evals.iter().all(|e| e.target.contains("focused")));
247    }
248
249    #[test]
250    fn applies_default_thresholds() {
251        let dir = tempfile::tempdir().unwrap();
252        fs::write(dir.path().join("simple.rs"), "fn f() { if true {} }").unwrap();
253
254        let evals = check_dir(dir.path());
255
256        assert_eq!(evals.len(), 1);
257        assert!(evals[0].is_pass()); // score 1, default warn 5
258    }
259
260    fn evidence_of(source: &str) -> Vec<Evidence> {
261        let dir = tempfile::tempdir().unwrap();
262        fs::write(dir.path().join("a.rs"), source).unwrap();
263
264        let mut evals = check_dir(dir.path());
265        let crate::Outcome::Completed { evidence, .. } = evals.remove(0).outcome else {
266            panic!("expected completed");
267        };
268        evidence
269    }
270
271    #[test_case(
272        "fn f() { if true {} }",
273        "flow break", "'if' conditional (+1)", None
274        ; "flow_break_has_no_suggestion"
275    )]
276    #[test_case(
277        "fn f() { for x in [1] { if true {} } }",
278        "nesting", "'if' nested 1 level: 'for > if' (+2)", Some("extract inner block into a function")
279        ; "nesting_shows_chain_and_suggests_extraction"
280    )]
281    #[test_case(
282        "fn f(x: bool) { if x {} else {} }",
283        "else", "'else' branch (+1)", Some("use a guard clause or early return")
284        ; "else_suggests_guard_clause"
285    )]
286    #[test_case(
287        "fn f(a: bool, b: bool) -> bool { a && b }",
288        "boolean logic", "'&&' operators (+1)", Some("extract into a named boolean")
289        ; "logical_single_operator"
290    )]
291    #[test_case(
292        "fn f(a: bool, b: bool, c: bool) -> bool { a && b || c }",
293        "boolean logic", "mixed '&&' and '||' operators (+2)", Some("extract into a named boolean")
294        ; "logical_mixed_operators"
295    )]
296    #[test_case(
297        "fn go(n: u64) -> u64 { go(n - 1) }",
298        "recursion", "recursive call to 'go' (+1)", Some("consider iterative approach")
299        ; "recursion_shows_function_name"
300    )]
301    #[test_case(
302        "fn f() { 'outer: loop { break 'outer; } }",
303        "jump", "'break' to label 'outer (+1)", Some("restructure to avoid labeled jump")
304        ; "jump_shows_label"
305    )]
306    fn evidence_formatting(source: &str, rule: &str, expected_found: &str, expected: Option<&str>) {
307        let evidence = evidence_of(source);
308        let entry = evidence
309            .iter()
310            .find(|e| e.rule.as_deref() == Some(rule))
311            .unwrap_or_else(|| panic!("no evidence with rule '{rule}'"));
312
313        assert_eq!(
314            entry.found, expected_found,
315            "evidence found mismatch for rule '{rule}'"
316        );
317        assert_eq!(entry.expected, expected.map(|s| Expected::Text(s.into())));
318    }
319
320    #[test]
321    fn evidence_includes_file_location() {
322        let evidence = evidence_of("fn f() { if true {} }");
323
324        assert!(evidence[0].location.as_ref().unwrap().contains("a.rs:1"));
325    }
326
327    #[test]
328    fn rejects_nonexistent_path() {
329        let result = check(&[PathBuf::from("/does/not/exist")], &Definition::default());
330
331        assert!(result.is_err());
332        assert_eq!(result.unwrap_err().code, "invalid_target");
333    }
334
335    #[test]
336    fn skips_non_rust_files() {
337        let dir = tempfile::tempdir().unwrap();
338        fs::write(dir.path().join("code.py"), "def foo(): pass").unwrap();
339
340        let evals = check_dir(dir.path());
341
342        assert_eq!(evals.len(), 1);
343        assert!(evals[0].is_pass()); // fallback pass, no rust files
344    }
345
346    #[test]
347    fn rejects_nonexistent_file() {
348        let result = check(
349            &[PathBuf::from("/nonexistent/file.rs")],
350            &Definition::default(),
351        );
352
353        assert!(result.is_err());
354        assert_eq!(result.unwrap_err().code, "invalid_target");
355    }
356
357    #[test]
358    fn rejects_unsupported_file_extension() {
359        let dir = tempfile::tempdir().unwrap();
360        let py_file = dir.path().join("code.py");
361        fs::write(&py_file, "def foo(): pass").unwrap();
362
363        let result = check(&[py_file], &Definition::default());
364
365        assert!(result.is_err());
366        assert_eq!(result.unwrap_err().code, "invalid_target");
367    }
368}