Skip to main content

tokmd_analysis_complexity/
lib.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5use tokmd_analysis_maintainability::compute_maintainability_index;
6use tokmd_analysis_types::{
7    ComplexityHistogram, ComplexityReport, ComplexityRisk, FileComplexity,
8    FunctionComplexityDetail, TechnicalDebtLevel, TechnicalDebtRatio,
9};
10use tokmd_types::{ExportData, FileKind, FileRow};
11
12use tokmd_analysis_util::{AnalysisLimits, normalize_path};
13
14const DEFAULT_MAX_FILE_BYTES: u64 = 128 * 1024;
15const MAX_COMPLEXITY_FILES: usize = 100;
16const TECHNICAL_DEBT_LOW_THRESHOLD: f64 = 30.0;
17const TECHNICAL_DEBT_MODERATE_THRESHOLD: f64 = 60.0;
18const TECHNICAL_DEBT_HIGH_THRESHOLD: f64 = 100.0;
19
20/// Map language strings to complexity-compatible names.
21fn map_language_for_complexity(lang: &str) -> &str {
22    match lang.to_lowercase().as_str() {
23        "rust" => "rust",
24        "javascript" | "jsx" => "javascript",
25        "typescript" | "tsx" => "typescript",
26        "python" => "python",
27        "go" => "go",
28        "c" => "c",
29        "c++" | "cpp" => "c++",
30        "java" => "java",
31        "c#" | "csharp" => "c#",
32        "php" => "php",
33        "ruby" => "ruby",
34        _ => lang,
35    }
36}
37
38/// Languages that support complexity analysis.
39fn is_complexity_lang(lang: &str) -> bool {
40    matches!(
41        lang.to_lowercase().as_str(),
42        "rust"
43            | "javascript"
44            | "typescript"
45            | "python"
46            | "go"
47            | "c"
48            | "c++"
49            | "java"
50            | "c#"
51            | "php"
52            | "ruby"
53    )
54}
55
56/// Build a complexity report by analyzing function counts, lengths, cyclomatic and cognitive complexity.
57pub fn build_complexity_report(
58    root: &Path,
59    files: &[PathBuf],
60    export: &ExportData,
61    limits: &AnalysisLimits,
62    detail_functions: bool,
63) -> Result<ComplexityReport> {
64    let mut row_map: BTreeMap<String, &FileRow> = BTreeMap::new();
65    for row in export.rows.iter().filter(|r| r.kind == FileKind::Parent) {
66        row_map.insert(normalize_path(&row.path, root), row);
67    }
68
69    let mut file_complexities: Vec<FileComplexity> = Vec::new();
70    let mut total_bytes = 0u64;
71    let max_total = limits.max_bytes;
72    let per_file_limit = limits.max_file_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES) as usize;
73
74    for rel in files {
75        if max_total.is_some_and(|limit| total_bytes >= limit) {
76            break;
77        }
78        let rel_str = rel.to_string_lossy().replace('\\', "/");
79        let row = match row_map.get(&rel_str) {
80            Some(r) => *r,
81            None => continue,
82        };
83        if !is_complexity_lang(&row.lang) {
84            continue;
85        }
86
87        let path = root.join(rel);
88        let bytes = match tokmd_content::read_head(&path, per_file_limit) {
89            Ok(b) => b,
90            Err(_) => continue,
91        };
92        total_bytes += bytes.len() as u64;
93
94        if !tokmd_content::is_text_like(&bytes) {
95            continue;
96        }
97
98        let text = String::from_utf8_lossy(&bytes);
99        let lang_mapped = map_language_for_complexity(&row.lang);
100        let (function_count, max_function_length) = count_functions(&row.lang, &text);
101        let cyclomatic = estimate_cyclomatic(&row.lang, &text);
102
103        // Compute cognitive complexity and nesting depth
104        let cognitive_result =
105            tokmd_content::complexity::estimate_cognitive_complexity(&text, lang_mapped);
106        let nesting_result = tokmd_content::complexity::analyze_nesting_depth(&text, lang_mapped);
107
108        let cognitive_complexity = if cognitive_result.function_count > 0 {
109            Some(cognitive_result.total)
110        } else {
111            None
112        };
113        let max_nesting = if nesting_result.max_depth > 0 {
114            Some(nesting_result.max_depth)
115        } else {
116            None
117        };
118
119        let risk_level = classify_risk_extended(
120            function_count,
121            max_function_length,
122            cyclomatic,
123            cognitive_complexity,
124            max_nesting,
125        );
126
127        let functions = if detail_functions {
128            Some(extract_function_details(&row.lang, &text))
129        } else {
130            None
131        };
132
133        file_complexities.push(FileComplexity {
134            path: rel_str,
135            module: row.module.clone(),
136            function_count,
137            max_function_length,
138            cyclomatic_complexity: cyclomatic,
139            cognitive_complexity,
140            max_nesting,
141            risk_level,
142            functions,
143        });
144    }
145
146    // Sort by cyclomatic complexity descending, then by path
147    file_complexities.sort_by(|a, b| {
148        b.cyclomatic_complexity
149            .cmp(&a.cyclomatic_complexity)
150            .then_with(|| a.path.cmp(&b.path))
151    });
152
153    // Compute aggregates before truncating
154    let total_functions: usize = file_complexities.iter().map(|f| f.function_count).sum();
155    let file_count = file_complexities.len();
156
157    let avg_function_length = if total_functions == 0 {
158        0.0
159    } else {
160        let total_max_len: usize = file_complexities
161            .iter()
162            .map(|f| f.max_function_length)
163            .sum();
164        round_f64(total_max_len as f64 / file_count as f64, 2)
165    };
166
167    let max_function_length = file_complexities
168        .iter()
169        .map(|f| f.max_function_length)
170        .max()
171        .unwrap_or(0);
172
173    let avg_cyclomatic = if file_count == 0 {
174        0.0
175    } else {
176        let total_cyclo: usize = file_complexities
177            .iter()
178            .map(|f| f.cyclomatic_complexity)
179            .sum();
180        round_f64(total_cyclo as f64 / file_count as f64, 2)
181    };
182
183    let max_cyclomatic = file_complexities
184        .iter()
185        .map(|f| f.cyclomatic_complexity)
186        .max()
187        .unwrap_or(0);
188
189    // Compute cognitive complexity aggregates
190    let cognitive_values: Vec<usize> = file_complexities
191        .iter()
192        .filter_map(|f| f.cognitive_complexity)
193        .collect();
194    let (avg_cognitive, max_cognitive) = if cognitive_values.is_empty() {
195        (None, None)
196    } else {
197        let total: usize = cognitive_values.iter().sum();
198        let max = cognitive_values.iter().copied().max().unwrap_or(0);
199        (
200            Some(round_f64(total as f64 / cognitive_values.len() as f64, 2)),
201            Some(max),
202        )
203    };
204
205    // Compute nesting depth aggregates
206    let nesting_values: Vec<usize> = file_complexities
207        .iter()
208        .filter_map(|f| f.max_nesting)
209        .collect();
210    let (avg_nesting_depth, max_nesting_depth) = if nesting_values.is_empty() {
211        (None, None)
212    } else {
213        let total: usize = nesting_values.iter().sum();
214        let max = nesting_values.iter().copied().max().unwrap_or(0);
215        (
216            Some(round_f64(total as f64 / nesting_values.len() as f64, 2)),
217            Some(max),
218        )
219    };
220
221    let high_risk_files = file_complexities
222        .iter()
223        .filter(|f| {
224            matches!(
225                f.risk_level,
226                ComplexityRisk::High | ComplexityRisk::Critical
227            )
228        })
229        .count();
230
231    // Generate histogram from all files before truncating
232    let histogram = generate_complexity_histogram(&file_complexities, 5);
233
234    // Compute maintainability index
235    let maintainability_index = if file_count == 0 {
236        None
237    } else {
238        average_parent_loc(export)
239            .and_then(|avg_loc| compute_maintainability_index(avg_cyclomatic, avg_loc, None))
240    };
241    let technical_debt = compute_technical_debt_ratio(export, &file_complexities);
242
243    // Only keep top files by complexity
244    file_complexities.truncate(MAX_COMPLEXITY_FILES);
245
246    Ok(ComplexityReport {
247        total_functions,
248        avg_function_length,
249        max_function_length,
250        avg_cyclomatic,
251        max_cyclomatic,
252        avg_cognitive,
253        max_cognitive,
254        avg_nesting_depth,
255        max_nesting_depth,
256        high_risk_files,
257        histogram: Some(histogram),
258        halstead: None, // Populated when halstead feature is enabled
259        maintainability_index,
260        technical_debt,
261        files: file_complexities,
262    })
263}
264
265/// Generate a histogram of cyclomatic complexity distribution.
266///
267/// Buckets files by cyclomatic complexity: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30+.
268///
269/// # Arguments
270/// * `files` - Slice of file complexity data
271/// * `bucket_size` - Size of each bucket (default 5)
272///
273/// # Returns
274/// A `ComplexityHistogram` with counts for each bucket
275///
276/// # Note
277/// This function is planned for integration in v1.6.0.
278pub fn generate_complexity_histogram(
279    files: &[FileComplexity],
280    bucket_size: u32,
281) -> ComplexityHistogram {
282    // 7 buckets: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30+
283    let num_buckets = 7;
284    let mut counts = vec![0u32; num_buckets];
285
286    for file in files {
287        let complexity = file.cyclomatic_complexity as u32;
288        let bucket = (complexity / bucket_size).min((num_buckets - 1) as u32) as usize;
289        counts[bucket] += 1;
290    }
291
292    ComplexityHistogram {
293        buckets: (0..num_buckets).map(|i| (i as u32) * bucket_size).collect(),
294        counts,
295        total: files.len() as u32,
296    }
297}
298
299/// Count functions and estimate max function length in lines.
300fn count_functions(lang: &str, text: &str) -> (usize, usize) {
301    let lines: Vec<&str> = text.lines().collect();
302    match lang.to_lowercase().as_str() {
303        "rust" => count_rust_functions(&lines),
304        "javascript" | "typescript" => count_js_functions(&lines),
305        "python" => count_python_functions(&lines),
306        "go" => count_go_functions(&lines),
307        "c" | "c++" | "java" | "c#" | "php" => count_c_style_functions(&lines),
308        "ruby" => count_ruby_functions(&lines),
309        _ => (0, 0),
310    }
311}
312
313/// Check if a trimmed line starts a Rust function definition.
314///
315/// Handles all visibility qualifiers including `pub(in path::here)`,
316/// optional `async`, `unsafe`, `const`, and `extern "ABI"` modifiers.
317fn is_rust_fn_start(trimmed: &str) -> bool {
318    // Fast path: find "fn " in the line
319    let Some(fn_pos) = trimmed.find("fn ") else {
320        return false;
321    };
322
323    // Everything before "fn " must be valid qualifiers
324    let prefix = trimmed[..fn_pos].trim();
325    if prefix.is_empty() {
326        return true; // bare "fn name"
327    }
328
329    // Parse prefix: valid tokens are pub/pub(...), async, unsafe, const, extern "..."
330    let mut rest = prefix;
331    while !rest.is_empty() {
332        rest = rest.trim_start();
333        if rest.is_empty() {
334            break;
335        }
336        if rest.starts_with("pub(") {
337            // Skip pub(...) with arbitrary content (e.g., pub(in crate::foo))
338            if let Some(close) = rest.find(')') {
339                rest = &rest[close + 1..];
340            } else {
341                return false; // Unclosed paren
342            }
343        } else if let Some(r) = rest.strip_prefix("pub") {
344            rest = r;
345        } else if let Some(r) = rest.strip_prefix("async") {
346            rest = r;
347        } else if let Some(r) = rest.strip_prefix("unsafe") {
348            rest = r;
349        } else if let Some(r) = rest.strip_prefix("const") {
350            rest = r;
351        } else if rest.starts_with("extern") {
352            // extern "ABI" - skip the ABI string
353            rest = rest["extern".len()..].trim_start();
354            if rest.starts_with('"') {
355                if let Some(close) = rest[1..].find('"') {
356                    rest = &rest[close + 2..];
357                } else {
358                    return false; // Unclosed string
359                }
360            }
361        } else {
362            return false; // Unknown token
363        }
364    }
365
366    true
367}
368
369fn count_rust_functions(lines: &[&str]) -> (usize, usize) {
370    let mut count = 0;
371    let mut max_len = 0;
372    let mut in_fn = false;
373    let mut fn_start = 0;
374    let mut brace_depth: i32 = 0;
375    let mut in_string = false;
376    let mut in_block_comment = false;
377
378    for (i, line) in lines.iter().enumerate() {
379        let trimmed = line.trim();
380
381        // Detect function start
382        if !in_fn && is_rust_fn_start(trimmed) {
383            count += 1;
384            in_fn = true;
385            fn_start = i;
386            brace_depth = 0;
387        }
388
389        if in_fn {
390            let chars: Vec<char> = line.chars().collect();
391            let mut j = 0;
392            while j < chars.len() {
393                let c = chars[j];
394                let next = chars.get(j + 1).copied();
395
396                if in_block_comment {
397                    if c == '*' && next == Some('/') {
398                        in_block_comment = false;
399                        j += 2;
400                        continue;
401                    }
402                    j += 1;
403                    continue;
404                }
405
406                if c == '/' && next == Some('/') {
407                    break; // Line comment
408                }
409
410                if c == '/' && next == Some('*') {
411                    in_block_comment = true;
412                    j += 2;
413                    continue;
414                }
415
416                if c == '"' && (j == 0 || chars[j - 1] != '\\') {
417                    in_string = !in_string;
418                    j += 1;
419                    continue;
420                }
421
422                if !in_string && !in_block_comment {
423                    if c == '{' {
424                        brace_depth += 1;
425                    } else if c == '}' {
426                        brace_depth = brace_depth.saturating_sub(1);
427                        if brace_depth == 0 {
428                            let fn_len = i - fn_start + 1;
429                            max_len = max_len.max(fn_len);
430                            in_fn = false;
431                            break;
432                        }
433                    }
434                }
435                j += 1;
436            }
437        }
438    }
439
440    (count, max_len)
441}
442
443fn count_js_functions(lines: &[&str]) -> (usize, usize) {
444    let mut count = 0;
445    let mut max_len = 0;
446    let mut in_fn = false;
447    let mut fn_start = 0;
448    let mut brace_depth = 0;
449
450    for (i, line) in lines.iter().enumerate() {
451        let trimmed = line.trim();
452
453        // Detect function declarations
454        let is_fn_start = trimmed.starts_with("function ")
455            || trimmed.starts_with("async function ")
456            || trimmed.contains("=> {")
457            || (trimmed.contains("(")
458                && trimmed.contains(") {")
459                && !trimmed.starts_with("if ")
460                && !trimmed.starts_with("while ")
461                && !trimmed.starts_with("for ")
462                && !trimmed.starts_with("switch "));
463
464        if !in_fn && is_fn_start {
465            count += 1;
466            in_fn = true;
467            fn_start = i;
468            brace_depth = 0;
469        }
470
471        if in_fn {
472            brace_depth += line.chars().filter(|&c| c == '{').count();
473            brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
474
475            if brace_depth == 0 && line.contains('}') {
476                let fn_len = i - fn_start + 1;
477                max_len = max_len.max(fn_len);
478                in_fn = false;
479            }
480        }
481    }
482
483    (count, max_len)
484}
485
486fn count_python_functions(lines: &[&str]) -> (usize, usize) {
487    let mut count = 0;
488    let mut max_len = 0;
489    let mut fn_start = 0;
490    let mut fn_indent = 0;
491    let mut in_fn = false;
492
493    for (i, line) in lines.iter().enumerate() {
494        let trimmed = line.trim();
495
496        if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
497            if in_fn {
498                // Previous function ended
499                let fn_len = i - fn_start;
500                max_len = max_len.max(fn_len);
501            }
502            count += 1;
503            in_fn = true;
504            fn_start = i;
505            fn_indent = line.len() - line.trim_start().len();
506        } else if in_fn && !trimmed.is_empty() && !trimmed.starts_with('#') {
507            let current_indent = line.len() - line.trim_start().len();
508            if current_indent <= fn_indent
509                && !trimmed.starts_with("def ")
510                && !trimmed.starts_with("async def ")
511            {
512                let fn_len = i - fn_start;
513                max_len = max_len.max(fn_len);
514                in_fn = false;
515            }
516        }
517    }
518
519    if in_fn {
520        let fn_len = lines.len() - fn_start;
521        max_len = max_len.max(fn_len);
522    }
523
524    (count, max_len)
525}
526
527fn count_go_functions(lines: &[&str]) -> (usize, usize) {
528    let mut count = 0;
529    let mut max_len = 0;
530    let mut in_fn = false;
531    let mut fn_start = 0;
532    let mut brace_depth = 0;
533
534    for (i, line) in lines.iter().enumerate() {
535        let trimmed = line.trim();
536
537        if !in_fn && trimmed.starts_with("func ") {
538            count += 1;
539            in_fn = true;
540            fn_start = i;
541            brace_depth = 0;
542        }
543
544        if in_fn {
545            brace_depth += line.chars().filter(|&c| c == '{').count();
546            brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
547
548            if brace_depth == 0 && line.contains('}') {
549                let fn_len = i - fn_start + 1;
550                max_len = max_len.max(fn_len);
551                in_fn = false;
552            }
553        }
554    }
555
556    (count, max_len)
557}
558
559fn count_c_style_functions(lines: &[&str]) -> (usize, usize) {
560    let mut count = 0;
561    let mut max_len = 0;
562    let mut in_fn = false;
563    let mut fn_start = 0;
564    let mut brace_depth = 0;
565
566    for (i, line) in lines.iter().enumerate() {
567        let trimmed = line.trim();
568
569        // Heuristic: line ends with ) { or ) followed by { on next line
570        let looks_like_fn = trimmed.ends_with(") {")
571            || (trimmed.ends_with(')') && i + 1 < lines.len() && lines[i + 1].trim() == "{");
572
573        // Exclude control structures
574        let is_control = trimmed.starts_with("if ")
575            || trimmed.starts_with("if(")
576            || trimmed.starts_with("while ")
577            || trimmed.starts_with("while(")
578            || trimmed.starts_with("for ")
579            || trimmed.starts_with("for(")
580            || trimmed.starts_with("switch ")
581            || trimmed.starts_with("switch(");
582
583        if !in_fn && looks_like_fn && !is_control {
584            count += 1;
585            in_fn = true;
586            fn_start = i;
587            brace_depth = 0;
588        }
589
590        if in_fn {
591            brace_depth += line.chars().filter(|&c| c == '{').count();
592            brace_depth = brace_depth.saturating_sub(line.chars().filter(|&c| c == '}').count());
593
594            if brace_depth == 0 && line.contains('}') {
595                let fn_len = i - fn_start + 1;
596                max_len = max_len.max(fn_len);
597                in_fn = false;
598            }
599        }
600    }
601
602    (count, max_len)
603}
604
605fn count_ruby_functions(lines: &[&str]) -> (usize, usize) {
606    let mut count = 0;
607    let mut max_len = 0;
608    let mut fn_start = 0;
609    let mut in_fn = false;
610    let mut depth = 0;
611
612    for (i, line) in lines.iter().enumerate() {
613        let trimmed = line.trim();
614
615        if trimmed.starts_with("def ") {
616            if !in_fn {
617                count += 1;
618                in_fn = true;
619                fn_start = i;
620                depth = 1;
621            } else {
622                depth += 1;
623            }
624        } else if in_fn {
625            // Count nested blocks
626            if trimmed.starts_with("do")
627                || trimmed.starts_with("class ")
628                || trimmed.starts_with("module ")
629                || trimmed.starts_with("begin")
630                || trimmed.starts_with("if ")
631                || trimmed.starts_with("unless ")
632                || trimmed.starts_with("case ")
633                || trimmed.starts_with("while ")
634                || trimmed.starts_with("until ")
635                || trimmed.starts_with("for ")
636            {
637                depth += 1;
638            }
639            if trimmed == "end" || trimmed.starts_with("end ") {
640                depth -= 1;
641                if depth == 0 {
642                    let fn_len = i - fn_start + 1;
643                    max_len = max_len.max(fn_len);
644                    in_fn = false;
645                }
646            }
647        }
648    }
649
650    (count, max_len)
651}
652
653/// Estimate cyclomatic complexity by counting branching keywords.
654fn estimate_cyclomatic(lang: &str, text: &str) -> usize {
655    // Base complexity is 1
656    let mut complexity = 1usize;
657
658    let keywords: &[&str] = match lang.to_lowercase().as_str() {
659        "rust" => &["if ", "match ", "while ", "for ", "loop ", "?", "&&", "||"],
660        "javascript" | "typescript" => {
661            &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
662        }
663        "python" => &["if ", "elif ", "while ", "for ", "except ", " and ", " or "],
664        "go" => &["if ", "case ", "for ", "select ", "&&", "||"],
665        "c" | "c++" | "java" | "c#" | "php" => {
666            &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
667        }
668        "ruby" => &[
669            "if ", "elsif ", "unless ", "while ", "until ", "for ", "when ", "rescue ", " and ",
670            " or ",
671        ],
672        _ => &[],
673    };
674
675    let lower = text.to_lowercase();
676    for keyword in keywords {
677        complexity += lower.matches(keyword).count();
678    }
679
680    complexity
681}
682
683/// Classify risk based on complexity metrics including cognitive and nesting.
684fn classify_risk_extended(
685    function_count: usize,
686    max_function_length: usize,
687    cyclomatic: usize,
688    cognitive: Option<usize>,
689    max_nesting: Option<usize>,
690) -> ComplexityRisk {
691    // Risk factors
692    let mut score = 0;
693
694    // Function count risk
695    if function_count > 50 {
696        score += 2;
697    } else if function_count > 20 {
698        score += 1;
699    }
700
701    // Function length risk (long functions are harder to maintain)
702    if max_function_length > 100 {
703        score += 3;
704    } else if max_function_length > 50 {
705        score += 2;
706    } else if max_function_length > 25 {
707        score += 1;
708    }
709
710    // Cyclomatic complexity risk
711    if cyclomatic > 50 {
712        score += 3;
713    } else if cyclomatic > 20 {
714        score += 2;
715    } else if cyclomatic > 10 {
716        score += 1;
717    }
718
719    // Cognitive complexity risk (higher thresholds than cyclomatic)
720    if let Some(cog) = cognitive {
721        if cog > 100 {
722            score += 3;
723        } else if cog > 50 {
724            score += 2;
725        } else if cog > 25 {
726            score += 1;
727        }
728    }
729
730    // Nesting depth risk
731    if let Some(nesting) = max_nesting {
732        if nesting > 8 {
733            score += 3;
734        } else if nesting > 5 {
735            score += 2;
736        } else if nesting > 4 {
737            score += 1;
738        }
739    }
740
741    match score {
742        0..=1 => ComplexityRisk::Low,
743        2..=4 => ComplexityRisk::Moderate,
744        5..=7 => ComplexityRisk::High,
745        _ => ComplexityRisk::Critical,
746    }
747}
748
749fn average_parent_loc(export: &ExportData) -> Option<f64> {
750    let total_code: usize = export
751        .rows
752        .iter()
753        .filter(|r| r.kind == FileKind::Parent)
754        .map(|r| r.code)
755        .sum();
756    let parent_count: usize = export
757        .rows
758        .iter()
759        .filter(|r| r.kind == FileKind::Parent)
760        .count();
761
762    if parent_count == 0 {
763        return None;
764    }
765
766    let avg_loc = total_code as f64 / parent_count as f64;
767    if avg_loc <= 0.0 {
768        return None;
769    }
770    Some(avg_loc)
771}
772
773/// Compute a complexity-to-size heuristic debt ratio.
774///
775/// Ratio = (sum cyclomatic + cognitive complexity points) / KLOC
776fn compute_technical_debt_ratio(
777    export: &ExportData,
778    file_complexities: &[FileComplexity],
779) -> Option<TechnicalDebtRatio> {
780    if file_complexities.is_empty() {
781        return None;
782    }
783
784    let total_code: usize = export
785        .rows
786        .iter()
787        .filter(|r| r.kind == FileKind::Parent)
788        .map(|r| r.code)
789        .sum();
790    if total_code == 0 {
791        return None;
792    }
793
794    let complexity_points: usize = file_complexities
795        .iter()
796        .map(|f| f.cyclomatic_complexity + f.cognitive_complexity.unwrap_or(0))
797        .sum();
798
799    let code_kloc = total_code as f64 / 1000.0;
800    let ratio = round_f64(complexity_points as f64 / code_kloc, 2);
801    let level = if ratio < TECHNICAL_DEBT_LOW_THRESHOLD {
802        TechnicalDebtLevel::Low
803    } else if ratio < TECHNICAL_DEBT_MODERATE_THRESHOLD {
804        TechnicalDebtLevel::Moderate
805    } else if ratio < TECHNICAL_DEBT_HIGH_THRESHOLD {
806        TechnicalDebtLevel::High
807    } else {
808        TechnicalDebtLevel::Critical
809    };
810
811    Some(TechnicalDebtRatio {
812        ratio,
813        complexity_points,
814        code_kloc: round_f64(code_kloc, 4),
815        level,
816    })
817}
818
819/// Extract function-level complexity details for a source file.
820fn extract_function_details(lang: &str, text: &str) -> Vec<FunctionComplexityDetail> {
821    let lines: Vec<&str> = text.lines().collect();
822    let mapped_lang = map_language_for_complexity(lang);
823
824    let fn_spans: Vec<(usize, usize, String)> = match lang.to_lowercase().as_str() {
825        "rust" => detect_fn_spans_rust(&lines),
826        "javascript" | "typescript" => detect_fn_spans_js(&lines),
827        "python" => detect_fn_spans_python(&lines),
828        "go" => detect_fn_spans_go(&lines),
829        "c" | "c++" | "java" | "c#" | "php" => detect_fn_spans_c_style(&lines),
830        _ => Vec::new(),
831    };
832
833    fn_spans
834        .into_iter()
835        .map(|(start, end, name)| {
836            let length = end.saturating_sub(start) + 1;
837            let fn_text = lines[start..=end.min(lines.len() - 1)].join("\n");
838            let cyclomatic = estimate_cyclomatic_inline(mapped_lang, &fn_text);
839
840            let cognitive_result =
841                tokmd_content::complexity::estimate_cognitive_complexity(&fn_text, mapped_lang);
842            let cognitive = Some(cognitive_result.total);
843
844            let nesting_result =
845                tokmd_content::complexity::analyze_nesting_depth(&fn_text, mapped_lang);
846            let max_nesting = if nesting_result.max_depth > 0 {
847                Some(nesting_result.max_depth)
848            } else {
849                None
850            };
851
852            let param_count = count_params(lines.get(start).unwrap_or(&""));
853
854            FunctionComplexityDetail {
855                name,
856                line_start: start + 1,
857                line_end: end + 1,
858                length,
859                cyclomatic,
860                cognitive,
861                max_nesting,
862                param_count: if param_count > 0 {
863                    Some(param_count)
864                } else {
865                    None
866                },
867            }
868        })
869        .collect()
870}
871
872/// Detect Rust function spans: (start_line, end_line, name).
873fn detect_fn_spans_rust(lines: &[&str]) -> Vec<(usize, usize, String)> {
874    let mut spans = Vec::new();
875    let mut i = 0;
876    while i < lines.len() {
877        let trimmed = lines[i].trim();
878        if is_rust_fn_start(trimmed) {
879            let name = extract_rust_fn_name(trimmed);
880            let start = i;
881            if let Some(end) = find_brace_end_at(lines, i) {
882                spans.push((start, end, name));
883                i = end + 1;
884            } else {
885                // No body found (trait sig, abstract, extern) — skip
886                i += 1;
887            }
888        } else {
889            i += 1;
890        }
891    }
892    spans
893}
894
895/// Detect JS/TS function spans.
896fn detect_fn_spans_js(lines: &[&str]) -> Vec<(usize, usize, String)> {
897    let mut spans = Vec::new();
898    let mut i = 0;
899    while i < lines.len() {
900        let trimmed = lines[i].trim();
901        let is_fn = trimmed.starts_with("function ")
902            || trimmed.starts_with("async function ")
903            || trimmed.starts_with("export function ")
904            || trimmed.starts_with("export async function ")
905            || trimmed.contains("=> {");
906        if is_fn && !trimmed.starts_with("//") {
907            let name = extract_js_fn_name(trimmed);
908            let start = i;
909            if let Some(end) = find_brace_end_at(lines, i) {
910                spans.push((start, end, name));
911                i = end + 1;
912            } else {
913                i += 1;
914            }
915        } else {
916            i += 1;
917        }
918    }
919    spans
920}
921
922/// Detect Python function spans.
923fn detect_fn_spans_python(lines: &[&str]) -> Vec<(usize, usize, String)> {
924    let mut spans = Vec::new();
925    let mut i = 0;
926    while i < lines.len() {
927        let trimmed = lines[i].trim();
928        if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
929            let name = extract_python_fn_name(trimmed);
930            let base_indent = lines[i].len() - lines[i].trim_start().len();
931
932            // Walk upward to include decorator lines (@...)
933            let mut start = i;
934            {
935                let mut k = i;
936                while k > 0 {
937                    let prev = lines[k - 1].trim();
938                    if prev.is_empty() {
939                        k -= 1;
940                        continue;
941                    }
942                    let prev_indent = lines[k - 1].len() - lines[k - 1].trim_start().len();
943                    if prev_indent == base_indent && prev.starts_with('@') {
944                        start = k - 1;
945                        k -= 1;
946                    } else {
947                        break;
948                    }
949                }
950            }
951            let mut end = i;
952            let mut j = i + 1;
953            while j < lines.len() {
954                let lt = lines[j].trim();
955                if lt.is_empty() || lt.starts_with('#') {
956                    j += 1;
957                    continue;
958                }
959                let indent = lines[j].len() - lines[j].trim_start().len();
960                if indent <= base_indent {
961                    break;
962                }
963                end = j;
964                j += 1;
965            }
966            spans.push((start, end, name));
967            i = end + 1;
968        } else {
969            i += 1;
970        }
971    }
972    spans
973}
974
975/// Detect Go function spans.
976fn detect_fn_spans_go(lines: &[&str]) -> Vec<(usize, usize, String)> {
977    let mut spans = Vec::new();
978    let mut i = 0;
979    while i < lines.len() {
980        let trimmed = lines[i].trim();
981        if trimmed.starts_with("func ") {
982            let name = extract_go_fn_name(trimmed);
983            let start = i;
984            if let Some(end) = find_brace_end_at(lines, i) {
985                spans.push((start, end, name));
986                i = end + 1;
987            } else {
988                i += 1;
989            }
990        } else {
991            i += 1;
992        }
993    }
994    spans
995}
996
997/// Detect C-style function spans.
998fn detect_fn_spans_c_style(lines: &[&str]) -> Vec<(usize, usize, String)> {
999    let mut spans = Vec::new();
1000    let mut i = 0;
1001    while i < lines.len() {
1002        let trimmed = lines[i].trim();
1003        let looks_like_fn = (trimmed.ends_with(") {") || trimmed.ends_with("){"))
1004            && !trimmed.starts_with("if ")
1005            && !trimmed.starts_with("if(")
1006            && !trimmed.starts_with("while ")
1007            && !trimmed.starts_with("for ")
1008            && !trimmed.starts_with("switch ")
1009            && !trimmed.starts_with("//")
1010            && !trimmed.starts_with('#');
1011        if looks_like_fn {
1012            let name = extract_c_fn_name(trimmed);
1013            let start = i;
1014            if let Some(end) = find_brace_end_at(lines, i) {
1015                spans.push((start, end, name));
1016                i = end + 1;
1017            } else {
1018                i += 1;
1019            }
1020        } else {
1021            i += 1;
1022        }
1023    }
1024    spans
1025}
1026
1027/// Find closing brace for a block starting at `start_line`.
1028///
1029/// Returns `None` if no opening brace is found (e.g., trait method
1030/// signatures, extern declarations, abstract methods).
1031fn find_brace_end_at(lines: &[&str], start_line: usize) -> Option<usize> {
1032    let mut depth: usize = 0;
1033    let mut found_open = false;
1034    for (i, line) in lines.iter().enumerate().skip(start_line) {
1035        for ch in line.chars() {
1036            if ch == '{' {
1037                depth += 1;
1038                found_open = true;
1039            } else if ch == '}' {
1040                depth = depth.saturating_sub(1);
1041                if found_open && depth == 0 {
1042                    return Some(i);
1043                }
1044            }
1045        }
1046    }
1047    // Both cases (no open brace, or unclosed braces) → None
1048    None
1049}
1050
1051/// Extract Rust function name from a line containing "fn ".
1052fn extract_rust_fn_name(line: &str) -> String {
1053    if let Some(idx) = line.find("fn ") {
1054        let after = &line[idx + 3..];
1055        let name: String = after
1056            .chars()
1057            .take_while(|c| c.is_alphanumeric() || *c == '_')
1058            .collect();
1059        if !name.is_empty() {
1060            return name;
1061        }
1062    }
1063    "<unknown>".to_string()
1064}
1065
1066fn extract_js_fn_name(line: &str) -> String {
1067    if let Some(idx) = line.find("function ") {
1068        let after = &line[idx + 9..];
1069        let name: String = after
1070            .chars()
1071            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1072            .collect();
1073        if !name.is_empty() {
1074            return name;
1075        }
1076    }
1077    if let Some(paren_idx) = line.find('(') {
1078        let before = line[..paren_idx].trim();
1079        let name: String = before
1080            .chars()
1081            .rev()
1082            .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
1083            .collect::<Vec<_>>()
1084            .into_iter()
1085            .rev()
1086            .collect();
1087        if !name.is_empty() {
1088            return name;
1089        }
1090    }
1091    "<anonymous>".to_string()
1092}
1093
1094fn extract_python_fn_name(line: &str) -> String {
1095    let keyword = if line.contains("async def ") {
1096        "async def "
1097    } else {
1098        "def "
1099    };
1100    if let Some(idx) = line.find(keyword) {
1101        let after = &line[idx + keyword.len()..];
1102        let name: String = after
1103            .chars()
1104            .take_while(|c| c.is_alphanumeric() || *c == '_')
1105            .collect();
1106        if !name.is_empty() {
1107            return name;
1108        }
1109    }
1110    "<unknown>".to_string()
1111}
1112
1113fn extract_go_fn_name(line: &str) -> String {
1114    if let Some(idx) = line.find("func ") {
1115        let after = &line[idx + 5..];
1116        let after = if after.starts_with('(') {
1117            if let Some(close) = after.find(')') {
1118                after[close + 1..].trim_start()
1119            } else {
1120                after
1121            }
1122        } else {
1123            after
1124        };
1125        let name: String = after
1126            .chars()
1127            .take_while(|c| c.is_alphanumeric() || *c == '_')
1128            .collect();
1129        if !name.is_empty() {
1130            return name;
1131        }
1132    }
1133    "<unknown>".to_string()
1134}
1135
1136fn extract_c_fn_name(line: &str) -> String {
1137    if let Some(paren_idx) = line.find('(') {
1138        let before = line[..paren_idx].trim();
1139        let name: String = before
1140            .chars()
1141            .rev()
1142            .take_while(|c| c.is_alphanumeric() || *c == '_')
1143            .collect::<Vec<_>>()
1144            .into_iter()
1145            .rev()
1146            .collect();
1147        if !name.is_empty() {
1148            return name;
1149        }
1150    }
1151    "<unknown>".to_string()
1152}
1153
1154/// Count function parameters from a line.
1155fn count_params(line: &str) -> usize {
1156    if let Some(open) = line.find('(')
1157        && let Some(close) = line.find(')')
1158    {
1159        let params = line[open + 1..close].trim();
1160        if params.is_empty() {
1161            return 0;
1162        }
1163        return params.split(',').count();
1164    }
1165    0
1166}
1167
1168/// Estimate cyclomatic complexity for a function body.
1169fn estimate_cyclomatic_inline(lang: &str, text: &str) -> usize {
1170    let mut complexity = 1usize;
1171    let keywords: &[&str] = match lang {
1172        "rust" => &["if ", "match ", "while ", "for ", "loop ", "?", "&&", "||"],
1173        "javascript" | "typescript" => {
1174            &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1175        }
1176        "python" => &["if ", "elif ", "while ", "for ", "except ", " and ", " or "],
1177        "go" => &["if ", "case ", "for ", "select ", "&&", "||"],
1178        "c" | "c++" | "java" | "c#" | "php" => {
1179            &["if ", "case ", "while ", "for ", "?", "&&", "||", "catch "]
1180        }
1181        _ => &[],
1182    };
1183    let lower = text.to_lowercase();
1184    for keyword in keywords {
1185        complexity += lower.matches(keyword).count();
1186    }
1187    complexity
1188}
1189
1190fn round_f64(val: f64, decimals: u32) -> f64 {
1191    let factor = 10f64.powi(decimals as i32);
1192    (val * factor).round() / factor
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198
1199    #[test]
1200    fn test_count_rust_functions() {
1201        let code = r#"
1202fn simple() {
1203    println!("hello");
1204}
1205
1206pub fn public_fn() {
1207    let x = 1;
1208    let y = 2;
1209}
1210
1211pub async fn async_fn() {
1212    todo!()
1213}
1214"#;
1215        let lines: Vec<&str> = code.lines().collect();
1216        let (count, _max_len) = count_rust_functions(&lines);
1217        assert_eq!(count, 3);
1218    }
1219
1220    #[test]
1221    fn test_count_python_functions() {
1222        let code = r#"
1223def foo():
1224    pass
1225
1226async def bar():
1227    await something()
1228
1229def baz():
1230    x = 1
1231    y = 2
1232    return x + y
1233"#;
1234        let lines: Vec<&str> = code.lines().collect();
1235        let (count, _max_len) = count_python_functions(&lines);
1236        assert_eq!(count, 3);
1237    }
1238
1239    #[test]
1240    fn test_estimate_cyclomatic_rust() {
1241        let code = r#"
1242fn complex(x: i32) -> i32 {
1243    if x > 0 {
1244        if x > 10 {
1245            x * 2
1246        } else {
1247            x + 1
1248        }
1249    } else {
1250        match x {
1251            -1 => 0,
1252            _ => x.abs(),
1253        }
1254    }
1255}
1256"#;
1257        let cyclo = estimate_cyclomatic("rust", code);
1258        // Base 1 + 2 ifs + 1 match = 4
1259        assert_eq!(cyclo, 4);
1260    }
1261
1262    #[test]
1263    fn test_estimate_cyclomatic_rust_no_else_if_double_count() {
1264        // "else if" should only count once (as "if"), not as both "if" and "else if"
1265        let code = r#"
1266fn branchy(x: i32) -> i32 {
1267    if x > 0 {
1268        1
1269    } else if x < 0 {
1270        -1
1271    } else if x == 0 {
1272        0
1273    } else {
1274        42
1275    }
1276}
1277"#;
1278        let cyclo = estimate_cyclomatic("rust", code);
1279        // Base 1 + 3 ifs (the initial "if" + 2 "else if" each matched by "if ")
1280        assert_eq!(cyclo, 4);
1281    }
1282
1283    #[test]
1284    fn test_estimate_cyclomatic_js_no_switch_double_count() {
1285        // "switch" removed; only "case" contributes
1286        let code = r#"
1287function classify(x) {
1288    switch (x) {
1289        case 1: return "one";
1290        case 2: return "two";
1291        case 3: return "three";
1292        default: return "other";
1293    }
1294}
1295"#;
1296        let cyclo = estimate_cyclomatic("javascript", code);
1297        // Base 1 + 3 cases = 4
1298        assert_eq!(cyclo, 4);
1299    }
1300
1301    #[test]
1302    fn test_classify_risk() {
1303        assert_eq!(
1304            classify_risk_extended(5, 10, 5, None, None),
1305            ComplexityRisk::Low
1306        );
1307        assert_eq!(
1308            classify_risk_extended(25, 30, 15, None, None),
1309            ComplexityRisk::Moderate
1310        );
1311        assert_eq!(
1312            classify_risk_extended(30, 60, 25, None, None),
1313            ComplexityRisk::High
1314        );
1315        assert_eq!(
1316            classify_risk_extended(60, 120, 60, None, None),
1317            ComplexityRisk::Critical
1318        );
1319    }
1320
1321    #[test]
1322    fn test_classify_risk_with_cognitive() {
1323        // Low cognitive should not change low risk
1324        assert_eq!(
1325            classify_risk_extended(5, 10, 5, Some(10), Some(2)),
1326            ComplexityRisk::Low
1327        );
1328        // High cognitive should increase risk
1329        assert!(matches!(
1330            classify_risk_extended(5, 10, 5, Some(60), Some(6)),
1331            ComplexityRisk::Moderate | ComplexityRisk::High
1332        ));
1333        // High nesting should increase risk
1334        assert!(matches!(
1335            classify_risk_extended(5, 10, 5, Some(10), Some(9)),
1336            ComplexityRisk::Moderate | ComplexityRisk::High
1337        ));
1338    }
1339
1340    #[test]
1341    fn test_is_complexity_lang() {
1342        assert!(is_complexity_lang("Rust"));
1343        assert!(is_complexity_lang("javascript"));
1344        assert!(is_complexity_lang("Python"));
1345        assert!(!is_complexity_lang("Markdown"));
1346        assert!(!is_complexity_lang("JSON"));
1347    }
1348
1349    #[test]
1350    fn test_is_rust_fn_start_extended() {
1351        // Standard cases
1352        assert!(is_rust_fn_start("fn foo()"));
1353        assert!(is_rust_fn_start("pub fn foo()"));
1354        assert!(is_rust_fn_start("pub(crate) fn foo()"));
1355        assert!(is_rust_fn_start("pub(super) fn foo()"));
1356        assert!(is_rust_fn_start("async fn foo()"));
1357        assert!(is_rust_fn_start("pub async fn foo()"));
1358        assert!(is_rust_fn_start("unsafe fn foo()"));
1359        assert!(is_rust_fn_start("const fn foo()"));
1360
1361        // Extended: pub(in path) visibility
1362        assert!(is_rust_fn_start("pub(in crate::foo) fn bar()"));
1363        assert!(is_rust_fn_start("pub(in crate::foo::bar) fn baz()"));
1364
1365        // Extended: extern "ABI" functions
1366        assert!(is_rust_fn_start(r#"extern "C" fn callback()"#));
1367        assert!(is_rust_fn_start(r#"pub extern "C" fn callback()"#));
1368        assert!(is_rust_fn_start(r#"pub unsafe extern "C" fn callback()"#));
1369
1370        // Extended: multi-qualifier combos
1371        assert!(is_rust_fn_start("pub(crate) unsafe async fn baz()"));
1372        assert!(is_rust_fn_start("pub(super) const fn helper()"));
1373
1374        // Negative cases
1375        assert!(!is_rust_fn_start("let fn_name = 5;"));
1376        assert!(!is_rust_fn_start("// fn foo()"));
1377        assert!(!is_rust_fn_start("struct Foo {"));
1378    }
1379
1380    #[test]
1381    fn test_detect_fn_rust_qualifiers() {
1382        let code = r#"
1383pub(crate) async fn crate_async() {
1384    todo!()
1385}
1386
1387pub(super) async fn super_async() {
1388    todo!()
1389}
1390
1391pub(crate) unsafe fn crate_unsafe() {
1392    todo!()
1393}
1394
1395pub unsafe fn public_unsafe() {
1396    todo!()
1397}
1398
1399pub(crate) const fn crate_const() -> u32 {
1400    42
1401}
1402
1403pub const fn public_const() -> u32 {
1404    0
1405}
1406"#;
1407        let lines: Vec<&str> = code.lines().collect();
1408        let spans = detect_fn_spans_rust(&lines);
1409        let names: Vec<&str> = spans.iter().map(|(_, _, n)| n.as_str()).collect();
1410        assert_eq!(
1411            names,
1412            vec![
1413                "crate_async",
1414                "super_async",
1415                "crate_unsafe",
1416                "public_unsafe",
1417                "crate_const",
1418                "public_const",
1419            ]
1420        );
1421
1422        // Also verify count_rust_functions picks them all up
1423        let (count, _) = count_rust_functions(&lines);
1424        assert_eq!(count, 6);
1425    }
1426
1427    #[test]
1428    fn test_detect_fn_python_decorators() {
1429        let code = r#"
1430@staticmethod
1431def plain_static():
1432    pass
1433
1434@app.route("/")
1435@login_required
1436def index():
1437    return "hello"
1438
1439def no_decorator():
1440    pass
1441"#;
1442        let lines: Vec<&str> = code.lines().collect();
1443        let spans = detect_fn_spans_python(&lines);
1444        assert_eq!(spans.len(), 3);
1445
1446        // First function: @staticmethod + def plain_static
1447        let (start, _end, ref name) = spans[0];
1448        assert_eq!(name, "plain_static");
1449        // The span should start at the decorator line
1450        assert!(lines[start].trim().starts_with('@'));
1451
1452        // Second function: two decorators + def index
1453        let (start2, _end2, ref name2) = spans[1];
1454        assert_eq!(name2, "index");
1455        assert!(lines[start2].trim().starts_with('@'));
1456
1457        // Third function: no decorator
1458        let (start3, _end3, ref name3) = spans[2];
1459        assert_eq!(name3, "no_decorator");
1460        assert!(lines[start3].trim().starts_with("def "));
1461    }
1462
1463    #[test]
1464    fn test_detect_fn_c_style_no_preprocessor() {
1465        let code = r#"
1466#define THING(x) { }
1467#define MACRO(a, b) { a + b; }
1468
1469int main(int argc, char** argv) {
1470    return 0;
1471}
1472"#;
1473        let lines: Vec<&str> = code.lines().collect();
1474        let spans = detect_fn_spans_c_style(&lines);
1475        // Should only detect main, not #define macros
1476        assert_eq!(spans.len(), 1);
1477        assert_eq!(spans[0].2, "main");
1478    }
1479
1480    #[test]
1481    fn test_compute_technical_debt_ratio() {
1482        let export = ExportData {
1483            rows: vec![FileRow {
1484                path: "src/lib.rs".to_string(),
1485                module: "src".to_string(),
1486                lang: "Rust".to_string(),
1487                kind: FileKind::Parent,
1488                code: 1000,
1489                comments: 0,
1490                blanks: 0,
1491                lines: 1000,
1492                bytes: 1000,
1493                tokens: 250,
1494            }],
1495            module_roots: vec![],
1496            module_depth: 1,
1497            children: tokmd_types::ChildIncludeMode::Separate,
1498        };
1499
1500        let files = vec![FileComplexity {
1501            path: "src/lib.rs".to_string(),
1502            module: "src".to_string(),
1503            function_count: 3,
1504            max_function_length: 20,
1505            cyclomatic_complexity: 12,
1506            cognitive_complexity: Some(8),
1507            max_nesting: Some(2),
1508            risk_level: ComplexityRisk::Moderate,
1509            functions: None,
1510        }];
1511
1512        let debt = compute_technical_debt_ratio(&export, &files).expect("debt ratio");
1513        assert_eq!(debt.complexity_points, 20);
1514        assert!((debt.ratio - 20.0).abs() < f64::EPSILON);
1515        assert!((debt.code_kloc - 1.0).abs() < f64::EPSILON);
1516        assert_eq!(debt.level, TechnicalDebtLevel::Low);
1517    }
1518
1519    #[test]
1520    fn test_compute_technical_debt_ratio_none_for_zero_code() {
1521        let export = ExportData {
1522            rows: vec![FileRow {
1523                path: "src/lib.rs".to_string(),
1524                module: "src".to_string(),
1525                lang: "Rust".to_string(),
1526                kind: FileKind::Parent,
1527                code: 0,
1528                comments: 0,
1529                blanks: 0,
1530                lines: 0,
1531                bytes: 0,
1532                tokens: 0,
1533            }],
1534            module_roots: vec![],
1535            module_depth: 1,
1536            children: tokmd_types::ChildIncludeMode::Separate,
1537        };
1538
1539        let files = vec![FileComplexity {
1540            path: "src/lib.rs".to_string(),
1541            module: "src".to_string(),
1542            function_count: 1,
1543            max_function_length: 1,
1544            cyclomatic_complexity: 1,
1545            cognitive_complexity: Some(1),
1546            max_nesting: Some(1),
1547            risk_level: ComplexityRisk::Low,
1548            functions: None,
1549        }];
1550
1551        assert!(compute_technical_debt_ratio(&export, &files).is_none());
1552    }
1553}