Skip to main content

codemem_engine/enrichment/
complexity.rs

1//! Cyclomatic and cognitive complexity metrics for functions/methods.
2
3use super::{resolve_path, EnrichResult};
4use crate::CodememEngine;
5use codemem_core::{CodememError, GraphBackend, NodeKind};
6use serde_json::json;
7use std::collections::HashMap;
8use std::path::Path;
9
10impl CodememEngine {
11    /// Enrich the graph with cyclomatic and cognitive complexity metrics for functions/methods.
12    ///
13    /// For each Function/Method node, reads the source file, counts decision points
14    /// (if/else/match/for/while/loop/&&/||) as cyclomatic complexity and measures
15    /// max nesting depth as a cognitive complexity proxy. High-complexity functions
16    /// (cyclomatic > 10) produce Insight memories.
17    pub fn enrich_complexity(
18        &self,
19        namespace: Option<&str>,
20        project_root: Option<&Path>,
21    ) -> Result<EnrichResult, CodememError> {
22        let all_nodes = {
23            let graph = self.lock_graph()?;
24            graph.get_all_nodes()
25        };
26
27        // Collect function/method nodes with file info
28        struct SymbolInfo {
29            node_id: String,
30            label: String,
31            file_path: String,
32            line_start: usize,
33            line_end: usize,
34        }
35
36        let mut symbols: Vec<SymbolInfo> = Vec::new();
37        for node in &all_nodes {
38            if !matches!(node.kind, NodeKind::Function | NodeKind::Method) {
39                continue;
40            }
41            let file_path = match node.payload.get("file_path").and_then(|v| v.as_str()) {
42                Some(fp) => fp.to_string(),
43                None => continue,
44            };
45            let line_start = node
46                .payload
47                .get("line_start")
48                .and_then(|v| v.as_u64())
49                .unwrap_or(0) as usize;
50            let line_end = node
51                .payload
52                .get("line_end")
53                .and_then(|v| v.as_u64())
54                .unwrap_or(0) as usize;
55            if line_end <= line_start {
56                continue;
57            }
58            symbols.push(SymbolInfo {
59                node_id: node.id.clone(),
60                label: node.label.clone(),
61                file_path,
62                line_start,
63                line_end,
64            });
65        }
66
67        // Cache file contents to avoid re-reading
68        let mut file_cache: HashMap<String, Vec<String>> = HashMap::new();
69        let mut annotated = 0usize;
70        let mut insights_stored = 0usize;
71
72        // Nodes to annotate (collected first, then applied in a single lock scope)
73        struct ComplexityData {
74            node_id: String,
75            cyclomatic: usize,
76            cognitive: usize,
77        }
78        let mut complexity_data: Vec<ComplexityData> = Vec::new();
79
80        // Insights to store (collected first, then stored outside the lock)
81        struct ComplexityInsight {
82            content: String,
83            importance: f64,
84            node_id: String,
85        }
86        let mut pending_insights: Vec<ComplexityInsight> = Vec::new();
87
88        for sym in &symbols {
89            let lines = file_cache.entry(sym.file_path.clone()).or_insert_with(|| {
90                std::fs::read_to_string(resolve_path(&sym.file_path, project_root))
91                    .unwrap_or_default()
92                    .lines()
93                    .map(String::from)
94                    .collect()
95            });
96
97            // Extract the function's lines (1-indexed to 0-indexed)
98            let start = sym.line_start.saturating_sub(1);
99            let end = sym.line_end.min(lines.len());
100            if start >= end {
101                continue;
102            }
103            let fn_lines = &lines[start..end];
104
105            // Count cyclomatic complexity: decision points
106            let mut cyclomatic: usize = 1; // base path
107            let mut max_depth: usize = 0;
108            let mut current_depth: usize = 0;
109
110            for line in fn_lines {
111                let trimmed = line.trim();
112
113                // Count decision points
114                for keyword in &[
115                    "if ", "if(", "else if", "match ", "for ", "for(", "while ", "while(", "loop ",
116                    "loop{",
117                ] {
118                    if trimmed.starts_with(keyword) || trimmed.contains(&format!(" {keyword}")) {
119                        cyclomatic += 1;
120                        break;
121                    }
122                }
123                // Count logical operators as additional branches
124                cyclomatic += trimmed.matches("&&").count();
125                cyclomatic += trimmed.matches("||").count();
126
127                // Track nesting depth via braces
128                for ch in trimmed.chars() {
129                    match ch {
130                        '{' => {
131                            current_depth += 1;
132                            max_depth = max_depth.max(current_depth);
133                        }
134                        '}' => {
135                            current_depth = current_depth.saturating_sub(1);
136                        }
137                        _ => {}
138                    }
139                }
140            }
141
142            complexity_data.push(ComplexityData {
143                node_id: sym.node_id.clone(),
144                cyclomatic,
145                cognitive: max_depth,
146            });
147            annotated += 1;
148
149            // High complexity threshold
150            if cyclomatic > 10 {
151                let importance = if cyclomatic > 20 { 0.9 } else { 0.7 };
152                pending_insights.push(ComplexityInsight {
153                    content: format!(
154                        "High complexity: {} — cyclomatic={}, max_nesting={} in {}",
155                        sym.label, cyclomatic, max_depth, sym.file_path
156                    ),
157                    importance,
158                    node_id: sym.node_id.clone(),
159                });
160            }
161        }
162
163        // Annotate graph nodes
164        {
165            let mut graph = self.lock_graph()?;
166            for data in &complexity_data {
167                if let Ok(Some(mut node)) = graph.get_node(&data.node_id) {
168                    node.payload
169                        .insert("cyclomatic_complexity".into(), json!(data.cyclomatic));
170                    node.payload
171                        .insert("cognitive_complexity".into(), json!(data.cognitive));
172                    let _ = graph.add_node(node);
173                }
174            }
175        }
176
177        // Store insights (outside graph lock)
178        for insight in &pending_insights {
179            if self
180                .store_insight(
181                    &insight.content,
182                    "complexity",
183                    &[],
184                    insight.importance,
185                    namespace,
186                    std::slice::from_ref(&insight.node_id),
187                )
188                .is_some()
189            {
190                insights_stored += 1;
191            }
192        }
193
194        self.save_index();
195
196        Ok(EnrichResult {
197            insights_stored,
198            details: json!({
199                "symbols_analyzed": annotated,
200                "high_complexity_count": pending_insights.len(),
201                "insights_stored": insights_stored,
202            }),
203        })
204    }
205}