loctree 0.8.16

Structural code intelligence for AI agents. Scan once, query everything.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
//! Audit report generation - markdown and todos output
//!
//! Generates human-readable audit reports with actionable findings.
//! Two output modes:
//! - Full markdown report (`loct audit`)
//! - Actionable todo checklist (`loct audit --todos`)

use std::fmt::Write as _;

use serde::Serialize;

use super::crowd::types::Crowd;
use super::cycles::ClassifiedCycle;
use super::dead_parrots::DeadExport;
use super::twins::ExactTwin;

/// Collected audit findings for report generation
#[derive(Debug, Default)]
pub struct AuditFindings {
    pub cycles: Vec<ClassifiedCycle>,
    pub dead_exports: Vec<DeadExport>,
    pub twins: Vec<ExactTwin>,
    pub orphan_files: Vec<OrphanFile>,
    pub shadow_exports: Vec<ShadowExport>,
    pub crowds: Vec<Crowd>,
    pub total_files: usize,
    pub total_loc: usize,
}

/// Orphan file with no importers
#[derive(Debug, Clone, Serialize)]
pub struct OrphanFile {
    pub path: String,
    pub loc: usize,
}

/// Shadow export - same symbol with some dead instances
#[derive(Debug, Clone, Serialize)]
pub struct ShadowExport {
    pub name: String,
    pub total_locations: usize,
    pub dead_locations: usize,
}

fn write_limit_notice(out: &mut String, total_items: usize, limit: Option<usize>, reason: &str) {
    if let Some(limit) = limit {
        let omitted = total_items.saturating_sub(limit);
        if omitted > 0 {
            writeln!(
                out,
                "- _{} additional items omitted by {}_",
                omitted, reason
            )
            .unwrap();
        }
    }
}

/// Generate full markdown audit report.
/// When `limit` is `None`, the report includes every finding.
pub fn generate_markdown_report(findings: &AuditFindings, limit: Option<usize>) -> String {
    let mut out = String::with_capacity(8192);
    let display_limit = limit.unwrap_or(usize::MAX);

    // Header
    writeln!(out, "# Codebase Audit Report\n").unwrap();
    writeln!(out, "_Generated by loctree_\n").unwrap();

    // Summary
    let total_issues = findings.cycles.len()
        + findings.dead_exports.len()
        + findings.twins.len()
        + findings.orphan_files.len()
        + findings.shadow_exports.len();

    let health_score = calculate_health_score(findings);

    writeln!(out, "## Summary\n").unwrap();
    writeln!(out, "| Metric | Value |\n|--------|-------|").unwrap();
    writeln!(out, "| Files | {} |", findings.total_files).unwrap();
    writeln!(out, "| Lines of Code | {} |", findings.total_loc).unwrap();
    writeln!(out, "| Total Issues | {} |", total_issues).unwrap();
    writeln!(out, "| Health Score | {}/100 |", health_score).unwrap();
    writeln!(out).unwrap();

    // Critical section - cycles and high-confidence dead exports
    let breaking_cycles: Vec<_> = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
        .collect();

    let high_confidence_dead: Vec<_> = findings
        .dead_exports
        .iter()
        .filter(|d| d.confidence == "high")
        .collect();

    if !breaking_cycles.is_empty() || !high_confidence_dead.is_empty() {
        writeln!(out, "## [CRITICAL] Action Required\n").unwrap();

        if !breaking_cycles.is_empty() {
            writeln!(out, "### Breaking Cycles ({})\n", breaking_cycles.len()).unwrap();
            for cycle in breaking_cycles.iter().take(display_limit) {
                writeln!(out, "- [ ] {}", format_cycle(cycle)).unwrap();
            }
            write_limit_notice(&mut out, breaking_cycles.len(), limit, "--limit");
            writeln!(out).unwrap();
        }

        if !high_confidence_dead.is_empty() {
            writeln!(
                out,
                "### Dead Exports - High Confidence ({})\n",
                high_confidence_dead.len()
            )
            .unwrap();
            for dead in high_confidence_dead.iter().take(display_limit) {
                writeln!(
                    out,
                    "- [ ] Remove `{}` in {}:{}",
                    dead.symbol,
                    dead.file,
                    dead.line.unwrap_or(0)
                )
                .unwrap();
            }
            write_limit_notice(&mut out, high_confidence_dead.len(), limit, "--limit");
            writeln!(out).unwrap();
        }
    }

    // Warnings section
    let structural_cycles: Vec<_> = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
        .collect();

    let low_confidence_dead: Vec<_> = findings
        .dead_exports
        .iter()
        .filter(|d| d.confidence != "high")
        .collect();

    if !structural_cycles.is_empty()
        || !low_confidence_dead.is_empty()
        || !findings.twins.is_empty()
        || !findings.shadow_exports.is_empty()
    {
        writeln!(out, "## [WARNING] Review Recommended\n").unwrap();

        if !structural_cycles.is_empty() {
            writeln!(out, "### Structural Cycles ({})\n", structural_cycles.len()).unwrap();
            for cycle in structural_cycles.iter().take(display_limit) {
                writeln!(out, "- [ ] {}", format_cycle(cycle)).unwrap();
            }
            write_limit_notice(&mut out, structural_cycles.len(), limit, "--limit");
            writeln!(out).unwrap();
        }

        if !findings.twins.is_empty() {
            writeln!(out, "### Duplicate Symbols ({})\n", findings.twins.len()).unwrap();
            for twin in findings.twins.iter().take(display_limit) {
                let locations: Vec<_> = twin
                    .locations
                    .iter()
                    .map(|l| l.file_path.as_str())
                    .collect();
                writeln!(
                    out,
                    "- [ ] `{}`: {} locations ({})",
                    twin.name,
                    twin.locations.len(),
                    locations.join(", ")
                )
                .unwrap();
            }
            write_limit_notice(&mut out, findings.twins.len(), limit, "--limit");
            writeln!(out).unwrap();
        }

        if !findings.shadow_exports.is_empty() {
            writeln!(
                out,
                "### Shadow Exports ({})\n",
                findings.shadow_exports.len()
            )
            .unwrap();
            writeln!(out, "_Same symbol with some dead instances_\n").unwrap();
            for shadow in findings.shadow_exports.iter().take(display_limit) {
                writeln!(
                    out,
                    "- [ ] `{}`: {}/{} locations dead",
                    shadow.name, shadow.dead_locations, shadow.total_locations
                )
                .unwrap();
            }
            write_limit_notice(&mut out, findings.shadow_exports.len(), limit, "--limit");
            writeln!(out).unwrap();
        }

        if !low_confidence_dead.is_empty() {
            writeln!(
                out,
                "### Dead Exports - Review Needed ({})\n",
                low_confidence_dead.len()
            )
            .unwrap();
            for dead in low_confidence_dead.iter().take(display_limit) {
                writeln!(
                    out,
                    "- [ ] Review `{}` in {}:{}",
                    dead.symbol,
                    dead.file,
                    dead.line.unwrap_or(0)
                )
                .unwrap();
            }
            write_limit_notice(&mut out, low_confidence_dead.len(), limit, "--limit");
            writeln!(out).unwrap();
        }
    }

    // Info section
    if !findings.orphan_files.is_empty() || !findings.crowds.is_empty() {
        writeln!(out, "## [INFO] For Reference\n").unwrap();

        if !findings.orphan_files.is_empty() {
            let total_orphan_loc: usize = findings.orphan_files.iter().map(|f| f.loc).sum();
            writeln!(
                out,
                "### Orphan Files ({} files, {} LOC)\n",
                findings.orphan_files.len(),
                total_orphan_loc
            )
            .unwrap();
            writeln!(out, "_Files with no importers (may be entry points)_\n").unwrap();
            for orphan in findings.orphan_files.iter().take(display_limit) {
                writeln!(out, "- `{}` ({} LOC)", orphan.path, orphan.loc).unwrap();
            }
            write_limit_notice(&mut out, findings.orphan_files.len(), limit, "--limit");
            writeln!(out).unwrap();
        }

        if !findings.crowds.is_empty() {
            writeln!(out, "### Crowds ({})\n", findings.crowds.len()).unwrap();
            writeln!(out, "_Files with high coupling_\n").unwrap();
            for crowd in findings.crowds.iter().take(display_limit) {
                writeln!(out, "- `{}`: {} files", crowd.pattern, crowd.members.len()).unwrap();
            }
            write_limit_notice(&mut out, findings.crowds.len(), limit, "--limit");
            writeln!(out).unwrap();
        }
    }

    // Quick wins
    let quick_wins = calculate_quick_wins(findings);
    if !quick_wins.is_empty() {
        writeln!(out, "## [QUICK WIN] Easy Improvements\n").unwrap();
        for (i, win) in quick_wins.iter().enumerate().take(5) {
            writeln!(out, "{}. {}", i + 1, win).unwrap();
        }
        write_limit_notice(&mut out, quick_wins.len(), Some(5), "quick-win summary cap");
        writeln!(out).unwrap();
    }

    out
}

/// Generate actionable todo checklist only.
pub fn generate_todos(findings: &AuditFindings, limit: Option<usize>) -> String {
    let mut out = String::with_capacity(4096);
    let display_limit = limit.unwrap_or(usize::MAX);

    let today = chrono::Local::now().format("%Y-%m-%d");
    writeln!(out, "# Audit Todos ({})\n", today).unwrap();

    // Critical
    let breaking_cycles: Vec<_> = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
        .collect();

    let high_confidence_dead: Vec<_> = findings
        .dead_exports
        .iter()
        .filter(|d| d.confidence == "high")
        .collect();

    if !breaking_cycles.is_empty() || !high_confidence_dead.is_empty() {
        writeln!(out, "## Critical\n").unwrap();

        for cycle in breaking_cycles.iter().take(display_limit) {
            writeln!(out, "- [ ] Break cycle: {}", format_cycle(cycle)).unwrap();
        }
        write_limit_notice(&mut out, breaking_cycles.len(), limit, "--limit");

        for dead in high_confidence_dead.iter().take(display_limit) {
            writeln!(
                out,
                "- [ ] Remove `{}` in {}:{}",
                dead.symbol,
                dead.file,
                dead.line.unwrap_or(0)
            )
            .unwrap();
        }
        write_limit_notice(&mut out, high_confidence_dead.len(), limit, "--limit");
        writeln!(out).unwrap();
    }

    // Warnings
    let structural_cycles: Vec<_> = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
        .collect();

    if !structural_cycles.is_empty() || !findings.twins.is_empty() {
        writeln!(out, "## Warnings\n").unwrap();

        for cycle in structural_cycles.iter().take(display_limit) {
            writeln!(out, "- [ ] Review cycle: {}", format_cycle(cycle)).unwrap();
        }
        write_limit_notice(&mut out, structural_cycles.len(), limit, "--limit");

        for twin in findings.twins.iter().take(display_limit) {
            writeln!(
                out,
                "- [ ] Consolidate `{}` ({} locations)",
                twin.name,
                twin.locations.len()
            )
            .unwrap();
        }
        write_limit_notice(&mut out, findings.twins.len(), limit, "--limit");
        writeln!(out).unwrap();
    }

    // Quick wins
    let quick_wins = calculate_quick_wins(findings);
    if !quick_wins.is_empty() {
        writeln!(out, "## Quick Wins\n").unwrap();
        for win in quick_wins.iter().take(5) {
            writeln!(out, "- [ ] {}", win).unwrap();
        }
        write_limit_notice(&mut out, quick_wins.len(), Some(5), "quick-win summary cap");
    }

    out
}

/// Format a cycle for display
fn format_cycle(cycle: &ClassifiedCycle) -> String {
    cycle.nodes.join(" → ")
}

/// Calculate health score (0-100)
fn calculate_health_score(findings: &AuditFindings) -> u8 {
    let mut score: i32 = 100;

    // Deduct for breaking cycles (severe)
    let breaking = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
        .count();
    score -= (breaking * 10) as i32;

    // Deduct for structural cycles (moderate)
    let structural = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Structural)
        .count();
    score -= (structural * 3) as i32;

    // Deduct for high-confidence dead exports
    let dead_high = findings
        .dead_exports
        .iter()
        .filter(|d| d.confidence == "high")
        .count();
    score -= (dead_high.min(20) * 2) as i32;

    // Deduct for twins
    score -= (findings.twins.len().min(10) * 2) as i32;

    score.clamp(0, 100) as u8
}

/// Calculate quick wins - easy fixes with high impact
fn calculate_quick_wins(findings: &AuditFindings) -> Vec<String> {
    let mut wins = Vec::new();

    // Group dead exports by directory
    let mut by_dir: std::collections::HashMap<String, (usize, usize)> =
        std::collections::HashMap::new();
    for dead in &findings.dead_exports {
        if dead.confidence == "high" {
            let dir = std::path::Path::new(&dead.file)
                .parent()
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_else(|| ".".to_string());
            let entry = by_dir.entry(dir).or_insert((0, 0));
            entry.0 += 1;
            // Estimate ~10 LOC per dead export
            entry.1 += 10;
        }
    }

    // Sort by count and take top directories
    let mut dirs: Vec<_> = by_dir.into_iter().collect();
    dirs.sort_by(|a, b| b.1.0.cmp(&a.1.0));

    for (dir, (count, loc_estimate)) in dirs.into_iter().take(3) {
        if count >= 3 {
            wins.push(format!(
                "Clean `{}`: remove {} dead exports (~{} LOC)",
                dir, count, loc_estimate
            ));
        }
    }

    // Breaking cycles are always high priority
    let breaking: Vec<_> = findings
        .cycles
        .iter()
        .filter(|c| c.compilability == super::cycles::CycleCompilability::Breaking)
        .take(2)
        .collect();

    for cycle in breaking {
        wins.push(format!(
            "Break cycle in `{}`",
            cycle.nodes.first().unwrap_or(&"unknown".to_string())
        ));
    }

    wins
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dead_export(symbol: &str, line: usize) -> DeadExport {
        DeadExport {
            file: "src/lib.rs".into(),
            symbol: symbol.into(),
            line: Some(line),
            confidence: "high".into(),
            reason: "unused export".into(),
            open_url: None,
            is_test: false,
        }
    }

    #[test]
    fn test_empty_findings_generates_report() {
        let findings = AuditFindings::default();
        let report = generate_markdown_report(&findings, None);
        assert!(report.contains("# Codebase Audit Report"));
        assert!(report.contains("Health Score | 100/100"));
    }

    #[test]
    fn test_todos_output() {
        let findings = AuditFindings::default();
        let todos = generate_todos(&findings, None);
        assert!(todos.contains("# Audit Todos"));
    }

    #[test]
    fn test_markdown_report_is_full_by_default() {
        let findings = AuditFindings {
            dead_exports: (0..3)
                .map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
                .collect(),
            ..AuditFindings::default()
        };

        let report = generate_markdown_report(&findings, None);

        assert!(report.contains("dead_0"));
        assert!(report.contains("dead_1"));
        assert!(report.contains("dead_2"));
        assert!(!report.contains("omitted by --limit"));
    }

    #[test]
    fn test_markdown_report_calls_out_explicit_limit() {
        let findings = AuditFindings {
            dead_exports: (0..3)
                .map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
                .collect(),
            ..AuditFindings::default()
        };

        let report = generate_markdown_report(&findings, Some(2));

        assert!(report.contains("dead_0"));
        assert!(report.contains("dead_1"));
        assert!(!report.contains("dead_2"));
        assert!(report.contains("1 additional items omitted by --limit"));
    }

    #[test]
    fn test_todos_call_out_explicit_limit() {
        let findings = AuditFindings {
            dead_exports: (0..3)
                .map(|idx| dead_export(&format!("dead_{idx}"), idx + 1))
                .collect(),
            ..AuditFindings::default()
        };

        let todos = generate_todos(&findings, Some(2));

        assert!(todos.contains("dead_0"));
        assert!(todos.contains("dead_1"));
        assert!(!todos.contains("dead_2"));
        assert!(todos.contains("1 additional items omitted by --limit"));
    }
}