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 tldr_core::walker::ProjectWalker;
27
28use super::ast_cache::AstCache;
29use super::error::{RemainingError, RemainingResult};
30use super::types::{TodoItem, TodoReport, TodoSummary};
31
32use crate::output::OutputWriter;
33
34use crate::commands::dead::collect_module_infos_with_refcounts;
36use tldr_core::analysis::dead::dead_code_analysis_refcount;
37use tldr_core::{collect_all_functions, get_code_structure, FunctionRef, IgnoreSpec, Language};
38
39const PRIORITY_DEAD_CODE: u32 = 1;
45const PRIORITY_COMPLEXITY: u32 = 2;
46const PRIORITY_COHESION: u32 = 3;
47const PRIORITY_EQUIVALENCE: u32 = 4;
48const PRIORITY_SIMILAR: u32 = 5;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SubAnalysis {
57 Dead,
58 Complexity,
59 Cohesion,
60 Equivalence,
61 Similar,
62}
63
64impl SubAnalysis {
65 pub fn all() -> &'static [SubAnalysis] {
67 &[
68 SubAnalysis::Dead,
69 SubAnalysis::Complexity,
70 SubAnalysis::Cohesion,
71 SubAnalysis::Equivalence,
72 SubAnalysis::Similar,
73 ]
74 }
75
76 pub fn quick() -> &'static [SubAnalysis] {
78 &[
79 SubAnalysis::Dead,
80 SubAnalysis::Complexity,
81 SubAnalysis::Cohesion,
82 SubAnalysis::Equivalence,
83 ]
84 }
85
86 pub fn priority(&self) -> u32 {
88 match self {
89 SubAnalysis::Dead => PRIORITY_DEAD_CODE,
90 SubAnalysis::Complexity => PRIORITY_COMPLEXITY,
91 SubAnalysis::Cohesion => PRIORITY_COHESION,
92 SubAnalysis::Equivalence => PRIORITY_EQUIVALENCE,
93 SubAnalysis::Similar => PRIORITY_SIMILAR,
94 }
95 }
96
97 pub fn category(&self) -> &'static str {
99 match self {
100 SubAnalysis::Dead => "dead_code",
101 SubAnalysis::Complexity => "complexity",
102 SubAnalysis::Cohesion => "cohesion",
103 SubAnalysis::Equivalence => "equivalence",
104 SubAnalysis::Similar => "similar",
105 }
106 }
107}
108
109impl std::str::FromStr for SubAnalysis {
110 type Err = String;
111
112 fn from_str(s: &str) -> Result<Self, Self::Err> {
113 match s.to_lowercase().as_str() {
114 "dead" | "dead_code" => Ok(SubAnalysis::Dead),
115 "complexity" | "complex" => Ok(SubAnalysis::Complexity),
116 "cohesion" | "lcom4" => Ok(SubAnalysis::Cohesion),
117 "equivalence" | "equiv" | "gvn" => Ok(SubAnalysis::Equivalence),
118 "similar" | "sim" => Ok(SubAnalysis::Similar),
119 _ => Err(format!("Unknown analysis: {}", s)),
120 }
121 }
122}
123
124#[derive(Debug, Args)]
141pub struct TodoArgs {
142 pub path: PathBuf,
144
145 #[arg(long)]
147 pub detail: Option<String>,
148
149 #[arg(long)]
151 pub quick: bool,
152
153 #[arg(long, default_value = "20")]
155 pub max_items: usize,
156
157 #[arg(long, short = 'O')]
159 pub output: Option<PathBuf>,
160}
161
162impl TodoArgs {
163 pub fn run(
165 &self,
166 format: crate::output::OutputFormat,
167 quiet: bool,
168 lang: Option<Language>,
169 ) -> Result<()> {
170 let writer = OutputWriter::new(format, quiet);
171 let start = Instant::now();
172
173 writer.progress(&format!(
174 "Analyzing {} for improvements...",
175 self.path.display()
176 ));
177
178 if !self.path.exists() {
180 return Err(RemainingError::file_not_found(&self.path).into());
181 }
182
183 let language = if let Some(l) = lang {
185 l
186 } else {
187 detect_language(&self.path)?
188 };
189
190 let mut cache = AstCache::default();
192
193 let analyses = if self.quick {
195 SubAnalysis::quick()
196 } else {
197 SubAnalysis::all()
198 };
199
200 let mut sub_results: HashMap<String, Value> = HashMap::new();
202 let mut all_items: Vec<TodoItem> = Vec::new();
203 let mut summary = TodoSummary::default();
204
205 for analysis in analyses {
206 writer.progress(&format!("Running {} analysis...", analysis.category()));
207
208 match run_sub_analysis(*analysis, &self.path, language, &mut cache) {
209 Ok((items, result_value)) => {
210 update_summary(&mut summary, *analysis, &items);
212
213 if let Some(ref detail) = self.detail {
215 if let Ok(detail_analysis) = detail.parse::<SubAnalysis>() {
216 if detail_analysis == *analysis {
217 sub_results.insert(analysis.category().to_string(), result_value);
218 }
219 }
220 }
221
222 all_items.extend(items);
224 }
225 Err(e) => {
226 writer.progress(&format!(
228 "Warning: {} analysis failed: {}",
229 analysis.category(),
230 e
231 ));
232 }
233 }
234 }
235
236 all_items.sort_by_key(|item| item.priority);
238
239 let total_items = all_items.len();
241 let truncated = self.max_items > 0 && total_items > self.max_items;
242 if truncated {
243 all_items.truncate(self.max_items);
244 }
245
246 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
248 let report = TodoReport {
249 wrapper: "todo".to_string(),
250 path: self.path.display().to_string(),
251 items: all_items,
252 summary,
253 sub_results,
254 total_elapsed_ms: elapsed_ms,
255 };
256
257 if let Some(ref output_path) = self.output {
259 if writer.is_text() {
261 let text = format_todo_text(&report, truncated, total_items);
262 fs::write(output_path, text)?;
263 } else {
264 let json = serde_json::to_string_pretty(&report)?;
265 fs::write(output_path, json)?;
266 }
267 } else {
268 if writer.is_text() {
270 let text = format_todo_text(&report, truncated, total_items);
271 writer.write_text(&text)?;
272 } else {
273 writer.write(&report)?;
274 }
275 }
276
277 Ok(())
278 }
279}
280
281fn run_sub_analysis(
287 analysis: SubAnalysis,
288 path: &Path,
289 language: Language,
290 _cache: &mut AstCache,
291) -> RemainingResult<(Vec<TodoItem>, Value)> {
292 match analysis {
293 SubAnalysis::Dead => run_dead_analysis(path, language),
294 SubAnalysis::Complexity => run_complexity_analysis(path, language),
295 SubAnalysis::Cohesion => run_cohesion_analysis(path),
296 SubAnalysis::Equivalence => run_equivalence_analysis(path),
297 SubAnalysis::Similar => run_similar_analysis(path),
298 }
299}
300
301fn run_dead_analysis(path: &Path, language: Language) -> RemainingResult<(Vec<TodoItem>, Value)> {
303 let project_root = if path.is_file() {
305 path.parent().unwrap_or(path)
306 } else {
307 path
308 };
309
310 let (module_infos, merged_ref_counts) =
312 collect_module_infos_with_refcounts(project_root, language, false);
313 let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
314
315 let report = dead_code_analysis_refcount(&all_functions, &merged_ref_counts, None)
317 .map_err(|e| RemainingError::analysis_error(format!("Dead code analysis failed: {}", e)))?;
318
319 let items: Vec<TodoItem> = report
321 .dead_functions
322 .iter()
323 .map(|func| {
324 TodoItem::new(
325 "dead_code",
326 PRIORITY_DEAD_CODE,
327 format!("Unused function: {}", func.name),
328 )
329 .with_location(func.file.display().to_string(), 0)
330 .with_severity("medium")
331 })
332 .collect();
333
334 let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
335
336 Ok((items, result_value))
337}
338
339fn run_complexity_analysis(
341 path: &Path,
342 language: Language,
343) -> RemainingResult<(Vec<TodoItem>, Value)> {
344 let structure = get_code_structure(path, language, 0, Some(&IgnoreSpec::default()))
346 .map_err(|e| RemainingError::analysis_error(format!("Failed to get structure: {}", e)))?;
347
348 let mut items = Vec::new();
349
350 for file in &structure.files {
352 for func_name in &file.functions {
353 let file_path = path.join(&file.path);
354 if let Ok(metrics) = tldr_core::calculate_complexity(
355 file_path.to_str().unwrap_or_default(),
356 func_name,
357 language,
358 ) {
359 if metrics.cyclomatic > 10 {
360 items.push(
361 TodoItem::new(
362 "complexity",
363 PRIORITY_COMPLEXITY,
364 format!(
365 "High complexity in {}: cyclomatic={}, consider refactoring",
366 func_name, metrics.cyclomatic
367 ),
368 )
369 .with_location(file.path.display().to_string(), 1)
370 .with_severity(if metrics.cyclomatic > 20 {
371 "high"
372 } else {
373 "medium"
374 })
375 .with_score(metrics.cyclomatic as f64 / 50.0),
376 );
377 }
378 }
379 }
380 }
381
382 let result_value = serde_json::json!({
383 "hotspots": items.len(),
384 "threshold": 10
385 });
386
387 Ok((items, result_value))
388}
389
390fn run_cohesion_analysis(path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
392 use crate::commands::patterns::cohesion::{run as run_cohesion, CohesionArgs};
393
394 let args = CohesionArgs {
395 path: path.to_path_buf(),
396 min_methods: 1,
397 include_dunder: false,
398 output_format: crate::commands::patterns::cohesion::OutputFormat::Json,
399 timeout: 30,
400 project_root: None,
401 lang: None,
402 };
403
404 let report = run_cohesion(args)
405 .map_err(|e| RemainingError::analysis_error(format!("Cohesion analysis failed: {}", e)))?;
406
407 let items: Vec<TodoItem> = report
408 .classes
409 .iter()
410 .filter(|c| c.lcom4 > 1)
411 .map(|c| {
412 TodoItem::new(
413 "cohesion",
414 PRIORITY_COHESION,
415 format!(
416 "Low cohesion in class {}: LCOM4={}, consider splitting",
417 c.class_name, c.lcom4
418 ),
419 )
420 .with_location(c.file_path.clone(), c.line)
421 .with_severity(if c.lcom4 > 3 { "high" } else { "medium" })
422 .with_score(c.lcom4 as f64 / 5.0)
423 })
424 .collect();
425
426 let result_value = serde_json::to_value(&report).unwrap_or(Value::Null);
427
428 Ok((items, result_value))
429}
430
431fn run_equivalence_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
433 let result_value = serde_json::json!({
436 "status": "not_implemented",
437 "message": "GVN equivalence analysis will be implemented in Phase 9"
438 });
439
440 Ok((Vec::new(), result_value))
441}
442
443fn run_similar_analysis(_path: &Path) -> RemainingResult<(Vec<TodoItem>, Value)> {
445 let result_value = serde_json::json!({
448 "status": "skipped",
449 "message": "Similar code analysis is expensive, consider using 'tldr similar' directly"
450 });
451
452 Ok((Vec::new(), result_value))
453}
454
455fn detect_language(path: &Path) -> RemainingResult<Language> {
461 if path.is_file() {
463 let ext = path
464 .extension()
465 .and_then(|e| e.to_str())
466 .unwrap_or_default();
467
468 match ext {
469 "py" => Ok(Language::Python),
470 "ts" | "tsx" => Ok(Language::TypeScript),
471 "js" | "jsx" => Ok(Language::JavaScript),
472 "rs" => Ok(Language::Rust),
473 "go" => Ok(Language::Go),
474 _ => Err(RemainingError::unsupported_language(ext)),
475 }
476 } else if path.is_dir() {
477 for entry in ProjectWalker::new(path).max_depth(2).iter() {
479 if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
480 match ext {
481 "py" => return Ok(Language::Python),
482 "ts" | "tsx" => return Ok(Language::TypeScript),
483 "js" | "jsx" => return Ok(Language::JavaScript),
484 "rs" => return Ok(Language::Rust),
485 "go" => return Ok(Language::Go),
486 _ => continue,
487 }
488 }
489 }
490 Ok(Language::Python)
492 } else {
493 Err(RemainingError::file_not_found(path))
494 }
495}
496
497fn update_summary(summary: &mut TodoSummary, analysis: SubAnalysis, items: &[TodoItem]) {
499 match analysis {
500 SubAnalysis::Dead => summary.dead_count = items.len() as u32,
501 SubAnalysis::Complexity => summary.hotspot_count = items.len() as u32,
502 SubAnalysis::Cohesion => summary.low_cohesion_count = items.len() as u32,
503 SubAnalysis::Equivalence => summary.equivalence_groups = items.len() as u32,
504 SubAnalysis::Similar => summary.similar_pairs = items.len() as u32,
505 }
506}
507
508pub fn format_todo_text(report: &TodoReport, truncated: bool, total_items: usize) -> String {
514 let mut lines = Vec::new();
515
516 lines.push(format!("TODO Report for: {}", report.path));
517 lines.push(format!("Total items: {}", total_items));
518 lines.push(String::new());
519
520 lines.push("Summary:".to_string());
522 lines.push(format!(" Dead code items: {}", report.summary.dead_count));
523 lines.push(format!(
524 " Complexity hotspots: {}",
525 report.summary.hotspot_count
526 ));
527 lines.push(format!(
528 " Low cohesion classes: {}",
529 report.summary.low_cohesion_count
530 ));
531 lines.push(format!(
532 " Similar code pairs: {}",
533 report.summary.similar_pairs
534 ));
535 lines.push(format!(
536 " Equivalence groups: {}",
537 report.summary.equivalence_groups
538 ));
539 lines.push(String::new());
540
541 if report.items.is_empty() {
542 lines.push("No improvement items found.".to_string());
543 } else {
544 lines.push("Items (sorted by priority):".to_string());
545 lines.push(String::new());
546
547 for (i, item) in report.items.iter().enumerate() {
548 lines.push(format!(
549 "{}. [{}] {} (priority: {})",
550 i + 1,
551 item.category,
552 item.description,
553 item.priority
554 ));
555
556 if !item.file.is_empty() {
557 lines.push(format!(" Location: {}:{}", item.file, item.line));
558 }
559
560 if !item.severity.is_empty() {
561 lines.push(format!(" Severity: {}", item.severity));
562 }
563 }
564
565 if truncated {
566 let remaining = total_items - report.items.len();
567 lines.push(String::new());
568 lines.push(format!(
569 "... and {} more items. Use --max-items 0 to show all.",
570 remaining
571 ));
572 }
573 }
574
575 lines.push(String::new());
576 lines.push(format!("Analysis time: {:.2}ms", report.total_elapsed_ms));
577
578 lines.join("\n")
579}
580
581#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_sub_analysis_from_str() {
591 assert_eq!("dead".parse::<SubAnalysis>().unwrap(), SubAnalysis::Dead);
592 assert_eq!(
593 "complexity".parse::<SubAnalysis>().unwrap(),
594 SubAnalysis::Complexity
595 );
596 assert_eq!(
597 "cohesion".parse::<SubAnalysis>().unwrap(),
598 SubAnalysis::Cohesion
599 );
600 assert!("unknown".parse::<SubAnalysis>().is_err());
601 }
602
603 #[test]
604 fn test_sub_analysis_priority() {
605 assert!(SubAnalysis::Dead.priority() < SubAnalysis::Complexity.priority());
606 assert!(SubAnalysis::Complexity.priority() < SubAnalysis::Cohesion.priority());
607 }
608
609 #[test]
610 fn test_quick_mode_skips_similar() {
611 let quick = SubAnalysis::quick();
612 let all = SubAnalysis::all();
613
614 assert!(quick.len() < all.len());
615 assert!(!quick.contains(&SubAnalysis::Similar));
616 assert!(all.contains(&SubAnalysis::Similar));
617 }
618
619 #[test]
620 fn test_format_todo_text() {
621 let mut report = TodoReport::new("/path/to/project");
622 report
623 .items
624 .push(TodoItem::new("dead_code", 1, "Unused function"));
625 report.summary.dead_count = 1;
626 report.total_elapsed_ms = 100.5;
627
628 let text = format_todo_text(&report, false, 1);
629 assert!(text.contains("TODO Report"));
630 assert!(text.contains("Dead code items: 1"));
631 assert!(text.contains("Unused function"));
632 }
633
634 #[test]
635 fn test_todo_args_max_items_default() {
636 use clap::Parser;
638
639 #[derive(Debug, Parser)]
640 struct Wrapper {
641 #[command(flatten)]
642 todo: TodoArgs,
643 }
644
645 let w = Wrapper::parse_from(["test", "src/"]);
646 assert_eq!(w.todo.max_items, 20, "default max_items should be 20");
647 }
648
649 #[test]
650 fn test_todo_args_max_items_flag() {
651 use clap::Parser;
653
654 #[derive(Debug, Parser)]
655 struct Wrapper {
656 #[command(flatten)]
657 todo: TodoArgs,
658 }
659
660 let w = Wrapper::parse_from(["test", "src/", "--max-items", "10"]);
661 assert_eq!(w.todo.max_items, 10);
662 }
663
664 #[test]
665 fn test_todo_output_respects_max_items() {
666 let mut report = TodoReport::new("/path/to/project");
668 for i in 0..20 {
669 report.items.push(TodoItem::new(
670 "dead_code",
671 1,
672 format!("Unused function: fn_{}", i),
673 ));
674 }
675 report.summary.dead_count = 20;
676 report.total_elapsed_ms = 50.0;
677
678 let max_items: usize = 5;
680 let total = report.items.len();
681 let truncated = total > max_items && max_items > 0;
682 if truncated {
683 report.items.truncate(max_items);
684 }
685
686 let text = format_todo_text(&report, truncated, total);
687 assert!(text.contains("1. [dead_code]"));
689 assert!(text.contains("5. [dead_code]"));
690 assert!(!text.contains("6. [dead_code]"));
691 assert!(text.contains("... and 15 more items"));
693 assert!(text.contains("--max-items 0"));
694 }
695
696 #[test]
697 fn test_todo_output_no_truncation_message_when_not_truncated() {
698 let mut report = TodoReport::new("/path/to/project");
699 for i in 0..3 {
700 report.items.push(TodoItem::new(
701 "dead_code",
702 1,
703 format!("Unused function: fn_{}", i),
704 ));
705 }
706 report.summary.dead_count = 3;
707 report.total_elapsed_ms = 10.0;
708
709 let text = format_todo_text(&report, false, 3);
710 assert!(!text.contains("... and"));
711 assert!(!text.contains("--max-items"));
712 }
713
714 #[test]
715 fn test_detect_language_from_extension() {
716 use std::fs::File;
717 use tempfile::TempDir;
718
719 let temp = TempDir::new().unwrap();
720 let py_file = temp.path().join("test.py");
721 File::create(&py_file).unwrap();
722
723 let lang = detect_language(&py_file).unwrap();
724 assert_eq!(lang, Language::Python);
725 }
726
727 #[test]
728 fn test_run_dead_analysis_uses_refcount() {
729 use std::fs;
732 use tempfile::TempDir;
733
734 let temp = TempDir::new().unwrap();
735 let py_file = temp.path().join("sample.py");
736 fs::write(
739 &py_file,
740 "def used_func():\n pass\n\ndef _dead_func():\n pass\n\nused_func()\n",
741 )
742 .unwrap();
743
744 let (items, value) = run_dead_analysis(temp.path(), Language::Python).unwrap();
745 let dead_names: Vec<&str> = items.iter().map(|i| i.description.as_str()).collect();
748 assert!(
749 dead_names.iter().any(|d| d.contains("_dead_func")),
750 "Expected _dead_func to be reported as dead, got: {:?}",
751 dead_names
752 );
753 assert!(
754 !dead_names.iter().any(|d| d.contains("used_func")),
755 "used_func should NOT be reported as dead, got: {:?}",
756 dead_names
757 );
758 assert!(!value.is_null(), "Expected non-null result value");
760 }
761}