Skip to main content

nusy_codegraph/
metrics.rs

1//! Code metrics computation — LOC, complexity, coverage, git history.
2//!
3//! The parser already computes LOC and cyclomatic complexity inline.
4//! This module adds:
5//! - `last_modified` from `git log` output
6//! - Coverage import from pytest JSON/XML reports
7//! - Query helpers for filtering nodes by metric thresholds
8
9use crate::schema::{CodeNode, CodeNodeKind};
10use std::collections::HashMap;
11use std::path::Path;
12use std::process::Command;
13
14/// Errors from metrics operations.
15#[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
29/// Enrich CodeNodes with `last_modified` timestamps from git log.
30///
31/// Runs `git log` for each unique file path and applies the timestamp
32/// to all nodes in that file.
33pub fn enrich_with_git_timestamps(nodes: &mut [CodeNode], repo_root: &Path) -> Result<()> {
34    // Collect unique file paths from node IDs
35    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    // Get last modified time for each file
43    let timestamps = git_last_modified_batch(repo_root, &file_paths)?;
44
45    // Apply timestamps to nodes
46    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
57/// Get the last modification timestamp (epoch millis) for multiple files via git.
58///
59/// Uses a single `git log` invocation to get timestamps for all files at once,
60/// avoiding O(n) process spawns for large codebases.
61fn 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    // Single-pass: `git log --format="%ct" --name-only` with all file paths.
70    // Each commit block: timestamp line, then file names, then blank line.
71    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        // Try to parse as a timestamp (pure digits)
98        if let Ok(epoch_secs) = trimmed.parse::<i64>() {
99            current_ts = Some(epoch_secs * 1000);
100        } else if let Some(ts) = current_ts {
101            // This is a file path line — only record the FIRST (most recent) timestamp
102            timestamps.entry(trimmed.to_string()).or_insert(ts);
103        }
104    }
105
106    Ok(timestamps)
107}
108
109/// Extract the file path from a CodeNode ID.
110///
111/// IDs look like `func:brain/utils.py::helper` → `brain/utils.py`
112fn 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/// Coverage data for a single file (line-level).
125#[derive(Debug, Clone, Default)]
126pub struct FileCoverage {
127    /// Lines that were executed.
128    pub covered_lines: Vec<u32>,
129    /// Lines that were not executed.
130    pub missing_lines: Vec<u32>,
131    /// Total executable lines.
132    pub total_lines: u32,
133    /// Coverage percentage (0.0 to 1.0).
134    pub coverage_pct: f64,
135}
136
137/// Enrich CodeNodes with coverage percentages from a coverage map.
138///
139/// The coverage map keys are file paths (matching node ID file paths).
140pub 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
150/// Parse a coverage.json (pytest-cov JSON format) into a file-level coverage map.
151///
152/// Expected format:
153/// ```json
154/// {
155///   "files": {
156///     "brain/utils.py": {
157///       "summary": { "covered_lines": 42, "missing_lines": 8, "percent_covered": 84.0 }
158///     }
159///   }
160/// }
161/// ```
162pub 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
191// ─── Query helpers ──────────────────────────────────────────────────────────
192
193/// Find nodes with cyclomatic complexity above a threshold.
194pub 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
206/// Find nodes with coverage below a threshold (0.0 to 1.0).
207pub 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
222/// Find the largest nodes by LOC.
223pub 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/// Compute aggregate metrics for the codebase.
240#[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
252/// Compute aggregate metrics from a set of CodeNodes.
253pub 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"); // 30 LOC
435        assert_eq!(largest[1].name, "save"); // 15 LOC
436    }
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); // from file node
448        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        // All nodes from signal.py should have 0.75 coverage
509        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        // Nodes from store.py should be unchanged
523        let store_node = nodes.iter().find(|n| n.id.contains("brain/store.py"));
524        assert!(store_node.is_some());
525        // store.py was not in coverage map, so it keeps its original value
526    }
527
528    #[test]
529    fn test_high_complexity_excludes_files() {
530        let nodes = sample_nodes();
531        let high = high_complexity_nodes(&nodes, 0);
532        // Should not include the File node even though it has no complexity
533        assert!(high.iter().all(|n| n.kind != CodeNodeKind::File));
534    }
535}