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 scute_test_utils::TestDir;
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 = TestDir::new().source_file(
214            "two.rs",
215            "fn a() {} fn b(x: i32) -> i32 { if x > 0 { 1 } else { -1 } }",
216        );
217
218        let evals = check_dir(&dir.root());
219
220        assert_eq!(evals.len(), 2);
221        assert!(evals[0].target.contains('a'));
222        assert!(evals[1].target.contains('b'));
223    }
224
225    #[test]
226    fn returns_single_pass_for_empty_directory() {
227        let dir = TestDir::new();
228
229        let evals = check_dir(&dir.root());
230
231        assert_eq!(evals.len(), 1);
232        assert!(evals[0].is_pass());
233    }
234
235    #[test]
236    fn scores_only_functions_in_specified_file() {
237        let dir = TestDir::new()
238            .source_file("target.rs", "fn focused() { if true {} }")
239            .source_file("other.rs", "fn ignored() { if true {} }");
240
241        let evals = check(&[dir.path("target.rs")], &Definition::default()).unwrap();
242
243        assert!(evals.iter().all(|e| e.target.contains("focused")));
244    }
245
246    #[test]
247    fn applies_default_thresholds() {
248        let dir = TestDir::new().source_file("simple.rs", "fn f() { if true {} }");
249
250        let evals = check_dir(&dir.root());
251
252        assert_eq!(evals.len(), 1);
253        assert!(evals[0].is_pass()); // score 1, default warn 5
254    }
255
256    fn evidence_of(source: &str) -> Vec<Evidence> {
257        let dir = TestDir::new().source_file("a.rs", source);
258
259        let mut evals = check_dir(&dir.root());
260        let crate::Outcome::Completed { evidence, .. } = evals.remove(0).outcome else {
261            panic!("expected completed");
262        };
263        evidence
264    }
265
266    #[test_case(
267        "fn f() { if true {} }",
268        "flow break", "'if' conditional (+1)", None
269        ; "flow_break_has_no_suggestion"
270    )]
271    #[test_case(
272        "fn f() { for x in [1] { if true {} } }",
273        "nesting", "'if' nested 1 level: 'for > if' (+2)", Some("extract inner block into a function")
274        ; "nesting_shows_chain_and_suggests_extraction"
275    )]
276    #[test_case(
277        "fn f(x: bool) { if x {} else {} }",
278        "else", "'else' branch (+1)", Some("use a guard clause or early return")
279        ; "else_suggests_guard_clause"
280    )]
281    #[test_case(
282        "fn f(a: bool, b: bool) -> bool { a && b }",
283        "boolean logic", "'&&' operators (+1)", Some("extract into a named boolean")
284        ; "logical_single_operator"
285    )]
286    #[test_case(
287        "fn f(a: bool, b: bool, c: bool) -> bool { a && b || c }",
288        "boolean logic", "mixed '&&' and '||' operators (+2)", Some("extract into a named boolean")
289        ; "logical_mixed_operators"
290    )]
291    #[test_case(
292        "fn go(n: u64) -> u64 { go(n - 1) }",
293        "recursion", "recursive call to 'go' (+1)", Some("consider iterative approach")
294        ; "recursion_shows_function_name"
295    )]
296    #[test_case(
297        "fn f() { 'outer: loop { break 'outer; } }",
298        "jump", "'break' to label 'outer (+1)", Some("restructure to avoid labeled jump")
299        ; "jump_shows_label"
300    )]
301    fn evidence_formatting(source: &str, rule: &str, expected_found: &str, expected: Option<&str>) {
302        let evidence = evidence_of(source);
303        let entry = evidence
304            .iter()
305            .find(|e| e.rule.as_deref() == Some(rule))
306            .unwrap_or_else(|| panic!("no evidence with rule '{rule}'"));
307
308        assert_eq!(
309            entry.found, expected_found,
310            "evidence found mismatch for rule '{rule}'"
311        );
312        assert_eq!(entry.expected, expected.map(|s| Expected::Text(s.into())));
313    }
314
315    #[test]
316    fn evidence_includes_file_location() {
317        let evidence = evidence_of("fn f() { if true {} }");
318
319        assert!(evidence[0].location.as_ref().unwrap().contains("a.rs:1"));
320    }
321
322    #[test]
323    fn rejects_nonexistent_path() {
324        let result = check(&[PathBuf::from("/does/not/exist")], &Definition::default());
325
326        assert!(result.is_err());
327        assert_eq!(result.unwrap_err().code, "invalid_target");
328    }
329
330    #[test]
331    fn skips_non_rust_files() {
332        let dir = TestDir::new().source_file("code.py", "def foo(): pass");
333
334        let evals = check_dir(&dir.root());
335
336        assert_eq!(evals.len(), 1);
337        assert!(evals[0].is_pass()); // fallback pass, no rust files
338    }
339
340    #[test]
341    fn rejects_nonexistent_file() {
342        let result = check(
343            &[PathBuf::from("/nonexistent/file.rs")],
344            &Definition::default(),
345        );
346
347        assert!(result.is_err());
348        assert_eq!(result.unwrap_err().code, "invalid_target");
349    }
350
351    #[test]
352    fn rejects_unsupported_file_extension() {
353        let dir = TestDir::new().source_file("code.py", "def foo(): pass");
354
355        let result = check(&[dir.path("code.py")], &Definition::default());
356
357        assert!(result.is_err());
358        assert_eq!(result.unwrap_err().code, "invalid_target");
359    }
360}