1use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::time::{Duration, Instant};
26
27use anyhow::Result;
28use clap::Args;
29use colored::Colorize;
30use tree_sitter::{Node, Parser};
31
32use tldr_core::analysis::clones::is_test_file;
33use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
34use tldr_core::ast::parser::ParserPool;
35use tldr_core::quality::coupling::{
36 analyze_coupling as core_analyze_coupling, compute_martin_metrics_from_deps,
37 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict, MartinMetricsReport,
38 MartinOptions,
39};
40use tldr_core::types::Language as TldrLanguage;
41
42use super::error::{PatternsError, PatternsResult};
43use super::types::{CouplingReport, CouplingVerdict, CrossCall, CrossCalls};
44use super::validation::{read_file_safe, validate_file_path, validate_file_path_in_project};
45use crate::output::{common_path_prefix, strip_prefix_display, OutputFormat};
46
47#[derive(Debug, Clone, Args)]
64pub struct CouplingArgs {
65 pub path_a: PathBuf,
67
68 pub path_b: Option<PathBuf>,
70
71 #[arg(long, default_value = "30")]
73 pub timeout: u64,
74
75 #[arg(long)]
77 pub project_root: Option<PathBuf>,
78
79 #[arg(long, short = 'n', default_value = "20")]
81 pub max_pairs: usize,
82
83 #[arg(long, default_value = "0")]
85 pub top: usize,
86
87 #[arg(long)]
89 pub cycles_only: bool,
90
91 #[arg(long)]
93 pub include_tests: bool,
94
95 #[arg(long, short = 'l')]
97 pub lang: Option<TldrLanguage>,
98}
99
100#[derive(Debug, Clone)]
106pub struct ModuleInfo {
107 pub path: PathBuf,
109 pub defined_names: HashSet<String>,
111 pub imports: HashMap<String, String>,
113 pub calls: Vec<(String, String, u32)>,
115 pub function_count: u32,
117}
118
119impl ModuleInfo {
120 fn new(path: PathBuf) -> Self {
121 Self {
122 path,
123 defined_names: HashSet::new(),
124 imports: HashMap::new(),
125 calls: Vec::new(),
126 function_count: 0,
127 }
128 }
129}
130
131struct LangConfig {
137 function_kinds: &'static [&'static str],
139 class_kinds: &'static [&'static str],
141 import_kinds: &'static [&'static str],
143 call_kinds: &'static [&'static str],
145 func_name_field: &'static str,
147 use_name_field: bool,
149 recurse_into_classes: bool,
151}
152
153fn lang_config_for(lang: TldrLanguage) -> LangConfig {
154 match lang {
155 TldrLanguage::Python => LangConfig {
156 function_kinds: &["function_definition", "async_function_definition"],
157 class_kinds: &["class_definition"],
158 import_kinds: &["import_statement", "import_from_statement"],
159 call_kinds: &["call"],
160 func_name_field: "name",
161 use_name_field: true,
162 recurse_into_classes: false,
163 },
164 TldrLanguage::Go => LangConfig {
165 function_kinds: &["function_declaration", "method_declaration"],
166 class_kinds: &["type_declaration"],
167 import_kinds: &["import_declaration"],
168 call_kinds: &["call_expression"],
169 func_name_field: "name",
170 use_name_field: true,
171 recurse_into_classes: false,
172 },
173 TldrLanguage::Rust => LangConfig {
174 function_kinds: &["function_item"],
175 class_kinds: &["struct_item", "enum_item", "trait_item", "impl_item"],
176 import_kinds: &["use_declaration"],
177 call_kinds: &["call_expression"],
178 func_name_field: "name",
179 use_name_field: true,
180 recurse_into_classes: true,
181 },
182 TldrLanguage::TypeScript | TldrLanguage::JavaScript => LangConfig {
183 function_kinds: &[
184 "function_declaration",
185 "method_definition",
186 "arrow_function",
187 ],
188 class_kinds: &["class_declaration"],
189 import_kinds: &["import_statement"],
190 call_kinds: &["call_expression"],
191 func_name_field: "name",
192 use_name_field: true,
193 recurse_into_classes: false,
194 },
195 TldrLanguage::Java => LangConfig {
196 function_kinds: &["method_declaration", "constructor_declaration"],
197 class_kinds: &["class_declaration", "interface_declaration"],
198 import_kinds: &["import_declaration"],
199 call_kinds: &["method_invocation"],
200 func_name_field: "name",
201 use_name_field: true,
202 recurse_into_classes: true,
203 },
204 TldrLanguage::C => LangConfig {
205 function_kinds: &["function_definition"],
206 class_kinds: &["struct_specifier", "enum_specifier"],
207 import_kinds: &["preproc_include"],
208 call_kinds: &["call_expression"],
209 func_name_field: "declarator",
210 use_name_field: true,
211 recurse_into_classes: false,
212 },
213 TldrLanguage::Cpp => LangConfig {
214 function_kinds: &["function_definition"],
215 class_kinds: &["class_specifier", "struct_specifier", "enum_specifier"],
216 import_kinds: &["preproc_include"],
217 call_kinds: &["call_expression"],
218 func_name_field: "declarator",
219 use_name_field: true,
220 recurse_into_classes: true,
221 },
222 TldrLanguage::Ruby => LangConfig {
223 function_kinds: &["method", "singleton_method"],
224 class_kinds: &["class", "module"],
225 import_kinds: &[], call_kinds: &["call", "command"],
227 func_name_field: "name",
228 use_name_field: true,
229 recurse_into_classes: true,
230 },
231 TldrLanguage::CSharp => LangConfig {
232 function_kinds: &["method_declaration", "constructor_declaration"],
233 class_kinds: &[
234 "class_declaration",
235 "interface_declaration",
236 "struct_declaration",
237 ],
238 import_kinds: &["using_directive"],
239 call_kinds: &["invocation_expression"],
240 func_name_field: "name",
241 use_name_field: true,
242 recurse_into_classes: true,
243 },
244 TldrLanguage::Php => LangConfig {
245 function_kinds: &["function_definition", "method_declaration"],
246 class_kinds: &["class_declaration", "interface_declaration"],
247 import_kinds: &["namespace_use_declaration"],
248 call_kinds: &["function_call_expression", "member_call_expression"],
249 func_name_field: "name",
250 use_name_field: true,
251 recurse_into_classes: true,
252 },
253 TldrLanguage::Scala => LangConfig {
254 function_kinds: &["function_definition"],
255 class_kinds: &["class_definition", "object_definition", "trait_definition"],
256 import_kinds: &["import_declaration"],
257 call_kinds: &["call_expression"],
258 func_name_field: "name",
259 use_name_field: true,
260 recurse_into_classes: true,
261 },
262 TldrLanguage::Elixir => LangConfig {
263 function_kinds: &["call"], class_kinds: &[],
265 import_kinds: &[], call_kinds: &["call"],
267 func_name_field: "",
268 use_name_field: false,
269 recurse_into_classes: false,
270 },
271 TldrLanguage::Lua | TldrLanguage::Luau => LangConfig {
272 function_kinds: &[
273 "function_declaration",
274 "local_function_declaration_statement",
275 ],
276 class_kinds: &[],
277 import_kinds: &[], call_kinds: &["function_call"],
279 func_name_field: "name",
280 use_name_field: true,
281 recurse_into_classes: false,
282 },
283 TldrLanguage::Ocaml => LangConfig {
284 function_kinds: &["let_binding", "value_definition"],
285 class_kinds: &["type_definition", "module_definition"],
286 import_kinds: &["open_statement"],
287 call_kinds: &["application"],
288 func_name_field: "",
289 use_name_field: false,
290 recurse_into_classes: false,
291 },
292 _ => LangConfig {
294 function_kinds: &["function_definition"],
295 class_kinds: &["class_definition"],
296 import_kinds: &["import_statement"],
297 call_kinds: &["call_expression"],
298 func_name_field: "name",
299 use_name_field: true,
300 recurse_into_classes: false,
301 },
302 }
303}
304
305fn detect_language(path: &Path) -> PatternsResult<TldrLanguage> {
307 TldrLanguage::from_path(path).ok_or_else(|| {
308 PatternsError::parse_error(
309 path,
310 format!(
311 "Unsupported file extension: {}",
312 path.extension()
313 .and_then(|e| e.to_str())
314 .unwrap_or("(none)")
315 ),
316 )
317 })
318}
319
320pub fn extract_module_info(path: &PathBuf, source: &str) -> PatternsResult<ModuleInfo> {
332 let lang = detect_language(path)?;
333
334 let ts_lang = ParserPool::get_ts_language(lang).ok_or_else(|| {
335 PatternsError::parse_error(path, format!("No tree-sitter grammar for {:?}", lang))
336 })?;
337
338 let mut parser = Parser::new();
339 parser
340 .set_language(&ts_lang)
341 .map_err(|e| PatternsError::parse_error(path, format!("Failed to set language: {}", e)))?;
342
343 let tree = parser
344 .parse(source, None)
345 .ok_or_else(|| PatternsError::parse_error(path, "Failed to parse source"))?;
346
347 let root = tree.root_node();
348 let config = lang_config_for(lang);
349 let mut info = ModuleInfo::new(path.clone());
350
351 extract_top_level_generic(&root, source, &mut info, &config, lang)?;
353
354 if matches!(
359 lang,
360 TldrLanguage::Go
361 | TldrLanguage::Java
362 | TldrLanguage::CSharp
363 | TldrLanguage::Php
364 | TldrLanguage::Scala
365 ) {
366 enrich_imports_from_qualified_calls(&mut info);
367 }
368
369 Ok(info)
370}
371
372fn enrich_imports_from_qualified_calls(info: &mut ModuleInfo) {
378 let mut new_imports: Vec<(String, String)> = Vec::new();
380
381 for (_caller, callee, _line) in &info.calls {
382 if info.imports.contains_key(callee) {
384 continue;
385 }
386 new_imports.push((callee.clone(), callee.clone()));
390 }
391
392 for (name, module) in new_imports {
393 info.imports.entry(name).or_insert(module);
394 }
395}
396
397fn extract_top_level_generic(
399 root: &Node,
400 source: &str,
401 info: &mut ModuleInfo,
402 config: &LangConfig,
403 lang: TldrLanguage,
404) -> PatternsResult<()> {
405 extract_definitions_recursive(root, source, info, config, lang, 0);
406 Ok(())
407}
408
409fn extract_definitions_recursive(
413 node: &Node,
414 source: &str,
415 info: &mut ModuleInfo,
416 config: &LangConfig,
417 lang: TldrLanguage,
418 depth: u32,
419) {
420 let mut cursor = node.walk();
421
422 for child in node.children(&mut cursor) {
423 let kind = child.kind();
424
425 if config.function_kinds.contains(&kind) {
427 if lang == TldrLanguage::Elixir && kind == "call" {
429 if let Some(name) = extract_elixir_def_name(&child, source) {
430 info.defined_names.insert(name.clone());
431 info.function_count += 1;
432 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
433 }
434 continue;
435 }
436
437 if let Some(name) = get_name_generic(&child, source, config, lang) {
438 info.defined_names.insert(name.clone());
439 info.function_count += 1;
440 extract_calls_generic(&child, source, &name, &mut info.calls, config, lang);
441 }
442 }
443 else if config.class_kinds.contains(&kind) {
445 if let Some(name) = get_name_generic(&child, source, config, lang) {
446 info.defined_names.insert(name);
447 }
448 if config.recurse_into_classes && depth < 3 {
450 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
451 }
452 }
453 else if config.import_kinds.contains(&kind) {
455 extract_imports_generic(&child, source, &mut info.imports, lang);
456 }
457 else if lang == TldrLanguage::Ruby && (kind == "call" || kind == "command") {
459 extract_ruby_require(&child, source, &mut info.imports);
460 }
461 else if is_body_container(kind, lang) {
463 extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
464 }
465 }
466}
467
468fn is_body_container(kind: &str, lang: TldrLanguage) -> bool {
470 match lang {
471 TldrLanguage::Java => matches!(kind, "class_body" | "program"),
472 TldrLanguage::CSharp => matches!(
473 kind,
474 "namespace_declaration"
475 | "file_scoped_namespace_declaration"
476 | "declaration_list"
477 | "class_body"
478 ),
479 TldrLanguage::Php => matches!(kind, "declaration_list" | "class_body" | "program"),
480 TldrLanguage::Scala => matches!(kind, "template_body"),
481 TldrLanguage::Cpp => matches!(kind, "declaration_list"),
482 TldrLanguage::Ruby => matches!(kind, "body_statement" | "program"),
483 _ => false,
484 }
485}
486
487fn get_name_generic(
489 node: &Node,
490 source: &str,
491 config: &LangConfig,
492 _lang: TldrLanguage,
493) -> Option<String> {
494 if config.use_name_field && !config.func_name_field.is_empty() {
496 if let Some(name_node) = node.child_by_field_name(config.func_name_field) {
497 return Some(extract_leaf_identifier(&name_node, source));
499 }
500 }
501
502 let mut cursor = node.walk();
504 for child in node.children(&mut cursor) {
505 if child.kind() == "identifier" || child.kind() == "name" {
506 return Some(node_text(&child, source));
507 }
508 }
509
510 None
511}
512
513fn extract_leaf_identifier(node: &Node, source: &str) -> String {
517 if node.kind() == "identifier" || node.kind() == "name" || node.child_count() == 0 {
518 return node_text(node, source);
519 }
520
521 let mut cursor = node.walk();
523 for child in node.children(&mut cursor) {
524 if child.kind() == "identifier" || child.kind() == "name" {
525 return node_text(&child, source);
526 }
527 let result = extract_leaf_identifier(&child, source);
529 if !result.is_empty() {
530 return result;
531 }
532 }
533
534 node_text(node, source)
535}
536
537fn extract_elixir_def_name(node: &Node, source: &str) -> Option<String> {
539 let mut cursor = node.walk();
542 for child in node.children(&mut cursor) {
543 let text = node_text(&child, source);
544 if text == "def" || text == "defp" {
545 if let Some(args) = child.next_sibling() {
547 return get_first_identifier(&args, source);
548 }
549 }
550 }
551 None
552}
553
554fn get_first_identifier(node: &Node, source: &str) -> Option<String> {
556 if node.kind() == "identifier" || node.kind() == "atom" {
557 return Some(node_text(node, source));
558 }
559 let mut cursor = node.walk();
560 for child in node.children(&mut cursor) {
561 if let Some(id) = get_first_identifier(&child, source) {
562 return Some(id);
563 }
564 }
565 None
566}
567
568fn extract_imports_generic(
574 node: &Node,
575 source: &str,
576 imports: &mut HashMap<String, String>,
577 lang: TldrLanguage,
578) {
579 match lang {
580 TldrLanguage::Python => extract_python_imports(node, source, imports),
581 TldrLanguage::Go => extract_go_imports(node, source, imports),
582 TldrLanguage::Rust => extract_rust_imports(node, source, imports),
583 TldrLanguage::TypeScript | TldrLanguage::JavaScript => {
584 extract_ts_imports(node, source, imports)
585 }
586 TldrLanguage::Java => extract_java_imports(node, source, imports),
587 TldrLanguage::C | TldrLanguage::Cpp => extract_c_imports(node, source, imports),
588 TldrLanguage::CSharp => extract_csharp_imports(node, source, imports),
589 TldrLanguage::Php => extract_php_imports(node, source, imports),
590 TldrLanguage::Scala => extract_scala_imports(node, source, imports),
591 TldrLanguage::Ocaml => extract_ocaml_imports(node, source, imports),
592 _ => extract_fallback_imports(node, source, imports),
595 }
596}
597
598fn extract_python_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
600 let kind = node.kind();
601 if kind == "import_statement" {
602 let mut cursor = node.walk();
603 for child in node.children(&mut cursor) {
604 if child.kind() == "dotted_name" {
605 let module_name = node_text(&child, source);
606 imports.insert(module_name.clone(), module_name);
607 } else if child.kind() == "aliased_import" {
608 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
609 imports.insert(alias, name);
610 }
611 }
612 }
613 } else if kind == "import_from_statement" {
614 let mut module_name = String::new();
615 let mut found_import_keyword = false;
616
617 let mut cursor = node.walk();
619 for child in node.children(&mut cursor) {
620 if child.kind() == "import" {
621 found_import_keyword = true;
622 continue;
623 }
624 if !found_import_keyword {
625 match child.kind() {
626 "dotted_name" | "relative_import" | "import_prefix" => {
627 module_name = node_text(&child, source);
628 }
629 _ => {}
630 }
631 }
632 }
633
634 let mut cursor2 = node.walk();
636 found_import_keyword = false;
637 for child in node.children(&mut cursor2) {
638 if child.kind() == "import" {
639 found_import_keyword = true;
640 continue;
641 }
642 if !found_import_keyword {
643 continue;
644 }
645 match child.kind() {
646 "dotted_name" | "identifier" => {
647 let name = node_text(&child, source);
648 imports.insert(name, module_name.clone());
649 }
650 "aliased_import" => {
651 if let (Some(name), Some(alias)) = extract_aliased_import(&child, source) {
652 imports.insert(alias, module_name.clone());
653 imports.insert(name, module_name.clone());
654 }
655 }
656 "wildcard_import" => {
657 imports.insert("*".to_string(), module_name.clone());
658 }
659 _ => {
660 extract_import_names_recursive(&child, source, &module_name, imports);
661 }
662 }
663 }
664 }
665}
666
667fn extract_import_names_recursive(
669 node: &Node,
670 source: &str,
671 module_name: &str,
672 imports: &mut HashMap<String, String>,
673) {
674 match node.kind() {
675 "dotted_name" | "identifier" => {
676 let name = node_text(node, source);
677 imports.insert(name, module_name.to_string());
678 }
679 "aliased_import" => {
680 if let (Some(name), Some(alias)) = extract_aliased_import(node, source) {
681 imports.insert(alias, module_name.to_string());
682 imports.insert(name, module_name.to_string());
683 }
684 }
685 _ => {
686 let mut cursor = node.walk();
687 for child in node.children(&mut cursor) {
688 extract_import_names_recursive(&child, source, module_name, imports);
689 }
690 }
691 }
692}
693
694fn extract_aliased_import(node: &Node, source: &str) -> (Option<String>, Option<String>) {
696 let mut name = None;
697 let mut alias = None;
698 let mut cursor = node.walk();
699
700 for child in node.children(&mut cursor) {
701 match child.kind() {
702 "dotted_name" | "identifier" => {
703 if name.is_none() {
704 name = Some(node_text(&child, source));
705 } else {
706 alias = Some(node_text(&child, source));
707 }
708 }
709 _ => {}
710 }
711 }
712
713 (name, alias)
714}
715
716fn extract_go_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
718 let mut stack = vec![*node];
720 while let Some(n) = stack.pop() {
721 if n.kind() == "import_spec" {
722 let path_node = n.child_by_field_name("path");
724 let name_node = n.child_by_field_name("name");
725
726 if let Some(path) = path_node {
727 let raw = node_text(&path, source);
728 let module_path = raw.trim_matches('"').to_string();
729 let short_name = if let Some(alias) = name_node {
731 node_text(&alias, source)
732 } else {
733 module_path
734 .rsplit('/')
735 .next()
736 .unwrap_or(&module_path)
737 .to_string()
738 };
739 imports.insert(short_name, module_path);
740 }
741 } else {
742 let mut cursor = n.walk();
743 for child in n.children(&mut cursor) {
744 stack.push(child);
745 }
746 }
747 }
748}
749
750fn extract_rust_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
752 let text = node_text(node, source);
754 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
756
757 if let Some(last) = trimmed.rsplit("::").next() {
759 if last.starts_with('{') {
760 let base = trimmed.rsplit_once("::").map(|x| x.0).unwrap_or("");
762 let items = last.trim_matches(|c| c == '{' || c == '}');
763 for item in items.split(',') {
764 let item = item.trim();
765 if !item.is_empty() {
766 imports.insert(item.to_string(), base.to_string());
767 }
768 }
769 } else {
770 imports.insert(last.to_string(), trimmed.to_string());
771 }
772 }
773}
774
775fn extract_ts_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
777 let mut module_path = String::new();
779 let mut cursor = node.walk();
780
781 if let Some(src) = node.child_by_field_name("source") {
783 let raw = node_text(&src, source);
784 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
785 } else {
786 for child in node.children(&mut cursor) {
788 if child.kind() == "string" {
789 let raw = node_text(&child, source);
790 module_path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
791 }
792 }
793 }
794
795 let mut cursor2 = node.walk();
797 for child in node.children(&mut cursor2) {
798 match child.kind() {
799 "import_clause" | "named_imports" | "import_specifier" => {
800 collect_identifiers_recursive(&child, source, &module_path, imports);
801 }
802 "namespace_import" => {
803 if let Some(name) = child.child_by_field_name("name") {
805 imports.insert(node_text(&name, source), module_path.clone());
806 } else {
807 let mut inner = child.walk();
809 let mut last_id = None;
810 for c in child.children(&mut inner) {
811 if c.kind() == "identifier" {
812 last_id = Some(node_text(&c, source));
813 }
814 }
815 if let Some(id) = last_id {
816 imports.insert(id, module_path.clone());
817 }
818 }
819 }
820 "identifier" => {
821 imports.insert(node_text(&child, source), module_path.clone());
822 }
823 _ => {}
824 }
825 }
826}
827
828fn extract_java_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
830 let text = node_text(node, source);
832 let trimmed = text
833 .trim_start_matches("import ")
834 .trim_start_matches("static ")
835 .trim_end_matches(';')
836 .trim();
837
838 if let Some(last) = trimmed.rsplit('.').next() {
839 imports.insert(last.to_string(), trimmed.to_string());
840 }
841}
842
843fn extract_c_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
845 let mut cursor = node.walk();
847 for child in node.children(&mut cursor) {
848 let kind = child.kind();
849 if kind == "system_lib_string" || kind == "string_literal" || kind == "string_content" {
850 let raw = node_text(&child, source);
851 let header = raw
852 .trim_matches(|c| c == '<' || c == '>' || c == '"')
853 .to_string();
854 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
856 imports.insert(short, header);
857 }
858 }
859
860 if let Some(path) = node.child_by_field_name("path") {
862 let raw = node_text(&path, source);
863 let header = raw
864 .trim_matches(|c| c == '<' || c == '>' || c == '"')
865 .to_string();
866 let short = header.rsplit('/').next().unwrap_or(&header).to_string();
867 imports.insert(short, header);
868 }
869}
870
871fn extract_csharp_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
873 let text = node_text(node, source);
874 let trimmed = text
875 .trim_start_matches("using ")
876 .trim_start_matches("static ")
877 .trim_end_matches(';')
878 .trim();
879
880 if let Some(last) = trimmed.rsplit('.').next() {
881 imports.insert(last.to_string(), trimmed.to_string());
882 }
883 if !trimmed.is_empty() {
885 imports.insert(trimmed.to_string(), trimmed.to_string());
886 }
887}
888
889fn extract_php_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
891 let text = node_text(node, source);
892 let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
893
894 if trimmed.contains('{') {
896 if let Some((base, group)) = trimmed.split_once('{') {
897 let base = base.trim_end_matches('\\');
898 let items = group.trim_end_matches('}');
899 for item in items.split(',') {
900 let item = item.trim();
901 if !item.is_empty() {
902 imports.insert(item.to_string(), format!("{}\\{}", base, item));
903 }
904 }
905 }
906 } else if let Some(last) = trimmed.rsplit('\\').next() {
907 imports.insert(last.to_string(), trimmed.to_string());
908 }
909}
910
911fn extract_scala_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
913 let text = node_text(node, source);
914 let trimmed = text.trim_start_matches("import ").trim();
915
916 if let Some(last) = trimmed.rsplit('.').next() {
917 imports.insert(last.to_string(), trimmed.to_string());
918 }
919}
920
921fn extract_ocaml_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
923 let text = node_text(node, source);
924 let trimmed = text.trim_start_matches("open ").trim();
925 if !trimmed.is_empty() {
926 imports.insert(trimmed.to_string(), trimmed.to_string());
927 }
928}
929
930fn extract_fallback_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
932 let text = node_text(node, source).trim().to_string();
933 if !text.is_empty() {
934 imports.insert(text.clone(), text);
935 }
936}
937
938fn extract_ruby_require(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
940 let mut cursor = node.walk();
942 let mut method_name = String::new();
943
944 for child in node.children(&mut cursor) {
945 match child.kind() {
946 "identifier" | "constant" => {
947 let text = node_text(&child, source);
948 if text == "require" || text == "require_relative" {
949 method_name = text;
950 }
951 }
952 "argument_list" | "string" | "string_content" => {
953 if !method_name.is_empty() {
954 let raw = node_text(&child, source);
955 let module = raw
956 .trim_matches(|c: char| c == '\'' || c == '"' || c == '(' || c == ')')
957 .to_string();
958 if !module.is_empty() {
959 let short = module.rsplit('/').next().unwrap_or(&module).to_string();
960 imports.insert(short, module);
961 }
962 return;
963 }
964 }
965 _ => {
966 if !method_name.is_empty() {
968 let mut inner = child.walk();
969 for grandchild in child.children(&mut inner) {
970 if grandchild.kind() == "string" || grandchild.kind() == "string_content" {
971 let raw = node_text(&grandchild, source);
972 let module = raw
973 .trim_matches(|c: char| c == '\'' || c == '"')
974 .to_string();
975 if !module.is_empty() {
976 let short =
977 module.rsplit('/').next().unwrap_or(&module).to_string();
978 imports.insert(short, module);
979 }
980 return;
981 }
982 }
983 }
984 }
985 }
986 }
987}
988
989fn collect_identifiers_recursive(
991 node: &Node,
992 source: &str,
993 module_path: &str,
994 imports: &mut HashMap<String, String>,
995) {
996 if node.kind() == "identifier" {
997 imports.insert(node_text(node, source), module_path.to_string());
998 return;
999 }
1000 let mut cursor = node.walk();
1001 for child in node.children(&mut cursor) {
1002 collect_identifiers_recursive(&child, source, module_path, imports);
1003 }
1004}
1005
1006fn extract_calls_generic(
1012 func_node: &Node,
1013 source: &str,
1014 caller_name: &str,
1015 calls: &mut Vec<(String, String, u32)>,
1016 config: &LangConfig,
1017 lang: TldrLanguage,
1018) {
1019 let mut stack = vec![*func_node];
1020
1021 while let Some(node) = stack.pop() {
1022 if config.call_kinds.contains(&node.kind()) {
1023 if let Some(callee) = extract_callee_generic(&node, source, lang) {
1024 let line = node.start_position().row as u32 + 1;
1025 calls.push((caller_name.to_string(), callee, line));
1026 }
1027 }
1028
1029 let mut cursor = node.walk();
1031 for child in node.children(&mut cursor) {
1032 stack.push(child);
1033 }
1034 }
1035}
1036
1037fn extract_callee_generic(call_node: &Node, source: &str, lang: TldrLanguage) -> Option<String> {
1045 match lang {
1046 TldrLanguage::Java => {
1047 if let Some(name) = call_node.child_by_field_name("name") {
1049 return Some(node_text(&name, source));
1050 }
1051 }
1052 TldrLanguage::Go => {
1053 if let Some(func) = call_node.child_by_field_name("function") {
1055 match func.kind() {
1056 "identifier" => return Some(node_text(&func, source)),
1057 "selector_expression" => {
1058 if let Some(field) = func.child_by_field_name("field") {
1060 return Some(node_text(&field, source));
1061 }
1062 }
1063 _ => return Some(node_text(&func, source)),
1064 }
1065 }
1066 }
1067 TldrLanguage::Php => {
1068 if let Some(func) = call_node.child_by_field_name("function") {
1070 return Some(extract_leaf_identifier(&func, source));
1071 }
1072 if let Some(name) = call_node.child_by_field_name("name") {
1073 return Some(node_text(&name, source));
1074 }
1075 }
1076 TldrLanguage::CSharp => {
1077 if let Some(func) = call_node.child_by_field_name("function") {
1079 return Some(extract_last_identifier(&func, source));
1080 }
1081 let mut cursor = call_node.walk();
1083 for child in call_node.children(&mut cursor) {
1084 if child.kind() == "member_access_expression" {
1085 if let Some(name) = child.child_by_field_name("name") {
1086 return Some(node_text(&name, source));
1087 }
1088 }
1089 if child.kind() == "identifier" {
1090 return Some(node_text(&child, source));
1091 }
1092 }
1093 }
1094 _ => {}
1095 }
1096
1097 let mut cursor = call_node.walk();
1099 for child in call_node.children(&mut cursor) {
1100 match child.kind() {
1101 "identifier" | "name" => {
1102 return Some(node_text(&child, source));
1103 }
1104 "attribute" | "member_expression" | "field_expression" | "selector_expression" => {
1105 return Some(extract_last_identifier(&child, source));
1107 }
1108 "scoped_identifier" | "qualified_identifier" => {
1109 return Some(extract_last_identifier(&child, source));
1111 }
1112 _ => {}
1113 }
1114 }
1115 None
1116}
1117
1118fn extract_last_identifier(node: &Node, source: &str) -> String {
1120 let mut last_id = node_text(node, source);
1121 let mut cursor = node.walk();
1122 for child in node.children(&mut cursor) {
1123 if child.kind() == "identifier"
1124 || child.kind() == "name"
1125 || child.kind() == "field_identifier"
1126 || child.kind() == "property_identifier"
1127 {
1128 last_id = node_text(&child, source);
1129 }
1130 }
1131 last_id
1132}
1133
1134fn node_text(node: &Node, source: &str) -> String {
1136 source[node.byte_range()].to_string()
1137}
1138
1139pub fn find_cross_calls(caller: &ModuleInfo, callee: &ModuleInfo) -> CrossCalls {
1150 let mut calls = Vec::new();
1151
1152 for (caller_func, callee_name, line) in &caller.calls {
1153 if caller.imports.contains_key(callee_name) && callee.defined_names.contains(callee_name) {
1157 calls.push(CrossCall {
1158 caller: caller_func.clone(),
1159 callee: callee_name.clone(),
1160 line: *line,
1161 });
1162 }
1163 }
1164
1165 let count = calls.len() as u32;
1166 CrossCalls { calls, count }
1167}
1168
1169pub fn compute_coupling_score(a_to_b: u32, b_to_a: u32, funcs_a: u32, funcs_b: u32) -> f64 {
1184 let total_funcs = funcs_a.saturating_add(funcs_b);
1185 if total_funcs == 0 {
1186 return 0.0;
1187 }
1188
1189 let cross_calls = a_to_b.saturating_add(b_to_a);
1190 let denominator = (total_funcs as f64) * 2.0;
1191
1192 (cross_calls as f64 / denominator).min(1.0)
1193}
1194
1195pub fn format_martin_text(report: &tldr_core::quality::coupling::MartinMetricsReport) -> String {
1204 let mut output = String::new();
1205
1206 output.push_str("Martin Coupling Metrics (project-wide)\n\n");
1207
1208 if report.metrics.is_empty() {
1209 output.push_str("No modules found.\n");
1210 return output;
1211 }
1212
1213 let max_path_len = report
1215 .metrics
1216 .iter()
1217 .map(|m| m.module.to_string_lossy().len())
1218 .max()
1219 .unwrap_or(6)
1220 .clamp(6, 40);
1221
1222 output.push_str(&format!(
1224 " {:<width$} | {:>2} | {:>2} | {:>6} | Cycle?\n",
1225 "Module",
1226 "Ca",
1227 "Ce",
1228 "I",
1229 width = max_path_len,
1230 ));
1231 output.push_str(&format!(
1232 "-{}-+----+----+--------+-------\n",
1233 "-".repeat(max_path_len),
1234 ));
1235
1236 for m in &report.metrics {
1238 let path_display = m.module.to_string_lossy();
1239 let truncated_path = if path_display.len() > max_path_len {
1240 format!(
1241 "...{}",
1242 &path_display[path_display.len() - (max_path_len - 3)..]
1243 )
1244 } else {
1245 path_display.to_string()
1246 };
1247
1248 let cycle_str = if m.in_cycle { "yes" } else { "--" };
1249
1250 output.push_str(&format!(
1251 " {:<width$} | {:>2} | {:>2} | {:.2} | {}\n",
1252 truncated_path,
1253 m.ca,
1254 m.ce,
1255 m.instability,
1256 cycle_str,
1257 width = max_path_len,
1258 ));
1259 }
1260
1261 output.push_str(&format!(
1263 "\nSummary: {} modules, {} cycles detected, avg instability: {:.2}\n",
1264 report.modules_analyzed, report.summary.total_cycles, report.summary.avg_instability,
1265 ));
1266
1267 if !report.cycles.is_empty() {
1269 output.push_str("\nCycles:\n");
1270 for (i, cycle) in report.cycles.iter().enumerate() {
1271 let path_strs: Vec<String> = cycle
1272 .path
1273 .iter()
1274 .map(|p| p.to_string_lossy().to_string())
1275 .collect();
1276 output.push_str(&format!(
1277 " {}. {} (length {})\n",
1278 i + 1,
1279 path_strs.join(" -> "),
1280 cycle.length,
1281 ));
1282 }
1283 }
1284
1285 output
1286}
1287
1288pub fn format_coupling_text(report: &CouplingReport) -> String {
1290 let mut lines = Vec::new();
1291
1292 lines.push(format!(
1293 "Coupling Analysis: {} <-> {}",
1294 report.path_a, report.path_b
1295 ));
1296 lines.push(String::new());
1297 lines.push(format!(
1298 "Score: {:.2} ({})",
1299 report.coupling_score,
1300 report.verdict
1301 ));
1302 lines.push(format!("Total cross-module calls: {}", report.total_calls));
1303 lines.push(String::new());
1304
1305 lines.push(format!(
1307 "Calls from {} to {}:",
1308 report.path_a, report.path_b
1309 ));
1310 if report.a_to_b.calls.is_empty() {
1311 lines.push(" (none)".to_string());
1312 } else {
1313 for call in &report.a_to_b.calls {
1314 lines.push(format!(
1315 " {} -> {} (line {})",
1316 call.caller, call.callee, call.line
1317 ));
1318 }
1319 }
1320 lines.push(String::new());
1321
1322 lines.push(format!(
1324 "Calls from {} to {}:",
1325 report.path_b, report.path_a
1326 ));
1327 if report.b_to_a.calls.is_empty() {
1328 lines.push(" (none)".to_string());
1329 } else {
1330 for call in &report.b_to_a.calls {
1331 lines.push(format!(
1332 " {} -> {} (line {})",
1333 call.caller, call.callee, call.line
1334 ));
1335 }
1336 }
1337
1338 lines.join("\n")
1339}
1340
1341pub fn run(args: CouplingArgs, format: OutputFormat) -> Result<()> {
1353 match args.path_b {
1355 Some(ref _path_b) => run_pair_mode(&args, format),
1356 None if args.path_a.is_dir() => run_project_mode(&args, format),
1357 None => {
1358 Err(anyhow::anyhow!(
1360 "For pair mode, provide two file paths: tldr coupling <file_a> <file_b>\n\
1361 For project-wide mode, provide a directory: tldr coupling <directory>"
1362 ))
1363 }
1364 }
1365}
1366
1367fn run_pair_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1369 let start = Instant::now();
1370 let timeout = Duration::from_secs(args.timeout);
1371
1372 let path_b_ref = args.path_b.as_ref().expect("pair mode requires path_b");
1373
1374 let path_a = if let Some(ref root) = args.project_root {
1376 validate_file_path_in_project(&args.path_a, root)?
1377 } else {
1378 validate_file_path(&args.path_a)?
1379 };
1380
1381 let path_b = if let Some(ref root) = args.project_root {
1382 validate_file_path_in_project(path_b_ref, root)?
1383 } else {
1384 validate_file_path(path_b_ref)?
1385 };
1386
1387 if start.elapsed() > timeout {
1389 return Err(PatternsError::Timeout {
1390 timeout_secs: args.timeout,
1391 }
1392 .into());
1393 }
1394
1395 let source_a = read_file_safe(&path_a)?;
1397 let source_b = read_file_safe(&path_b)?;
1398
1399 if start.elapsed() > timeout {
1401 return Err(PatternsError::Timeout {
1402 timeout_secs: args.timeout,
1403 }
1404 .into());
1405 }
1406
1407 if path_a == path_b {
1409 let report = CouplingReport {
1410 path_a: path_a.to_string_lossy().to_string(),
1411 path_b: path_b.to_string_lossy().to_string(),
1412 a_to_b: CrossCalls::default(),
1413 b_to_a: CrossCalls::default(),
1414 total_calls: 0,
1415 coupling_score: 1.0,
1416 verdict: CouplingVerdict::VeryHigh,
1417 };
1418
1419 output_pair_report(&report, format)?;
1420 return Ok(());
1421 }
1422
1423 let info_a = extract_module_info(&path_a, &source_a)?;
1425 let info_b = extract_module_info(&path_b, &source_b)?;
1426
1427 if start.elapsed() > timeout {
1429 return Err(PatternsError::Timeout {
1430 timeout_secs: args.timeout,
1431 }
1432 .into());
1433 }
1434
1435 let a_to_b = find_cross_calls(&info_a, &info_b);
1437 let b_to_a = find_cross_calls(&info_b, &info_a);
1438
1439 let total_calls = a_to_b.count.saturating_add(b_to_a.count);
1441 let coupling_score = compute_coupling_score(
1442 a_to_b.count,
1443 b_to_a.count,
1444 info_a.function_count,
1445 info_b.function_count,
1446 );
1447 let verdict = CouplingVerdict::from_score(coupling_score);
1448
1449 let report = CouplingReport {
1451 path_a: path_a.to_string_lossy().to_string(),
1452 path_b: path_b.to_string_lossy().to_string(),
1453 a_to_b,
1454 b_to_a,
1455 total_calls,
1456 coupling_score,
1457 verdict,
1458 };
1459
1460 output_pair_report(&report, format)?;
1461
1462 Ok(())
1463}
1464
1465fn run_project_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1467 let mut pairwise_report = core_analyze_coupling(&args.path_a, None, Some(args.max_pairs))
1469 .map_err(|e| anyhow::anyhow!("coupling analysis failed: {}", e))?;
1470
1471 if !args.include_tests {
1473 pairwise_report
1474 .top_pairs
1475 .retain(|pair| !is_test_file(&pair.source) && !is_test_file(&pair.target));
1476 }
1477
1478 let martin_options = MartinOptions {
1480 top: args.top,
1481 cycles_only: args.cycles_only,
1482 };
1483 let mut martin_report = match analyze_dependencies(&args.path_a, &DepsOptions::default()) {
1484 Ok(deps_report) => compute_martin_metrics_from_deps(&deps_report, &martin_options),
1485 Err(_) => MartinMetricsReport::default(), };
1487
1488 if !args.include_tests {
1490 let pre_count = martin_report.metrics.len();
1491 martin_report.metrics.retain(|m| !is_test_file(&m.module));
1492 martin_report.modules_analyzed = martin_report.metrics.len();
1493
1494 if martin_report.metrics.len() < pre_count {
1496 if martin_report.metrics.is_empty() {
1497 martin_report.summary.avg_instability = 0.0;
1498 martin_report.summary.most_stable = None;
1499 martin_report.summary.most_unstable = None;
1500 } else {
1501 let sum: f64 = martin_report.metrics.iter().map(|m| m.instability).sum();
1502 martin_report.summary.avg_instability = sum / martin_report.metrics.len() as f64;
1503 martin_report.summary.most_stable = martin_report
1504 .metrics
1505 .iter()
1506 .min_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1507 .map(|m| m.module.clone());
1508 martin_report.summary.most_unstable = martin_report
1509 .metrics
1510 .iter()
1511 .max_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1512 .map(|m| m.module.clone());
1513 }
1514 martin_report
1516 .cycles
1517 .retain(|cycle| cycle.path.iter().all(|m| !is_test_file(m)));
1518 martin_report.summary.total_cycles = martin_report.cycles.len();
1519 }
1520 }
1521
1522 output_project_report_with_martin(&pairwise_report, &martin_report, format)?;
1523 Ok(())
1524}
1525
1526fn output_project_report_with_martin(
1528 pairwise_report: &CoreCouplingReport,
1529 martin_report: &MartinMetricsReport,
1530 format: OutputFormat,
1531) -> Result<()> {
1532 match format {
1533 OutputFormat::Text => {
1534 println!("{}", format_martin_text(martin_report));
1536 if !pairwise_report.top_pairs.is_empty() {
1537 println!("{}", format_coupling_project_text(pairwise_report));
1538 }
1539 }
1540 OutputFormat::Compact => {
1541 let combined = serde_json::json!({
1542 "martin_metrics": serde_json::to_value(martin_report)?,
1543 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1544 });
1545 let json = serde_json::to_string(&combined)?;
1546 println!("{}", json);
1547 }
1548 _ => {
1549 let combined = serde_json::json!({
1550 "martin_metrics": serde_json::to_value(martin_report)?,
1551 "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1552 });
1553 let json = serde_json::to_string_pretty(&combined)?;
1554 println!("{}", json);
1555 }
1556 }
1557 Ok(())
1558}
1559
1560fn output_pair_report(report: &CouplingReport, format: OutputFormat) -> Result<()> {
1562 match format {
1563 OutputFormat::Text => {
1564 println!("{}", format_coupling_text(report));
1565 }
1566 OutputFormat::Compact => {
1567 let json = serde_json::to_string(report)?;
1568 println!("{}", json);
1569 }
1570 _ => {
1571 let json = serde_json::to_string_pretty(report)?;
1572 println!("{}", json);
1573 }
1574 }
1575 Ok(())
1576}
1577
1578pub fn format_coupling_project_text(report: &CoreCouplingReport) -> String {
1585 let mut output = String::new();
1586
1587 output.push_str(&format!(
1588 "{}\n\n",
1589 "Coupling Analysis (project-wide)".bold()
1590 ));
1591
1592 if report.top_pairs.is_empty() {
1593 output.push_str(&format!(
1594 "Summary: {} modules, 0 pairs analyzed\n",
1595 report.modules_analyzed,
1596 ));
1597 return output;
1598 }
1599
1600 let all_paths: Vec<&Path> = report
1602 .top_pairs
1603 .iter()
1604 .flat_map(|p| [p.source.as_path(), p.target.as_path()])
1605 .collect();
1606 let prefix = common_path_prefix(&all_paths);
1607
1608 output.push_str(&format!(
1610 " {:>5} {:>5} {:>7} {:>10} {}\n",
1611 "Score", "Calls", "Imports", "Verdict", "Source -> Target"
1612 ));
1613
1614 for pair in &report.top_pairs {
1616 let source_rel = strip_prefix_display(&pair.source, &prefix);
1617 let target_rel = strip_prefix_display(&pair.target, &prefix);
1618
1619 let verdict_str = match pair.verdict {
1620 CoreVerdict::Tight => "tight".red().bold().to_string(),
1621 CoreVerdict::Moderate => "moderate".yellow().to_string(),
1622 CoreVerdict::Loose => "loose".green().to_string(),
1623 };
1624
1625 let score_str = format!("{:.2}", pair.score);
1626 let score_colored = match pair.verdict {
1627 CoreVerdict::Tight => score_str.red().bold().to_string(),
1628 CoreVerdict::Moderate => score_str.yellow().to_string(),
1629 CoreVerdict::Loose => score_str.green().to_string(),
1630 };
1631
1632 output.push_str(&format!(
1633 " {:>5} {:>5} {:>7} {:>10} {} -> {}\n",
1634 score_colored, pair.call_count, pair.import_count, verdict_str, source_rel, target_rel,
1635 ));
1636 }
1637
1638 let avg_str = report
1640 .avg_coupling_score
1641 .map(|s| format!("{:.2}", s))
1642 .unwrap_or_else(|| "N/A".to_string());
1643
1644 output.push_str(&format!(
1645 "\nSummary: {} modules, {} pairs analyzed, {} tight, avg score: {}\n",
1646 report.modules_analyzed, report.pairs_analyzed, report.tight_coupling_count, avg_str,
1647 ));
1648
1649 if report.truncated == Some(true) {
1650 if let Some(total) = report.total_pairs {
1651 output.push_str(&format!(
1652 " (showing top {} of {} pairs)\n",
1653 report.top_pairs.len(),
1654 total,
1655 ));
1656 }
1657 }
1658
1659 output
1660}
1661
1662#[cfg(test)]
1667mod tests {
1668 use super::*;
1669 use std::fs;
1670 use tempfile::TempDir;
1671
1672 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
1674 let path = dir.path().join(name);
1675 fs::write(&path, content).unwrap();
1676 path
1677 }
1678
1679 #[test]
1684 fn test_compute_coupling_score_no_calls() {
1685 let score = compute_coupling_score(0, 0, 5, 5);
1686 assert_eq!(score, 0.0);
1687 }
1688
1689 #[test]
1690 fn test_compute_coupling_score_unidirectional() {
1691 let score = compute_coupling_score(2, 0, 5, 5);
1694 assert!((score - 0.1).abs() < 0.001);
1695 }
1696
1697 #[test]
1698 fn test_compute_coupling_score_bidirectional() {
1699 let score = compute_coupling_score(3, 2, 5, 5);
1702 assert!((score - 0.25).abs() < 0.001);
1703 }
1704
1705 #[test]
1706 fn test_compute_coupling_score_no_functions() {
1707 let score = compute_coupling_score(5, 5, 0, 0);
1708 assert_eq!(score, 0.0);
1709 }
1710
1711 #[test]
1712 fn test_compute_coupling_score_clamped() {
1713 let score = compute_coupling_score(100, 100, 1, 1);
1715 assert_eq!(score, 1.0);
1716 }
1717
1718 #[test]
1723 fn test_verdict_low() {
1724 assert_eq!(CouplingVerdict::from_score(0.0), CouplingVerdict::Low);
1725 assert_eq!(CouplingVerdict::from_score(0.1), CouplingVerdict::Low);
1726 assert_eq!(CouplingVerdict::from_score(0.19), CouplingVerdict::Low);
1727 }
1728
1729 #[test]
1730 fn test_verdict_moderate() {
1731 assert_eq!(CouplingVerdict::from_score(0.2), CouplingVerdict::Moderate);
1732 assert_eq!(CouplingVerdict::from_score(0.3), CouplingVerdict::Moderate);
1733 assert_eq!(CouplingVerdict::from_score(0.39), CouplingVerdict::Moderate);
1734 }
1735
1736 #[test]
1737 fn test_verdict_high() {
1738 assert_eq!(CouplingVerdict::from_score(0.4), CouplingVerdict::High);
1739 assert_eq!(CouplingVerdict::from_score(0.5), CouplingVerdict::High);
1740 assert_eq!(CouplingVerdict::from_score(0.59), CouplingVerdict::High);
1741 }
1742
1743 #[test]
1744 fn test_verdict_very_high() {
1745 assert_eq!(CouplingVerdict::from_score(0.6), CouplingVerdict::VeryHigh);
1746 assert_eq!(CouplingVerdict::from_score(0.8), CouplingVerdict::VeryHigh);
1747 assert_eq!(CouplingVerdict::from_score(1.0), CouplingVerdict::VeryHigh);
1748 }
1749
1750 #[test]
1755 fn test_extract_defined_names() {
1756 let source = r#"
1757def func_a():
1758 pass
1759
1760async def func_b():
1761 pass
1762
1763class MyClass:
1764 pass
1765"#;
1766 let temp = TempDir::new().unwrap();
1767 let path = create_test_file(&temp, "test.py", source);
1768 let info = extract_module_info(&path, source).unwrap();
1769
1770 assert!(info.defined_names.contains("func_a"));
1771 assert!(info.defined_names.contains("func_b"));
1772 assert!(info.defined_names.contains("MyClass"));
1773 assert_eq!(info.function_count, 2);
1774 }
1775
1776 #[test]
1777 fn test_extract_imports() {
1778 let source = r#"
1779import os
1780import sys as system
1781from pathlib import Path
1782from collections import defaultdict, Counter
1783from typing import List as L
1784"#;
1785 let temp = TempDir::new().unwrap();
1786 let path = create_test_file(&temp, "test.py", source);
1787 let info = extract_module_info(&path, source).unwrap();
1788
1789 assert!(info.imports.contains_key("os"));
1790 assert!(info.imports.contains_key("system"));
1791 assert!(info.imports.contains_key("Path"));
1792 assert!(info.imports.contains_key("defaultdict"));
1793 assert!(info.imports.contains_key("Counter"));
1794 }
1795
1796 #[test]
1797 fn test_extract_calls() {
1798 let source = r#"
1799def caller():
1800 result = helper()
1801 obj.method()
1802 other_func(1, 2, 3)
1803 return result
1804"#;
1805 let temp = TempDir::new().unwrap();
1806 let path = create_test_file(&temp, "test.py", source);
1807 let info = extract_module_info(&path, source).unwrap();
1808
1809 let callees: Vec<&str> = info
1811 .calls
1812 .iter()
1813 .map(|(_, callee, _)| callee.as_str())
1814 .collect();
1815 assert!(callees.contains(&"helper"));
1816 assert!(callees.contains(&"method"));
1817 assert!(callees.contains(&"other_func"));
1818 }
1819
1820 #[test]
1825 fn test_find_cross_calls_simple() {
1826 let temp = TempDir::new().unwrap();
1827
1828 let source_a = r#"
1830from module_b import helper
1831
1832def caller():
1833 return helper()
1834"#;
1835 let path_a = create_test_file(&temp, "module_a.py", source_a);
1836 let info_a = extract_module_info(&path_a, source_a).unwrap();
1837
1838 let source_b = r#"
1840def helper():
1841 return 42
1842"#;
1843 let path_b = create_test_file(&temp, "module_b.py", source_b);
1844 let info_b = extract_module_info(&path_b, source_b).unwrap();
1845
1846 let cross_calls = find_cross_calls(&info_a, &info_b);
1847
1848 assert_eq!(cross_calls.count, 1);
1849 assert_eq!(cross_calls.calls[0].caller, "caller");
1850 assert_eq!(cross_calls.calls[0].callee, "helper");
1851 }
1852
1853 #[test]
1854 fn test_find_cross_calls_no_import() {
1855 let temp = TempDir::new().unwrap();
1856
1857 let source_a = r#"
1859def caller():
1860 return helper()
1861"#;
1862 let path_a = create_test_file(&temp, "module_a.py", source_a);
1863 let info_a = extract_module_info(&path_a, source_a).unwrap();
1864
1865 let source_b = r#"
1867def helper():
1868 return 42
1869"#;
1870 let path_b = create_test_file(&temp, "module_b.py", source_b);
1871 let info_b = extract_module_info(&path_b, source_b).unwrap();
1872
1873 let cross_calls = find_cross_calls(&info_a, &info_b);
1874
1875 assert_eq!(cross_calls.count, 0);
1877 }
1878
1879 #[test]
1880 fn test_find_cross_calls_bidirectional() {
1881 let temp = TempDir::new().unwrap();
1882
1883 let source_a = r#"
1885from module_b import helper_b
1886
1887def func_a():
1888 return helper_b()
1889"#;
1890 let path_a = create_test_file(&temp, "module_a.py", source_a);
1891 let info_a = extract_module_info(&path_a, source_a).unwrap();
1892
1893 let source_b = r#"
1895from module_a import func_a
1896
1897def helper_b():
1898 return 42
1899
1900def caller_b():
1901 return func_a()
1902"#;
1903 let path_b = create_test_file(&temp, "module_b.py", source_b);
1904 let info_b = extract_module_info(&path_b, source_b).unwrap();
1905
1906 let a_to_b = find_cross_calls(&info_a, &info_b);
1907 let b_to_a = find_cross_calls(&info_b, &info_a);
1908
1909 assert_eq!(a_to_b.count, 1);
1910 assert_eq!(b_to_a.count, 1);
1911 }
1912
1913 #[test]
1918 fn test_format_coupling_text() {
1919 let report = CouplingReport {
1920 path_a: "src/auth.py".to_string(),
1921 path_b: "src/user.py".to_string(),
1922 a_to_b: CrossCalls {
1923 calls: vec![CrossCall {
1924 caller: "login".to_string(),
1925 callee: "get_user".to_string(),
1926 line: 10,
1927 }],
1928 count: 1,
1929 },
1930 b_to_a: CrossCalls::default(),
1931 total_calls: 1,
1932 coupling_score: 0.15,
1933 verdict: CouplingVerdict::Low,
1934 };
1935
1936 let text = format_coupling_text(&report);
1937
1938 assert!(text.contains("src/auth.py"));
1939 assert!(text.contains("src/user.py"));
1940 assert!(text.contains("0.15"));
1941 assert!(text.contains("low"));
1942 assert!(text.contains("login"));
1943 assert!(text.contains("get_user"));
1944 assert!(text.contains("line 10"));
1945 }
1946
1947 #[test]
1952 fn test_run_no_coupling() {
1953 let temp = TempDir::new().unwrap();
1954
1955 let source_a = r#"
1956def standalone_a():
1957 return 1
1958"#;
1959 let source_b = r#"
1960def standalone_b():
1961 return 2
1962"#;
1963
1964 let path_a = create_test_file(&temp, "a.py", source_a);
1965 let path_b = create_test_file(&temp, "b.py", source_b);
1966
1967 let args = CouplingArgs {
1968 path_a: path_a.clone(),
1969 path_b: Some(path_b.clone()),
1970 timeout: 30,
1971 project_root: None,
1972 max_pairs: 20,
1973 top: 0,
1974 cycles_only: false,
1975 lang: None,
1976 include_tests: false,
1977 };
1978
1979 let result = run(args, OutputFormat::Json);
1981 assert!(result.is_ok());
1982 }
1983
1984 #[test]
1985 fn test_run_with_coupling() {
1986 let temp = TempDir::new().unwrap();
1987
1988 let source_a = r#"
1989from b import helper
1990
1991def caller():
1992 return helper()
1993"#;
1994 let source_b = r#"
1995def helper():
1996 return 42
1997"#;
1998
1999 let path_a = create_test_file(&temp, "a.py", source_a);
2000 let path_b = create_test_file(&temp, "b.py", source_b);
2001
2002 let args = CouplingArgs {
2003 path_a: path_a.clone(),
2004 path_b: Some(path_b.clone()),
2005 timeout: 30,
2006 project_root: None,
2007 max_pairs: 20,
2008 top: 0,
2009 cycles_only: false,
2010 lang: None,
2011 include_tests: false,
2012 };
2013
2014 let result = run(args, OutputFormat::Json);
2015 assert!(result.is_ok());
2016 }
2017
2018 #[test]
2023 fn test_go_extract_module_info() {
2024 let source = r#"
2025package main
2026
2027import (
2028 "fmt"
2029 "myapp/utils"
2030)
2031
2032func Caller() {
2033 utils.Helper()
2034 fmt.Println("hello")
2035}
2036
2037func Standalone() int {
2038 return 42
2039}
2040"#;
2041 let temp = TempDir::new().unwrap();
2042 let path = create_test_file(&temp, "main.go", source);
2043 let info = extract_module_info(&path, source).unwrap();
2044
2045 assert!(info.defined_names.contains("Caller"), "missing Caller");
2047 assert!(
2048 info.defined_names.contains("Standalone"),
2049 "missing Standalone"
2050 );
2051 assert_eq!(info.function_count, 2);
2052
2053 assert!(
2055 info.imports.contains_key("fmt") || info.imports.values().any(|v| v.contains("fmt")),
2056 "missing fmt import: {:?}",
2057 info.imports
2058 );
2059 }
2060
2061 #[test]
2062 fn test_go_cross_calls() {
2063 let temp = TempDir::new().unwrap();
2064
2065 let source_a = r#"
2066package main
2067
2068import "myapp/pkg_b"
2069
2070func CallerA() {
2071 pkg_b.HelperB()
2072}
2073"#;
2074 let source_b = r#"
2075package pkg_b
2076
2077func HelperB() int {
2078 return 42
2079}
2080"#;
2081 let path_a = create_test_file(&temp, "a.go", source_a);
2082 let path_b = create_test_file(&temp, "b.go", source_b);
2083
2084 let info_a = extract_module_info(&path_a, source_a).unwrap();
2085 let info_b = extract_module_info(&path_b, source_b).unwrap();
2086
2087 let a_to_b = find_cross_calls(&info_a, &info_b);
2089 assert!(
2090 a_to_b.count >= 1,
2091 "expected cross-calls from A to B, got {}",
2092 a_to_b.count
2093 );
2094 }
2095
2096 #[test]
2097 fn test_rust_extract_module_info() {
2098 let source = r#"
2099use std::collections::HashMap;
2100use crate::module_b::helper;
2101
2102pub fn caller() {
2103 let _ = helper();
2104}
2105
2106fn standalone() -> i32 {
2107 42
2108}
2109"#;
2110 let temp = TempDir::new().unwrap();
2111 let path = create_test_file(&temp, "lib.rs", source);
2112 let info = extract_module_info(&path, source).unwrap();
2113
2114 assert!(info.defined_names.contains("caller"), "missing caller");
2116 assert!(
2117 info.defined_names.contains("standalone"),
2118 "missing standalone"
2119 );
2120 assert_eq!(info.function_count, 2);
2121
2122 assert!(
2124 !info.imports.is_empty(),
2125 "should have imports: {:?}",
2126 info.imports
2127 );
2128 }
2129
2130 #[test]
2131 fn test_typescript_extract_module_info() {
2132 let source = r#"
2133import { helper } from './module_b';
2134import * as utils from './utils';
2135
2136function caller(): void {
2137 helper();
2138 utils.doStuff();
2139}
2140
2141function standalone(): number {
2142 return 42;
2143}
2144"#;
2145 let temp = TempDir::new().unwrap();
2146 let path = create_test_file(&temp, "main.ts", source);
2147 let info = extract_module_info(&path, source).unwrap();
2148
2149 assert!(info.defined_names.contains("caller"), "missing caller");
2151 assert!(
2152 info.defined_names.contains("standalone"),
2153 "missing standalone"
2154 );
2155 assert_eq!(info.function_count, 2);
2156
2157 assert!(
2159 !info.imports.is_empty(),
2160 "should have imports: {:?}",
2161 info.imports
2162 );
2163 }
2164
2165 #[test]
2166 fn test_java_extract_module_info() {
2167 let source = r#"
2168import com.example.utils.Helper;
2169import java.util.List;
2170
2171public class Main {
2172 public void caller() {
2173 Helper.doWork();
2174 }
2175
2176 public int standalone() {
2177 return 42;
2178 }
2179}
2180"#;
2181 let temp = TempDir::new().unwrap();
2182 let path = create_test_file(&temp, "Main.java", source);
2183 let info = extract_module_info(&path, source).unwrap();
2184
2185 assert!(info.defined_names.contains("caller"), "missing caller");
2187 assert!(
2188 info.defined_names.contains("standalone"),
2189 "missing standalone"
2190 );
2191
2192 assert!(
2194 !info.imports.is_empty(),
2195 "should have imports: {:?}",
2196 info.imports
2197 );
2198 }
2199
2200 #[test]
2201 fn test_c_extract_module_info() {
2202 let source = r#"
2203#include <stdio.h>
2204#include "mylib.h"
2205
2206void caller() {
2207 helper();
2208 printf("hello\n");
2209}
2210
2211int standalone() {
2212 return 42;
2213}
2214"#;
2215 let temp = TempDir::new().unwrap();
2216 let path = create_test_file(&temp, "main.c", source);
2217 let info = extract_module_info(&path, source).unwrap();
2218
2219 assert!(info.defined_names.contains("caller"), "missing caller");
2221 assert!(
2222 info.defined_names.contains("standalone"),
2223 "missing standalone"
2224 );
2225 assert_eq!(info.function_count, 2);
2226
2227 assert!(
2229 !info.imports.is_empty(),
2230 "should have imports from #include: {:?}",
2231 info.imports
2232 );
2233 }
2234
2235 #[test]
2236 fn test_ruby_extract_module_info() {
2237 let source = r#"
2238require 'json'
2239require_relative 'helper'
2240
2241def caller
2242 helper_method
2243 JSON.parse("{}")
2244end
2245
2246def standalone
2247 42
2248end
2249"#;
2250 let temp = TempDir::new().unwrap();
2251 let path = create_test_file(&temp, "main.rb", source);
2252 let info = extract_module_info(&path, source).unwrap();
2253
2254 assert!(info.defined_names.contains("caller"), "missing caller");
2256 assert!(
2257 info.defined_names.contains("standalone"),
2258 "missing standalone"
2259 );
2260 assert_eq!(info.function_count, 2);
2261
2262 assert!(
2264 !info.imports.is_empty(),
2265 "should have imports from require: {:?}",
2266 info.imports
2267 );
2268 }
2269
2270 #[test]
2271 fn test_cpp_extract_module_info() {
2272 let source = r#"
2273#include <iostream>
2274#include "mylib.hpp"
2275
2276void caller() {
2277 helper();
2278 std::cout << "hello" << std::endl;
2279}
2280
2281int standalone() {
2282 return 42;
2283}
2284"#;
2285 let temp = TempDir::new().unwrap();
2286 let path = create_test_file(&temp, "main.cpp", source);
2287 let info = extract_module_info(&path, source).unwrap();
2288
2289 assert!(info.defined_names.contains("caller"), "missing caller");
2290 assert!(
2291 info.defined_names.contains("standalone"),
2292 "missing standalone"
2293 );
2294 assert_eq!(info.function_count, 2);
2295 assert!(
2296 !info.imports.is_empty(),
2297 "should have imports from #include: {:?}",
2298 info.imports
2299 );
2300 }
2301
2302 #[test]
2303 fn test_php_extract_module_info() {
2304 let source = r#"<?php
2305use App\Utils\Helper;
2306use Symfony\Component\Console\Command;
2307
2308function caller() {
2309 Helper::doWork();
2310}
2311
2312function standalone() {
2313 return 42;
2314}
2315"#;
2316 let temp = TempDir::new().unwrap();
2317 let path = create_test_file(&temp, "main.php", source);
2318 let info = extract_module_info(&path, source).unwrap();
2319
2320 assert!(info.defined_names.contains("caller"), "missing caller");
2321 assert!(
2322 info.defined_names.contains("standalone"),
2323 "missing standalone"
2324 );
2325 assert_eq!(info.function_count, 2);
2326 assert!(
2327 !info.imports.is_empty(),
2328 "should have imports from use: {:?}",
2329 info.imports
2330 );
2331 }
2332
2333 #[test]
2334 fn test_csharp_extract_module_info() {
2335 let source = r#"
2336using System;
2337using MyApp.Utils;
2338
2339public class Main {
2340 public void Caller() {
2341 Helper.DoWork();
2342 }
2343
2344 public int Standalone() {
2345 return 42;
2346 }
2347}
2348"#;
2349 let temp = TempDir::new().unwrap();
2350 let path = create_test_file(&temp, "Main.cs", source);
2351 let info = extract_module_info(&path, source).unwrap();
2352
2353 assert!(info.defined_names.contains("Caller"), "missing Caller");
2354 assert!(
2355 info.defined_names.contains("Standalone"),
2356 "missing Standalone"
2357 );
2358 assert!(
2359 !info.imports.is_empty(),
2360 "should have imports from using: {:?}",
2361 info.imports
2362 );
2363 }
2364
2365 #[test]
2366 fn test_run_go_coupling() {
2367 let temp = TempDir::new().unwrap();
2368
2369 let source_a = r#"
2370package main
2371
2372func standalone_a() int {
2373 return 1
2374}
2375"#;
2376 let source_b = r#"
2377package main
2378
2379func standalone_b() int {
2380 return 2
2381}
2382"#;
2383
2384 let path_a = create_test_file(&temp, "a.go", source_a);
2385 let path_b = create_test_file(&temp, "b.go", source_b);
2386
2387 let args = CouplingArgs {
2388 path_a: path_a.clone(),
2389 path_b: Some(path_b.clone()),
2390 timeout: 30,
2391 project_root: None,
2392 max_pairs: 20,
2393 top: 0,
2394 cycles_only: false,
2395 lang: None,
2396 include_tests: false,
2397 };
2398
2399 let result = run(args, OutputFormat::Json);
2400 assert!(
2401 result.is_ok(),
2402 "coupling should work for Go files: {:?}",
2403 result.err()
2404 );
2405 }
2406
2407 #[test]
2408 fn test_run_rust_coupling() {
2409 let temp = TempDir::new().unwrap();
2410
2411 let source_a = r#"
2412fn standalone_a() -> i32 {
2413 1
2414}
2415"#;
2416 let source_b = r#"
2417fn standalone_b() -> i32 {
2418 2
2419}
2420"#;
2421
2422 let path_a = create_test_file(&temp, "a.rs", source_a);
2423 let path_b = create_test_file(&temp, "b.rs", source_b);
2424
2425 let args = CouplingArgs {
2426 path_a: path_a.clone(),
2427 path_b: Some(path_b.clone()),
2428 timeout: 30,
2429 project_root: None,
2430 max_pairs: 20,
2431 top: 0,
2432 cycles_only: false,
2433 lang: None,
2434 include_tests: false,
2435 };
2436
2437 let result = run(args, OutputFormat::Json);
2438 assert!(
2439 result.is_ok(),
2440 "coupling should work for Rust files: {:?}",
2441 result.err()
2442 );
2443 }
2444
2445 #[test]
2446 fn test_unsupported_extension_returns_error() {
2447 let temp = TempDir::new().unwrap();
2448 let path = create_test_file(&temp, "data.xyz", "some content");
2449 let result = extract_module_info(&path, "some content");
2450 assert!(
2451 result.is_err(),
2452 "unsupported file extension should return error"
2453 );
2454 }
2455
2456 #[test]
2461 fn test_coupling_args_pair_mode_backward_compat() {
2462 let args = CouplingArgs {
2464 path_a: PathBuf::from("src/a.py"),
2465 path_b: Some(PathBuf::from("src/b.py")),
2466 timeout: 30,
2467 project_root: None,
2468 max_pairs: 20,
2469 top: 0,
2470 cycles_only: false,
2471 lang: None,
2472 include_tests: false,
2473 };
2474 assert!(args.path_b.is_some());
2475 }
2476
2477 #[test]
2478 fn test_coupling_args_project_wide_mode() {
2479 let args = CouplingArgs {
2481 path_a: PathBuf::from("src/"),
2482 path_b: None,
2483 timeout: 30,
2484 project_root: None,
2485 max_pairs: 20,
2486 top: 0,
2487 cycles_only: false,
2488 lang: None,
2489 include_tests: false,
2490 };
2491 assert!(args.path_b.is_none());
2492 }
2493
2494 #[test]
2495 fn test_coupling_args_max_pairs_default() {
2496 let args = CouplingArgs {
2497 path_a: PathBuf::from("src/"),
2498 path_b: None,
2499 timeout: 30,
2500 project_root: None,
2501 max_pairs: 20,
2502 top: 0,
2503 cycles_only: false,
2504 lang: None,
2505 include_tests: false,
2506 };
2507 assert_eq!(args.max_pairs, 20);
2508 }
2509
2510 #[test]
2511 fn test_coupling_args_max_pairs_custom() {
2512 let args = CouplingArgs {
2513 path_a: PathBuf::from("src/"),
2514 path_b: None,
2515 timeout: 30,
2516 project_root: None,
2517 max_pairs: 5,
2518 top: 0,
2519 cycles_only: false,
2520 lang: None,
2521 include_tests: false,
2522 };
2523 assert_eq!(args.max_pairs, 5);
2524 }
2525
2526 #[test]
2527 fn test_run_project_wide_mode() {
2528 let temp = TempDir::new().unwrap();
2529
2530 let source_a = r#"
2532from b import helper
2533
2534def caller():
2535 return helper()
2536"#;
2537 let source_b = r#"
2538def helper():
2539 return 42
2540"#;
2541 let source_c = r#"
2542def standalone():
2543 return 99
2544"#;
2545
2546 create_test_file(&temp, "a.py", source_a);
2547 create_test_file(&temp, "b.py", source_b);
2548 create_test_file(&temp, "c.py", source_c);
2549
2550 let args = CouplingArgs {
2552 path_a: temp.path().to_path_buf(),
2553 path_b: None,
2554 timeout: 30,
2555 project_root: None,
2556 max_pairs: 20,
2557 top: 0,
2558 cycles_only: false,
2559 lang: None,
2560 include_tests: false,
2561 };
2562
2563 let result = run(args, OutputFormat::Json);
2564 assert!(
2565 result.is_ok(),
2566 "project-wide coupling should succeed: {:?}",
2567 result.err()
2568 );
2569 }
2570
2571 #[test]
2572 fn test_run_pair_mode_still_works() {
2573 let temp = TempDir::new().unwrap();
2575
2576 let source_a = r#"
2577from b import helper
2578
2579def caller():
2580 return helper()
2581"#;
2582 let source_b = r#"
2583def helper():
2584 return 42
2585"#;
2586
2587 let path_a = create_test_file(&temp, "a.py", source_a);
2588 let path_b = create_test_file(&temp, "b.py", source_b);
2589
2590 let args = CouplingArgs {
2591 path_a: path_a.clone(),
2592 path_b: Some(path_b.clone()),
2593 timeout: 30,
2594 project_root: None,
2595 max_pairs: 20,
2596 top: 0,
2597 cycles_only: false,
2598 lang: None,
2599 include_tests: false,
2600 };
2601
2602 let result = run(args, OutputFormat::Json);
2603 assert!(
2604 result.is_ok(),
2605 "pair mode should still work: {:?}",
2606 result.err()
2607 );
2608 }
2609
2610 #[test]
2611 fn test_format_coupling_project_text_basic() {
2612 use tldr_core::quality::coupling::{
2613 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2614 ModuleCoupling as CoreModuleCoupling,
2615 };
2616
2617 let report = CoreCouplingReport {
2618 modules_analyzed: 10,
2619 pairs_analyzed: 45,
2620 total_cross_file_pairs: 8,
2621 avg_coupling_score: Some(0.25),
2622 tight_coupling_count: 2,
2623 top_pairs: vec![
2624 CoreModuleCoupling {
2625 source: PathBuf::from("src/services/auth.rs"),
2626 target: PathBuf::from("src/db/users.rs"),
2627 import_count: 8,
2628 call_count: 12,
2629 calls_source_to_target: vec![],
2630 calls_target_to_source: vec![],
2631 shared_imports: vec![],
2632 score: 0.72,
2633 verdict: CoreVerdict::Tight,
2634 },
2635 CoreModuleCoupling {
2636 source: PathBuf::from("src/api/routes.rs"),
2637 target: PathBuf::from("src/services/auth.rs"),
2638 import_count: 5,
2639 call_count: 7,
2640 calls_source_to_target: vec![],
2641 calls_target_to_source: vec![],
2642 shared_imports: vec![],
2643 score: 0.55,
2644 verdict: CoreVerdict::Moderate,
2645 },
2646 CoreModuleCoupling {
2647 source: PathBuf::from("src/handlers/web.rs"),
2648 target: PathBuf::from("src/api/routes.rs"),
2649 import_count: 3,
2650 call_count: 5,
2651 calls_source_to_target: vec![],
2652 calls_target_to_source: vec![],
2653 shared_imports: vec![],
2654 score: 0.15,
2655 verdict: CoreVerdict::Loose,
2656 },
2657 ],
2658 truncated: None,
2659 total_pairs: None,
2660 shown_pairs: None,
2661 };
2662
2663 let text = format_coupling_project_text(&report);
2664
2665 assert!(
2667 text.contains("project-wide"),
2668 "should contain 'project-wide': {}",
2669 text
2670 );
2671 assert!(
2673 text.contains("Score"),
2674 "should contain Score header: {}",
2675 text
2676 );
2677 assert!(
2678 text.contains("Calls"),
2679 "should contain Calls header: {}",
2680 text
2681 );
2682 assert!(
2683 text.contains("Imports"),
2684 "should contain Imports header: {}",
2685 text
2686 );
2687 assert!(
2688 text.contains("Verdict"),
2689 "should contain Verdict header: {}",
2690 text
2691 );
2692 assert!(
2694 text.contains("0.72"),
2695 "should contain tight score: {}",
2696 text
2697 );
2698 assert!(
2699 text.contains("0.55"),
2700 "should contain moderate score: {}",
2701 text
2702 );
2703 assert!(
2704 text.contains("0.15"),
2705 "should contain loose score: {}",
2706 text
2707 );
2708 assert!(
2710 text.contains("tight"),
2711 "should contain tight verdict: {}",
2712 text
2713 );
2714 assert!(
2715 text.contains("moderate"),
2716 "should contain moderate verdict: {}",
2717 text
2718 );
2719 assert!(
2720 text.contains("loose"),
2721 "should contain loose verdict: {}",
2722 text
2723 );
2724 assert!(
2726 text.contains("10 modules"),
2727 "should contain module count: {}",
2728 text
2729 );
2730 assert!(
2731 text.contains("45 pairs"),
2732 "should contain pair count: {}",
2733 text
2734 );
2735 assert!(
2736 text.contains("2 tight"),
2737 "should contain tight count: {}",
2738 text
2739 );
2740 }
2741
2742 #[test]
2743 fn test_format_coupling_project_text_empty() {
2744 use tldr_core::quality::coupling::CouplingReport as CoreCouplingReport;
2745
2746 let report = CoreCouplingReport::default();
2747
2748 let text = format_coupling_project_text(&report);
2749
2750 assert!(
2751 text.contains("project-wide"),
2752 "should contain 'project-wide': {}",
2753 text
2754 );
2755 assert!(
2756 text.contains("0 modules"),
2757 "should contain zero modules: {}",
2758 text
2759 );
2760 }
2761
2762 #[test]
2767 fn test_format_martin_text_basic() {
2768 use tldr_core::quality::coupling::{
2769 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2770 };
2771
2772 let report = MartinMetricsReport {
2773 schema_version: "1.0".to_string(),
2774 modules_analyzed: 2,
2775 metrics: vec![
2776 MartinModuleMetrics {
2777 module: PathBuf::from("src/api.py"),
2778 ca: 0,
2779 ce: 3,
2780 instability: 1.0,
2781 in_cycle: false,
2782 },
2783 MartinModuleMetrics {
2784 module: PathBuf::from("src/db.py"),
2785 ca: 2,
2786 ce: 0,
2787 instability: 0.0,
2788 in_cycle: false,
2789 },
2790 ],
2791 cycles: vec![],
2792 summary: MartinSummary {
2793 avg_instability: 0.5,
2794 total_cycles: 0,
2795 most_stable: Some(PathBuf::from("src/db.py")),
2796 most_unstable: Some(PathBuf::from("src/api.py")),
2797 },
2798 };
2799
2800 let text = format_martin_text(&report);
2801 assert!(
2802 text.contains("Module"),
2803 "should contain Module header: {}",
2804 text
2805 );
2806 assert!(text.contains("Ca"), "should contain Ca header: {}", text);
2807 assert!(text.contains("Ce"), "should contain Ce header: {}", text);
2808 assert!(
2809 text.contains("Cycle?"),
2810 "should contain Cycle? header: {}",
2811 text
2812 );
2813 }
2814
2815 #[test]
2816 fn test_format_martin_text_empty() {
2817 use tldr_core::quality::coupling::MartinMetricsReport;
2818
2819 let report = MartinMetricsReport::default();
2820 let text = format_martin_text(&report);
2821 assert!(
2822 text.contains("No modules found"),
2823 "empty report should say 'No modules found': {}",
2824 text
2825 );
2826 }
2827
2828 #[test]
2829 fn test_format_martin_text_with_cycles() {
2830 use tldr_core::analysis::deps::DepCycle;
2831 use tldr_core::quality::coupling::{
2832 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2833 };
2834
2835 let cycle = DepCycle::new(vec![PathBuf::from("a.py"), PathBuf::from("b.py")]);
2836 let report = MartinMetricsReport {
2837 schema_version: "1.0".to_string(),
2838 modules_analyzed: 2,
2839 metrics: vec![
2840 MartinModuleMetrics {
2841 module: PathBuf::from("a.py"),
2842 ca: 1,
2843 ce: 1,
2844 instability: 0.5,
2845 in_cycle: true,
2846 },
2847 MartinModuleMetrics {
2848 module: PathBuf::from("b.py"),
2849 ca: 1,
2850 ce: 1,
2851 instability: 0.5,
2852 in_cycle: true,
2853 },
2854 ],
2855 cycles: vec![cycle],
2856 summary: MartinSummary {
2857 avg_instability: 0.5,
2858 total_cycles: 1,
2859 most_stable: Some(PathBuf::from("a.py")),
2860 most_unstable: Some(PathBuf::from("a.py")),
2861 },
2862 };
2863
2864 let text = format_martin_text(&report);
2865 assert!(
2866 text.contains("Cycles:"),
2867 "should contain 'Cycles:' section: {}",
2868 text
2869 );
2870 assert!(
2871 text.contains("->"),
2872 "should contain '->' in cycle display: {}",
2873 text
2874 );
2875 }
2876
2877 #[test]
2878 fn test_format_martin_text_no_cycles() {
2879 use tldr_core::quality::coupling::{
2880 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2881 };
2882
2883 let report = MartinMetricsReport {
2884 schema_version: "1.0".to_string(),
2885 modules_analyzed: 1,
2886 metrics: vec![MartinModuleMetrics {
2887 module: PathBuf::from("a.py"),
2888 ca: 0,
2889 ce: 0,
2890 instability: 0.0,
2891 in_cycle: false,
2892 }],
2893 cycles: vec![],
2894 summary: MartinSummary {
2895 avg_instability: 0.0,
2896 total_cycles: 0,
2897 most_stable: Some(PathBuf::from("a.py")),
2898 most_unstable: Some(PathBuf::from("a.py")),
2899 },
2900 };
2901
2902 let text = format_martin_text(&report);
2903 assert!(
2904 !text.contains("Cycles:"),
2905 "should NOT contain 'Cycles:' section when no cycles: {}",
2906 text
2907 );
2908 }
2909
2910 #[test]
2911 fn test_format_martin_text_summary_line() {
2912 use tldr_core::quality::coupling::{
2913 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2914 };
2915
2916 let report = MartinMetricsReport {
2917 schema_version: "1.0".to_string(),
2918 modules_analyzed: 3,
2919 metrics: vec![MartinModuleMetrics {
2920 module: PathBuf::from("a.py"),
2921 ca: 0,
2922 ce: 1,
2923 instability: 1.0,
2924 in_cycle: false,
2925 }],
2926 cycles: vec![],
2927 summary: MartinSummary {
2928 avg_instability: 0.5,
2929 total_cycles: 0,
2930 most_stable: Some(PathBuf::from("c.py")),
2931 most_unstable: Some(PathBuf::from("a.py")),
2932 },
2933 };
2934
2935 let text = format_martin_text(&report);
2936 assert!(
2937 text.contains("modules"),
2938 "should contain 'modules' in summary: {}",
2939 text
2940 );
2941 assert!(
2942 text.contains("avg instability"),
2943 "should contain 'avg instability' in summary: {}",
2944 text
2945 );
2946 }
2947
2948 #[test]
2949 fn test_format_coupling_project_text_path_stripping() {
2950 use tldr_core::quality::coupling::{
2951 CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2952 ModuleCoupling as CoreModuleCoupling,
2953 };
2954
2955 let report = CoreCouplingReport {
2956 modules_analyzed: 2,
2957 pairs_analyzed: 1,
2958 total_cross_file_pairs: 1,
2959 avg_coupling_score: Some(0.50),
2960 tight_coupling_count: 0,
2961 top_pairs: vec![CoreModuleCoupling {
2962 source: PathBuf::from("/home/user/project/src/auth.rs"),
2963 target: PathBuf::from("/home/user/project/src/db.rs"),
2964 import_count: 3,
2965 call_count: 4,
2966 calls_source_to_target: vec![],
2967 calls_target_to_source: vec![],
2968 shared_imports: vec![],
2969 score: 0.50,
2970 verdict: CoreVerdict::Moderate,
2971 }],
2972 truncated: None,
2973 total_pairs: None,
2974 shown_pairs: None,
2975 };
2976
2977 let text = format_coupling_project_text(&report);
2978
2979 assert!(
2981 text.contains("auth.rs"),
2982 "should show relative path auth.rs: {}",
2983 text
2984 );
2985 assert!(
2986 text.contains("db.rs"),
2987 "should show relative path db.rs: {}",
2988 text
2989 );
2990 assert!(
2992 !text.contains("/home/user/project/src/auth.rs"),
2993 "should strip common prefix from paths: {}",
2994 text
2995 );
2996 }
2997
2998 #[test]
3003 fn test_coupling_args_top_flag() {
3004 let args = CouplingArgs {
3006 path_a: PathBuf::from("src/"),
3007 path_b: None,
3008 timeout: 30,
3009 project_root: None,
3010 max_pairs: 20,
3011 top: 5,
3012 cycles_only: false,
3013 lang: None,
3014 include_tests: false,
3015 };
3016 assert_eq!(args.top, 5);
3017 }
3018
3019 #[test]
3020 fn test_coupling_args_cycles_only_flag() {
3021 let args = CouplingArgs {
3023 path_a: PathBuf::from("src/"),
3024 path_b: None,
3025 timeout: 30,
3026 project_root: None,
3027 max_pairs: 20,
3028 top: 0,
3029 cycles_only: true,
3030 lang: None,
3031 include_tests: false,
3032 };
3033 assert!(args.cycles_only);
3034 }
3035
3036 #[test]
3037 fn test_coupling_args_defaults() {
3038 let args = CouplingArgs {
3040 path_a: PathBuf::from("src/"),
3041 path_b: None,
3042 timeout: 30,
3043 project_root: None,
3044 max_pairs: 20,
3045 top: 0,
3046 cycles_only: false,
3047 lang: None,
3048 include_tests: false,
3049 };
3050 assert_eq!(args.top, 0);
3051 assert!(!args.cycles_only);
3052 }
3053
3054 #[test]
3055 fn test_project_mode_produces_martin_output() {
3056 let temp = TempDir::new().unwrap();
3058
3059 create_test_file(
3060 &temp,
3061 "a.py",
3062 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3063 );
3064 create_test_file(
3065 &temp,
3066 "b.py",
3067 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3068 );
3069 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3070
3071 let args = CouplingArgs {
3072 path_a: temp.path().to_path_buf(),
3073 path_b: None,
3074 timeout: 30,
3075 project_root: None,
3076 max_pairs: 20,
3077 top: 0,
3078 cycles_only: false,
3079 lang: None,
3080 include_tests: false,
3081 };
3082
3083 let result = run(args, OutputFormat::Text);
3085 assert!(
3086 result.is_ok(),
3087 "project mode should succeed: {:?}",
3088 result.err()
3089 );
3090 }
3093
3094 #[test]
3095 fn test_project_mode_json_has_martin_fields() {
3096 use serde_json::Value;
3097
3098 let temp = TempDir::new().unwrap();
3099
3100 create_test_file(
3101 &temp,
3102 "a.py",
3103 "from b import helper_b\n\ndef func_a():\n return helper_b()\n",
3104 );
3105 create_test_file(
3106 &temp,
3107 "b.py",
3108 "from c import helper_c\n\ndef helper_b():\n return helper_c()\n",
3109 );
3110 create_test_file(&temp, "c.py", "def helper_c():\n return 42\n");
3111
3112 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3114 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3115
3116 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3117 let martin_report = compute_martin_metrics_from_deps(
3118 &deps_report,
3119 &MartinOptions {
3120 top: 0,
3121 cycles_only: false,
3122 },
3123 );
3124
3125 let json = serde_json::to_string_pretty(&martin_report).unwrap();
3126 let parsed: Value = serde_json::from_str(&json).unwrap();
3127
3128 assert!(
3129 parsed.get("modules_analyzed").is_some(),
3130 "JSON should have 'modules_analyzed': {}",
3131 json
3132 );
3133 assert!(
3134 parsed.get("metrics").is_some(),
3135 "JSON should have 'metrics': {}",
3136 json
3137 );
3138 assert!(
3139 parsed.get("summary").is_some(),
3140 "JSON should have 'summary': {}",
3141 json
3142 );
3143 }
3144
3145 #[test]
3146 fn test_project_mode_cycles_only_filter() {
3147 let temp = TempDir::new().unwrap();
3149
3150 create_test_file(
3151 &temp,
3152 "a.py",
3153 "from b import func_b\n\ndef func_a():\n return func_b()\n",
3154 );
3155 create_test_file(
3156 &temp,
3157 "b.py",
3158 "from a import func_a\n\ndef func_b():\n return func_a()\n",
3159 );
3160 create_test_file(
3161 &temp,
3162 "c.py",
3163 "from d import func_d\n\ndef func_c():\n return func_d()\n",
3164 );
3165 create_test_file(&temp, "d.py", "def func_d():\n return 42\n");
3166
3167 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3168 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3169
3170 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3171 let martin_report = compute_martin_metrics_from_deps(
3172 &deps_report,
3173 &MartinOptions {
3174 top: 0,
3175 cycles_only: true,
3176 },
3177 );
3178
3179 for m in &martin_report.metrics {
3181 assert!(
3182 m.in_cycle,
3183 "cycles_only filter should only include cycle modules, got: {:?}",
3184 m.module
3185 );
3186 }
3187 }
3188
3189 #[test]
3190 fn test_project_mode_top_n_limits() {
3191 let temp = TempDir::new().unwrap();
3193
3194 create_test_file(
3195 &temp,
3196 "a.py",
3197 "from b import fb\n\ndef fa():\n return fb()\n",
3198 );
3199 create_test_file(
3200 &temp,
3201 "b.py",
3202 "from c import fc\n\ndef fb():\n return fc()\n",
3203 );
3204 create_test_file(
3205 &temp,
3206 "c.py",
3207 "from d import fd\n\ndef fc():\n return fd()\n",
3208 );
3209 create_test_file(
3210 &temp,
3211 "d.py",
3212 "from e import fe\n\ndef fd():\n return fe()\n",
3213 );
3214 create_test_file(&temp, "e.py", "def fe():\n return 42\n");
3215
3216 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3217 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3218
3219 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3220 let martin_report = compute_martin_metrics_from_deps(
3221 &deps_report,
3222 &MartinOptions {
3223 top: 2,
3224 cycles_only: false,
3225 },
3226 );
3227
3228 assert!(
3229 martin_report.metrics.len() <= 2,
3230 "top 2 should limit metrics to at most 2, got {}",
3231 martin_report.metrics.len()
3232 );
3233 assert!(
3235 martin_report.modules_analyzed >= 3,
3236 "modules_analyzed should reflect total (not filtered), got {}",
3237 martin_report.modules_analyzed
3238 );
3239 }
3240
3241 #[test]
3242 fn test_pair_mode_unchanged() {
3243 let temp = TempDir::new().unwrap();
3245
3246 let path_a = create_test_file(&temp, "a.py", "def standalone_a():\n return 1\n");
3247 let path_b = create_test_file(&temp, "b.py", "def standalone_b():\n return 2\n");
3248
3249 let args = CouplingArgs {
3250 path_a: path_a.clone(),
3251 path_b: Some(path_b.clone()),
3252 timeout: 30,
3253 project_root: None,
3254 max_pairs: 20,
3255 top: 3,
3256 cycles_only: true,
3257 lang: None,
3258 include_tests: false,
3259 };
3260
3261 let result = run(args, OutputFormat::Json);
3263 assert!(
3264 result.is_ok(),
3265 "pair mode with new flags should still work: {:?}",
3266 result.err()
3267 );
3268 }
3269
3270 #[test]
3271 fn test_project_mode_empty_dir() {
3272 let temp = TempDir::new().unwrap();
3275
3276 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3277 use tldr_core::quality::coupling::MartinMetricsReport;
3278
3279 let deps_result = analyze_dependencies(temp.path(), &DepsOptions::default());
3280 match deps_result {
3283 Err(_) => {
3284 let empty_report = MartinMetricsReport::default();
3285 let text = format_martin_text(&empty_report);
3286 assert!(
3287 text.contains("No modules found"),
3288 "empty report should say 'No modules found': {}",
3289 text
3290 );
3291 }
3292 Ok(deps_report) => {
3293 use tldr_core::quality::coupling::{
3295 compute_martin_metrics_from_deps, MartinOptions,
3296 };
3297 let martin_report = compute_martin_metrics_from_deps(
3298 &deps_report,
3299 &MartinOptions {
3300 top: 0,
3301 cycles_only: false,
3302 },
3303 );
3304 assert_eq!(
3305 martin_report.modules_analyzed, 0,
3306 "empty dir should have 0 modules"
3307 );
3308 let text = format_martin_text(&martin_report);
3309 assert!(
3310 text.contains("No modules found"),
3311 "empty dir text should say 'No modules found': {}",
3312 text
3313 );
3314 }
3315 }
3316 }
3317
3318 #[test]
3319 fn test_project_mode_single_file() {
3320 let temp = TempDir::new().unwrap();
3321
3322 create_test_file(&temp, "only.py", "def lonely():\n return 1\n");
3323
3324 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3325 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3326
3327 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3328 let martin_report = compute_martin_metrics_from_deps(
3329 &deps_report,
3330 &MartinOptions {
3331 top: 0,
3332 cycles_only: false,
3333 },
3334 );
3335
3336 assert!(
3338 martin_report.modules_analyzed >= 1,
3339 "single file should produce at least 1 module, got {}",
3340 martin_report.modules_analyzed
3341 );
3342 }
3343
3344 #[test]
3349 fn test_format_martin_json_schema() {
3350 use serde_json::Value;
3352 use tldr_core::quality::coupling::{
3353 MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3354 };
3355
3356 let report = MartinMetricsReport {
3357 schema_version: "1.0".to_string(),
3358 modules_analyzed: 1,
3359 metrics: vec![MartinModuleMetrics {
3360 module: PathBuf::from("a.py"),
3361 ca: 0,
3362 ce: 0,
3363 instability: 0.0,
3364 in_cycle: false,
3365 }],
3366 cycles: vec![],
3367 summary: MartinSummary {
3368 avg_instability: 0.0,
3369 total_cycles: 0,
3370 most_stable: Some(PathBuf::from("a.py")),
3371 most_unstable: Some(PathBuf::from("a.py")),
3372 },
3373 };
3374
3375 let json_str = serde_json::to_string_pretty(&report).unwrap();
3376 let parsed: Value = serde_json::from_str(&json_str).unwrap();
3377
3378 assert_eq!(
3379 parsed["schema_version"].as_str(),
3380 Some("1.0"),
3381 "JSON should contain schema_version=1.0, got: {}",
3382 json_str
3383 );
3384 }
3385
3386 #[test]
3387 fn test_project_mode_top_and_cycles_combined() {
3388 let temp = TempDir::new().unwrap();
3390
3391 create_test_file(
3394 &temp,
3395 "a.py",
3396 "from b import fb\n\ndef fa():\n return fb()\n",
3397 );
3398 create_test_file(
3399 &temp,
3400 "b.py",
3401 "from a import fa\nfrom c import fc\n\ndef fb():\n return fa() + fc()\n",
3402 );
3403 create_test_file(
3404 &temp,
3405 "c.py",
3406 "from b import fb\n\ndef fc():\n return fb()\n",
3407 );
3408 create_test_file(&temp, "d.py", "def fd():\n return 42\n");
3409
3410 use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3411 use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3412
3413 let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3414 let martin_report = compute_martin_metrics_from_deps(
3415 &deps_report,
3416 &MartinOptions {
3417 top: 2,
3418 cycles_only: true,
3419 },
3420 );
3421
3422 assert!(
3424 martin_report.metrics.len() <= 2,
3425 "top 2 + cycles_only should limit to at most 2 modules, got {}",
3426 martin_report.metrics.len()
3427 );
3428 for m in &martin_report.metrics {
3429 assert!(
3430 m.in_cycle,
3431 "all returned modules should be in_cycle, but {:?} is not",
3432 m.module
3433 );
3434 }
3435 }
3436
3437 #[test]
3438 fn test_coupling_args_lang_flag() {
3439 let args = CouplingArgs {
3441 path_a: PathBuf::from("src/a.ts"),
3442 path_b: Some(PathBuf::from("src/b.ts")),
3443 timeout: 30,
3444 project_root: None,
3445 max_pairs: 20,
3446 top: 0,
3447 cycles_only: false,
3448 lang: Some(TldrLanguage::TypeScript),
3449 include_tests: false,
3450 };
3451 assert_eq!(args.lang, Some(TldrLanguage::TypeScript));
3452
3453 let args_auto = CouplingArgs {
3455 path_a: PathBuf::from("src/a.py"),
3456 path_b: None,
3457 timeout: 30,
3458 project_root: None,
3459 max_pairs: 20,
3460 top: 0,
3461 cycles_only: false,
3462 lang: None,
3463 include_tests: false,
3464 };
3465 assert_eq!(args_auto.lang, None);
3466 }
3467}