1use 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
32use 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
37const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SubAnalysis {
55 Dead,
56 Complexity,
57 Cohesion,
58 Equivalence,
59 Similar,
60}
61
62impl SubAnalysis {
63 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 pub fn quick() -> &'static [SubAnalysis] {
76 &[
77 SubAnalysis::Dead,
78 SubAnalysis::Complexity,
79 SubAnalysis::Cohesion,
80 SubAnalysis::Equivalence,
81 ]
82 }
83
84 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 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#[derive(Debug, Args)]
139pub struct TodoArgs {
140 pub path: PathBuf,
142
143 #[arg(long)]
145 pub detail: Option<String>,
146
147 #[arg(long)]
149 pub quick: bool,
150
151 #[arg(long, default_value = "20")]
153 pub max_items: usize,
154
155 #[arg(long, short = 'O')]
157 pub output: Option<PathBuf>,
158}
159
160impl TodoArgs {
161 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 if !self.path.exists() {
178 return Err(RemainingError::file_not_found(&self.path).into());
179 }
180
181 let language = if let Some(l) = lang {
183 l
184 } else {
185 detect_language(&self.path)?
186 };
187
188 let mut cache = AstCache::default();
190
191 let analyses = if self.quick {
193 SubAnalysis::quick()
194 } else {
195 SubAnalysis::all()
196 };
197
198 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(&mut summary, *analysis, &items);
210
211 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 all_items.extend(items);
222 }
223 Err(e) => {
224 writer.progress(&format!(
226 "Warning: {} analysis failed: {}",
227 analysis.category(),
228 e
229 ));
230 }
231 }
232 }
233
234 {
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 all_items.sort_by_key(|item| item.priority);
250
251 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 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 if let Some(ref output_path) = self.output {
271 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 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
293fn 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
313fn run_dead_analysis(path: &Path, language: Language) -> RemainingResult<(Vec<TodoItem>, Value)> {
315 let project_root = if path.is_file() {
317 path.parent().unwrap_or(path)
318 } else {
319 path
320 };
321
322 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 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 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
352fn 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 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
402fn 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
445fn run_equivalence_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
447 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
457fn run_similar_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
459 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
469fn 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 return Ok(Language::Python);
501 }
502 Err(RemainingError::file_not_found(path))
503}
504
505fn 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
516pub 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 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#[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 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 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 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 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 assert!(text.contains("1. [dead_code]"));
697 assert!(text.contains("5. [dead_code]"));
698 assert!(!text.contains("6. [dead_code]"));
699 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 use std::fs;
740 use tempfile::TempDir;
741
742 let temp = TempDir::new().unwrap();
743 let py_file = temp.path().join("sample.py");
744 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 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 assert!(!value.is_null(), "Expected non-null result value");
768 }
769}