1use std::io::{self, Write};
13use std::path::Path;
14
15use colored::Colorize;
16use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
17use serde::Serialize;
18
19pub fn common_path_prefix(paths: &[&Path]) -> std::path::PathBuf {
23 if paths.is_empty() {
24 return std::path::PathBuf::new();
25 }
26 if paths.len() == 1 {
27 return paths[0].parent().unwrap_or(Path::new("")).to_path_buf();
28 }
29
30 let first = paths[0];
31 let components: Vec<_> = first.components().collect();
32 let mut prefix_len = components.len();
33
34 for path in &paths[1..] {
35 let other: Vec<_> = path.components().collect();
36 let mut match_len = 0;
37 for (a, b) in components.iter().zip(other.iter()) {
38 if a == b {
39 match_len += 1;
40 } else {
41 break;
42 }
43 }
44 prefix_len = prefix_len.min(match_len);
45 }
46
47 let mut result = std::path::PathBuf::new();
49 for comp in components.iter().take(prefix_len) {
50 result.push(comp);
51 }
52 result
53}
54
55pub fn strip_prefix_display(path: &Path, prefix: &Path) -> String {
58 if prefix.as_os_str().is_empty() {
59 return path.display().to_string();
60 }
61 match path.strip_prefix(prefix) {
62 Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
63 _ => path.display().to_string(),
64 }
65}
66
67#[derive(Debug, Clone, Copy, Default, clap::ValueEnum, PartialEq, Eq)]
69pub enum OutputFormat {
70 #[default]
72 Json,
73 Text,
75 Compact,
77 Sarif,
79 Dot,
81}
82
83pub struct OutputWriter {
85 format: OutputFormat,
86 quiet: bool,
87}
88
89impl OutputWriter {
90 pub fn new(format: OutputFormat, quiet: bool) -> Self {
92 Self { format, quiet }
93 }
94
95 pub fn write<T: Serialize>(&self, value: &T) -> io::Result<()> {
97 let stdout = io::stdout();
98 let mut handle = stdout.lock();
99
100 match self.format {
101 OutputFormat::Json | OutputFormat::Sarif => {
102 serde_json::to_writer_pretty(&mut handle, value)?;
104 writeln!(handle)?;
105 }
106 OutputFormat::Compact => {
107 serde_json::to_writer(&mut handle, value)?;
108 writeln!(handle)?;
109 }
110 OutputFormat::Text | OutputFormat::Dot => {
111 serde_json::to_writer_pretty(&mut handle, value)?;
113 writeln!(handle)?;
114 }
115 }
116
117 Ok(())
118 }
119
120 pub fn write_text(&self, text: &str) -> io::Result<()> {
122 let stdout = io::stdout();
123 let mut handle = stdout.lock();
124 writeln!(handle, "{}", text)?;
125 Ok(())
126 }
127
128 pub fn progress(&self, message: &str) {
130 if !self.quiet {
131 eprintln!("{}", message.dimmed());
132 }
133 }
134
135 pub fn is_text(&self) -> bool {
137 matches!(self.format, OutputFormat::Text)
138 }
139
140 #[allow(dead_code)]
142 pub fn is_json(&self) -> bool {
143 matches!(
144 self.format,
145 OutputFormat::Json | OutputFormat::Compact | OutputFormat::Sarif
146 )
147 }
148
149 pub fn is_dot(&self) -> bool {
151 matches!(self.format, OutputFormat::Dot)
152 }
153}
154
155pub fn format_file_tree_text(tree: &tldr_core::FileTree, indent: usize) -> String {
161 let mut output = String::new();
162 format_tree_node(tree, &mut output, indent, "");
163 output
164}
165
166fn format_tree_node(tree: &tldr_core::FileTree, output: &mut String, indent: usize, prefix: &str) {
167 let indent_str = " ".repeat(indent);
168 let icon_plain = match tree.node_type {
170 tldr_core::NodeType::Dir => "[D]".yellow().to_string(),
171 tldr_core::NodeType::File => "[F]".blue().to_string(),
172 };
173
174 output.push_str(&format!(
175 "{}{}{} {}\n",
176 prefix, indent_str, icon_plain, tree.name
177 ));
178
179 for (i, child) in tree.children.iter().enumerate() {
180 let is_last = i == tree.children.len() - 1;
181 let new_prefix = if is_last { "`-- " } else { "|-- " };
182 let cont_prefix = if is_last { " " } else { "| " };
183 format_tree_node(
184 child,
185 output,
186 0,
187 &format!("{}{}{}", prefix, cont_prefix, new_prefix),
188 );
189 }
190}
191
192pub fn format_structure_text(structure: &tldr_core::CodeStructure) -> String {
194 let mut output = String::new();
195
196 output.push_str(&format!(
197 "{} ({} files)\n",
198 structure.root.display().to_string().bold(),
199 structure.files.len()
200 ));
201 output.push_str(&format!(
202 "Language: {}\n\n",
203 format!("{:?}", structure.language).cyan()
204 ));
205
206 let prefix = &structure.root;
208
209 for file in &structure.files {
210 let rel = strip_prefix_display(&file.path, prefix);
211 output.push_str(&format!("{}\n", rel.green()));
212
213 if !file.functions.is_empty() {
214 output.push_str(" Functions:\n");
215 for func in &file.functions {
216 output.push_str(&format!(" - {}\n", func));
217 }
218 }
219
220 if !file.classes.is_empty() {
221 output.push_str(" Classes:\n");
222 for class in &file.classes {
223 output.push_str(&format!(" - {}\n", class));
224 }
225 }
226
227 output.push('\n');
228 }
229
230 output
231}
232
233pub fn format_imports_text(imports: &[tldr_core::types::ImportInfo]) -> String {
244 use std::collections::BTreeMap;
245
246 let mut output = String::new();
247
248 if imports.is_empty() {
249 output.push_str("No imports found.\n");
250 return output;
251 }
252
253 let mut from_groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
255 let mut bare_imports: Vec<String> = Vec::new();
256
257 for imp in imports {
258 if imp.is_from && !imp.names.is_empty() {
259 let names = from_groups.entry(&imp.module).or_default();
260 for name in &imp.names {
261 names.push(name);
262 }
263 } else if let Some(alias) = &imp.alias {
264 bare_imports.push(format!("{} as {}", imp.module, alias));
265 } else {
266 bare_imports.push(imp.module.clone());
267 }
268 }
269
270 for (module, names) in &from_groups {
272 output.push_str(&format!(
273 "from {}: {}\n",
274 module.cyan(),
275 names.join(", ").green(),
276 ));
277 }
278
279 if !bare_imports.is_empty() {
281 if !from_groups.is_empty() {
282 output.push('\n');
283 }
284 output.push_str(&format!("import {}\n", bare_imports.join(", ").cyan()));
285 }
286
287 output
288}
289
290pub fn format_importers_text(report: &tldr_core::types::ImportersReport) -> String {
300 let mut output = String::new();
301
302 if report.importers.is_empty() {
303 output.push_str("No files import this module.\n");
304 return output;
305 }
306
307 let paths: Vec<&Path> = report.importers.iter().map(|i| i.file.as_path()).collect();
309 let prefix = common_path_prefix(&paths);
310
311 let max_loc_width = report
313 .importers
314 .iter()
315 .map(|i| format!("{}:{}", strip_prefix_display(&i.file, &prefix), i.line).len())
316 .max()
317 .unwrap_or(20);
318
319 for imp in &report.importers {
320 let rel_path = strip_prefix_display(&imp.file, &prefix);
321 let loc = format!("{}:{}", rel_path, imp.line);
322 output.push_str(&format!(
323 " {:<width$} {}\n",
324 loc.green(),
325 imp.import_statement.dimmed(),
326 width = max_loc_width,
327 ));
328 }
329
330 output
331}
332
333pub fn format_cfg_text(cfg: &tldr_core::CfgInfo) -> String {
335 let mut output = String::new();
336
337 output.push_str(&format!(
338 "CFG for {} (complexity: {})\n\n",
339 cfg.function.bold().cyan(),
340 cfg.cyclomatic_complexity.to_string().yellow()
341 ));
342
343 let mut table = Table::new();
345 table
346 .load_preset(UTF8_FULL)
347 .set_content_arrangement(ContentArrangement::Dynamic)
348 .set_header(vec![
349 Cell::new("Block").fg(Color::Cyan),
350 Cell::new("Type").fg(Color::Cyan),
351 Cell::new("Lines").fg(Color::Cyan),
352 Cell::new("Calls").fg(Color::Cyan),
353 ]);
354
355 for block in &cfg.blocks {
356 table.add_row(vec![
357 Cell::new(block.id),
358 Cell::new(format!("{:?}", block.block_type)),
359 Cell::new(format!("{}-{}", block.lines.0, block.lines.1)),
360 Cell::new(block.calls.join(", ")),
361 ]);
362 }
363
364 output.push_str(&table.to_string());
365 output.push_str("\n\nEdges:\n");
366
367 for edge in &cfg.edges {
368 let edge_str = match edge.edge_type {
369 tldr_core::EdgeType::True => format!("{} -> {} (true)", edge.from, edge.to).green(),
370 tldr_core::EdgeType::False => format!("{} -> {} (false)", edge.from, edge.to).red(),
371 tldr_core::EdgeType::Unconditional => format!("{} -> {}", edge.from, edge.to).normal(),
372 tldr_core::EdgeType::BackEdge => {
373 format!("{} -> {} (back)", edge.from, edge.to).yellow()
374 }
375 _ => format!("{} -> {} ({:?})", edge.from, edge.to, edge.edge_type).normal(),
376 };
377 output.push_str(&format!(" {}\n", edge_str));
378 }
379
380 output
381}
382
383pub fn format_dfg_text(dfg: &tldr_core::DfgInfo) -> String {
385 let mut output = String::new();
386
387 output.push_str(&format!(
388 "DFG for {} ({} variables)\n\n",
389 dfg.function.bold().cyan(),
390 dfg.variables.len().to_string().yellow()
391 ));
392
393 output.push_str("Variables: ");
394 output.push_str(&dfg.variables.join(", "));
395 output.push_str("\n\n");
396
397 let mut table = Table::new();
399 table
400 .load_preset(UTF8_FULL)
401 .set_content_arrangement(ContentArrangement::Dynamic)
402 .set_header(vec![
403 Cell::new("Var").fg(Color::Cyan),
404 Cell::new("Type").fg(Color::Cyan),
405 Cell::new("Line").fg(Color::Cyan),
406 Cell::new("Col").fg(Color::Cyan),
407 ]);
408
409 for var_ref in &dfg.refs {
410 let type_str = match var_ref.ref_type {
411 tldr_core::RefType::Definition => "def",
412 tldr_core::RefType::Update => "upd",
413 tldr_core::RefType::Use => "use",
414 };
415 table.add_row(vec![
416 Cell::new(&var_ref.name),
417 Cell::new(type_str),
418 Cell::new(var_ref.line),
419 Cell::new(var_ref.column),
420 ]);
421 }
422
423 output.push_str(&table.to_string());
424 output
425}
426
427fn collect_caller_tree_paths<'a>(tree: &'a tldr_core::CallerTree, paths: &mut Vec<&'a Path>) {
429 paths.push(tree.file.as_path());
430 for caller in &tree.callers {
431 collect_caller_tree_paths(caller, paths);
432 }
433}
434
435pub fn format_impact_text(report: &tldr_core::ImpactReport, type_aware: bool) -> String {
437 let mut output = String::new();
438
439 let type_aware_suffix = if type_aware { " (type-aware)" } else { "" };
440 output.push_str(&format!(
441 "Impact Analysis{} ({} targets)\n\n",
442 type_aware_suffix,
443 report.total_targets.to_string().yellow()
444 ));
445
446 if let Some(ref stats) = report.type_resolution {
448 if stats.enabled {
449 output.push_str(&stats.summary());
450 output.push_str("\n\n");
451 }
452 }
453
454 let mut all_paths = Vec::new();
456 for tree in report.targets.values() {
457 collect_caller_tree_paths(tree, &mut all_paths);
458 }
459 let prefix = common_path_prefix(&all_paths);
460
461 for (key, tree) in &report.targets {
462 output.push_str(&format!("{}\n", key.bold().cyan()));
463 format_caller_tree(tree, &mut output, 1, type_aware, &prefix);
464 output.push('\n');
465 }
466
467 output
468}
469
470fn format_caller_tree(
471 tree: &tldr_core::CallerTree,
472 output: &mut String,
473 depth: usize,
474 type_aware: bool,
475 prefix: &Path,
476) {
477 let indent = " ".repeat(depth);
478 let file_str = strip_prefix_display(&tree.file, prefix);
479
480 let confidence_str = if type_aware {
482 if let Some(confidence) = &tree.confidence {
483 format!(" [{}]", confidence)
484 } else {
485 String::new()
486 }
487 } else {
488 String::new()
489 };
490
491 output.push_str(&format!(
492 "{}{}:{} ({} callers){}\n",
493 indent,
494 file_str.dimmed(),
495 tree.function.green(),
496 tree.caller_count,
497 confidence_str
498 ));
499
500 if tree.truncated {
501 output.push_str(&format!("{} [truncated - cycle detected]\n", indent));
502 }
503
504 if let Some(note) = &tree.note {
505 output.push_str(&format!("{} Note: {}\n", indent, note.dimmed()));
506 }
507
508 for caller in &tree.callers {
509 format_caller_tree(caller, output, depth + 1, type_aware, prefix);
510 }
511}
512
513pub fn format_dead_code_text(report: &tldr_core::DeadCodeReport) -> String {
515 let mut output = String::new();
516
517 output.push_str(&format!(
518 "Dead Code Analysis\n\nDefinitely dead: {} / {} functions ({:.1}% dead)\n",
519 report.total_dead.to_string().red(),
520 report.total_functions,
521 report.dead_percentage
522 ));
523
524 if report.total_possibly_dead > 0 {
525 output.push_str(&format!(
526 "Possibly dead (public but uncalled): {}\n",
527 report.total_possibly_dead.to_string().yellow()
528 ));
529 }
530
531 output.push('\n');
532
533 if !report.by_file.is_empty() {
534 let paths: Vec<&Path> = report.by_file.keys().map(|p| p.as_path()).collect();
536 let prefix = common_path_prefix(&paths);
537
538 output.push_str("Definitely dead:\n");
539 for (file, funcs) in &report.by_file {
540 let rel = strip_prefix_display(file, &prefix);
541 output.push_str(&format!("{}\n", rel.green()));
542 for func in funcs {
543 output.push_str(&format!(" - {}\n", func.red()));
544 }
545 output.push('\n');
546 }
547 }
548
549 output
550}
551
552pub fn format_complexity_text(metrics: &tldr_core::types::ComplexityMetrics) -> String {
563 let mut output = String::new();
564
565 output.push_str(&format!("Complexity: {}\n", metrics.function.bold().cyan()));
566 output.push_str(&format!(" Cyclomatic: {}\n", metrics.cyclomatic));
567 output.push_str(&format!(" Cognitive: {}\n", metrics.cognitive));
568 output.push_str(&format!(" Nesting depth: {}\n", metrics.nesting_depth));
569 output.push_str(&format!(" Lines of code: {}\n", metrics.lines_of_code));
570
571 output
572}
573
574pub fn format_cognitive_text(report: &tldr_core::metrics::CognitiveReport) -> String {
588 let mut output = String::new();
589
590 let violation_count = report.violations.len();
591 output.push_str(&format!(
592 "Cognitive Complexity ({} functions, {} violations)\n\n",
593 report.summary.total_functions,
594 if violation_count > 0 {
595 violation_count.to_string().red().to_string()
596 } else {
597 "0".green().to_string()
598 }
599 ));
600
601 if report.functions.is_empty() {
602 output.push_str(" No functions found.\n");
603 return output;
604 }
605
606 let parents: Vec<&Path> = report
609 .functions
610 .iter()
611 .filter_map(|f| Path::new(f.file.as_str()).parent())
612 .collect();
613 let prefix = if parents.is_empty() {
614 std::path::PathBuf::new()
615 } else {
616 common_path_prefix(&parents)
617 };
618
619 output.push_str(&format!(
621 " {:>3} {:>5} {:>4} {:<9} {:<28} {}\n",
622 "#", "Score", "Nest", "Status", "Function", "File"
623 ));
624
625 for (i, f) in report.functions.iter().enumerate() {
626 let rel = strip_prefix_display(Path::new(&f.file), &prefix);
627 let status = match f.threshold_status {
628 tldr_core::metrics::CognitiveThresholdStatus::Severe => {
629 "SEVERE".red().bold().to_string()
630 }
631 tldr_core::metrics::CognitiveThresholdStatus::Violation => {
632 "VIOLATION".yellow().to_string()
633 }
634 _ => "ok".green().to_string(),
635 };
636
637 let name = if f.name.len() > 28 {
639 format!("{}...", &f.name[..25])
640 } else {
641 f.name.clone()
642 };
643
644 output.push_str(&format!(
645 " {:>3} {:>5} {:>4} {:<9} {:<28} {}:{}\n",
646 i + 1,
647 f.cognitive,
648 f.max_nesting,
649 status,
650 name,
651 rel,
652 f.line
653 ));
654 }
655
656 output.push_str(&format!(
658 "\nSummary: avg={:.1}, max={}, compliance={:.1}%\n",
659 report.summary.avg_cognitive, report.summary.max_cognitive, report.summary.compliance_rate
660 ));
661
662 output
663}
664
665pub fn format_maintainability_text(
681 report: &tldr_core::quality::maintainability::MaintainabilityReport,
682) -> String {
683 let mut output = String::new();
684
685 output.push_str(&format!(
686 "Maintainability Index ({} files, avg MI={:.1})\n\n",
687 report.summary.files_analyzed, report.summary.average_mi
688 ));
689
690 let grades = ['A', 'B', 'C', 'D', 'F'];
692 let mut grade_parts = Vec::new();
693 for g in &grades {
694 let count = report.summary.by_grade.get(g).unwrap_or(&0);
695 if *count > 0 {
696 grade_parts.push(format!("{}={}", g, count));
697 }
698 }
699 output.push_str(&format!(
700 "Grade distribution: {}\n\n",
701 grade_parts.join(" ")
702 ));
703
704 if report.files.is_empty() {
705 output.push_str(" No files analyzed.\n");
706 return output;
707 }
708
709 let mut files: Vec<_> = report.files.iter().collect();
711 files.sort_by(|a, b| a.mi.partial_cmp(&b.mi).unwrap_or(std::cmp::Ordering::Equal));
712
713 let paths: Vec<&Path> = files.iter().filter_map(|f| f.path.parent()).collect();
715 let prefix = common_path_prefix(&paths);
716
717 output.push_str(&format!(
719 " {:>3} {:>5} {:>5} {:>4} {:>5} {}\n",
720 "#", "MI", "Grade", "LOC", "AvgCC", "File"
721 ));
722
723 let limit = files.len().min(30);
725 for (i, f) in files.iter().take(limit).enumerate() {
726 let rel = strip_prefix_display(&f.path, &prefix);
727 let grade_str = match f.grade {
728 'F' => format!("{}", f.grade).red().bold().to_string(),
729 'D' => format!("{}", f.grade).yellow().to_string(),
730 _ => format!("{}", f.grade),
731 };
732
733 output.push_str(&format!(
734 " {:>3} {:>5.1} {:>5} {:>4} {:>5.1} {}\n",
735 i + 1,
736 f.mi,
737 grade_str,
738 f.loc,
739 f.avg_complexity,
740 rel
741 ));
742 }
743
744 if files.len() > limit {
745 output.push_str(&format!("\n ... and {} more files\n", files.len() - limit));
746 }
747
748 output
749}
750
751pub fn format_search_text(matches: &[tldr_core::SearchMatch]) -> String {
753 let mut output = String::new();
754
755 output.push_str(&format!(
756 "Found {} matches\n\n",
757 matches.len().to_string().yellow()
758 ));
759
760 let paths: Vec<&Path> = matches.iter().map(|m| m.file.as_path()).collect();
762 let prefix = common_path_prefix(&paths);
763
764 for m in matches {
765 let rel = strip_prefix_display(&m.file, &prefix);
766 output.push_str(&format!(
767 "{}:{}: {}\n",
768 rel.green(),
769 m.line.to_string().cyan(),
770 m.content.trim()
771 ));
772
773 if let Some(context) = &m.context {
774 for line in context {
775 output.push_str(&format!(" {}\n", line.dimmed()));
776 }
777 }
778 }
779
780 output
781}
782
783pub fn format_enriched_search_text(report: &tldr_core::EnrichedSearchReport) -> String {
790 let mut output = String::new();
791
792 output.push_str(&format!("query: \"{}\"\n", report.query));
793 output.push_str(&format!(
794 "{} results from {} files ({})\n\n",
795 report.results.len(),
796 report.total_files_searched,
797 report.search_mode
798 ));
799
800 if report.results.is_empty() {
801 output.push_str(" No results found.\n");
802 return output;
803 }
804
805 let paths: Vec<&Path> = report.results.iter().map(|r| r.file.as_path()).collect();
807 let prefix = common_path_prefix(&paths);
808
809 for (i, result) in report.results.iter().enumerate() {
810 let rel = strip_prefix_display(&result.file, &prefix);
811 let line_range = format!("{}-{}", result.line_range.0, result.line_range.1);
812
813 let kind_prefix = match result.kind.as_str() {
815 "function" => "fn ",
816 "method" => "method ",
817 "class" => "class ",
818 "struct" => "struct ",
819 "module" => "mod ",
820 _ => "",
821 };
822 output.push_str(&format!(
823 "{}. {}{} ({}:{}) [{:.2}]\n",
824 i + 1,
825 kind_prefix,
826 result.name,
827 rel,
828 line_range,
829 result.score
830 ));
831
832 if !result.signature.is_empty() {
834 output.push_str(&format!(" {}\n", result.signature));
835 }
836
837 if !result.callers.is_empty() {
839 let callers_str = format_name_list(&result.callers, 5);
840 output.push_str(&format!(" Called by: {}\n", callers_str));
841 }
842
843 if !result.callees.is_empty() {
845 let callees_str = format_name_list(&result.callees, 5);
846 output.push_str(&format!(" Calls: {}\n", callees_str));
847 }
848
849 if !result.preview.is_empty() && result.kind != "module" {
851 let preview_lines: Vec<&str> = result.preview.lines().collect();
852 let start =
854 if preview_lines.first().map(|l| l.trim()) == Some(result.signature.as_str()) {
855 1
856 } else {
857 0
858 };
859 if start < preview_lines.len() {
860 output.push_str(" ---\n");
861 for line in &preview_lines[start..preview_lines.len().min(start + 4)] {
862 output.push_str(&format!(" {}\n", line));
863 }
864 }
865 }
866
867 if i < report.results.len() - 1 {
869 output.push('\n');
870 }
871 }
872
873 output
874}
875
876fn format_name_list(names: &[String], max: usize) -> String {
878 if names.len() <= max {
879 names.join(", ")
880 } else {
881 let shown: Vec<&str> = names[..max].iter().map(|s| s.as_str()).collect();
882 format!("{}, ... and {} more", shown.join(", "), names.len() - max)
883 }
884}
885
886pub fn format_smells_text(report: &tldr_core::SmellsReport) -> String {
888 let mut output = String::new();
889
890 output.push_str(&format!(
891 "Code Smells Report ({} issues)\n\n",
892 report.smells.len().to_string().yellow()
893 ));
894
895 if report.smells.is_empty() {
896 output.push_str(" No code smells detected.\n");
897 return output;
898 }
899
900 let paths: Vec<&Path> = report.smells.iter().map(|s| s.file.as_path()).collect();
902 let prefix = if paths.is_empty() {
903 std::path::PathBuf::new()
904 } else {
905 common_path_prefix(&paths)
906 };
907
908 output.push_str(&format!(
910 " {:>3} {:>3} {:<20} {:<28} {}\n",
911 "#", "Sev", "Type", "Name", "File:Line"
912 ));
913
914 for (i, smell) in report.smells.iter().enumerate() {
915 let sev_str = match smell.severity {
917 3 => smell.severity.to_string().red(),
918 2 => smell.severity.to_string().yellow(),
919 _ => smell.severity.to_string().white(),
920 }
921 .to_string();
922
923 let type_str = {
925 let base = format!("{}", smell.smell_type);
926 let colored = match smell.smell_type {
927 tldr_core::SmellType::GodClass => base.red(),
928 tldr_core::SmellType::LongMethod => base.yellow(),
929 tldr_core::SmellType::LongParameterList => base.magenta(),
930 tldr_core::SmellType::LowCohesion => base.yellow(),
931 tldr_core::SmellType::TightCoupling => base.red(),
932 tldr_core::SmellType::DeadCode => base.dimmed(),
933 tldr_core::SmellType::CodeClone => base.cyan(),
934 tldr_core::SmellType::HighCognitiveComplexity => base.red(),
935 tldr_core::SmellType::DeepNesting => base.yellow(),
936 tldr_core::SmellType::DataClass => base.cyan(),
937 tldr_core::SmellType::LazyElement => base.dimmed(),
938 tldr_core::SmellType::MessageChain => base.magenta(),
939 tldr_core::SmellType::PrimitiveObsession => base.cyan(),
940 tldr_core::SmellType::FeatureEnvy => base.yellow(),
941 tldr_core::SmellType::MiddleMan => base.yellow(),
942 tldr_core::SmellType::RefusedBequest => base.magenta(),
943 tldr_core::SmellType::InappropriateIntimacy => base.red(),
944 tldr_core::SmellType::DataClumps => base.white(),
945 };
946 colored.to_string()
947 };
948
949 let name = if smell.name.len() > 28 {
951 format!("{}...", &smell.name[..25])
952 } else {
953 smell.name.clone()
954 };
955
956 let rel_file = strip_prefix_display(&smell.file, &prefix);
958
959 output.push_str(&format!(
960 " {:>3} {:>3} {:<20} {:<28} {}:{}\n",
961 i + 1,
962 sev_str,
963 type_str,
964 name,
965 rel_file,
966 smell.line
967 ));
968 }
969
970 output.push('\n');
972
973 let sev3 = report.smells.iter().filter(|s| s.severity == 3).count();
974 let sev2 = report.smells.iter().filter(|s| s.severity == 2).count();
975 let sev1 = report.smells.iter().filter(|s| s.severity == 1).count();
976 let unique_files = report.by_file.len();
977 output.push_str(&format!(
978 "Summary: {} smells found ({} {}, {} {}, {} {}) across {} files\n",
979 report.smells.len(),
980 sev3,
981 "sev-3".red(),
982 sev2,
983 "sev-2".yellow(),
984 sev1,
985 "sev-1",
986 unique_files,
987 ));
988
989 let mut type_counts: Vec<(String, usize)> = report
991 .summary
992 .by_type
993 .iter()
994 .map(|(k, v)| (k.clone(), *v))
995 .collect();
996 type_counts.sort_by(|a, b| b.1.cmp(&a.1));
997 let breakdown: Vec<String> = type_counts
998 .iter()
999 .map(|(name, count)| format!("{}: {}", name, count))
1000 .collect();
1001 output.push_str(&format!(" {}\n", breakdown.join(", ")));
1002
1003 output
1004}
1005
1006pub fn format_secrets_text(report: &tldr_core::SecretsReport) -> String {
1008 let mut output = String::new();
1009
1010 output.push_str(&format!(
1011 "Secrets Scan ({} findings, {} files scanned)\n\n",
1012 report.findings.len().to_string().yellow(),
1013 report.files_scanned
1014 ));
1015
1016 if report.findings.is_empty() {
1017 output.push_str(" No secrets detected.\n");
1018 return output;
1019 }
1020
1021 let paths: Vec<&Path> = report.findings.iter().map(|f| f.file.as_path()).collect();
1023 let prefix = if paths.is_empty() {
1024 std::path::PathBuf::new()
1025 } else {
1026 common_path_prefix(&paths)
1027 };
1028
1029 output.push_str(&format!(
1031 " {:<8} {:<14} {:<40} {:>5} {}\n",
1032 "Severity", "Pattern", "File", "Line", "Value"
1033 ));
1034
1035 for finding in &report.findings {
1036 let sev_str = match finding.severity {
1037 tldr_core::Severity::Critical => finding.severity.to_string().red(),
1038 tldr_core::Severity::High => finding.severity.to_string().red(),
1039 tldr_core::Severity::Medium => finding.severity.to_string().yellow(),
1040 tldr_core::Severity::Low => finding.severity.to_string().white(),
1041 }
1042 .to_string();
1043
1044 let rel_file = strip_prefix_display(&finding.file, &prefix);
1045
1046 let file_display = if rel_file.len() > 40 {
1048 format!("...{}", &rel_file[rel_file.len() - 37..])
1049 } else {
1050 rel_file
1051 };
1052
1053 output.push_str(&format!(
1054 " {:<8} {:<14} {:<40} {:>5} {}\n",
1055 sev_str, finding.pattern, file_display, finding.line, finding.masked_value
1056 ));
1057 }
1058
1059 output.push('\n');
1061 let critical = report
1062 .findings
1063 .iter()
1064 .filter(|f| f.severity == tldr_core::Severity::Critical)
1065 .count();
1066 let high = report
1067 .findings
1068 .iter()
1069 .filter(|f| f.severity == tldr_core::Severity::High)
1070 .count();
1071 let medium = report
1072 .findings
1073 .iter()
1074 .filter(|f| f.severity == tldr_core::Severity::Medium)
1075 .count();
1076 let low = report
1077 .findings
1078 .iter()
1079 .filter(|f| f.severity == tldr_core::Severity::Low)
1080 .count();
1081 let mut parts = Vec::new();
1082 if critical > 0 {
1083 parts.push(format!("{} {}", critical, "critical".red()));
1084 }
1085 if high > 0 {
1086 parts.push(format!("{} {}", high, "high".red()));
1087 }
1088 if medium > 0 {
1089 parts.push(format!("{} {}", medium, "medium".yellow()));
1090 }
1091 if low > 0 {
1092 parts.push(format!("{} {}", low, "low"));
1093 }
1094 output.push_str(&format!("Summary: {}\n", parts.join(", ")));
1095
1096 output
1097}
1098
1099pub fn format_whatbreaks_text(
1124 report: &tldr_core::analysis::whatbreaks::WhatbreaksReport,
1125) -> String {
1126 let mut output = String::new();
1127
1128 output.push_str(&format!(
1130 "What Breaks: {} ({})\n",
1131 report.target.bold().cyan(),
1132 report.target_type.to_string().yellow()
1133 ));
1134 output.push('\n');
1135
1136 let summary = &report.summary;
1138
1139 if summary.direct_caller_count > 0
1140 || report.target_type == tldr_core::analysis::whatbreaks::TargetType::Function
1141 {
1142 output.push_str(&format!(
1143 "Direct callers: {}\n",
1144 if summary.direct_caller_count > 0 {
1145 summary.direct_caller_count.to_string().green().to_string()
1146 } else {
1147 "0".to_string()
1148 }
1149 ));
1150 output.push_str(&format!(
1151 "Transitive callers: {}\n",
1152 if summary.transitive_caller_count > 0 {
1153 summary
1154 .transitive_caller_count
1155 .to_string()
1156 .green()
1157 .to_string()
1158 } else {
1159 "0".to_string()
1160 }
1161 ));
1162 }
1163
1164 if summary.importer_count > 0
1165 || report.target_type != tldr_core::analysis::whatbreaks::TargetType::Function
1166 {
1167 output.push_str(&format!(
1168 "Importing modules: {}\n",
1169 if summary.importer_count > 0 {
1170 format!("{} files", summary.importer_count)
1171 .green()
1172 .to_string()
1173 } else {
1174 "0 files".to_string()
1175 }
1176 ));
1177 }
1178
1179 if summary.affected_test_count > 0
1180 || report.target_type == tldr_core::analysis::whatbreaks::TargetType::File
1181 {
1182 output.push_str(&format!(
1183 "Affected tests: {}\n",
1184 if summary.affected_test_count > 0 {
1185 format!("{} test files", summary.affected_test_count)
1186 .yellow()
1187 .to_string()
1188 } else {
1189 "0 test files".to_string()
1190 }
1191 ));
1192 }
1193
1194 output.push('\n');
1195
1196 let has_errors = report
1198 .sub_results
1199 .values()
1200 .any(|r| r.error.is_some() || !r.warnings.is_empty());
1201 if has_errors {
1202 output.push_str("Issues:\n");
1203
1204 let mut sub_results: Vec<_> = report.sub_results.iter().collect();
1205 sub_results.sort_by_key(|(name, _)| *name);
1206
1207 for (name, result) in sub_results {
1208 if let Some(error) = &result.error {
1209 output.push_str(&format!(" {} error: {}\n", name, error.red()));
1210 }
1211 for warning in &result.warnings {
1212 output.push_str(&format!(" {} warning: {}\n", name, warning.yellow()));
1213 }
1214 }
1215 }
1216
1217 output
1218}
1219
1220pub fn format_hubs_text(report: &tldr_core::analysis::hubs::HubReport) -> String {
1231 let mut output = String::new();
1232
1233 output.push_str(&format!(
1235 "Hub Detection ({} hubs / {} nodes)\n\n",
1236 report.hub_count.to_string().yellow(),
1237 report.total_nodes,
1238 ));
1239
1240 if report.hubs.is_empty() {
1242 output.push_str("No hubs found.\n");
1243 return output;
1244 }
1245
1246 let paths: Vec<&Path> = report.hubs.iter().map(|h| h.file.as_path()).collect();
1248 let prefix = common_path_prefix(&paths);
1249
1250 let max_func = report
1252 .hubs
1253 .iter()
1254 .map(|h| h.name.len())
1255 .max()
1256 .unwrap_or(8)
1257 .max(8);
1258 let max_file = report
1259 .hubs
1260 .iter()
1261 .map(|h| strip_prefix_display(&h.file, &prefix).len())
1262 .max()
1263 .unwrap_or(4)
1264 .max(4);
1265
1266 output.push_str(&format!(
1268 " {:<3} {:<8} {:<width_f$} {:<width_p$} {:>5} {:>3} {:>3}\n",
1269 "#",
1270 "Risk",
1271 "Function",
1272 "File",
1273 "Score",
1274 "In",
1275 "Out",
1276 width_f = max_func,
1277 width_p = max_file,
1278 ));
1279
1280 for (i, hub) in report.hubs.iter().enumerate() {
1281 let risk_str = format!("{}", hub.risk_level).to_uppercase();
1282 let rel_file = strip_prefix_display(&hub.file, &prefix);
1283
1284 output.push_str(&format!(
1285 " {:<3} {:<8} {:<width_f$} {:<width_p$} {:>5.3} {:>3} {:>3}\n",
1286 i + 1,
1287 risk_str,
1288 hub.name,
1289 rel_file,
1290 hub.composite_score,
1291 hub.callers_count,
1292 hub.callees_count,
1293 width_f = max_func,
1294 width_p = max_file,
1295 ));
1296 }
1297
1298 output
1299}
1300
1301pub fn format_change_impact_text(report: &tldr_core::ChangeImpactReport) -> String {
1306 let mut output = String::new();
1307
1308 output.push_str(&"Change Impact Analysis\n".bold().to_string());
1310 output.push_str("======================\n\n");
1311
1312 output.push_str(&format!("Detection: {}\n", report.detection_method.cyan()));
1314
1315 output.push_str(&format!(
1317 "Changed: {} files\n\n",
1318 report.changed_files.len().to_string().yellow()
1319 ));
1320
1321 if !report.changed_files.is_empty() {
1322 output.push_str(&"Changed Files:\n".bold().to_string());
1323 for file in &report.changed_files {
1324 output.push_str(&format!(" {}\n", file.display().to_string().green()));
1325 }
1326 output.push('\n');
1327 }
1328
1329 let test_func_count = report.affected_test_functions.len();
1331 output.push_str(&format!(
1332 "Affected Tests: {} files, {} functions\n",
1333 report.affected_tests.len().to_string().yellow(),
1334 test_func_count.to_string().yellow()
1335 ));
1336
1337 if !report.affected_tests.is_empty() {
1338 for test in &report.affected_tests {
1339 output.push_str(&format!(" {}\n", test.display().to_string().cyan()));
1340 for tf in &report.affected_test_functions {
1342 if tf.file == *test {
1343 let func_name = if let Some(ref class) = tf.class {
1344 format!("{}::{}", class, tf.function)
1345 } else {
1346 tf.function.clone()
1347 };
1348 output.push_str(&format!(" - {} (line {})\n", func_name.green(), tf.line));
1349 }
1350 }
1351 }
1352 output.push('\n');
1353 } else {
1354 output.push_str(" No tests affected.\n\n");
1355 }
1356
1357 if !report.affected_functions.is_empty() {
1359 output.push_str(&format!(
1360 "Affected Functions: {}\n",
1361 report.affected_functions.len().to_string().yellow()
1362 ));
1363 for func in &report.affected_functions {
1364 output.push_str(&format!(
1365 " {} ({})\n",
1366 func.name.green(),
1367 func.file.display().to_string().dimmed()
1368 ));
1369 }
1370 output.push('\n');
1371 }
1372
1373 if let Some(ref metadata) = report.metadata {
1375 output.push_str(&format!(
1376 "Call Graph: {} edges\n",
1377 metadata.call_graph_edges
1378 ));
1379 output.push_str(&format!(
1380 "Traversal Depth: {}\n",
1381 metadata.analysis_depth.unwrap_or(0)
1382 ));
1383 }
1384
1385 output
1386}
1387
1388pub fn format_diagnostics_text(
1394 report: &tldr_core::diagnostics::DiagnosticsReport,
1395 filtered_count: usize,
1396) -> String {
1397 let mut output = String::new();
1398
1399 let tool_names: Vec<&str> = report.tools_run.iter().map(|t| t.name.as_str()).collect();
1402 let tools_part = tool_names.join(" + ");
1403
1404 let summary = &report.summary;
1405 let mut counts: Vec<String> = Vec::new();
1406 if summary.errors > 0 {
1407 counts.push(format!(
1408 "{} {}",
1409 summary.errors,
1410 if summary.errors == 1 {
1411 "error"
1412 } else {
1413 "errors"
1414 }
1415 ));
1416 }
1417 if summary.warnings > 0 {
1418 counts.push(format!(
1419 "{} {}",
1420 summary.warnings,
1421 if summary.warnings == 1 {
1422 "warning"
1423 } else {
1424 "warnings"
1425 }
1426 ));
1427 }
1428 if summary.info > 0 {
1429 counts.push(format!(
1430 "{} {}",
1431 summary.info,
1432 if summary.info == 1 { "info" } else { "infos" }
1433 ));
1434 }
1435 if summary.hints > 0 {
1436 counts.push(format!(
1437 "{} {}",
1438 summary.hints,
1439 if summary.hints == 1 { "hint" } else { "hints" }
1440 ));
1441 }
1442
1443 let counts_part = if counts.is_empty() {
1444 "No issues found".to_string()
1445 } else {
1446 counts.join(", ")
1447 };
1448
1449 output.push_str(&format!(
1450 "{} | {} files | {}\n",
1451 tools_part, report.files_analyzed, counts_part
1452 ));
1453
1454 if report.diagnostics.is_empty() {
1456 } else {
1458 output.push('\n');
1459
1460 let parents: Vec<&std::path::Path> = report
1465 .diagnostics
1466 .iter()
1467 .filter_map(|d| d.file.parent())
1468 .collect();
1469 let prefix = common_path_prefix(&parents);
1470
1471 let mut sorted_diags: Vec<&tldr_core::diagnostics::Diagnostic> =
1473 report.diagnostics.iter().collect();
1474 sorted_diags.sort_by(|a, b| {
1475 a.file
1476 .cmp(&b.file)
1477 .then(a.line.cmp(&b.line))
1478 .then(a.column.cmp(&b.column))
1479 });
1480
1481 for diag in &sorted_diags {
1482 let rel_path = strip_prefix_display(&diag.file, &prefix);
1483
1484 let severity_str = match diag.severity {
1486 tldr_core::diagnostics::Severity::Error => "error",
1487 tldr_core::diagnostics::Severity::Warning => "warning",
1488 tldr_core::diagnostics::Severity::Information => "info",
1489 tldr_core::diagnostics::Severity::Hint => "hint",
1490 };
1491
1492 let code_str = diag
1494 .code
1495 .as_ref()
1496 .map(|c| format!("[{}]", c))
1497 .unwrap_or_default();
1498
1499 let message = diag.message.lines().next().unwrap_or(&diag.message);
1501
1502 output.push_str(&format!(
1505 "{}:{}:{}: {}{} {} ({})\n",
1506 rel_path, diag.line, diag.column, severity_str, code_str, message, diag.source
1507 ));
1508 }
1509 }
1510
1511 if filtered_count > 0 {
1513 output.push_str(&format!(
1514 "\n({} issues filtered by severity/ignore settings)\n",
1515 filtered_count
1516 ));
1517 }
1518
1519 output
1520}
1521
1522pub fn clone_type_description(clone_type: &tldr_core::analysis::CloneType) -> &'static str {
1531 use tldr_core::analysis::CloneType;
1532 match clone_type {
1533 CloneType::Type1 => "exact match (identical code)",
1534 CloneType::Type2 => "identical structure, renamed identifiers/literals",
1535 CloneType::Type3 => "similar structure with additions/deletions",
1536 }
1537}
1538
1539pub fn empty_results_hints(
1543 options: &tldr_core::analysis::ClonesOptions,
1544 stats: &tldr_core::analysis::CloneStats,
1545) -> Vec<String> {
1546 vec![
1547 format!(
1548 "Analyzed {} files, {} tokens",
1549 stats.files_analyzed, stats.total_tokens
1550 ),
1551 format!(
1552 "Current threshold: {:.0}% - try --threshold 0.6 for more matches",
1553 options.threshold * 100.0
1554 ),
1555 format!(
1556 "Current min-tokens: {} - try --min-tokens 30 for smaller clones",
1557 options.min_tokens
1558 ),
1559 ]
1560}
1561
1562pub fn escape_dot_id(id: &str) -> String {
1569 let normalized = id.replace('\\', "/");
1571
1572 let escaped = normalized.replace('"', r#"\""#);
1574
1575 format!("\"{}\"", escaped)
1577}
1578
1579pub fn format_clones_text(report: &tldr_core::analysis::ClonesReport) -> String {
1597 let mut output = String::new();
1598
1599 output.push_str(&format!(
1601 "Clone Detection: {} pairs in {} files ({} tokens)\n",
1602 report.stats.clones_found, report.stats.files_analyzed, report.stats.total_tokens
1603 ));
1604
1605 if report.clone_pairs.is_empty() {
1606 output.push_str("\nNo clones found.\n");
1607 return output;
1608 }
1609
1610 output.push('\n');
1611
1612 let all_paths: Vec<&Path> = report
1614 .clone_pairs
1615 .iter()
1616 .flat_map(|p| [p.fragment1.file.as_path(), p.fragment2.file.as_path()])
1617 .collect();
1618 let prefix = common_path_prefix(&all_paths);
1619
1620 output.push_str(&format!(
1622 " {:>2} {:>3} {:<4} {:<30} {:>9} {:<30} {:>9}\n",
1623 "#", "Sim", "Type", "File A", "Lines", "File B", "Lines"
1624 ));
1625
1626 for pair in &report.clone_pairs {
1627 let sim = (pair.similarity * 100.0) as u32;
1628 let type_short = match pair.clone_type {
1629 tldr_core::analysis::CloneType::Type1 => "T1",
1630 tldr_core::analysis::CloneType::Type2 => "T2",
1631 tldr_core::analysis::CloneType::Type3 => "T3",
1632 };
1633
1634 let file_a = strip_prefix_display(&pair.fragment1.file, &prefix);
1635 let file_b = strip_prefix_display(&pair.fragment2.file, &prefix);
1636 let lines_a = format!("{}-{}", pair.fragment1.start_line, pair.fragment1.end_line);
1637 let lines_b = format!("{}-{}", pair.fragment2.start_line, pair.fragment2.end_line);
1638
1639 let file_a_display = if file_a.len() > 30 {
1641 format!("...{}", &file_a[file_a.len() - 27..])
1642 } else {
1643 file_a
1644 };
1645 let file_b_display = if file_b.len() > 30 {
1646 format!("...{}", &file_b[file_b.len() - 27..])
1647 } else {
1648 file_b
1649 };
1650
1651 output.push_str(&format!(
1652 " {:>2} {:>3}% {:<4} {:<30} {:>9} {:<30} {:>9}\n",
1653 pair.id, sim, type_short, file_a_display, lines_a, file_b_display, lines_b
1654 ));
1655 }
1656
1657 output
1658}
1659
1660pub fn format_clones_dot(report: &tldr_core::analysis::ClonesReport) -> String {
1674 let mut output = String::new();
1675
1676 output.push_str("digraph clones {\n");
1677 output.push_str(" rankdir=LR;\n");
1678 output.push_str(" node [shape=box, fontname=\"Helvetica\"];\n");
1679 output.push_str(" edge [fontname=\"Helvetica\", fontsize=10];\n");
1680 output.push('\n');
1681
1682 for pair in &report.clone_pairs {
1684 let node1 = format!(
1685 "{}:{}-{}",
1686 pair.fragment1.file.display(),
1687 pair.fragment1.start_line,
1688 pair.fragment1.end_line
1689 );
1690 let node2 = format!(
1691 "{}:{}-{}",
1692 pair.fragment2.file.display(),
1693 pair.fragment2.start_line,
1694 pair.fragment2.end_line
1695 );
1696
1697 let node1_escaped = escape_dot_id(&node1);
1699 let node2_escaped = escape_dot_id(&node2);
1700
1701 let similarity_pct = (pair.similarity * 100.0) as u32;
1702 let type_abbrev = match pair.clone_type {
1703 tldr_core::analysis::CloneType::Type1 => "T1",
1704 tldr_core::analysis::CloneType::Type2 => "T2",
1705 tldr_core::analysis::CloneType::Type3 => "T3",
1706 };
1707
1708 output.push_str(&format!(
1709 " {} -> {} [label=\"{}% {}\"];\n",
1710 node1_escaped, node2_escaped, similarity_pct, type_abbrev
1711 ));
1712 }
1713
1714 output.push_str("}\n");
1715 output
1716}
1717
1718pub fn format_similarity_text(report: &tldr_core::analysis::SimilarityReport) -> String {
1745 let mut output = String::new();
1746
1747 output.push_str(&"Similarity Analysis\n".bold().to_string());
1749 output.push_str("===================\n\n");
1750
1751 output.push_str(&format!(
1753 "Fragment 1: {} ({} tokens, {} lines)\n",
1754 report.fragment1.file.display().to_string().cyan(),
1755 report.fragment1.tokens,
1756 report.fragment1.lines
1757 ));
1758 if let Some(func) = &report.fragment1.function {
1759 output.push_str(&format!(" Function: {}\n", func.green()));
1760 }
1761 if let Some((start, end)) = report.fragment1.line_range {
1762 output.push_str(&format!(" Lines: {}-{}\n", start, end));
1763 }
1764
1765 output.push_str(&format!(
1766 "Fragment 2: {} ({} tokens, {} lines)\n",
1767 report.fragment2.file.display().to_string().cyan(),
1768 report.fragment2.tokens,
1769 report.fragment2.lines
1770 ));
1771 if let Some(func) = &report.fragment2.function {
1772 output.push_str(&format!(" Function: {}\n", func.green()));
1773 }
1774 if let Some((start, end)) = report.fragment2.line_range {
1775 output.push_str(&format!(" Lines: {}-{}\n", start, end));
1776 }
1777
1778 output.push('\n');
1779
1780 output.push_str(&"Similarity Scores:\n".bold().to_string());
1782 let dice_pct = (report.similarity.dice * 100.0) as u32;
1783 let jaccard_pct = (report.similarity.jaccard * 100.0) as u32;
1784
1785 output.push_str(&format!(
1786 " Dice: {:.4} ({}%)\n",
1787 report.similarity.dice,
1788 dice_pct.to_string().green()
1789 ));
1790 output.push_str(&format!(
1791 " Jaccard: {:.4} ({}%)\n",
1792 report.similarity.jaccard,
1793 jaccard_pct.to_string().green()
1794 ));
1795
1796 if let Some(cosine) = report.similarity.cosine {
1797 let cosine_pct = (cosine * 100.0) as u32;
1798 output.push_str(&format!(
1799 " Cosine: {:.4} ({}%)\n",
1800 cosine,
1801 cosine_pct.to_string().green()
1802 ));
1803 }
1804
1805 output.push('\n');
1806
1807 output.push_str(&format!(
1809 "Interpretation: {}\n\n",
1810 report.similarity.interpretation.cyan()
1811 ));
1812
1813 output.push_str(&"Token Breakdown:\n".bold().to_string());
1815 output.push_str(&format!(
1816 " Shared tokens: {}\n",
1817 report.token_breakdown.shared_tokens.to_string().green()
1818 ));
1819 output.push_str(&format!(
1820 " Unique to #1: {}\n",
1821 report.token_breakdown.unique_to_fragment1
1822 ));
1823 output.push_str(&format!(
1824 " Unique to #2: {}\n",
1825 report.token_breakdown.unique_to_fragment2
1826 ));
1827 output.push_str(&format!(
1828 " Total unique: {}\n",
1829 report.token_breakdown.total_unique
1830 ));
1831
1832 output.push('\n');
1834 output.push_str(&format!(
1835 "Metric: {:?}, N-gram size: {}\n",
1836 report.config.metric, report.config.ngram_size
1837 ));
1838
1839 if let Some(lang) = &report.config.language {
1840 output.push_str(&format!("Language: {}\n", lang));
1841 }
1842
1843 output
1844}
1845
1846pub mod sarif {
1860 use serde::Serialize;
1861 use std::path::Path;
1862 use tldr_core::analysis::{CloneType, ClonesReport};
1863
1864 #[derive(Debug, Serialize)]
1866 pub struct SarifLog {
1867 #[serde(rename = "$schema")]
1868 pub schema: String,
1869 pub version: String,
1870 pub runs: Vec<SarifRun>,
1871 }
1872
1873 #[derive(Debug, Serialize)]
1875 pub struct SarifRun {
1876 pub tool: SarifTool,
1877 pub results: Vec<SarifResult>,
1878 #[serde(skip_serializing_if = "Option::is_none")]
1879 pub invocations: Option<Vec<SarifInvocation>>,
1880 }
1881
1882 #[derive(Debug, Serialize)]
1884 pub struct SarifTool {
1885 pub driver: SarifDriver,
1886 }
1887
1888 #[derive(Debug, Serialize)]
1890 pub struct SarifDriver {
1891 pub name: String,
1892 pub version: String,
1893 #[serde(rename = "informationUri", skip_serializing_if = "Option::is_none")]
1894 pub information_uri: Option<String>,
1895 pub rules: Vec<SarifRule>,
1896 }
1897
1898 #[derive(Debug, Serialize)]
1900 pub struct SarifRule {
1901 pub id: String,
1902 pub name: String,
1903 #[serde(rename = "shortDescription")]
1904 pub short_description: SarifMessage,
1905 #[serde(rename = "fullDescription", skip_serializing_if = "Option::is_none")]
1906 pub full_description: Option<SarifMessage>,
1907 #[serde(rename = "helpUri", skip_serializing_if = "Option::is_none")]
1908 pub help_uri: Option<String>,
1909 #[serde(
1910 rename = "defaultConfiguration",
1911 skip_serializing_if = "Option::is_none"
1912 )]
1913 pub default_configuration: Option<SarifConfiguration>,
1914 }
1915
1916 #[derive(Debug, Serialize)]
1918 pub struct SarifConfiguration {
1919 pub level: String,
1920 }
1921
1922 #[derive(Debug, Serialize)]
1924 pub struct SarifResult {
1925 #[serde(rename = "ruleId")]
1926 pub rule_id: String,
1927 pub level: String,
1928 pub message: SarifMessage,
1929 pub locations: Vec<SarifLocation>,
1930 #[serde(rename = "relatedLocations", skip_serializing_if = "Vec::is_empty")]
1931 pub related_locations: Vec<SarifLocation>,
1932 #[serde(
1933 rename = "partialFingerprints",
1934 skip_serializing_if = "Option::is_none"
1935 )]
1936 pub partial_fingerprints: Option<SarifFingerprints>,
1937 }
1938
1939 #[derive(Debug, Serialize)]
1941 pub struct SarifMessage {
1942 pub text: String,
1943 }
1944
1945 #[derive(Debug, Serialize)]
1947 pub struct SarifLocation {
1948 #[serde(rename = "physicalLocation")]
1949 pub physical_location: SarifPhysicalLocation,
1950 #[serde(skip_serializing_if = "Option::is_none")]
1951 pub id: Option<usize>,
1952 }
1953
1954 #[derive(Debug, Serialize)]
1956 pub struct SarifPhysicalLocation {
1957 #[serde(rename = "artifactLocation")]
1958 pub artifact_location: SarifArtifactLocation,
1959 pub region: SarifRegion,
1960 }
1961
1962 #[derive(Debug, Serialize)]
1964 pub struct SarifArtifactLocation {
1965 pub uri: String,
1966 #[serde(rename = "uriBaseId", skip_serializing_if = "Option::is_none")]
1967 pub uri_base_id: Option<String>,
1968 }
1969
1970 #[derive(Debug, Serialize)]
1972 pub struct SarifRegion {
1973 #[serde(rename = "startLine")]
1974 pub start_line: usize,
1975 #[serde(rename = "endLine", skip_serializing_if = "Option::is_none")]
1976 pub end_line: Option<usize>,
1977 }
1978
1979 #[derive(Debug, Serialize)]
1981 pub struct SarifFingerprints {
1982 #[serde(
1983 rename = "primaryLocationLineHash",
1984 skip_serializing_if = "Option::is_none"
1985 )]
1986 pub primary_location_line_hash: Option<String>,
1987 }
1988
1989 #[derive(Debug, Serialize)]
1991 pub struct SarifInvocation {
1992 #[serde(rename = "executionSuccessful")]
1993 pub execution_successful: bool,
1994 }
1995
1996 fn clone_type_rule_id(clone_type: CloneType) -> &'static str {
1998 match clone_type {
1999 CloneType::Type1 => "clone/type-1",
2000 CloneType::Type2 => "clone/type-2",
2001 CloneType::Type3 => "clone/type-3",
2002 }
2003 }
2004
2005 fn clone_type_description(clone_type: CloneType) -> &'static str {
2007 match clone_type {
2008 CloneType::Type1 => "Exact code clone (identical except whitespace/comments)",
2009 CloneType::Type2 => "Parameterized clone (renamed identifiers/literals)",
2010 CloneType::Type3 => "Gapped clone (structural similarity with modifications)",
2011 }
2012 }
2013
2014 fn clone_type_level(clone_type: CloneType) -> &'static str {
2016 match clone_type {
2017 CloneType::Type1 => "warning", CloneType::Type2 => "warning",
2019 CloneType::Type3 => "note", }
2021 }
2022
2023 fn path_to_uri(path: &Path, root: &Path) -> String {
2025 let relative = path.strip_prefix(root).unwrap_or(path);
2027 relative.to_string_lossy().replace('\\', "/")
2028 }
2029
2030 pub fn format_clones_sarif(report: &ClonesReport) -> SarifLog {
2032 let rules = vec![
2034 SarifRule {
2035 id: "clone/type-1".to_string(),
2036 name: "ExactClone".to_string(),
2037 short_description: SarifMessage {
2038 text: "Exact code clone detected".to_string(),
2039 },
2040 full_description: Some(SarifMessage {
2041 text: "Type-1 clone: Identical code fragments (ignoring whitespace and comments). Consider extracting to a shared function or module.".to_string(),
2042 }),
2043 help_uri: None,
2044 default_configuration: Some(SarifConfiguration {
2045 level: "warning".to_string(),
2046 }),
2047 },
2048 SarifRule {
2049 id: "clone/type-2".to_string(),
2050 name: "ParameterizedClone".to_string(),
2051 short_description: SarifMessage {
2052 text: "Parameterized clone detected".to_string(),
2053 },
2054 full_description: Some(SarifMessage {
2055 text: "Type-2 clone: Code fragments with renamed identifiers or different literal values. The structure is identical. Consider refactoring to accept parameters.".to_string(),
2056 }),
2057 help_uri: None,
2058 default_configuration: Some(SarifConfiguration {
2059 level: "warning".to_string(),
2060 }),
2061 },
2062 SarifRule {
2063 id: "clone/type-3".to_string(),
2064 name: "GappedClone".to_string(),
2065 short_description: SarifMessage {
2066 text: "Similar code pattern detected".to_string(),
2067 },
2068 full_description: Some(SarifMessage {
2069 text: "Type-3 clone: Code fragments with similar structure but some statements added, removed, or modified. May indicate copy-paste programming.".to_string(),
2070 }),
2071 help_uri: None,
2072 default_configuration: Some(SarifConfiguration {
2073 level: "note".to_string(),
2074 }),
2075 },
2076 ];
2077
2078 let results: Vec<SarifResult> = report
2080 .clone_pairs
2081 .iter()
2082 .map(|pair| {
2083 let rule_id = clone_type_rule_id(pair.clone_type).to_string();
2084 let level = clone_type_level(pair.clone_type).to_string();
2085
2086 let primary_location = SarifLocation {
2088 physical_location: SarifPhysicalLocation {
2089 artifact_location: SarifArtifactLocation {
2090 uri: path_to_uri(&pair.fragment1.file, &report.root),
2091 uri_base_id: Some("%SRCROOT%".to_string()),
2092 },
2093 region: SarifRegion {
2094 start_line: pair.fragment1.start_line,
2095 end_line: Some(pair.fragment1.end_line),
2096 },
2097 },
2098 id: None,
2099 };
2100
2101 let related_location = SarifLocation {
2103 physical_location: SarifPhysicalLocation {
2104 artifact_location: SarifArtifactLocation {
2105 uri: path_to_uri(&pair.fragment2.file, &report.root),
2106 uri_base_id: Some("%SRCROOT%".to_string()),
2107 },
2108 region: SarifRegion {
2109 start_line: pair.fragment2.start_line,
2110 end_line: Some(pair.fragment2.end_line),
2111 },
2112 },
2113 id: Some(1),
2114 };
2115
2116 let message = format!(
2117 "{} ({:.0}% similar to {}:{})",
2118 clone_type_description(pair.clone_type),
2119 pair.similarity * 100.0,
2120 path_to_uri(&pair.fragment2.file, &report.root),
2121 pair.fragment2.start_line
2122 );
2123
2124 SarifResult {
2125 rule_id,
2126 level,
2127 message: SarifMessage { text: message },
2128 locations: vec![primary_location],
2129 related_locations: vec![related_location],
2130 partial_fingerprints: Some(SarifFingerprints {
2131 primary_location_line_hash: Some(format!(
2132 "{}:{}:{}:{}",
2133 path_to_uri(&pair.fragment1.file, &report.root),
2134 pair.fragment1.start_line,
2135 path_to_uri(&pair.fragment2.file, &report.root),
2136 pair.fragment2.start_line
2137 )),
2138 }),
2139 }
2140 })
2141 .collect();
2142
2143 SarifLog {
2144 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
2145 version: "2.1.0".to_string(),
2146 runs: vec![SarifRun {
2147 tool: SarifTool {
2148 driver: SarifDriver {
2149 name: "tldr".to_string(),
2150 version: env!("CARGO_PKG_VERSION").to_string(),
2151 information_uri: Some("https://github.com/anthropics/claude-code".to_string()),
2152 rules,
2153 },
2154 },
2155 results,
2156 invocations: Some(vec![SarifInvocation {
2157 execution_successful: true,
2158 }]),
2159 }],
2160 }
2161 }
2162}
2163
2164pub fn format_module_info_text(info: &tldr_core::types::ModuleInfo) -> String {
2194 let mut output = String::new();
2195
2196 output.push_str(&format!(
2198 "{} ({})\n",
2199 info.file_path.display().to_string().bold(),
2200 info.language.as_str().cyan()
2201 ));
2202
2203 if let Some(ref doc) = info.docstring {
2205 let truncated = if doc.len() > 80 {
2206 format!("{}...", &doc[..77])
2207 } else {
2208 doc.clone()
2209 };
2210 output.push_str(&format!(" \"{}\"\n", truncated.dimmed()));
2211 }
2212
2213 output.push('\n');
2214
2215 if !info.imports.is_empty() {
2217 output.push_str(&format!("{} ({})\n", "Imports".bold(), info.imports.len()));
2218 output.push_str(&format!(
2219 " {}",
2220 format_imports_text(&info.imports)
2221 .lines()
2222 .collect::<Vec<_>>()
2223 .join("\n ")
2224 ));
2225 output.push('\n');
2226 }
2227
2228 if !info.functions.is_empty() {
2230 output.push_str(&format!(
2231 "{} ({})\n",
2232 "Functions".bold(),
2233 info.functions.len()
2234 ));
2235 for func in &info.functions {
2236 format_function_line(&mut output, func, " ");
2237 }
2238 output.push('\n');
2239 }
2240
2241 if !info.classes.is_empty() {
2243 output.push_str(&format!("{} ({})\n", "Classes".bold(), info.classes.len()));
2244 for class in &info.classes {
2245 let bases_str = if class.bases.is_empty() {
2247 String::new()
2248 } else {
2249 format!("({})", class.bases.join(", "))
2250 };
2251 output.push_str(&format!(
2252 " {}{} L{}\n",
2253 class.name.green(),
2254 bases_str,
2255 class.line_number
2256 ));
2257
2258 if let Some(ref doc) = class.docstring {
2260 let truncated = if doc.len() > 80 {
2261 format!("{}...", &doc[..77])
2262 } else {
2263 doc.clone()
2264 };
2265 output.push_str(&format!(" \"{}\"\n", truncated.dimmed()));
2266 }
2267
2268 if !class.fields.is_empty() {
2270 let fields_summary: Vec<String> = class
2271 .fields
2272 .iter()
2273 .map(|f| {
2274 if let Some(ref ft) = f.field_type {
2275 format!("{}: {}", f.name, ft)
2276 } else {
2277 f.name.clone()
2278 }
2279 })
2280 .collect();
2281 output.push_str(&format!(" Fields: {}\n", fields_summary.join(", ")));
2282 }
2283
2284 if !class.methods.is_empty() {
2286 let methods_summary: Vec<String> = class
2287 .methods
2288 .iter()
2289 .map(|m| {
2290 let async_prefix = if m.is_async { "async " } else { "" };
2291 let params_str = m.params.join(", ");
2292 let ret = m
2293 .return_type
2294 .as_ref()
2295 .map(|r| format!(" -> {}", r))
2296 .unwrap_or_default();
2297 format!("{}{}({}){}", async_prefix, m.name, params_str, ret)
2298 })
2299 .collect();
2300 output.push_str(&format!(" Methods: {}\n", methods_summary.join(", ")));
2301 }
2302 }
2303 output.push('\n');
2304 }
2305
2306 if !info.constants.is_empty() {
2308 output.push_str(&format!(
2309 "{} ({})\n",
2310 "Constants".bold(),
2311 info.constants.len()
2312 ));
2313 for c in &info.constants {
2314 let type_str = c
2315 .field_type
2316 .as_ref()
2317 .map(|t| format!(": {}", t))
2318 .unwrap_or_default();
2319 let val_str = c
2320 .default_value
2321 .as_ref()
2322 .map(|v| format!(" = {}", v))
2323 .unwrap_or_default();
2324 output.push_str(&format!(
2325 " {}{}{} L{}\n",
2326 c.name.cyan(),
2327 type_str,
2328 val_str,
2329 c.line_number
2330 ));
2331 }
2332 output.push('\n');
2333 }
2334
2335 let total_edges: usize = info.call_graph.calls.values().map(|v| v.len()).sum();
2337 if total_edges > 0 {
2338 output.push_str(&format!(
2339 "{} ({} edges)\n",
2340 "Call Graph".bold(),
2341 total_edges
2342 ));
2343
2344 let mut callers: Vec<_> = info.call_graph.calls.keys().collect();
2346 callers.sort();
2347
2348 let mut shown = 0;
2349 for caller in callers {
2350 if shown >= 10 {
2351 let remaining = total_edges - shown;
2352 if remaining > 0 {
2353 output.push_str(&format!(" ... and {} more edges\n", remaining));
2354 }
2355 break;
2356 }
2357 if let Some(callees) = info.call_graph.calls.get(caller.as_str()) {
2358 for callee in callees {
2359 output.push_str(&format!(" {} -> {}\n", caller.dimmed(), callee.green()));
2360 shown += 1;
2361 if shown >= 10 {
2362 break;
2363 }
2364 }
2365 }
2366 }
2367 }
2368
2369 output
2370}
2371
2372fn format_function_line(output: &mut String, func: &tldr_core::types::FunctionInfo, indent: &str) {
2374 let async_prefix = if func.is_async { "async " } else { "" };
2375 let params_str = func.params.join(", ");
2376 let ret_str = func
2377 .return_type
2378 .as_ref()
2379 .map(|r| format!(" -> {}", r))
2380 .unwrap_or_default();
2381 output.push_str(&format!(
2382 "{}{}{}({}){} L{}\n",
2383 indent,
2384 async_prefix.cyan(),
2385 func.name.green(),
2386 params_str,
2387 ret_str,
2388 func.line_number
2389 ));
2390
2391 if let Some(ref doc) = func.docstring {
2393 let truncated = if doc.len() > 60 {
2394 format!("{}...", &doc[..57])
2395 } else {
2396 doc.clone()
2397 };
2398 output.push_str(&format!("{} \"{}\"\n", indent, truncated.dimmed()));
2399 }
2400}
2401
2402pub fn format_clones_sarif(report: &tldr_core::analysis::ClonesReport) -> String {
2404 let sarif_log = sarif::format_clones_sarif(report);
2405 serde_json::to_string_pretty(&sarif_log).unwrap_or_else(|_| "{}".to_string())
2406}
2407
2408#[cfg(test)]
2413#[path = "output_tests.rs"]
2414mod output_tests;