scute_core/code_complexity/
check.rs1use 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#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields, rename_all = "kebab-case")]
23pub struct Definition {
24 pub thresholds: Option<Thresholds>,
27 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
40pub 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()); }
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()); }
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}