Skip to main content

tldr_cli/commands/remaining/
todo.rs

1//! Todo command - Improvement aggregator
2//!
3//! Aggregates improvement suggestions from multiple sub-analyses:
4//! - Dead code analysis (existing `tldr dead`)
5//! - Complexity analysis (existing `tldr complexity`)
6//! - Cohesion analysis (existing `tldr cohesion`)
7//! - Equivalence analysis (implement later, stub for now)
8//! - Similar code analysis (existing `tldr similar`)
9//!
10//! # Example
11//!
12//! ```bash
13//! tldr todo src/
14//! tldr todo src/main.py --quick
15//! tldr todo src/ --detail dead --format text
16//! ```
17
18use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23use anyhow::Result;
24use clap::Args;
25use serde_json::Value;
26use super::ast_cache::AstCache;
27use super::error::{RemainingError, RemainingResult};
28use super::types::{TodoItem, TodoReport, TodoSummary};
29
30use crate::output::OutputWriter;
31
32// Import existing analysis modules
33use crate::commands::dead::collect_module_infos_with_refcounts;
34use tldr_core::analysis::dead::dead_code_analysis_refcount;
35use tldr_core::{collect_all_functions, FunctionRef, Language};
36
37// =============================================================================
38// Constants
39// =============================================================================
40
41/// Priority levels for different categories
42const PRIORITY_DEAD_CODE: u32 = 1;
43const PRIORITY_COMPLEXITY: u32 = 2;
44const PRIORITY_COHESION: u32 = 3;
45const PRIORITY_EQUIVALENCE: u32 = 4;
46const PRIORITY_SIMILAR: u32 = 5;
47
48// =============================================================================
49// Sub-Analysis Enum
50// =============================================================================
51
52/// Types of sub-analyses that todo command orchestrates
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SubAnalysis {
55    Dead,
56    Complexity,
57    Cohesion,
58    Equivalence,
59    Similar,
60}
61
62impl SubAnalysis {
63    /// Get all analyses for full mode
64    pub fn all() -> &'static [SubAnalysis] {
65        &[
66            SubAnalysis::Dead,
67            SubAnalysis::Complexity,
68            SubAnalysis::Cohesion,
69            SubAnalysis::Equivalence,
70            SubAnalysis::Similar,
71        ]
72    }
73
74    /// Get analyses for quick mode (skip similar which is slowest)
75    pub fn quick() -> &'static [SubAnalysis] {
76        &[
77            SubAnalysis::Dead,
78            SubAnalysis::Complexity,
79            SubAnalysis::Cohesion,
80            SubAnalysis::Equivalence,
81        ]
82    }
83
84    /// Get the priority for this analysis type
85    pub fn priority(&self) -> u32 {
86        match self {
87            SubAnalysis::Dead => PRIORITY_DEAD_CODE,
88            SubAnalysis::Complexity => PRIORITY_COMPLEXITY,
89            SubAnalysis::Cohesion => PRIORITY_COHESION,
90            SubAnalysis::Equivalence => PRIORITY_EQUIVALENCE,
91            SubAnalysis::Similar => PRIORITY_SIMILAR,
92        }
93    }
94
95    /// Get the category name for this analysis
96    pub fn category(&self) -> &'static str {
97        match self {
98            SubAnalysis::Dead => "dead_code",
99            SubAnalysis::Complexity => "complexity",
100            SubAnalysis::Cohesion => "cohesion",
101            SubAnalysis::Equivalence => "equivalence",
102            SubAnalysis::Similar => "similar",
103        }
104    }
105}
106
107impl std::str::FromStr for SubAnalysis {
108    type Err = String;
109
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        match s.to_lowercase().as_str() {
112            "dead" | "dead_code" => Ok(SubAnalysis::Dead),
113            "complexity" | "complex" => Ok(SubAnalysis::Complexity),
114            "cohesion" | "lcom4" => Ok(SubAnalysis::Cohesion),
115            "equivalence" | "equiv" | "gvn" => Ok(SubAnalysis::Equivalence),
116            "similar" | "sim" => Ok(SubAnalysis::Similar),
117            _ => Err(format!("Unknown analysis: {}", s)),
118        }
119    }
120}
121
122// =============================================================================
123// CLI Arguments
124// =============================================================================
125
126/// Aggregate improvement suggestions from multiple analyses
127///
128/// Runs dead code, complexity, cohesion, equivalence, and similar code analyses,
129/// then aggregates findings into a priority-sorted list of improvement items.
130///
131/// # Example
132///
133/// ```bash
134/// tldr todo src/
135/// tldr todo src/main.py --quick
136/// tldr todo src/ --detail dead
137/// ```
138#[derive(Debug, Args)]
139pub struct TodoArgs {
140    /// File or directory to analyze
141    pub path: PathBuf,
142
143    /// Show details for specific sub-analysis
144    #[arg(long)]
145    pub detail: Option<String>,
146
147    /// Run quick mode (skip similar analysis)
148    #[arg(long)]
149    pub quick: bool,
150
151    /// Maximum number of items to display (0 = show all)
152    #[arg(long, default_value = "20")]
153    pub max_items: usize,
154
155    /// Output file (optional, stdout if not specified)
156    #[arg(long, short = 'O')]
157    pub output: Option<PathBuf>,
158}
159
160impl TodoArgs {
161    /// Run the todo command
162    pub fn run(
163        &self,
164        format: crate::output::OutputFormat,
165        quiet: bool,
166        lang: Option<Language>,
167    ) -> Result<()> {
168        let writer = OutputWriter::new(format, quiet);
169        let start = Instant::now();
170
171        writer.progress(&format!(
172            "Analyzing {} for improvements...",
173            self.path.display()
174        ));
175
176        // Validate path exists
177        if !self.path.exists() {
178            return Err(RemainingError::file_not_found(&self.path).into());
179        }
180
181        // Determine language from CLI option or auto-detect
182        let language = if let Some(l) = lang {
183            l
184        } else {
185            detect_language(&self.path)?
186        };
187
188        // Create AST cache for shared parsing
189        let mut cache = AstCache::default();
190
191        // Determine which analyses to run
192        let analyses = if self.quick {
193            SubAnalysis::quick()
194        } else {
195            SubAnalysis::all()
196        };
197
198        // Run sub-analyses and collect results
199        let mut sub_results: HashMap<String, Value> = HashMap::new();
200        let mut all_items: Vec<TodoItem> = Vec::new();
201        let mut summary = TodoSummary::default();
202
203        for analysis in analyses {
204            writer.progress(&format!("Running {} analysis...", analysis.category()));
205
206            match run_sub_analysis(*analysis, &self.path, language, &mut cache) {
207                Ok((items, result_value)) => {
208                    // Update summary
209                    update_summary(&mut summary, *analysis, &items);
210
211                    // Store raw results if detail requested (match by parsing the detail arg)
212                    if let Some(ref detail) = self.detail {
213                        if let Ok(detail_analysis) = detail.parse::<SubAnalysis>() {
214                            if detail_analysis == *analysis {
215                                sub_results.insert(analysis.category().to_string(), result_value);
216                            }
217                        }
218                    }
219
220                    // Add items to aggregate list
221                    all_items.extend(items);
222                }
223                Err(e) => {
224                    // Log error but continue with other analyses
225                    writer.progress(&format!(
226                        "Warning: {} analysis failed: {}",
227                        analysis.category(),
228                        e
229                    ));
230                }
231            }
232        }
233
234        // cross-cutting-and-clear-fix-bugs-v1 (P18.Pattern-B): when the
235        // sub-analyzers (e.g. csharp complexity) emit the same finding
236        // twice — once with a bare function name and once with a
237        // class-qualified name — at the same source line, dedup by
238        // (category, file, line) so the user sees one item per real
239        // problem.
240        {
241            use std::collections::HashSet;
242            let mut seen: HashSet<(String, String, u32)> = HashSet::new();
243            all_items.retain(|item| {
244                seen.insert((item.category.clone(), item.file.clone(), item.line))
245            });
246        }
247
248        // Sort items by priority
249        all_items.sort_by_key(|item| item.priority);
250
251        // Apply max_items truncation
252        let total_items = all_items.len();
253        let truncated = self.max_items > 0 && total_items > self.max_items;
254        if truncated {
255            all_items.truncate(self.max_items);
256        }
257
258        // Build report
259        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
260        let report = TodoReport {
261            wrapper: "todo".to_string(),
262            path: self.path.display().to_string(),
263            items: all_items,
264            summary,
265            sub_results,
266            total_elapsed_ms: elapsed_ms,
267        };
268
269        // Write output
270        if let Some(ref output_path) = self.output {
271            // Write to file based on format
272            if writer.is_text() {
273                let text = format_todo_text(&report, truncated, total_items);
274                fs::write(output_path, text)?;
275            } else {
276                let json = serde_json::to_string_pretty(&report)?;
277                fs::write(output_path, json)?;
278            }
279        } else {
280            // Write to stdout
281            if writer.is_text() {
282                let text = format_todo_text(&report, truncated, total_items);
283                writer.write_text(&text)?;
284            } else {
285                writer.write(&report)?;
286            }
287        }
288
289        Ok(())
290    }
291}
292
293// =============================================================================
294// Sub-Analysis Runners
295// =============================================================================
296
297/// Run a sub-analysis and return items + raw result
298fn run_sub_analysis(
299    analysis: SubAnalysis,
300    path: &Path,
301    language: Language,
302    _cache: &mut AstCache,
303) -> RemainingResult<(Vec<TodoItem>, Value)> {
304    match analysis {
305        SubAnalysis::Dead => run_dead_analysis(path, language),
306        SubAnalysis::Complexity => run_complexity_analysis(path, language),
307        SubAnalysis::Cohesion => run_cohesion_analysis(path, language),
308        SubAnalysis::Equivalence => run_equivalence_analysis(path),
309        SubAnalysis::Similar => run_similar_analysis(path),
310    }
311}
312
313/// Run dead code analysis using reference counting (low false-positive rate)
314fn run_dead_analysis(path: &Path, language: Language) -> RemainingResult<(Vec<TodoItem>, Value)> {
315    // For single files, use parent directory for scanning (needs directory context)
316    let project_root = if path.is_file() {
317        path.parent().unwrap_or(path)
318    } else {
319        path
320    };
321
322    // Single-pass: collect module infos and identifier reference counts together
323    let (module_infos, merged_ref_counts) =
324        collect_module_infos_with_refcounts(project_root, language, false);
325    let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
326
327    // Run refcount-based analysis (rescues functions that are referenced by name)
328    let report = dead_code_analysis_refcount(&all_functions, &merged_ref_counts, None)
329        .map_err(|e| RemainingError::analysis_error(format!("Dead code analysis failed: {}", e)))?;
330
331    // Convert to TodoItems. Preserve the real start line from DeadFunction
332    // (BUG-05: previously hardcoded 0, losing the line of the dead symbol).
333    let items: Vec<TodoItem> = report
334        .dead_functions
335        .iter()
336        .map(|func| {
337            TodoItem::new(
338                "dead_code",
339                PRIORITY_DEAD_CODE,
340                format!("Unused function: {}", func.name),
341            )
342            .with_location(func.file.display().to_string(), func.line as u32)
343            .with_severity("medium")
344        })
345        .collect();
346
347    let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
348
349    Ok((items, result_value))
350}
351
352/// Run complexity analysis (hotspots)
353///
354/// WRAPPER-CROSS-CONSISTENCY-V1 (BUG-04): delegates to
355/// `tldr_core::quality::complexity::analyze_complexity` with the same
356/// `hotspot_threshold = 10` that `tldr health` uses (default
357/// `ThresholdPreset::Default`). Previously this routed through
358/// `tldr_core::calculate_complexity` per-function — a different code path
359/// — and produced divergent hotspot counts vs. health (e.g. flask
360/// health=11 vs todo=6). Sharing the canonical analyzer makes
361/// `tldr health` and `tldr todo` agree by construction on the same path.
362fn run_complexity_analysis(
363    path: &Path,
364    language: Language,
365) -> RemainingResult<(Vec<TodoItem>, Value)> {
366    use tldr_core::quality::complexity::{analyze_complexity, ComplexityOptions};
367
368    let options = ComplexityOptions {
369        hotspot_threshold: 10,
370        // Don't truncate: `health` aggregates the full hotspot count,
371        // so todo must enumerate all hotspots to match.
372        max_hotspots: usize::MAX,
373        ..Default::default()
374    };
375
376    let report = analyze_complexity(path, Some(language), Some(options))
377        .map_err(|e| RemainingError::analysis_error(format!("Complexity analysis failed: {}", e)))?;
378
379    let items: Vec<TodoItem> = report
380        .hotspots
381        .iter()
382        .map(|h| {
383            TodoItem::new(
384                "complexity",
385                PRIORITY_COMPLEXITY,
386                format!(
387                    "High complexity in {}: cyclomatic={}, consider refactoring",
388                    h.name, h.cyclomatic
389                ),
390            )
391            .with_location(h.file.display().to_string(), h.line as u32)
392            .with_severity(if h.cyclomatic > 20 { "high" } else { "medium" })
393            .with_score(h.cyclomatic as f64 / 50.0)
394        })
395        .collect();
396
397    let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
398
399    Ok((items, result_value))
400}
401
402/// Run cohesion analysis (LCOM4)
403///
404/// WRAPPER-CROSS-CONSISTENCY-V1 (BUG-04): delegates to
405/// `tldr_core::quality::cohesion::analyze_cohesion` with the same
406/// `threshold = 2` that `tldr health` uses (default
407/// `ThresholdPreset::Default`). Previously this routed through
408/// `crate::commands::patterns::cohesion::run` (a different impl) and
409/// applied `lcom4 > 1` — both differed from health's `lcom4 > 2`. Sharing
410/// the canonical analyzer + threshold makes `tldr health` and
411/// `tldr todo` agree by construction on the same path.
412fn run_cohesion_analysis(
413    path: &Path,
414    language: Language,
415) -> RemainingResult<(Vec<TodoItem>, Value)> {
416    use tldr_core::quality::cohesion::analyze_cohesion;
417
418    let report = analyze_cohesion(path, Some(language), 2)
419        .map_err(|e| RemainingError::analysis_error(format!("Cohesion analysis failed: {}", e)))?;
420
421    let items: Vec<TodoItem> = report
422        .classes
423        .iter()
424        .filter(|c| c.lcom4 > 2)
425        .map(|c| {
426            TodoItem::new(
427                "cohesion",
428                PRIORITY_COHESION,
429                format!(
430                    "Low cohesion in class {}: LCOM4={}, consider splitting",
431                    c.name, c.lcom4
432                ),
433            )
434            .with_location(c.file.display().to_string(), c.line as u32)
435            .with_severity(if c.lcom4 > 3 { "high" } else { "medium" })
436            .with_score(c.lcom4 as f64 / 5.0)
437        })
438        .collect();
439
440    let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
441
442    Ok((items, result_value))
443}
444
445/// Run equivalence analysis (GVN - stub for now)
446fn run_equivalence_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
447    // TODO: Implement GVN equivalence detection in Phase 9
448    // For now, return empty results
449    let result_value = serde_json::json!({
450        "status": "not_implemented",
451        "message": "GVN equivalence analysis will be implemented in Phase 9"
452    });
453
454    Ok((Vec::new(), result_value))
455}
456
457/// Run similar code analysis (stub - uses semantic search)
458fn run_similar_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
459    // TODO: Integrate with tldr similar command
460    // For now, return empty results as similar analysis is expensive
461    let result_value = serde_json::json!({
462        "status": "skipped",
463        "message": "Similar code analysis is expensive, consider using 'tldr similar' directly"
464    });
465
466    Ok((Vec::new(), result_value))
467}
468
469// =============================================================================
470// Helper Functions
471// =============================================================================
472
473/// Detect language from path (auto-detect from extension or directory contents).
474///
475/// cross-language-extraction-v2 P2.BUG-3: previously this function only
476/// recognised 5 languages (Python / TS / JS / Rust / Go), so `tldr todo`
477/// without `--lang` would silently default to Python on Java/Kotlin/Elixir/
478/// OCaml/Ruby/PHP/Scala/C#/Lua trees and emit zero items. The other commands
479/// (`structure`, `vuln`, `secure`, …) had been migrated to
480/// `Language::from_path` / `Language::from_directory` in the AA1 milestone;
481/// `todo` was missed. We now route through the shared helpers for parity.
482fn detect_language(path: &Path) -> RemainingResult<Language> {
483    if path.is_file() {
484        if let Some(lang) = Language::from_path(path) {
485            return Ok(lang);
486        }
487        let ext = path
488            .extension()
489            .and_then(|e| e.to_str())
490            .unwrap_or_default();
491        return Err(RemainingError::unsupported_language(ext));
492    }
493    if path.is_dir() {
494        if let Some(lang) = Language::from_directory(path) {
495            return Ok(lang);
496        }
497        // Empty / unrecognised directory: keep the prior fallback to Python so
498        // existing callers that point at directories with no source files still
499        // get a deterministic answer rather than a hard error.
500        return Ok(Language::Python);
501    }
502    Err(RemainingError::file_not_found(path))
503}
504
505/// Update summary based on analysis results
506fn update_summary(summary: &mut TodoSummary, analysis: SubAnalysis, items: &[TodoItem]) {
507    match analysis {
508        SubAnalysis::Dead => summary.dead_count = items.len() as u32,
509        SubAnalysis::Complexity => summary.hotspot_count = items.len() as u32,
510        SubAnalysis::Cohesion => summary.low_cohesion_count = items.len() as u32,
511        SubAnalysis::Equivalence => summary.equivalence_groups = items.len() as u32,
512        SubAnalysis::Similar => summary.similar_pairs = items.len() as u32,
513    }
514}
515
516/// Format todo report as human-readable text
517///
518/// When `truncated` is true, a footer message is appended indicating how many
519/// items were omitted and how to see all of them. `total_items` is the count
520/// before truncation.
521pub fn format_todo_text(report: &TodoReport, truncated: bool, total_items: usize) -> String {
522    let mut lines = Vec::new();
523
524    lines.push(format!("TODO Report for: {}", report.path));
525    lines.push(format!("Total items: {}", total_items));
526    lines.push(String::new());
527
528    // Summary
529    lines.push("Summary:".to_string());
530    lines.push(format!("  Dead code items: {}", report.summary.dead_count));
531    lines.push(format!(
532        "  Complexity hotspots: {}",
533        report.summary.hotspot_count
534    ));
535    lines.push(format!(
536        "  Low cohesion classes: {}",
537        report.summary.low_cohesion_count
538    ));
539    lines.push(format!(
540        "  Similar code pairs: {}",
541        report.summary.similar_pairs
542    ));
543    lines.push(format!(
544        "  Equivalence groups: {}",
545        report.summary.equivalence_groups
546    ));
547    lines.push(String::new());
548
549    if report.items.is_empty() {
550        lines.push("No improvement items found.".to_string());
551    } else {
552        lines.push("Items (sorted by priority):".to_string());
553        lines.push(String::new());
554
555        for (i, item) in report.items.iter().enumerate() {
556            lines.push(format!(
557                "{}. [{}] {} (priority: {})",
558                i + 1,
559                item.category,
560                item.description,
561                item.priority
562            ));
563
564            if !item.file.is_empty() {
565                lines.push(format!("   Location: {}:{}", item.file, item.line));
566            }
567
568            if !item.severity.is_empty() {
569                lines.push(format!("   Severity: {}", item.severity));
570            }
571        }
572
573        if truncated {
574            let remaining = total_items - report.items.len();
575            lines.push(String::new());
576            lines.push(format!(
577                "... and {} more items. Use --max-items 0 to show all.",
578                remaining
579            ));
580        }
581    }
582
583    lines.push(String::new());
584    lines.push(format!("Analysis time: {:.2}ms", report.total_elapsed_ms));
585
586    lines.join("\n")
587}
588
589// =============================================================================
590// Tests
591// =============================================================================
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn test_sub_analysis_from_str() {
599        assert_eq!("dead".parse::<SubAnalysis>().unwrap(), SubAnalysis::Dead);
600        assert_eq!(
601            "complexity".parse::<SubAnalysis>().unwrap(),
602            SubAnalysis::Complexity
603        );
604        assert_eq!(
605            "cohesion".parse::<SubAnalysis>().unwrap(),
606            SubAnalysis::Cohesion
607        );
608        assert!("unknown".parse::<SubAnalysis>().is_err());
609    }
610
611    #[test]
612    fn test_sub_analysis_priority() {
613        assert!(SubAnalysis::Dead.priority() < SubAnalysis::Complexity.priority());
614        assert!(SubAnalysis::Complexity.priority() < SubAnalysis::Cohesion.priority());
615    }
616
617    #[test]
618    fn test_quick_mode_skips_similar() {
619        let quick = SubAnalysis::quick();
620        let all = SubAnalysis::all();
621
622        assert!(quick.len() < all.len());
623        assert!(!quick.contains(&SubAnalysis::Similar));
624        assert!(all.contains(&SubAnalysis::Similar));
625    }
626
627    #[test]
628    fn test_format_todo_text() {
629        let mut report = TodoReport::new("/path/to/project");
630        report
631            .items
632            .push(TodoItem::new("dead_code", 1, "Unused function"));
633        report.summary.dead_count = 1;
634        report.total_elapsed_ms = 100.5;
635
636        let text = format_todo_text(&report, false, 1);
637        assert!(text.contains("TODO Report"));
638        assert!(text.contains("Dead code items: 1"));
639        assert!(text.contains("Unused function"));
640    }
641
642    #[test]
643    fn test_todo_args_max_items_default() {
644        // Default max_items should be 20
645        use clap::Parser;
646
647        #[derive(Debug, Parser)]
648        struct Wrapper {
649            #[command(flatten)]
650            todo: TodoArgs,
651        }
652
653        let w = Wrapper::parse_from(["test", "src/"]);
654        assert_eq!(w.todo.max_items, 20, "default max_items should be 20");
655    }
656
657    #[test]
658    fn test_todo_args_max_items_flag() {
659        // --max-items 10 should parse correctly
660        use clap::Parser;
661
662        #[derive(Debug, Parser)]
663        struct Wrapper {
664            #[command(flatten)]
665            todo: TodoArgs,
666        }
667
668        let w = Wrapper::parse_from(["test", "src/", "--max-items", "10"]);
669        assert_eq!(w.todo.max_items, 10);
670    }
671
672    #[test]
673    fn test_todo_output_respects_max_items() {
674        // When max_items is set, format_todo_text should only show that many items
675        let mut report = TodoReport::new("/path/to/project");
676        for i in 0..20 {
677            report.items.push(TodoItem::new(
678                "dead_code",
679                1,
680                format!("Unused function: fn_{}", i),
681            ));
682        }
683        report.summary.dead_count = 20;
684        report.total_elapsed_ms = 50.0;
685
686        // Apply max_items=5 truncation
687        let max_items: usize = 5;
688        let total = report.items.len();
689        let truncated = total > max_items && max_items > 0;
690        if truncated {
691            report.items.truncate(max_items);
692        }
693
694        let text = format_todo_text(&report, truncated, total);
695        // Should contain exactly 5 numbered items
696        assert!(text.contains("1. [dead_code]"));
697        assert!(text.contains("5. [dead_code]"));
698        assert!(!text.contains("6. [dead_code]"));
699        // Should contain truncation message
700        assert!(text.contains("... and 15 more items"));
701        assert!(text.contains("--max-items 0"));
702    }
703
704    #[test]
705    fn test_todo_output_no_truncation_message_when_not_truncated() {
706        let mut report = TodoReport::new("/path/to/project");
707        for i in 0..3 {
708            report.items.push(TodoItem::new(
709                "dead_code",
710                1,
711                format!("Unused function: fn_{}", i),
712            ));
713        }
714        report.summary.dead_count = 3;
715        report.total_elapsed_ms = 10.0;
716
717        let text = format_todo_text(&report, false, 3);
718        assert!(!text.contains("... and"));
719        assert!(!text.contains("--max-items"));
720    }
721
722    #[test]
723    fn test_detect_language_from_extension() {
724        use std::fs::File;
725        use tempfile::TempDir;
726
727        let temp = TempDir::new().unwrap();
728        let py_file = temp.path().join("test.py");
729        File::create(&py_file).unwrap();
730
731        let lang = detect_language(&py_file).unwrap();
732        assert_eq!(lang, Language::Python);
733    }
734
735    #[test]
736    fn test_run_dead_analysis_uses_refcount() {
737        // Verify run_dead_analysis uses the refcount-based analyzer (not old call-graph).
738        // Create a minimal Python project with one "dead" function.
739        use std::fs;
740        use tempfile::TempDir;
741
742        let temp = TempDir::new().unwrap();
743        let py_file = temp.path().join("sample.py");
744        // _dead_func is private (leading underscore) and only appears once (definition),
745        // so refcount=1 -> dead. used_func appears twice (def + call), so refcount=2 -> alive.
746        fs::write(
747            &py_file,
748            "def used_func():\n    pass\n\ndef _dead_func():\n    pass\n\nused_func()\n",
749        )
750        .unwrap();
751
752        let (items, value) = run_dead_analysis(temp.path(), Language::Python).unwrap();
753        // The refcount analyzer should find _dead_func as dead (private, ref_count=1)
754        // but not used_func (ref_count=2, rescued by refcount).
755        let dead_names: Vec<&str> = items.iter().map(|i| i.description.as_str()).collect();
756        assert!(
757            dead_names.iter().any(|d| d.contains("_dead_func")),
758            "Expected _dead_func to be reported as dead, got: {:?}",
759            dead_names
760        );
761        assert!(
762            !dead_names.iter().any(|d| d.contains("used_func")),
763            "used_func should NOT be reported as dead, got: {:?}",
764            dead_names
765        );
766        // The result value should serialize successfully
767        assert!(!value.is_null(), "Expected non-null result value");
768    }
769}