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