1use crate::schema::{CodeNode, CodeNodeKind};
10use std::collections::HashMap;
11use std::path::Path;
12use std::process::Command;
13
14#[derive(Debug, thiserror::Error)]
16pub enum MetricsError {
17 #[error("IO error: {0}")]
18 Io(#[from] std::io::Error),
19
20 #[error("Git error: {0}")]
21 Git(String),
22
23 #[error("JSON parse error: {0}")]
24 Json(String),
25}
26
27pub type Result<T> = std::result::Result<T, MetricsError>;
28
29pub fn enrich_with_git_timestamps(nodes: &mut [CodeNode], repo_root: &Path) -> Result<()> {
34 let file_paths: Vec<String> = nodes
36 .iter()
37 .filter_map(|n| extract_file_path(&n.id))
38 .collect::<std::collections::HashSet<_>>()
39 .into_iter()
40 .collect();
41
42 let timestamps = git_last_modified_batch(repo_root, &file_paths)?;
44
45 for node in nodes.iter_mut() {
47 if let Some(file_path) = extract_file_path(&node.id)
48 && let Some(&ts) = timestamps.get(&file_path)
49 {
50 node.last_modified = Some(ts);
51 }
52 }
53
54 Ok(())
55}
56
57fn git_last_modified_batch(
62 repo_root: &Path,
63 file_paths: &[String],
64) -> Result<HashMap<String, i64>> {
65 if file_paths.is_empty() {
66 return Ok(HashMap::new());
67 }
68
69 let mut args = vec![
72 "log".to_string(),
73 "--format=format:%ct".to_string(),
74 "--name-only".to_string(),
75 "--".to_string(),
76 ];
77 args.extend(file_paths.iter().cloned());
78
79 let output = Command::new("git")
80 .args(&args)
81 .current_dir(repo_root)
82 .output()?;
83
84 if !output.status.success() {
85 return Err(MetricsError::Git("git log batch failed".to_string()));
86 }
87
88 let stdout = String::from_utf8_lossy(&output.stdout);
89 let mut timestamps: HashMap<String, i64> = HashMap::new();
90 let mut current_ts: Option<i64> = None;
91
92 for line in stdout.lines() {
93 let trimmed = line.trim();
94 if trimmed.is_empty() {
95 continue;
96 }
97 if let Ok(epoch_secs) = trimmed.parse::<i64>() {
99 current_ts = Some(epoch_secs * 1000);
100 } else if let Some(ts) = current_ts {
101 timestamps.entry(trimmed.to_string()).or_insert(ts);
103 }
104 }
105
106 Ok(timestamps)
107}
108
109fn extract_file_path(id: &str) -> Option<String> {
113 let after_prefix = id.split_once(':').map(|(_, rest)| rest)?;
114 let file_part = after_prefix
115 .split_once("::")
116 .map_or(after_prefix, |(f, _)| f);
117 if file_part.ends_with(".py") {
118 Some(file_part.to_string())
119 } else {
120 None
121 }
122}
123
124#[derive(Debug, Clone, Default)]
126pub struct FileCoverage {
127 pub covered_lines: Vec<u32>,
129 pub missing_lines: Vec<u32>,
131 pub total_lines: u32,
133 pub coverage_pct: f64,
135}
136
137pub fn enrich_with_coverage(nodes: &mut [CodeNode], coverage: &HashMap<String, FileCoverage>) {
141 for node in nodes.iter_mut() {
142 if let Some(file_path) = extract_file_path(&node.id)
143 && let Some(cov) = coverage.get(&file_path)
144 {
145 node.coverage_pct = Some(cov.coverage_pct);
146 }
147 }
148}
149
150pub fn parse_coverage_json(json_str: &str) -> Result<HashMap<String, FileCoverage>> {
163 let parsed: serde_json::Value =
164 serde_json::from_str(json_str).map_err(|e| MetricsError::Json(e.to_string()))?;
165
166 let mut coverage = HashMap::new();
167
168 let Some(files) = parsed.get("files").and_then(|f| f.as_object()) else {
169 return Ok(coverage);
170 };
171
172 for (path, file_data) in files {
173 if let Some(pct) = file_data
174 .get("summary")
175 .and_then(|s| s.get("percent_covered"))
176 .and_then(|p| p.as_f64())
177 {
178 coverage.insert(
179 path.clone(),
180 FileCoverage {
181 coverage_pct: pct / 100.0,
182 ..Default::default()
183 },
184 );
185 }
186 }
187
188 Ok(coverage)
189}
190
191pub fn high_complexity_nodes(nodes: &[CodeNode], threshold: i32) -> Vec<&CodeNode> {
195 nodes
196 .iter()
197 .filter(|n| {
198 matches!(
199 n.kind,
200 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Test
201 ) && n.cyclomatic_complexity.is_some_and(|c| c > threshold)
202 })
203 .collect()
204}
205
206pub fn low_coverage_nodes(nodes: &[CodeNode], threshold: f64) -> Vec<&CodeNode> {
208 nodes
209 .iter()
210 .filter(|n| {
211 matches!(
212 n.kind,
213 CodeNodeKind::Function
214 | CodeNodeKind::Method
215 | CodeNodeKind::Test
216 | CodeNodeKind::Class
217 ) && n.coverage_pct.is_some_and(|c| c < threshold)
218 })
219 .collect()
220}
221
222pub fn largest_nodes(nodes: &[CodeNode], top_k: usize) -> Vec<&CodeNode> {
224 let mut sortable: Vec<&CodeNode> = nodes
225 .iter()
226 .filter(|n| {
227 matches!(
228 n.kind,
229 CodeNodeKind::Function | CodeNodeKind::Method | CodeNodeKind::Class
230 ) && n.loc.is_some()
231 })
232 .collect();
233
234 sortable.sort_by(|a, b| b.loc.cmp(&a.loc));
235 sortable.truncate(top_k);
236 sortable
237}
238
239#[derive(Debug, Clone)]
241pub struct CodebaseMetrics {
242 pub total_files: usize,
243 pub total_classes: usize,
244 pub total_functions: usize,
245 pub total_methods: usize,
246 pub total_tests: usize,
247 pub total_loc: i64,
248 pub avg_complexity: f64,
249 pub avg_coverage: Option<f64>,
250}
251
252pub fn compute_codebase_metrics(nodes: &[CodeNode]) -> CodebaseMetrics {
254 let total_files = nodes
255 .iter()
256 .filter(|n| n.kind == CodeNodeKind::File)
257 .count();
258 let total_classes = nodes
259 .iter()
260 .filter(|n| n.kind == CodeNodeKind::Class)
261 .count();
262 let total_functions = nodes
263 .iter()
264 .filter(|n| n.kind == CodeNodeKind::Function)
265 .count();
266 let total_methods = nodes
267 .iter()
268 .filter(|n| n.kind == CodeNodeKind::Method)
269 .count();
270 let total_tests = nodes
271 .iter()
272 .filter(|n| n.kind == CodeNodeKind::Test)
273 .count();
274
275 let total_loc: i64 = nodes
276 .iter()
277 .filter(|n| n.kind == CodeNodeKind::File)
278 .filter_map(|n| n.loc.map(|l| l as i64))
279 .sum();
280
281 let complexities: Vec<f64> = nodes
282 .iter()
283 .filter_map(|n| n.cyclomatic_complexity.map(|c| c as f64))
284 .collect();
285 let avg_complexity = if complexities.is_empty() {
286 0.0
287 } else {
288 complexities.iter().sum::<f64>() / complexities.len() as f64
289 };
290
291 let coverages: Vec<f64> = nodes.iter().filter_map(|n| n.coverage_pct).collect();
292 let avg_coverage = if coverages.is_empty() {
293 None
294 } else {
295 Some(coverages.iter().sum::<f64>() / coverages.len() as f64)
296 };
297
298 CodebaseMetrics {
299 total_files,
300 total_classes,
301 total_functions,
302 total_methods,
303 total_tests,
304 total_loc,
305 avg_complexity,
306 avg_coverage,
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn sample_nodes() -> Vec<CodeNode> {
315 vec![
316 CodeNode {
317 id: "file:brain/signal.py".to_string(),
318 kind: CodeNodeKind::File,
319 parent_id: None,
320 name: "signal.py".to_string(),
321 signature: None,
322 docstring: None,
323 body_hash: None,
324 body: None,
325 loc: Some(200),
326 cyclomatic_complexity: None,
327 coverage_pct: None,
328 last_modified: None,
329 ..Default::default()
330 },
331 CodeNode {
332 id: "func:brain/signal.py::fuse".to_string(),
333 kind: CodeNodeKind::Function,
334 parent_id: Some("mod:brain/signal.py".to_string()),
335 name: "fuse".to_string(),
336 signature: Some("def fuse(signals)".to_string()),
337 docstring: None,
338 body_hash: None,
339 body: None,
340 loc: Some(30),
341 cyclomatic_complexity: Some(8),
342 coverage_pct: Some(0.9),
343 last_modified: None,
344 ..Default::default()
345 },
346 CodeNode {
347 id: "func:brain/signal.py::simple".to_string(),
348 kind: CodeNodeKind::Function,
349 parent_id: Some("mod:brain/signal.py".to_string()),
350 name: "simple".to_string(),
351 signature: Some("def simple()".to_string()),
352 docstring: None,
353 body_hash: None,
354 body: None,
355 loc: Some(5),
356 cyclomatic_complexity: Some(1),
357 coverage_pct: Some(0.3),
358 last_modified: None,
359 ..Default::default()
360 },
361 CodeNode {
362 id: "method:brain/store.py::Store::save".to_string(),
363 kind: CodeNodeKind::Method,
364 parent_id: Some("class:brain/store.py::Store".to_string()),
365 name: "save".to_string(),
366 signature: Some("def save(self, key, value)".to_string()),
367 docstring: None,
368 body_hash: None,
369 body: None,
370 loc: Some(15),
371 cyclomatic_complexity: Some(12),
372 coverage_pct: Some(0.4),
373 last_modified: None,
374 ..Default::default()
375 },
376 CodeNode {
377 id: "func:brain/test_signal.py::test_fuse".to_string(),
378 kind: CodeNodeKind::Test,
379 parent_id: None,
380 name: "test_fuse".to_string(),
381 signature: Some("def test_fuse()".to_string()),
382 docstring: None,
383 body_hash: None,
384 body: None,
385 loc: Some(10),
386 cyclomatic_complexity: Some(2),
387 coverage_pct: Some(1.0),
388 last_modified: None,
389 ..Default::default()
390 },
391 ]
392 }
393
394 #[test]
395 fn test_extract_file_path() {
396 assert_eq!(
397 extract_file_path("func:brain/utils.py::helper"),
398 Some("brain/utils.py".to_string())
399 );
400 assert_eq!(
401 extract_file_path("file:brain/main.py"),
402 Some("brain/main.py".to_string())
403 );
404 assert_eq!(
405 extract_file_path("method:brain/store.py::Store::save"),
406 Some("brain/store.py".to_string())
407 );
408 assert_eq!(extract_file_path("invalid"), None);
409 }
410
411 #[test]
412 fn test_high_complexity_nodes() {
413 let nodes = sample_nodes();
414 let high = high_complexity_nodes(&nodes, 10);
415 assert_eq!(high.len(), 1);
416 assert_eq!(high[0].name, "save");
417 }
418
419 #[test]
420 fn test_low_coverage_nodes() {
421 let nodes = sample_nodes();
422 let low = low_coverage_nodes(&nodes, 0.5);
423 assert_eq!(low.len(), 2);
424 let names: Vec<&str> = low.iter().map(|n| n.name.as_str()).collect();
425 assert!(names.contains(&"simple"));
426 assert!(names.contains(&"save"));
427 }
428
429 #[test]
430 fn test_largest_nodes() {
431 let nodes = sample_nodes();
432 let largest = largest_nodes(&nodes, 2);
433 assert_eq!(largest.len(), 2);
434 assert_eq!(largest[0].name, "fuse"); assert_eq!(largest[1].name, "save"); }
437
438 #[test]
439 fn test_compute_codebase_metrics() {
440 let nodes = sample_nodes();
441 let metrics = compute_codebase_metrics(&nodes);
442
443 assert_eq!(metrics.total_files, 1);
444 assert_eq!(metrics.total_functions, 2);
445 assert_eq!(metrics.total_methods, 1);
446 assert_eq!(metrics.total_tests, 1);
447 assert_eq!(metrics.total_loc, 200); assert!(metrics.avg_complexity > 0.0);
449 assert!(metrics.avg_coverage.is_some());
450 }
451
452 #[test]
453 fn test_parse_coverage_json() {
454 let json = r#"{
455 "meta": {"version": "7.4"},
456 "files": {
457 "brain/signal.py": {
458 "summary": {
459 "covered_lines": 42,
460 "missing_lines": 8,
461 "percent_covered": 84.0
462 }
463 },
464 "brain/store.py": {
465 "summary": {
466 "covered_lines": 20,
467 "missing_lines": 30,
468 "percent_covered": 40.0
469 }
470 }
471 }
472 }"#;
473
474 let coverage = parse_coverage_json(json).unwrap();
475 assert_eq!(coverage.len(), 2);
476 assert!(
477 (coverage["brain/signal.py"].coverage_pct - 0.84).abs() < 0.01,
478 "Expected ~0.84, got {}",
479 coverage["brain/signal.py"].coverage_pct
480 );
481 assert!(
482 (coverage["brain/store.py"].coverage_pct - 0.40).abs() < 0.01,
483 "Expected ~0.40, got {}",
484 coverage["brain/store.py"].coverage_pct
485 );
486 }
487
488 #[test]
489 fn test_parse_coverage_json_empty() {
490 let coverage = parse_coverage_json("{}").unwrap();
491 assert!(coverage.is_empty());
492 }
493
494 #[test]
495 fn test_enrich_with_coverage() {
496 let mut nodes = sample_nodes();
497 let mut coverage = HashMap::new();
498 coverage.insert(
499 "brain/signal.py".to_string(),
500 FileCoverage {
501 coverage_pct: 0.75,
502 ..Default::default()
503 },
504 );
505
506 enrich_with_coverage(&mut nodes, &coverage);
507
508 let signal_nodes: Vec<_> = nodes
510 .iter()
511 .filter(|n| n.id.contains("brain/signal.py"))
512 .collect();
513 for node in &signal_nodes {
514 assert_eq!(
515 node.coverage_pct,
516 Some(0.75),
517 "{} should have coverage 0.75",
518 node.id
519 );
520 }
521
522 let store_node = nodes.iter().find(|n| n.id.contains("brain/store.py"));
524 assert!(store_node.is_some());
525 }
527
528 #[test]
529 fn test_high_complexity_excludes_files() {
530 let nodes = sample_nodes();
531 let high = high_complexity_nodes(&nodes, 0);
532 assert!(high.iter().all(|n| n.kind != CodeNodeKind::File));
534 }
535}