Skip to main content

gid_core/
complexity.rs

1//! Complexity assessment — classify tasks using code graph structure
2//!
3//! Instead of guessing complexity from text (unreliable), we use the
4//! actual code graph to measure structural complexity. This is the
5//! GID way: let the graph tell you.
6
7use crate::code_graph::{CodeGraph, NodeKind, EdgeRelation};
8use std::collections::HashSet;
9
10/// Complexity level of a task/issue
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Complexity {
13    Simple,
14    Medium,
15    Complex,
16}
17
18impl std::fmt::Display for Complexity {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Complexity::Simple => write!(f, "simple"),
22            Complexity::Medium => write!(f, "medium"),
23            Complexity::Complex => write!(f, "complex"),
24        }
25    }
26}
27
28/// Result of complexity assessment
29#[derive(Debug, Clone)]
30pub struct ComplexityReport {
31    pub complexity: Complexity,
32    pub relevant_nodes: usize,
33    pub relevant_files: usize,
34    pub class_count: usize,
35    pub inheritance_edges: usize,
36    pub import_edges: usize,
37    pub test_count: usize,
38    pub summary: String,
39}
40
41/// Assess complexity from the code graph structure.
42///
43/// Uses relevant node count, edge density, and inheritance depth
44/// to determine whether the task is simple, medium, or complex.
45/// Zero LLM calls — pure structural analysis.
46pub fn assess_complexity_from_graph(
47    code_graph: &CodeGraph,
48    keywords: &[&str],
49    test_count: usize,
50) -> ComplexityReport {
51    let relevant = code_graph.find_relevant_nodes(keywords);
52    let relevant_count = relevant.len();
53
54    // Count relevant files (unique file paths)
55    let relevant_files: HashSet<&str> = relevant.iter()
56        .map(|n| n.file_path.as_str())
57        .collect();
58    let file_count = relevant_files.len();
59
60    // Count classes in relevant nodes
61    let class_count = relevant.iter()
62        .filter(|n| n.kind == NodeKind::Class)
63        .count();
64
65    // Count inheritance edges involving relevant nodes
66    let relevant_ids: HashSet<&str> = relevant.iter()
67        .map(|n| n.id.as_str())
68        .collect();
69    let inheritance_edges = code_graph.edges.iter()
70        .filter(|e| {
71            e.relation == EdgeRelation::Inherits
72                && (relevant_ids.contains(e.from.as_str()) || relevant_ids.contains(e.to.as_str()))
73        })
74        .count();
75
76    // Count import edges between relevant files
77    let import_edges = code_graph.edges.iter()
78        .filter(|e| {
79            e.relation == EdgeRelation::Imports
80                && (relevant_ids.contains(e.from.as_str()) || relevant_ids.contains(e.to.as_str()))
81        })
82        .count();
83
84    tracing::debug!(
85        "Graph complexity metrics: relevant_nodes={}, files={}, classes={}, inheritance={}, imports={}, tests={}",
86        relevant_count, file_count, class_count, inheritance_edges, import_edges, test_count
87    );
88
89    // Decision logic
90    let complexity = if relevant_count <= 2 && file_count <= 1 && class_count == 0 && test_count <= 1 {
91        Complexity::Simple
92    } else if relevant_count >= 6 || file_count >= 3 || inheritance_edges >= 2 || import_edges >= 4 || test_count > 3 {
93        Complexity::Complex
94    } else {
95        Complexity::Medium
96    };
97
98    let summary = format!(
99        "Complexity: {:?} (nodes={}, files={}, classes={}, inherit={}, imports={}, tests={})",
100        complexity, relevant_count, file_count, class_count, inheritance_edges, import_edges, test_count
101    );
102
103    tracing::info!("{}", summary);
104
105    ComplexityReport {
106        complexity,
107        relevant_nodes: relevant_count,
108        relevant_files: file_count,
109        class_count,
110        inheritance_edges,
111        import_edges,
112        test_count,
113        summary,
114    }
115}
116
117/// Assess complexity from a problem statement
118pub fn assess_complexity(
119    code_graph: &CodeGraph,
120    problem_statement: &str,
121    test_count: usize,
122) -> ComplexityReport {
123    let keywords = CodeGraph::extract_keywords(problem_statement);
124    let keyword_refs: Vec<&str> = keywords.iter().map(|s| *s).collect();
125    assess_complexity_from_graph(code_graph, &keyword_refs, test_count)
126}
127
128/// Quick check if a change is high-risk based on caller count
129pub fn is_high_risk_change(code_graph: &CodeGraph, node_ids: &[&str]) -> bool {
130    let total_callers: usize = node_ids.iter()
131        .map(|id| code_graph.get_callers(id).len())
132        .sum();
133    
134    // High risk if total callers > 10 or any single node has > 5 callers
135    let max_callers = node_ids.iter()
136        .map(|id| code_graph.get_callers(id).len())
137        .max()
138        .unwrap_or(0);
139    
140    total_callers > 10 || max_callers > 5
141}
142
143/// Get risk level for a set of changed files
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum RiskLevel {
146    Low,      // < 5 total callers
147    Medium,   // 5-20 total callers
148    High,     // 20-50 total callers
149    Critical, // > 50 total callers
150}
151
152impl std::fmt::Display for RiskLevel {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            RiskLevel::Low => write!(f, "low"),
156            RiskLevel::Medium => write!(f, "medium"),
157            RiskLevel::High => write!(f, "high"),
158            RiskLevel::Critical => write!(f, "critical"),
159        }
160    }
161}
162
163pub fn assess_risk_level(code_graph: &CodeGraph, node_ids: &[&str]) -> RiskLevel {
164    let total_callers: usize = node_ids.iter()
165        .map(|id| code_graph.get_callers(id).len())
166        .sum();
167    
168    match total_callers {
169        0..=5 => RiskLevel::Low,
170        6..=20 => RiskLevel::Medium,
171        21..=50 => RiskLevel::High,
172        _ => RiskLevel::Critical,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::code_graph::{CodeGraph, CodeNode, CodeEdge};
180
181    #[test]
182    fn test_empty_graph_defaults_simple() {
183        let graph = CodeGraph::default();
184        let report = assess_complexity_from_graph(&graph, &["test"], 0);
185        assert_eq!(report.complexity, Complexity::Simple);
186    }
187
188    #[test]
189    fn test_complex_with_many_files() {
190        let mut graph = CodeGraph::default();
191        
192        // Add nodes from multiple files
193        for i in 0..5 {
194            let file_path = format!("file{}.py", i);
195            graph.nodes.push(CodeNode {
196                id: format!("class:{}:TestClass{}", file_path, i),
197                kind: NodeKind::Class,
198                name: format!("TestClass{}", i),
199                file_path,
200                line: Some(1),
201                decorators: vec![],
202                signature: None,
203                docstring: None,
204                line_count: 10,
205                is_test: false,
206            });
207        }
208        
209        graph.build_indexes();
210        
211        let report = assess_complexity_from_graph(&graph, &["TestClass"], 0);
212        assert_eq!(report.complexity, Complexity::Complex);
213        assert!(report.relevant_files >= 3);
214    }
215
216    #[test]
217    fn test_risk_level() {
218        let mut graph = CodeGraph::default();
219        
220        // Create a function with many callers
221        graph.nodes.push(CodeNode {
222            id: "func:core.py:hot_func".into(),
223            kind: NodeKind::Function,
224            name: "hot_func".into(),
225            file_path: "core.py".into(),
226            line: Some(10),
227            decorators: vec![],
228            signature: None,
229            docstring: None,
230            line_count: 20,
231            is_test: false,
232        });
233        
234        // Add many callers
235        for i in 0..30 {
236            let caller_id = format!("func:caller{}.py:caller_{}", i, i);
237            graph.nodes.push(CodeNode {
238                id: caller_id.clone(),
239                kind: NodeKind::Function,
240                name: format!("caller_{}", i),
241                file_path: format!("caller{}.py", i),
242                line: Some(1),
243                decorators: vec![],
244                signature: None,
245                docstring: None,
246                line_count: 5,
247                is_test: false,
248            });
249            graph.edges.push(CodeEdge::new(&caller_id, "func:core.py:hot_func", EdgeRelation::Calls));
250        }
251        
252        graph.build_indexes();
253        
254        let risk = assess_risk_level(&graph, &["func:core.py:hot_func"]);
255        assert_eq!(risk, RiskLevel::High);
256    }
257}