Skip to main content

tldr_cli/commands/patterns/
coupling.rs

1//! Coupling command - Cross-module coupling analysis
2//!
3//! Analyzes coupling between two source modules by tracking cross-module
4//! function calls. Computes a coupling score (0.0-1.0) and provides a verdict.
5//!
6//! Supports all languages with tree-sitter grammars: Python, Go, Rust,
7//! TypeScript, JavaScript, Java, C, C++, Ruby, C#, PHP, Scala, Elixir,
8//! Lua, Luau, and OCaml.
9//!
10//! # Example Usage
11//!
12//! ```bash
13//! tldr coupling src/auth.py src/user.py
14//! tldr coupling src/gin.go src/context.go --format text
15//! tldr coupling src/lib.rs src/utils.rs --timeout 30
16//! ```
17//!
18//! # TIGER Mitigations
19//!
20//! - **E02**: `--timeout` flag with default 30 seconds
21//! - **T02**: All path validation through `validation.rs`
22
23use 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// =============================================================================
48// CLI Arguments
49// =============================================================================
50
51/// Analyze coupling between source modules.
52///
53/// Two modes:
54/// - **Pair mode** (2 args): `tldr coupling file_a file_b` -- compare two files
55/// - **Project-wide mode** (1 arg): `tldr coupling directory/` -- scan all pairs
56///
57/// Measures cross-module function calls and computes a coupling score.
58/// A lower score indicates looser coupling (good), while a higher score
59/// indicates tighter coupling (may need refactoring).
60///
61/// Supports: Python, Go, Rust, TypeScript, JavaScript, Java, C, C++,
62/// Ruby, C#, PHP, Scala, Elixir, Lua, Luau, OCaml.
63#[derive(Debug, Clone, Args)]
64pub struct CouplingArgs {
65    /// First source module (pair mode) or directory to scan (project-wide mode)
66    pub path_a: PathBuf,
67
68    /// Second source module (pair mode). Omit for project-wide scan.
69    pub path_b: Option<PathBuf>,
70
71    /// Timeout in seconds (TIGER E02 mitigation)
72    #[arg(long, default_value = "30")]
73    pub timeout: u64,
74
75    /// Project root for path validation (optional)
76    #[arg(long)]
77    pub project_root: Option<PathBuf>,
78
79    /// Maximum number of pairs to show in project-wide mode (default: 20)
80    #[arg(long, short = 'n', default_value = "20")]
81    pub max_pairs: usize,
82
83    /// Limit output to top N modules ranked by instability (project-wide mode only). 0 = show all.
84    #[arg(long, default_value = "0")]
85    pub top: usize,
86
87    /// Only show modules involved in dependency cycles (project-wide mode only)
88    #[arg(long)]
89    pub cycles_only: bool,
90
91    /// Include test files in analysis (excluded by default)
92    #[arg(long)]
93    pub include_tests: bool,
94
95    /// Language filter (auto-detected if omitted)
96    #[arg(long, short = 'l')]
97    pub lang: Option<TldrLanguage>,
98}
99
100// =============================================================================
101// Module Information
102// =============================================================================
103
104/// Information extracted from a source module for coupling analysis.
105#[derive(Debug, Clone)]
106pub struct ModuleInfo {
107    /// Path to the module
108    pub path: PathBuf,
109    /// Names defined at module level (functions, classes)
110    pub defined_names: HashSet<String>,
111    /// Imports: alias/name -> source module
112    pub imports: HashMap<String, String>,
113    /// Call sites: (caller_func, callee_name, line)
114    pub calls: Vec<(String, String, u32)>,
115    /// Total function count for normalization
116    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
131// =============================================================================
132// Language Configuration
133// =============================================================================
134
135/// Language-specific AST node kind configuration for coupling analysis.
136struct LangConfig {
137    /// Node kinds that represent function/method definitions
138    function_kinds: &'static [&'static str],
139    /// Node kinds that represent class/type definitions
140    class_kinds: &'static [&'static str],
141    /// Node kinds that represent import statements
142    import_kinds: &'static [&'static str],
143    /// Node kinds that represent function/method calls
144    call_kinds: &'static [&'static str],
145    /// Field name to get the function name (e.g. "name")
146    func_name_field: &'static str,
147    /// Whether to look for the name child by field or first identifier
148    use_name_field: bool,
149    /// Whether to recurse into class bodies for method definitions
150    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: &[], // Ruby uses require/require_relative as function calls
226            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"], // def/defp are calls in Elixir AST
264            class_kinds: &[],
265            import_kinds: &[], // import/use/require are calls in Elixir AST
266            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: &[], // Lua uses require() as a function call
278            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        // Kotlin and Swift are not yet supported by the parser pool
293        _ => 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
305/// Detect the language from a file path, returning a PatternsError if unsupported.
306fn 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
320// =============================================================================
321// Module Extraction
322// =============================================================================
323
324/// Extract module information from source code.
325///
326/// Detects language from file extension, parses the source using tree-sitter,
327/// and extracts:
328/// - Top-level function and class definitions
329/// - Import statements (language-specific)
330/// - Function call sites within function bodies
331pub 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 definitions and imports
352    extract_top_level_generic(&root, source, &mut info, &config, lang)?;
353
354    // Post-processing: for package-based languages (Go, Java, C#, PHP, etc.),
355    // when we see calls like `pkg.Func()`, we need to also register `Func` as
356    // an import so that cross-call detection works. This handles languages where
357    // you import a package/namespace and call functions through it (not by name).
358    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
372/// For package-based languages, add function names from qualified calls to the imports map.
373///
374/// When source has `import "pkg"` and calls `pkg.Func()`, the callee is extracted as "Func"
375/// but the import key is "pkg". This function adds "Func" -> "pkg" to the imports so that
376/// `find_cross_calls` can detect it.
377fn enrich_imports_from_qualified_calls(info: &mut ModuleInfo) {
378    // Collect new import entries to avoid borrowing conflicts
379    let mut new_imports: Vec<(String, String)> = Vec::new();
380
381    for (_caller, callee, _line) in &info.calls {
382        // If the callee is already in imports, no need to add
383        if info.imports.contains_key(callee) {
384            continue;
385        }
386        // Add it as an import reference (the callee name maps to itself as module)
387        // This enables cross-call detection: if the other module defines this function,
388        // it will be detected as a cross-call.
389        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
397/// Extract top-level definitions and imports from the AST root (generic, multi-language).
398fn 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
409/// Recursively extract definitions, imports, and calls from the AST.
410///
411/// `depth` controls recursion into class/module bodies.
412fn 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        // Check for function definitions
426        if config.function_kinds.contains(&kind) {
427            // Special case: Elixir's def/defp are call nodes
428            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        // Check for class/type definitions
444        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            // Recurse into class bodies to find methods
449            if config.recurse_into_classes && depth < 3 {
450                extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
451            }
452        }
453        // Check for import statements
454        else if config.import_kinds.contains(&kind) {
455            extract_imports_generic(&child, source, &mut info.imports, lang);
456        }
457        // Ruby: detect require/require_relative calls at top level
458        else if lang == TldrLanguage::Ruby && (kind == "call" || kind == "command") {
459            extract_ruby_require(&child, source, &mut info.imports);
460        }
461        // For languages where module body is nested (Java class_body, C# namespace, etc.)
462        else if is_body_container(kind, lang) {
463            extract_definitions_recursive(&child, source, info, config, lang, depth + 1);
464        }
465    }
466}
467
468/// Check if a node kind is a body container that should be recursed into.
469fn 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
487/// Get the name of a function/class/type definition node (generic).
488fn get_name_generic(
489    node: &Node,
490    source: &str,
491    config: &LangConfig,
492    _lang: TldrLanguage,
493) -> Option<String> {
494    // Try field-based lookup first (most languages)
495    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            // For C/C++, the declarator may be a function_declarator wrapping an identifier
498            return Some(extract_leaf_identifier(&name_node, source));
499        }
500    }
501
502    // Fallback: find the first identifier child
503    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
513/// Extract the leaf identifier from a node that might be a complex declarator.
514///
515/// Handles C/C++ patterns like `function_declarator -> identifier`.
516fn 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    // Recurse to find the first identifier
522    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        // Recurse into function_declarator, pointer_declarator, etc.
528        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
537/// Extract Elixir def/defp function name from a call node.
538fn extract_elixir_def_name(node: &Node, source: &str) -> Option<String> {
539    // In Elixir AST, `def foo(args)` is a call where the target is "def"
540    // and the first argument contains the function name
541    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            // Next sibling should have the function name
546            if let Some(args) = child.next_sibling() {
547                return get_first_identifier(&args, source);
548            }
549        }
550    }
551    None
552}
553
554/// Get the first identifier in a subtree.
555fn 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
568// =============================================================================
569// Import Extraction (Generic)
570// =============================================================================
571
572/// Extract imports from an import node (generic, multi-language).
573fn 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        // Languages using function-call-based imports (Ruby, Lua, Elixir)
593        // are handled separately in the caller
594        _ => extract_fallback_imports(node, source, imports),
595    }
596}
597
598/// Python: import X, from X import Y
599fn 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        // First pass: find the module name
618        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        // Second pass: find all imported names
635        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
667/// Recursively extract imported names from a node subtree (Python).
668fn 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
694/// Extract name and alias from an aliased_import node (Python).
695fn 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
716/// Go: import "pkg" or import ( "pkg1"; "pkg2" )
717fn extract_go_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
718    // import_declaration can contain import_spec or import_spec_list
719    let mut stack = vec![*node];
720    while let Some(n) = stack.pop() {
721        if n.kind() == "import_spec" {
722            // import_spec has optional name (alias) and path (string literal)
723            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                // Use the last component as the key (e.g., "fmt" from "fmt", "render" from "gin/render")
730                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
750/// Rust: use crate::module::item;
751fn extract_rust_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
752    // use_declaration contains a scoped_identifier or use_wildcard
753    let text = node_text(node, source);
754    // Strip "use " prefix and ";" suffix
755    let trimmed = text.trim_start_matches("use ").trim_end_matches(';').trim();
756
757    // Handle use a::b::{c, d} or use a::b::c
758    if let Some(last) = trimmed.rsplit("::").next() {
759        if last.starts_with('{') {
760            // Grouped imports: use a::b::{c, d}
761            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
775/// TypeScript/JavaScript: import { x } from 'y'; import * as x from 'y';
776fn extract_ts_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
777    // Find the source string (the module path)
778    let mut module_path = String::new();
779    let mut cursor = node.walk();
780
781    // Find the source/from clause
782    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        // Fallback: look for string children
787        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    // Extract imported names
796    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                // import * as name
804                if let Some(name) = child.child_by_field_name("name") {
805                    imports.insert(node_text(&name, source), module_path.clone());
806                } else {
807                    // Fallback: get last identifier
808                    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
828/// Java: import com.example.Class;
829fn extract_java_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
830    // import_declaration has a scoped_identifier child
831    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
843/// C/C++: #include <header.h> or #include "header.h"
844fn extract_c_imports(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
845    // preproc_include has a path child (system_lib_string or string_literal)
846    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            // Use the filename without path as key
855            let short = header.rsplit('/').next().unwrap_or(&header).to_string();
856            imports.insert(short, header);
857        }
858    }
859
860    // Fallback: if the path child is wrapped
861    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
871/// C#: using System.Collections;
872fn 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    // Also add the full path
884    if !trimmed.is_empty() {
885        imports.insert(trimmed.to_string(), trimmed.to_string());
886    }
887}
888
889/// PHP: use App\Utils\Helper;
890fn 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    // Handle grouped: use App\{A, B}
895    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
911/// Scala: import com.example.Class
912fn 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
921/// OCaml: open Module
922fn 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
930/// Fallback import extraction: just record the text.
931fn 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
938/// Ruby: detect require/require_relative calls
939fn extract_ruby_require(node: &Node, source: &str, imports: &mut HashMap<String, String>) {
940    // In Ruby, require is a method call: require 'json' or require_relative 'helper'
941    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                // Recurse into argument list
967                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
989/// Collect identifiers from a subtree and add them as imports.
990fn 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
1006// =============================================================================
1007// Call Extraction (Generic)
1008// =============================================================================
1009
1010/// Extract call sites within a function body (generic, multi-language).
1011fn 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        // Push children to stack (depth-first traversal)
1030        let mut cursor = node.walk();
1031        for child in node.children(&mut cursor) {
1032            stack.push(child);
1033        }
1034    }
1035}
1036
1037/// Extract the callee name from a call node (generic, multi-language).
1038///
1039/// Handles:
1040/// - Simple calls: `func()` -> "func"
1041/// - Attribute/method calls: `obj.method()` -> "method"
1042/// - Selector calls (Go): `pkg.Func()` -> "Func"
1043/// - Java method invocation: `obj.method()` -> "method"
1044fn extract_callee_generic(call_node: &Node, source: &str, lang: TldrLanguage) -> Option<String> {
1045    match lang {
1046        TldrLanguage::Java => {
1047            // Java method_invocation: child_by_field_name("name") gives the method name
1048            if let Some(name) = call_node.child_by_field_name("name") {
1049                return Some(node_text(&name, source));
1050            }
1051        }
1052        TldrLanguage::Go => {
1053            // Go call_expression: function field is the callee
1054            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                        // pkg.Func() -> extract "Func"
1059                        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            // PHP function_call_expression or member_call_expression
1069            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            // C# invocation_expression: function is first child
1078            if let Some(func) = call_node.child_by_field_name("function") {
1079                return Some(extract_last_identifier(&func, source));
1080            }
1081            // Fallback
1082            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    // Generic fallback: works for Python, Rust, TypeScript, C, C++, Ruby, etc.
1098    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                // Get the method/field name (after the dot)
1106                return Some(extract_last_identifier(&child, source));
1107            }
1108            "scoped_identifier" | "qualified_identifier" => {
1109                // Rust/C++ path::func()
1110                return Some(extract_last_identifier(&child, source));
1111            }
1112            _ => {}
1113        }
1114    }
1115    None
1116}
1117
1118/// Extract the last identifier from a dotted/scoped expression.
1119fn 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
1134/// Get text content of a node.
1135fn node_text(node: &Node, source: &str) -> String {
1136    source[node.byte_range()].to_string()
1137}
1138
1139// =============================================================================
1140// Cross-Call Detection
1141// =============================================================================
1142
1143/// Find cross-module calls from caller module to callee module.
1144///
1145/// A cross-call is detected when:
1146/// 1. The caller module imports a name from the callee module
1147/// 2. The caller module calls that imported name
1148/// 3. The callee module defines that name
1149pub 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        // Check if the callee name is:
1154        // 1. Imported by the caller module
1155        // 2. Defined in the callee module
1156        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
1169// =============================================================================
1170// Coupling Score Computation
1171// =============================================================================
1172
1173/// Compute coupling score between two modules.
1174///
1175/// The score is computed as:
1176/// `cross_calls / (total_functions * 2)`
1177///
1178/// Where:
1179/// - `cross_calls` = calls from A to B + calls from B to A
1180/// - `total_functions` = functions in A + functions in B
1181///
1182/// The score is clamped to [0.0, 1.0].
1183pub 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
1195// =============================================================================
1196// Text Formatting
1197// =============================================================================
1198
1199/// Format Martin metrics report as human-readable text.
1200///
1201/// Renders a table of per-module Ca, Ce, Instability, and cycle membership,
1202/// followed by a summary line and (if applicable) a list of detected cycles.
1203pub 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    // Compute column width for module path (min 6 for "Module", max 40)
1214    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    // Header
1223    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    // Rows
1237    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    // Summary line
1262    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    // Cycles section (only if cycles exist)
1268    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
1288/// Format coupling report as human-readable text.
1289pub 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, report.verdict
1300    ));
1301    lines.push(format!("Total cross-module calls: {}", report.total_calls));
1302    lines.push(String::new());
1303
1304    // A -> B calls
1305    lines.push(format!(
1306        "Calls from {} to {}:",
1307        report.path_a, report.path_b
1308    ));
1309    if report.a_to_b.calls.is_empty() {
1310        lines.push("  (none)".to_string());
1311    } else {
1312        for call in &report.a_to_b.calls {
1313            lines.push(format!(
1314                "  {} -> {} (line {})",
1315                call.caller, call.callee, call.line
1316            ));
1317        }
1318    }
1319    lines.push(String::new());
1320
1321    // B -> A calls
1322    lines.push(format!(
1323        "Calls from {} to {}:",
1324        report.path_b, report.path_a
1325    ));
1326    if report.b_to_a.calls.is_empty() {
1327        lines.push("  (none)".to_string());
1328    } else {
1329        for call in &report.b_to_a.calls {
1330            lines.push(format!(
1331                "  {} -> {} (line {})",
1332                call.caller, call.callee, call.line
1333            ));
1334        }
1335    }
1336
1337    lines.join("\n")
1338}
1339
1340// =============================================================================
1341// Entry Point
1342// =============================================================================
1343
1344/// Run the coupling command.
1345///
1346/// Two modes:
1347/// - **Pair mode**: `path_b` is `Some(...)` -- compare two files (original behavior)
1348/// - **Project-wide mode**: `path_b` is `None` and `path_a` is a directory -- scan all pairs
1349///
1350/// Language is auto-detected from file extensions.
1351pub fn run(args: CouplingArgs, format: OutputFormat) -> Result<()> {
1352    // Determine mode based on arguments
1353    match args.path_b {
1354        Some(ref _path_b) => run_pair_mode(&args, format),
1355        None if args.path_a.is_dir() => run_project_mode(&args, format),
1356        None => {
1357            // path_a is a file but no path_b -- ambiguous
1358            Err(anyhow::anyhow!(
1359                "For pair mode, provide two file paths: tldr coupling <file_a> <file_b>\n\
1360                 For project-wide mode, provide a directory: tldr coupling <directory>"
1361            ))
1362        }
1363    }
1364}
1365
1366/// Run pair mode: compare two specific files (original behavior).
1367fn run_pair_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1368    let start = Instant::now();
1369    let timeout = Duration::from_secs(args.timeout);
1370
1371    let path_b_ref = args.path_b.as_ref().expect("pair mode requires path_b");
1372
1373    // Validate paths (TIGER T02 mitigation)
1374    let path_a = if let Some(ref root) = args.project_root {
1375        validate_file_path_in_project(&args.path_a, root)?
1376    } else {
1377        validate_file_path(&args.path_a)?
1378    };
1379
1380    let path_b = if let Some(ref root) = args.project_root {
1381        validate_file_path_in_project(path_b_ref, root)?
1382    } else {
1383        validate_file_path(path_b_ref)?
1384    };
1385
1386    // Check timeout after path validation
1387    if start.elapsed() > timeout {
1388        return Err(PatternsError::Timeout {
1389            timeout_secs: args.timeout,
1390        }
1391        .into());
1392    }
1393
1394    // Read source files
1395    let source_a = read_file_safe(&path_a)?;
1396    let source_b = read_file_safe(&path_b)?;
1397
1398    // Check timeout after file read
1399    if start.elapsed() > timeout {
1400        return Err(PatternsError::Timeout {
1401            timeout_secs: args.timeout,
1402        }
1403        .into());
1404    }
1405
1406    // Handle self-coupling case
1407    if path_a == path_b {
1408        let report = CouplingReport {
1409            path_a: path_a.to_string_lossy().to_string(),
1410            path_b: path_b.to_string_lossy().to_string(),
1411            a_to_b: CrossCalls::default(),
1412            b_to_a: CrossCalls::default(),
1413            total_calls: 0,
1414            coupling_score: 1.0,
1415            verdict: CouplingVerdict::VeryHigh,
1416        };
1417
1418        output_pair_report(&report, format)?;
1419        return Ok(());
1420    }
1421
1422    // Extract module information
1423    let info_a = extract_module_info(&path_a, &source_a)?;
1424    let info_b = extract_module_info(&path_b, &source_b)?;
1425
1426    // Check timeout after parsing
1427    if start.elapsed() > timeout {
1428        return Err(PatternsError::Timeout {
1429            timeout_secs: args.timeout,
1430        }
1431        .into());
1432    }
1433
1434    // Find cross-module calls
1435    let a_to_b = find_cross_calls(&info_a, &info_b);
1436    let b_to_a = find_cross_calls(&info_b, &info_a);
1437
1438    // Compute coupling score
1439    let total_calls = a_to_b.count.saturating_add(b_to_a.count);
1440    let coupling_score = compute_coupling_score(
1441        a_to_b.count,
1442        b_to_a.count,
1443        info_a.function_count,
1444        info_b.function_count,
1445    );
1446    let verdict = CouplingVerdict::from_score(coupling_score);
1447
1448    // Build report
1449    let report = CouplingReport {
1450        path_a: path_a.to_string_lossy().to_string(),
1451        path_b: path_b.to_string_lossy().to_string(),
1452        a_to_b,
1453        b_to_a,
1454        total_calls,
1455        coupling_score,
1456        verdict,
1457    };
1458
1459    output_pair_report(&report, format)?;
1460
1461    Ok(())
1462}
1463
1464/// Run project-wide mode: scan a directory for all coupling pairs.
1465fn run_project_mode(args: &CouplingArgs, format: OutputFormat) -> Result<()> {
1466    // Existing pairwise coupling analysis
1467    let mut pairwise_report = core_analyze_coupling(&args.path_a, None, Some(args.max_pairs))
1468        .map_err(|e| anyhow::anyhow!("coupling analysis failed: {}", e))?;
1469
1470    // Filter test files from pairwise by default
1471    if !args.include_tests {
1472        pairwise_report
1473            .top_pairs
1474            .retain(|pair| !is_test_file(&pair.source) && !is_test_file(&pair.target));
1475    }
1476
1477    // Martin metrics: compute from dependency graph
1478    let martin_options = MartinOptions {
1479        top: args.top,
1480        cycles_only: args.cycles_only,
1481    };
1482    let mut martin_report = match analyze_dependencies(&args.path_a, &DepsOptions::default()) {
1483        Ok(deps_report) => compute_martin_metrics_from_deps(&deps_report, &martin_options),
1484        Err(_) => MartinMetricsReport::default(), // no source files or unsupported language
1485    };
1486
1487    // Filter test files by default (--include-tests to keep them)
1488    if !args.include_tests {
1489        let pre_count = martin_report.metrics.len();
1490        martin_report.metrics.retain(|m| !is_test_file(&m.module));
1491        martin_report.modules_analyzed = martin_report.metrics.len();
1492
1493        // Recalculate summary if we filtered anything
1494        if martin_report.metrics.len() < pre_count {
1495            if martin_report.metrics.is_empty() {
1496                martin_report.summary.avg_instability = 0.0;
1497                martin_report.summary.most_stable = None;
1498                martin_report.summary.most_unstable = None;
1499            } else {
1500                let sum: f64 = martin_report.metrics.iter().map(|m| m.instability).sum();
1501                martin_report.summary.avg_instability = sum / martin_report.metrics.len() as f64;
1502                martin_report.summary.most_stable = martin_report
1503                    .metrics
1504                    .iter()
1505                    .min_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1506                    .map(|m| m.module.clone());
1507                martin_report.summary.most_unstable = martin_report
1508                    .metrics
1509                    .iter()
1510                    .max_by(|a, b| a.instability.partial_cmp(&b.instability).unwrap())
1511                    .map(|m| m.module.clone());
1512            }
1513            // Filter cycles to only include non-test modules
1514            martin_report
1515                .cycles
1516                .retain(|cycle| cycle.path.iter().all(|m| !is_test_file(m)));
1517            martin_report.summary.total_cycles = martin_report.cycles.len();
1518        }
1519    }
1520
1521    output_project_report_with_martin(&pairwise_report, &martin_report, format)?;
1522    Ok(())
1523}
1524
1525/// Output the project-wide report with Martin metrics in the specified format.
1526fn output_project_report_with_martin(
1527    pairwise_report: &CoreCouplingReport,
1528    martin_report: &MartinMetricsReport,
1529    format: OutputFormat,
1530) -> Result<()> {
1531    match format {
1532        OutputFormat::Text => {
1533            // Martin metrics first, then pairwise coupling
1534            println!("{}", format_martin_text(martin_report));
1535            if !pairwise_report.top_pairs.is_empty() {
1536                println!("{}", format_coupling_project_text(pairwise_report));
1537            }
1538        }
1539        OutputFormat::Compact => {
1540            let combined = serde_json::json!({
1541                "martin_metrics": serde_json::to_value(martin_report)?,
1542                "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1543            });
1544            let json = serde_json::to_string(&combined)?;
1545            println!("{}", json);
1546        }
1547        _ => {
1548            let combined = serde_json::json!({
1549                "martin_metrics": serde_json::to_value(martin_report)?,
1550                "pairwise_coupling": serde_json::to_value(pairwise_report)?,
1551            });
1552            let json = serde_json::to_string_pretty(&combined)?;
1553            println!("{}", json);
1554        }
1555    }
1556    Ok(())
1557}
1558
1559/// Output the pair-mode report in the specified format.
1560fn output_pair_report(report: &CouplingReport, format: OutputFormat) -> Result<()> {
1561    match format {
1562        OutputFormat::Text => {
1563            println!("{}", format_coupling_text(report));
1564        }
1565        OutputFormat::Compact => {
1566            let json = serde_json::to_string(report)?;
1567            println!("{}", json);
1568        }
1569        _ => {
1570            let json = serde_json::to_string_pretty(report)?;
1571            println!("{}", json);
1572        }
1573    }
1574    Ok(())
1575}
1576
1577/// Format a project-wide coupling report as human-readable text.
1578///
1579/// Renders a ranked table of the highest-coupling module pairs with color coding:
1580/// - Tight (>= 0.6): red bold
1581/// - Moderate (0.3-0.6): yellow
1582/// - Loose (< 0.3): green
1583pub fn format_coupling_project_text(report: &CoreCouplingReport) -> String {
1584    let mut output = String::new();
1585
1586    output.push_str(&format!(
1587        "{}\n\n",
1588        "Coupling Analysis (project-wide)".bold()
1589    ));
1590
1591    if report.top_pairs.is_empty() {
1592        output.push_str(&format!(
1593            "Summary: {} modules, 0 pairs analyzed\n",
1594            report.modules_analyzed,
1595        ));
1596        return output;
1597    }
1598
1599    // Compute common path prefix for relative display
1600    let all_paths: Vec<&Path> = report
1601        .top_pairs
1602        .iter()
1603        .flat_map(|p| [p.source.as_path(), p.target.as_path()])
1604        .collect();
1605    let prefix = common_path_prefix(&all_paths);
1606
1607    // Header
1608    output.push_str(&format!(
1609        " {:>5}  {:>5}  {:>7}  {:>10}  {}\n",
1610        "Score", "Calls", "Imports", "Verdict", "Source -> Target"
1611    ));
1612
1613    // Rows
1614    for pair in &report.top_pairs {
1615        let source_rel = strip_prefix_display(&pair.source, &prefix);
1616        let target_rel = strip_prefix_display(&pair.target, &prefix);
1617
1618        let verdict_str = match pair.verdict {
1619            CoreVerdict::Tight => "tight".red().bold().to_string(),
1620            CoreVerdict::Moderate => "moderate".yellow().to_string(),
1621            CoreVerdict::Loose => "loose".green().to_string(),
1622        };
1623
1624        let score_str = format!("{:.2}", pair.score);
1625        let score_colored = match pair.verdict {
1626            CoreVerdict::Tight => score_str.red().bold().to_string(),
1627            CoreVerdict::Moderate => score_str.yellow().to_string(),
1628            CoreVerdict::Loose => score_str.green().to_string(),
1629        };
1630
1631        output.push_str(&format!(
1632            " {:>5}  {:>5}  {:>7}  {:>10}  {} -> {}\n",
1633            score_colored, pair.call_count, pair.import_count, verdict_str, source_rel, target_rel,
1634        ));
1635    }
1636
1637    // Summary line
1638    let avg_str = report
1639        .avg_coupling_score
1640        .map(|s| format!("{:.2}", s))
1641        .unwrap_or_else(|| "N/A".to_string());
1642
1643    output.push_str(&format!(
1644        "\nSummary: {} modules, {} pairs analyzed, {} tight, avg score: {}\n",
1645        report.modules_analyzed, report.pairs_analyzed, report.tight_coupling_count, avg_str,
1646    ));
1647
1648    if report.truncated == Some(true) {
1649        if let Some(total) = report.total_pairs {
1650            output.push_str(&format!(
1651                "  (showing top {} of {} pairs)\n",
1652                report.top_pairs.len(),
1653                total,
1654            ));
1655        }
1656    }
1657
1658    output
1659}
1660
1661// =============================================================================
1662// Tests
1663// =============================================================================
1664
1665#[cfg(test)]
1666mod tests {
1667    use super::*;
1668    use std::fs;
1669    use tempfile::TempDir;
1670
1671    /// Create a test file in a temp directory.
1672    fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
1673        let path = dir.path().join(name);
1674        fs::write(&path, content).unwrap();
1675        path
1676    }
1677
1678    // -------------------------------------------------------------------------
1679    // compute_coupling_score Tests
1680    // -------------------------------------------------------------------------
1681
1682    #[test]
1683    fn test_compute_coupling_score_no_calls() {
1684        let score = compute_coupling_score(0, 0, 5, 5);
1685        assert_eq!(score, 0.0);
1686    }
1687
1688    #[test]
1689    fn test_compute_coupling_score_unidirectional() {
1690        // 2 calls from A to B, 5 functions in A, 5 in B
1691        // score = 2 / (10 * 2) = 2/20 = 0.1
1692        let score = compute_coupling_score(2, 0, 5, 5);
1693        assert!((score - 0.1).abs() < 0.001);
1694    }
1695
1696    #[test]
1697    fn test_compute_coupling_score_bidirectional() {
1698        // 3 calls A->B, 2 calls B->A, 5 functions each
1699        // score = 5 / (10 * 2) = 5/20 = 0.25
1700        let score = compute_coupling_score(3, 2, 5, 5);
1701        assert!((score - 0.25).abs() < 0.001);
1702    }
1703
1704    #[test]
1705    fn test_compute_coupling_score_no_functions() {
1706        let score = compute_coupling_score(5, 5, 0, 0);
1707        assert_eq!(score, 0.0);
1708    }
1709
1710    #[test]
1711    fn test_compute_coupling_score_clamped() {
1712        // Many calls, few functions -> clamped to 1.0
1713        let score = compute_coupling_score(100, 100, 1, 1);
1714        assert_eq!(score, 1.0);
1715    }
1716
1717    // -------------------------------------------------------------------------
1718    // CouplingVerdict Tests
1719    // -------------------------------------------------------------------------
1720
1721    #[test]
1722    fn test_verdict_low() {
1723        assert_eq!(CouplingVerdict::from_score(0.0), CouplingVerdict::Low);
1724        assert_eq!(CouplingVerdict::from_score(0.1), CouplingVerdict::Low);
1725        assert_eq!(CouplingVerdict::from_score(0.19), CouplingVerdict::Low);
1726    }
1727
1728    #[test]
1729    fn test_verdict_moderate() {
1730        assert_eq!(CouplingVerdict::from_score(0.2), CouplingVerdict::Moderate);
1731        assert_eq!(CouplingVerdict::from_score(0.3), CouplingVerdict::Moderate);
1732        assert_eq!(CouplingVerdict::from_score(0.39), CouplingVerdict::Moderate);
1733    }
1734
1735    #[test]
1736    fn test_verdict_high() {
1737        assert_eq!(CouplingVerdict::from_score(0.4), CouplingVerdict::High);
1738        assert_eq!(CouplingVerdict::from_score(0.5), CouplingVerdict::High);
1739        assert_eq!(CouplingVerdict::from_score(0.59), CouplingVerdict::High);
1740    }
1741
1742    #[test]
1743    fn test_verdict_very_high() {
1744        assert_eq!(CouplingVerdict::from_score(0.6), CouplingVerdict::VeryHigh);
1745        assert_eq!(CouplingVerdict::from_score(0.8), CouplingVerdict::VeryHigh);
1746        assert_eq!(CouplingVerdict::from_score(1.0), CouplingVerdict::VeryHigh);
1747    }
1748
1749    // -------------------------------------------------------------------------
1750    // extract_module_info Tests
1751    // -------------------------------------------------------------------------
1752
1753    #[test]
1754    fn test_extract_defined_names() {
1755        let source = r#"
1756def func_a():
1757    pass
1758
1759async def func_b():
1760    pass
1761
1762class MyClass:
1763    pass
1764"#;
1765        let temp = TempDir::new().unwrap();
1766        let path = create_test_file(&temp, "test.py", source);
1767        let info = extract_module_info(&path, source).unwrap();
1768
1769        assert!(info.defined_names.contains("func_a"));
1770        assert!(info.defined_names.contains("func_b"));
1771        assert!(info.defined_names.contains("MyClass"));
1772        assert_eq!(info.function_count, 2);
1773    }
1774
1775    #[test]
1776    fn test_extract_imports() {
1777        let source = r#"
1778import os
1779import sys as system
1780from pathlib import Path
1781from collections import defaultdict, Counter
1782from typing import List as L
1783"#;
1784        let temp = TempDir::new().unwrap();
1785        let path = create_test_file(&temp, "test.py", source);
1786        let info = extract_module_info(&path, source).unwrap();
1787
1788        assert!(info.imports.contains_key("os"));
1789        assert!(info.imports.contains_key("system"));
1790        assert!(info.imports.contains_key("Path"));
1791        assert!(info.imports.contains_key("defaultdict"));
1792        assert!(info.imports.contains_key("Counter"));
1793    }
1794
1795    #[test]
1796    fn test_extract_calls() {
1797        let source = r#"
1798def caller():
1799    result = helper()
1800    obj.method()
1801    other_func(1, 2, 3)
1802    return result
1803"#;
1804        let temp = TempDir::new().unwrap();
1805        let path = create_test_file(&temp, "test.py", source);
1806        let info = extract_module_info(&path, source).unwrap();
1807
1808        // Should find calls to helper, method, other_func
1809        let callees: Vec<&str> = info
1810            .calls
1811            .iter()
1812            .map(|(_, callee, _)| callee.as_str())
1813            .collect();
1814        assert!(callees.contains(&"helper"));
1815        assert!(callees.contains(&"method"));
1816        assert!(callees.contains(&"other_func"));
1817    }
1818
1819    // -------------------------------------------------------------------------
1820    // find_cross_calls Tests
1821    // -------------------------------------------------------------------------
1822
1823    #[test]
1824    fn test_find_cross_calls_simple() {
1825        let temp = TempDir::new().unwrap();
1826
1827        // Module A imports and calls helper from module B
1828        let source_a = r#"
1829from module_b import helper
1830
1831def caller():
1832    return helper()
1833"#;
1834        let path_a = create_test_file(&temp, "module_a.py", source_a);
1835        let info_a = extract_module_info(&path_a, source_a).unwrap();
1836
1837        // Module B defines helper
1838        let source_b = r#"
1839def helper():
1840    return 42
1841"#;
1842        let path_b = create_test_file(&temp, "module_b.py", source_b);
1843        let info_b = extract_module_info(&path_b, source_b).unwrap();
1844
1845        let cross_calls = find_cross_calls(&info_a, &info_b);
1846
1847        assert_eq!(cross_calls.count, 1);
1848        assert_eq!(cross_calls.calls[0].caller, "caller");
1849        assert_eq!(cross_calls.calls[0].callee, "helper");
1850    }
1851
1852    #[test]
1853    fn test_find_cross_calls_no_import() {
1854        let temp = TempDir::new().unwrap();
1855
1856        // Module A calls helper but doesn't import it
1857        let source_a = r#"
1858def caller():
1859    return helper()
1860"#;
1861        let path_a = create_test_file(&temp, "module_a.py", source_a);
1862        let info_a = extract_module_info(&path_a, source_a).unwrap();
1863
1864        // Module B defines helper
1865        let source_b = r#"
1866def helper():
1867    return 42
1868"#;
1869        let path_b = create_test_file(&temp, "module_b.py", source_b);
1870        let info_b = extract_module_info(&path_b, source_b).unwrap();
1871
1872        let cross_calls = find_cross_calls(&info_a, &info_b);
1873
1874        // No cross-calls since helper wasn't imported
1875        assert_eq!(cross_calls.count, 0);
1876    }
1877
1878    #[test]
1879    fn test_find_cross_calls_bidirectional() {
1880        let temp = TempDir::new().unwrap();
1881
1882        // Module A imports and calls helper from B
1883        let source_a = r#"
1884from module_b import helper_b
1885
1886def func_a():
1887    return helper_b()
1888"#;
1889        let path_a = create_test_file(&temp, "module_a.py", source_a);
1890        let info_a = extract_module_info(&path_a, source_a).unwrap();
1891
1892        // Module B imports and calls func_a from A
1893        let source_b = r#"
1894from module_a import func_a
1895
1896def helper_b():
1897    return 42
1898
1899def caller_b():
1900    return func_a()
1901"#;
1902        let path_b = create_test_file(&temp, "module_b.py", source_b);
1903        let info_b = extract_module_info(&path_b, source_b).unwrap();
1904
1905        let a_to_b = find_cross_calls(&info_a, &info_b);
1906        let b_to_a = find_cross_calls(&info_b, &info_a);
1907
1908        assert_eq!(a_to_b.count, 1);
1909        assert_eq!(b_to_a.count, 1);
1910    }
1911
1912    // -------------------------------------------------------------------------
1913    // format_coupling_text Tests
1914    // -------------------------------------------------------------------------
1915
1916    #[test]
1917    fn test_format_coupling_text() {
1918        let report = CouplingReport {
1919            path_a: "src/auth.py".to_string(),
1920            path_b: "src/user.py".to_string(),
1921            a_to_b: CrossCalls {
1922                calls: vec![CrossCall {
1923                    caller: "login".to_string(),
1924                    callee: "get_user".to_string(),
1925                    line: 10,
1926                }],
1927                count: 1,
1928            },
1929            b_to_a: CrossCalls::default(),
1930            total_calls: 1,
1931            coupling_score: 0.15,
1932            verdict: CouplingVerdict::Low,
1933        };
1934
1935        let text = format_coupling_text(&report);
1936
1937        assert!(text.contains("src/auth.py"));
1938        assert!(text.contains("src/user.py"));
1939        assert!(text.contains("0.15"));
1940        assert!(text.contains("low"));
1941        assert!(text.contains("login"));
1942        assert!(text.contains("get_user"));
1943        assert!(text.contains("line 10"));
1944    }
1945
1946    // -------------------------------------------------------------------------
1947    // Integration Tests
1948    // -------------------------------------------------------------------------
1949
1950    #[test]
1951    fn test_run_no_coupling() {
1952        let temp = TempDir::new().unwrap();
1953
1954        let source_a = r#"
1955def standalone_a():
1956    return 1
1957"#;
1958        let source_b = r#"
1959def standalone_b():
1960    return 2
1961"#;
1962
1963        let path_a = create_test_file(&temp, "a.py", source_a);
1964        let path_b = create_test_file(&temp, "b.py", source_b);
1965
1966        let args = CouplingArgs {
1967            path_a: path_a.clone(),
1968            path_b: Some(path_b.clone()),
1969            timeout: 30,
1970            project_root: None,
1971            max_pairs: 20,
1972            top: 0,
1973            cycles_only: false,
1974            lang: None,
1975            include_tests: false,
1976        };
1977
1978        // Just verify it runs without error
1979        let result = run(args, OutputFormat::Json);
1980        assert!(result.is_ok());
1981    }
1982
1983    #[test]
1984    fn test_run_with_coupling() {
1985        let temp = TempDir::new().unwrap();
1986
1987        let source_a = r#"
1988from b import helper
1989
1990def caller():
1991    return helper()
1992"#;
1993        let source_b = r#"
1994def helper():
1995    return 42
1996"#;
1997
1998        let path_a = create_test_file(&temp, "a.py", source_a);
1999        let path_b = create_test_file(&temp, "b.py", source_b);
2000
2001        let args = CouplingArgs {
2002            path_a: path_a.clone(),
2003            path_b: Some(path_b.clone()),
2004            timeout: 30,
2005            project_root: None,
2006            max_pairs: 20,
2007            top: 0,
2008            cycles_only: false,
2009            lang: None,
2010            include_tests: false,
2011        };
2012
2013        let result = run(args, OutputFormat::Json);
2014        assert!(result.is_ok());
2015    }
2016
2017    // =========================================================================
2018    // Multi-language Tests
2019    // =========================================================================
2020
2021    #[test]
2022    fn test_go_extract_module_info() {
2023        let source = r#"
2024package main
2025
2026import (
2027    "fmt"
2028    "myapp/utils"
2029)
2030
2031func Caller() {
2032    utils.Helper()
2033    fmt.Println("hello")
2034}
2035
2036func Standalone() int {
2037    return 42
2038}
2039"#;
2040        let temp = TempDir::new().unwrap();
2041        let path = create_test_file(&temp, "main.go", source);
2042        let info = extract_module_info(&path, source).unwrap();
2043
2044        // Should detect functions
2045        assert!(info.defined_names.contains("Caller"), "missing Caller");
2046        assert!(
2047            info.defined_names.contains("Standalone"),
2048            "missing Standalone"
2049        );
2050        assert_eq!(info.function_count, 2);
2051
2052        // Should detect imports
2053        assert!(
2054            info.imports.contains_key("fmt") || info.imports.values().any(|v| v.contains("fmt")),
2055            "missing fmt import: {:?}",
2056            info.imports
2057        );
2058    }
2059
2060    #[test]
2061    fn test_go_cross_calls() {
2062        let temp = TempDir::new().unwrap();
2063
2064        let source_a = r#"
2065package main
2066
2067import "myapp/pkg_b"
2068
2069func CallerA() {
2070    pkg_b.HelperB()
2071}
2072"#;
2073        let source_b = r#"
2074package pkg_b
2075
2076func HelperB() int {
2077    return 42
2078}
2079"#;
2080        let path_a = create_test_file(&temp, "a.go", source_a);
2081        let path_b = create_test_file(&temp, "b.go", source_b);
2082
2083        let info_a = extract_module_info(&path_a, source_a).unwrap();
2084        let info_b = extract_module_info(&path_b, source_b).unwrap();
2085
2086        // Should find the cross-call from A to B
2087        let a_to_b = find_cross_calls(&info_a, &info_b);
2088        assert!(
2089            a_to_b.count >= 1,
2090            "expected cross-calls from A to B, got {}",
2091            a_to_b.count
2092        );
2093    }
2094
2095    #[test]
2096    fn test_rust_extract_module_info() {
2097        let source = r#"
2098use std::collections::HashMap;
2099use crate::module_b::helper;
2100
2101pub fn caller() {
2102    let _ = helper();
2103}
2104
2105fn standalone() -> i32 {
2106    42
2107}
2108"#;
2109        let temp = TempDir::new().unwrap();
2110        let path = create_test_file(&temp, "lib.rs", source);
2111        let info = extract_module_info(&path, source).unwrap();
2112
2113        // Should detect functions
2114        assert!(info.defined_names.contains("caller"), "missing caller");
2115        assert!(
2116            info.defined_names.contains("standalone"),
2117            "missing standalone"
2118        );
2119        assert_eq!(info.function_count, 2);
2120
2121        // Should detect imports
2122        assert!(
2123            !info.imports.is_empty(),
2124            "should have imports: {:?}",
2125            info.imports
2126        );
2127    }
2128
2129    #[test]
2130    fn test_typescript_extract_module_info() {
2131        let source = r#"
2132import { helper } from './module_b';
2133import * as utils from './utils';
2134
2135function caller(): void {
2136    helper();
2137    utils.doStuff();
2138}
2139
2140function standalone(): number {
2141    return 42;
2142}
2143"#;
2144        let temp = TempDir::new().unwrap();
2145        let path = create_test_file(&temp, "main.ts", source);
2146        let info = extract_module_info(&path, source).unwrap();
2147
2148        // Should detect functions
2149        assert!(info.defined_names.contains("caller"), "missing caller");
2150        assert!(
2151            info.defined_names.contains("standalone"),
2152            "missing standalone"
2153        );
2154        assert_eq!(info.function_count, 2);
2155
2156        // Should detect imports
2157        assert!(
2158            !info.imports.is_empty(),
2159            "should have imports: {:?}",
2160            info.imports
2161        );
2162    }
2163
2164    #[test]
2165    fn test_java_extract_module_info() {
2166        let source = r#"
2167import com.example.utils.Helper;
2168import java.util.List;
2169
2170public class Main {
2171    public void caller() {
2172        Helper.doWork();
2173    }
2174
2175    public int standalone() {
2176        return 42;
2177    }
2178}
2179"#;
2180        let temp = TempDir::new().unwrap();
2181        let path = create_test_file(&temp, "Main.java", source);
2182        let info = extract_module_info(&path, source).unwrap();
2183
2184        // Should detect methods
2185        assert!(info.defined_names.contains("caller"), "missing caller");
2186        assert!(
2187            info.defined_names.contains("standalone"),
2188            "missing standalone"
2189        );
2190
2191        // Should detect imports
2192        assert!(
2193            !info.imports.is_empty(),
2194            "should have imports: {:?}",
2195            info.imports
2196        );
2197    }
2198
2199    #[test]
2200    fn test_c_extract_module_info() {
2201        let source = r#"
2202#include <stdio.h>
2203#include "mylib.h"
2204
2205void caller() {
2206    helper();
2207    printf("hello\n");
2208}
2209
2210int standalone() {
2211    return 42;
2212}
2213"#;
2214        let temp = TempDir::new().unwrap();
2215        let path = create_test_file(&temp, "main.c", source);
2216        let info = extract_module_info(&path, source).unwrap();
2217
2218        // Should detect functions
2219        assert!(info.defined_names.contains("caller"), "missing caller");
2220        assert!(
2221            info.defined_names.contains("standalone"),
2222            "missing standalone"
2223        );
2224        assert_eq!(info.function_count, 2);
2225
2226        // Should detect includes as imports
2227        assert!(
2228            !info.imports.is_empty(),
2229            "should have imports from #include: {:?}",
2230            info.imports
2231        );
2232    }
2233
2234    #[test]
2235    fn test_ruby_extract_module_info() {
2236        let source = r#"
2237require 'json'
2238require_relative 'helper'
2239
2240def caller
2241  helper_method
2242  JSON.parse("{}")
2243end
2244
2245def standalone
2246  42
2247end
2248"#;
2249        let temp = TempDir::new().unwrap();
2250        let path = create_test_file(&temp, "main.rb", source);
2251        let info = extract_module_info(&path, source).unwrap();
2252
2253        // Should detect methods
2254        assert!(info.defined_names.contains("caller"), "missing caller");
2255        assert!(
2256            info.defined_names.contains("standalone"),
2257            "missing standalone"
2258        );
2259        assert_eq!(info.function_count, 2);
2260
2261        // Should detect requires as imports
2262        assert!(
2263            !info.imports.is_empty(),
2264            "should have imports from require: {:?}",
2265            info.imports
2266        );
2267    }
2268
2269    #[test]
2270    fn test_cpp_extract_module_info() {
2271        let source = r#"
2272#include <iostream>
2273#include "mylib.hpp"
2274
2275void caller() {
2276    helper();
2277    std::cout << "hello" << std::endl;
2278}
2279
2280int standalone() {
2281    return 42;
2282}
2283"#;
2284        let temp = TempDir::new().unwrap();
2285        let path = create_test_file(&temp, "main.cpp", source);
2286        let info = extract_module_info(&path, source).unwrap();
2287
2288        assert!(info.defined_names.contains("caller"), "missing caller");
2289        assert!(
2290            info.defined_names.contains("standalone"),
2291            "missing standalone"
2292        );
2293        assert_eq!(info.function_count, 2);
2294        assert!(
2295            !info.imports.is_empty(),
2296            "should have imports from #include: {:?}",
2297            info.imports
2298        );
2299    }
2300
2301    #[test]
2302    fn test_php_extract_module_info() {
2303        let source = r#"<?php
2304use App\Utils\Helper;
2305use Symfony\Component\Console\Command;
2306
2307function caller() {
2308    Helper::doWork();
2309}
2310
2311function standalone() {
2312    return 42;
2313}
2314"#;
2315        let temp = TempDir::new().unwrap();
2316        let path = create_test_file(&temp, "main.php", source);
2317        let info = extract_module_info(&path, source).unwrap();
2318
2319        assert!(info.defined_names.contains("caller"), "missing caller");
2320        assert!(
2321            info.defined_names.contains("standalone"),
2322            "missing standalone"
2323        );
2324        assert_eq!(info.function_count, 2);
2325        assert!(
2326            !info.imports.is_empty(),
2327            "should have imports from use: {:?}",
2328            info.imports
2329        );
2330    }
2331
2332    #[test]
2333    fn test_csharp_extract_module_info() {
2334        let source = r#"
2335using System;
2336using MyApp.Utils;
2337
2338public class Main {
2339    public void Caller() {
2340        Helper.DoWork();
2341    }
2342
2343    public int Standalone() {
2344        return 42;
2345    }
2346}
2347"#;
2348        let temp = TempDir::new().unwrap();
2349        let path = create_test_file(&temp, "Main.cs", source);
2350        let info = extract_module_info(&path, source).unwrap();
2351
2352        assert!(info.defined_names.contains("Caller"), "missing Caller");
2353        assert!(
2354            info.defined_names.contains("Standalone"),
2355            "missing Standalone"
2356        );
2357        assert!(
2358            !info.imports.is_empty(),
2359            "should have imports from using: {:?}",
2360            info.imports
2361        );
2362    }
2363
2364    #[test]
2365    fn test_run_go_coupling() {
2366        let temp = TempDir::new().unwrap();
2367
2368        let source_a = r#"
2369package main
2370
2371func standalone_a() int {
2372    return 1
2373}
2374"#;
2375        let source_b = r#"
2376package main
2377
2378func standalone_b() int {
2379    return 2
2380}
2381"#;
2382
2383        let path_a = create_test_file(&temp, "a.go", source_a);
2384        let path_b = create_test_file(&temp, "b.go", source_b);
2385
2386        let args = CouplingArgs {
2387            path_a: path_a.clone(),
2388            path_b: Some(path_b.clone()),
2389            timeout: 30,
2390            project_root: None,
2391            max_pairs: 20,
2392            top: 0,
2393            cycles_only: false,
2394            lang: None,
2395            include_tests: false,
2396        };
2397
2398        let result = run(args, OutputFormat::Json);
2399        assert!(
2400            result.is_ok(),
2401            "coupling should work for Go files: {:?}",
2402            result.err()
2403        );
2404    }
2405
2406    #[test]
2407    fn test_run_rust_coupling() {
2408        let temp = TempDir::new().unwrap();
2409
2410        let source_a = r#"
2411fn standalone_a() -> i32 {
2412    1
2413}
2414"#;
2415        let source_b = r#"
2416fn standalone_b() -> i32 {
2417    2
2418}
2419"#;
2420
2421        let path_a = create_test_file(&temp, "a.rs", source_a);
2422        let path_b = create_test_file(&temp, "b.rs", source_b);
2423
2424        let args = CouplingArgs {
2425            path_a: path_a.clone(),
2426            path_b: Some(path_b.clone()),
2427            timeout: 30,
2428            project_root: None,
2429            max_pairs: 20,
2430            top: 0,
2431            cycles_only: false,
2432            lang: None,
2433            include_tests: false,
2434        };
2435
2436        let result = run(args, OutputFormat::Json);
2437        assert!(
2438            result.is_ok(),
2439            "coupling should work for Rust files: {:?}",
2440            result.err()
2441        );
2442    }
2443
2444    #[test]
2445    fn test_unsupported_extension_returns_error() {
2446        let temp = TempDir::new().unwrap();
2447        let path = create_test_file(&temp, "data.xyz", "some content");
2448        let result = extract_module_info(&path, "some content");
2449        assert!(
2450            result.is_err(),
2451            "unsupported file extension should return error"
2452        );
2453    }
2454
2455    // =========================================================================
2456    // Project-Wide Scan Mode Tests
2457    // =========================================================================
2458
2459    #[test]
2460    fn test_coupling_args_pair_mode_backward_compat() {
2461        // Pair mode: path_a and path_b both set
2462        let args = CouplingArgs {
2463            path_a: PathBuf::from("src/a.py"),
2464            path_b: Some(PathBuf::from("src/b.py")),
2465            timeout: 30,
2466            project_root: None,
2467            max_pairs: 20,
2468            top: 0,
2469            cycles_only: false,
2470            lang: None,
2471            include_tests: false,
2472        };
2473        assert!(args.path_b.is_some());
2474    }
2475
2476    #[test]
2477    fn test_coupling_args_project_wide_mode() {
2478        // Project-wide mode: only path_a, no path_b
2479        let args = CouplingArgs {
2480            path_a: PathBuf::from("src/"),
2481            path_b: None,
2482            timeout: 30,
2483            project_root: None,
2484            max_pairs: 20,
2485            top: 0,
2486            cycles_only: false,
2487            lang: None,
2488            include_tests: false,
2489        };
2490        assert!(args.path_b.is_none());
2491    }
2492
2493    #[test]
2494    fn test_coupling_args_max_pairs_default() {
2495        let args = CouplingArgs {
2496            path_a: PathBuf::from("src/"),
2497            path_b: None,
2498            timeout: 30,
2499            project_root: None,
2500            max_pairs: 20,
2501            top: 0,
2502            cycles_only: false,
2503            lang: None,
2504            include_tests: false,
2505        };
2506        assert_eq!(args.max_pairs, 20);
2507    }
2508
2509    #[test]
2510    fn test_coupling_args_max_pairs_custom() {
2511        let args = CouplingArgs {
2512            path_a: PathBuf::from("src/"),
2513            path_b: None,
2514            timeout: 30,
2515            project_root: None,
2516            max_pairs: 5,
2517            top: 0,
2518            cycles_only: false,
2519            lang: None,
2520            include_tests: false,
2521        };
2522        assert_eq!(args.max_pairs, 5);
2523    }
2524
2525    #[test]
2526    fn test_run_project_wide_mode() {
2527        let temp = TempDir::new().unwrap();
2528
2529        // Create a small project with multiple Python files
2530        let source_a = r#"
2531from b import helper
2532
2533def caller():
2534    return helper()
2535"#;
2536        let source_b = r#"
2537def helper():
2538    return 42
2539"#;
2540        let source_c = r#"
2541def standalone():
2542    return 99
2543"#;
2544
2545        create_test_file(&temp, "a.py", source_a);
2546        create_test_file(&temp, "b.py", source_b);
2547        create_test_file(&temp, "c.py", source_c);
2548
2549        // Project-wide: pass directory as path_a, no path_b
2550        let args = CouplingArgs {
2551            path_a: temp.path().to_path_buf(),
2552            path_b: None,
2553            timeout: 30,
2554            project_root: None,
2555            max_pairs: 20,
2556            top: 0,
2557            cycles_only: false,
2558            lang: None,
2559            include_tests: false,
2560        };
2561
2562        let result = run(args, OutputFormat::Json);
2563        assert!(
2564            result.is_ok(),
2565            "project-wide coupling should succeed: {:?}",
2566            result.err()
2567        );
2568    }
2569
2570    #[test]
2571    fn test_run_pair_mode_still_works() {
2572        // Backward compatibility: pair mode must still work
2573        let temp = TempDir::new().unwrap();
2574
2575        let source_a = r#"
2576from b import helper
2577
2578def caller():
2579    return helper()
2580"#;
2581        let source_b = r#"
2582def helper():
2583    return 42
2584"#;
2585
2586        let path_a = create_test_file(&temp, "a.py", source_a);
2587        let path_b = create_test_file(&temp, "b.py", source_b);
2588
2589        let args = CouplingArgs {
2590            path_a: path_a.clone(),
2591            path_b: Some(path_b.clone()),
2592            timeout: 30,
2593            project_root: None,
2594            max_pairs: 20,
2595            top: 0,
2596            cycles_only: false,
2597            lang: None,
2598            include_tests: false,
2599        };
2600
2601        let result = run(args, OutputFormat::Json);
2602        assert!(
2603            result.is_ok(),
2604            "pair mode should still work: {:?}",
2605            result.err()
2606        );
2607    }
2608
2609    #[test]
2610    fn test_format_coupling_project_text_basic() {
2611        use tldr_core::quality::coupling::{
2612            CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2613            ModuleCoupling as CoreModuleCoupling,
2614        };
2615
2616        let report = CoreCouplingReport {
2617            modules_analyzed: 10,
2618            pairs_analyzed: 45,
2619            total_cross_file_pairs: 8,
2620            avg_coupling_score: Some(0.25),
2621            tight_coupling_count: 2,
2622            top_pairs: vec![
2623                CoreModuleCoupling {
2624                    source: PathBuf::from("src/services/auth.rs"),
2625                    target: PathBuf::from("src/db/users.rs"),
2626                    import_count: 8,
2627                    call_count: 12,
2628                    calls_source_to_target: vec![],
2629                    calls_target_to_source: vec![],
2630                    shared_imports: vec![],
2631                    score: 0.72,
2632                    verdict: CoreVerdict::Tight,
2633                },
2634                CoreModuleCoupling {
2635                    source: PathBuf::from("src/api/routes.rs"),
2636                    target: PathBuf::from("src/services/auth.rs"),
2637                    import_count: 5,
2638                    call_count: 7,
2639                    calls_source_to_target: vec![],
2640                    calls_target_to_source: vec![],
2641                    shared_imports: vec![],
2642                    score: 0.55,
2643                    verdict: CoreVerdict::Moderate,
2644                },
2645                CoreModuleCoupling {
2646                    source: PathBuf::from("src/handlers/web.rs"),
2647                    target: PathBuf::from("src/api/routes.rs"),
2648                    import_count: 3,
2649                    call_count: 5,
2650                    calls_source_to_target: vec![],
2651                    calls_target_to_source: vec![],
2652                    shared_imports: vec![],
2653                    score: 0.15,
2654                    verdict: CoreVerdict::Loose,
2655                },
2656            ],
2657            truncated: None,
2658            total_pairs: None,
2659            shown_pairs: None,
2660        };
2661
2662        let text = format_coupling_project_text(&report);
2663
2664        // Header
2665        assert!(
2666            text.contains("project-wide"),
2667            "should contain 'project-wide': {}",
2668            text
2669        );
2670        // Table header columns
2671        assert!(
2672            text.contains("Score"),
2673            "should contain Score header: {}",
2674            text
2675        );
2676        assert!(
2677            text.contains("Calls"),
2678            "should contain Calls header: {}",
2679            text
2680        );
2681        assert!(
2682            text.contains("Imports"),
2683            "should contain Imports header: {}",
2684            text
2685        );
2686        assert!(
2687            text.contains("Verdict"),
2688            "should contain Verdict header: {}",
2689            text
2690        );
2691        // Data rows
2692        assert!(
2693            text.contains("0.72"),
2694            "should contain tight score: {}",
2695            text
2696        );
2697        assert!(
2698            text.contains("0.55"),
2699            "should contain moderate score: {}",
2700            text
2701        );
2702        assert!(
2703            text.contains("0.15"),
2704            "should contain loose score: {}",
2705            text
2706        );
2707        // Verdict labels
2708        assert!(
2709            text.contains("tight"),
2710            "should contain tight verdict: {}",
2711            text
2712        );
2713        assert!(
2714            text.contains("moderate"),
2715            "should contain moderate verdict: {}",
2716            text
2717        );
2718        assert!(
2719            text.contains("loose"),
2720            "should contain loose verdict: {}",
2721            text
2722        );
2723        // Summary line
2724        assert!(
2725            text.contains("10 modules"),
2726            "should contain module count: {}",
2727            text
2728        );
2729        assert!(
2730            text.contains("45 pairs"),
2731            "should contain pair count: {}",
2732            text
2733        );
2734        assert!(
2735            text.contains("2 tight"),
2736            "should contain tight count: {}",
2737            text
2738        );
2739    }
2740
2741    #[test]
2742    fn test_format_coupling_project_text_empty() {
2743        use tldr_core::quality::coupling::CouplingReport as CoreCouplingReport;
2744
2745        let report = CoreCouplingReport::default();
2746
2747        let text = format_coupling_project_text(&report);
2748
2749        assert!(
2750            text.contains("project-wide"),
2751            "should contain 'project-wide': {}",
2752            text
2753        );
2754        assert!(
2755            text.contains("0 modules"),
2756            "should contain zero modules: {}",
2757            text
2758        );
2759    }
2760
2761    // =========================================================================
2762    // Martin Metrics Text Formatter Tests
2763    // =========================================================================
2764
2765    #[test]
2766    fn test_format_martin_text_basic() {
2767        use tldr_core::quality::coupling::{
2768            MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2769        };
2770
2771        let report = MartinMetricsReport {
2772            schema_version: "1.0".to_string(),
2773            modules_analyzed: 2,
2774            metrics: vec![
2775                MartinModuleMetrics {
2776                    module: PathBuf::from("src/api.py"),
2777                    ca: 0,
2778                    ce: 3,
2779                    instability: 1.0,
2780                    in_cycle: false,
2781                },
2782                MartinModuleMetrics {
2783                    module: PathBuf::from("src/db.py"),
2784                    ca: 2,
2785                    ce: 0,
2786                    instability: 0.0,
2787                    in_cycle: false,
2788                },
2789            ],
2790            cycles: vec![],
2791            summary: MartinSummary {
2792                avg_instability: 0.5,
2793                total_cycles: 0,
2794                most_stable: Some(PathBuf::from("src/db.py")),
2795                most_unstable: Some(PathBuf::from("src/api.py")),
2796            },
2797        };
2798
2799        let text = format_martin_text(&report);
2800        assert!(
2801            text.contains("Module"),
2802            "should contain Module header: {}",
2803            text
2804        );
2805        assert!(text.contains("Ca"), "should contain Ca header: {}", text);
2806        assert!(text.contains("Ce"), "should contain Ce header: {}", text);
2807        assert!(
2808            text.contains("Cycle?"),
2809            "should contain Cycle? header: {}",
2810            text
2811        );
2812    }
2813
2814    #[test]
2815    fn test_format_martin_text_empty() {
2816        use tldr_core::quality::coupling::MartinMetricsReport;
2817
2818        let report = MartinMetricsReport::default();
2819        let text = format_martin_text(&report);
2820        assert!(
2821            text.contains("No modules found"),
2822            "empty report should say 'No modules found': {}",
2823            text
2824        );
2825    }
2826
2827    #[test]
2828    fn test_format_martin_text_with_cycles() {
2829        use tldr_core::analysis::deps::DepCycle;
2830        use tldr_core::quality::coupling::{
2831            MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2832        };
2833
2834        let cycle = DepCycle::new(vec![PathBuf::from("a.py"), PathBuf::from("b.py")]);
2835        let report = MartinMetricsReport {
2836            schema_version: "1.0".to_string(),
2837            modules_analyzed: 2,
2838            metrics: vec![
2839                MartinModuleMetrics {
2840                    module: PathBuf::from("a.py"),
2841                    ca: 1,
2842                    ce: 1,
2843                    instability: 0.5,
2844                    in_cycle: true,
2845                },
2846                MartinModuleMetrics {
2847                    module: PathBuf::from("b.py"),
2848                    ca: 1,
2849                    ce: 1,
2850                    instability: 0.5,
2851                    in_cycle: true,
2852                },
2853            ],
2854            cycles: vec![cycle],
2855            summary: MartinSummary {
2856                avg_instability: 0.5,
2857                total_cycles: 1,
2858                most_stable: Some(PathBuf::from("a.py")),
2859                most_unstable: Some(PathBuf::from("a.py")),
2860            },
2861        };
2862
2863        let text = format_martin_text(&report);
2864        assert!(
2865            text.contains("Cycles:"),
2866            "should contain 'Cycles:' section: {}",
2867            text
2868        );
2869        assert!(
2870            text.contains("->"),
2871            "should contain '->' in cycle display: {}",
2872            text
2873        );
2874    }
2875
2876    #[test]
2877    fn test_format_martin_text_no_cycles() {
2878        use tldr_core::quality::coupling::{
2879            MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2880        };
2881
2882        let report = MartinMetricsReport {
2883            schema_version: "1.0".to_string(),
2884            modules_analyzed: 1,
2885            metrics: vec![MartinModuleMetrics {
2886                module: PathBuf::from("a.py"),
2887                ca: 0,
2888                ce: 0,
2889                instability: 0.0,
2890                in_cycle: false,
2891            }],
2892            cycles: vec![],
2893            summary: MartinSummary {
2894                avg_instability: 0.0,
2895                total_cycles: 0,
2896                most_stable: Some(PathBuf::from("a.py")),
2897                most_unstable: Some(PathBuf::from("a.py")),
2898            },
2899        };
2900
2901        let text = format_martin_text(&report);
2902        assert!(
2903            !text.contains("Cycles:"),
2904            "should NOT contain 'Cycles:' section when no cycles: {}",
2905            text
2906        );
2907    }
2908
2909    #[test]
2910    fn test_format_martin_text_summary_line() {
2911        use tldr_core::quality::coupling::{
2912            MartinMetricsReport, MartinModuleMetrics, MartinSummary,
2913        };
2914
2915        let report = MartinMetricsReport {
2916            schema_version: "1.0".to_string(),
2917            modules_analyzed: 3,
2918            metrics: vec![MartinModuleMetrics {
2919                module: PathBuf::from("a.py"),
2920                ca: 0,
2921                ce: 1,
2922                instability: 1.0,
2923                in_cycle: false,
2924            }],
2925            cycles: vec![],
2926            summary: MartinSummary {
2927                avg_instability: 0.5,
2928                total_cycles: 0,
2929                most_stable: Some(PathBuf::from("c.py")),
2930                most_unstable: Some(PathBuf::from("a.py")),
2931            },
2932        };
2933
2934        let text = format_martin_text(&report);
2935        assert!(
2936            text.contains("modules"),
2937            "should contain 'modules' in summary: {}",
2938            text
2939        );
2940        assert!(
2941            text.contains("avg instability"),
2942            "should contain 'avg instability' in summary: {}",
2943            text
2944        );
2945    }
2946
2947    #[test]
2948    fn test_format_coupling_project_text_path_stripping() {
2949        use tldr_core::quality::coupling::{
2950            CouplingReport as CoreCouplingReport, CouplingVerdict as CoreVerdict,
2951            ModuleCoupling as CoreModuleCoupling,
2952        };
2953
2954        let report = CoreCouplingReport {
2955            modules_analyzed: 2,
2956            pairs_analyzed: 1,
2957            total_cross_file_pairs: 1,
2958            avg_coupling_score: Some(0.50),
2959            tight_coupling_count: 0,
2960            top_pairs: vec![CoreModuleCoupling {
2961                source: PathBuf::from("/home/user/project/src/auth.rs"),
2962                target: PathBuf::from("/home/user/project/src/db.rs"),
2963                import_count: 3,
2964                call_count: 4,
2965                calls_source_to_target: vec![],
2966                calls_target_to_source: vec![],
2967                shared_imports: vec![],
2968                score: 0.50,
2969                verdict: CoreVerdict::Moderate,
2970            }],
2971            truncated: None,
2972            total_pairs: None,
2973            shown_pairs: None,
2974        };
2975
2976        let text = format_coupling_project_text(&report);
2977
2978        // Should strip common prefix and show relative paths
2979        assert!(
2980            text.contains("auth.rs"),
2981            "should show relative path auth.rs: {}",
2982            text
2983        );
2984        assert!(
2985            text.contains("db.rs"),
2986            "should show relative path db.rs: {}",
2987            text
2988        );
2989        // Should NOT contain the full absolute path
2990        assert!(
2991            !text.contains("/home/user/project/src/auth.rs"),
2992            "should strip common prefix from paths: {}",
2993            text
2994        );
2995    }
2996
2997    // =========================================================================
2998    // Phase 3: CLI Args + Project-Mode Martin Integration Tests
2999    // =========================================================================
3000
3001    #[test]
3002    fn test_coupling_args_top_flag() {
3003        // Verify CouplingArgs can be constructed with top == 5
3004        let args = CouplingArgs {
3005            path_a: PathBuf::from("src/"),
3006            path_b: None,
3007            timeout: 30,
3008            project_root: None,
3009            max_pairs: 20,
3010            top: 5,
3011            cycles_only: false,
3012            lang: None,
3013            include_tests: false,
3014        };
3015        assert_eq!(args.top, 5);
3016    }
3017
3018    #[test]
3019    fn test_coupling_args_cycles_only_flag() {
3020        // Verify CouplingArgs can be constructed with cycles_only == true
3021        let args = CouplingArgs {
3022            path_a: PathBuf::from("src/"),
3023            path_b: None,
3024            timeout: 30,
3025            project_root: None,
3026            max_pairs: 20,
3027            top: 0,
3028            cycles_only: true,
3029            lang: None,
3030            include_tests: false,
3031        };
3032        assert!(args.cycles_only);
3033    }
3034
3035    #[test]
3036    fn test_coupling_args_defaults() {
3037        // Verify default values: top == 0, cycles_only == false
3038        let args = CouplingArgs {
3039            path_a: PathBuf::from("src/"),
3040            path_b: None,
3041            timeout: 30,
3042            project_root: None,
3043            max_pairs: 20,
3044            top: 0,
3045            cycles_only: false,
3046            lang: None,
3047            include_tests: false,
3048        };
3049        assert_eq!(args.top, 0);
3050        assert!(!args.cycles_only);
3051    }
3052
3053    #[test]
3054    fn test_project_mode_produces_martin_output() {
3055        // Create tempdir with 3 Python files: a imports b, b imports c, c standalone
3056        let temp = TempDir::new().unwrap();
3057
3058        create_test_file(
3059            &temp,
3060            "a.py",
3061            "from b import helper_b\n\ndef func_a():\n    return helper_b()\n",
3062        );
3063        create_test_file(
3064            &temp,
3065            "b.py",
3066            "from c import helper_c\n\ndef helper_b():\n    return helper_c()\n",
3067        );
3068        create_test_file(&temp, "c.py", "def helper_c():\n    return 42\n");
3069
3070        let args = CouplingArgs {
3071            path_a: temp.path().to_path_buf(),
3072            path_b: None,
3073            timeout: 30,
3074            project_root: None,
3075            max_pairs: 20,
3076            top: 0,
3077            cycles_only: false,
3078            lang: None,
3079            include_tests: false,
3080        };
3081
3082        // Run project mode and capture output
3083        let result = run(args, OutputFormat::Text);
3084        assert!(
3085            result.is_ok(),
3086            "project mode should succeed: {:?}",
3087            result.err()
3088        );
3089        // The text output goes to stdout; we verify it doesn't fail.
3090        // For deeper content check, call the internal function directly.
3091    }
3092
3093    #[test]
3094    fn test_project_mode_json_has_martin_fields() {
3095        use serde_json::Value;
3096
3097        let temp = TempDir::new().unwrap();
3098
3099        create_test_file(
3100            &temp,
3101            "a.py",
3102            "from b import helper_b\n\ndef func_a():\n    return helper_b()\n",
3103        );
3104        create_test_file(
3105            &temp,
3106            "b.py",
3107            "from c import helper_c\n\ndef helper_b():\n    return helper_c()\n",
3108        );
3109        create_test_file(&temp, "c.py", "def helper_c():\n    return 42\n");
3110
3111        // Call the internal functions directly to get the martin report
3112        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3113        use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3114
3115        let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3116        let martin_report = compute_martin_metrics_from_deps(
3117            &deps_report,
3118            &MartinOptions {
3119                top: 0,
3120                cycles_only: false,
3121            },
3122        );
3123
3124        let json = serde_json::to_string_pretty(&martin_report).unwrap();
3125        let parsed: Value = serde_json::from_str(&json).unwrap();
3126
3127        assert!(
3128            parsed.get("modules_analyzed").is_some(),
3129            "JSON should have 'modules_analyzed': {}",
3130            json
3131        );
3132        assert!(
3133            parsed.get("metrics").is_some(),
3134            "JSON should have 'metrics': {}",
3135            json
3136        );
3137        assert!(
3138            parsed.get("summary").is_some(),
3139            "JSON should have 'summary': {}",
3140            json
3141        );
3142    }
3143
3144    #[test]
3145    fn test_project_mode_cycles_only_filter() {
3146        // Create A->B->A cycle + C->D no-cycle
3147        let temp = TempDir::new().unwrap();
3148
3149        create_test_file(
3150            &temp,
3151            "a.py",
3152            "from b import func_b\n\ndef func_a():\n    return func_b()\n",
3153        );
3154        create_test_file(
3155            &temp,
3156            "b.py",
3157            "from a import func_a\n\ndef func_b():\n    return func_a()\n",
3158        );
3159        create_test_file(
3160            &temp,
3161            "c.py",
3162            "from d import func_d\n\ndef func_c():\n    return func_d()\n",
3163        );
3164        create_test_file(&temp, "d.py", "def func_d():\n    return 42\n");
3165
3166        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3167        use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3168
3169        let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3170        let martin_report = compute_martin_metrics_from_deps(
3171            &deps_report,
3172            &MartinOptions {
3173                top: 0,
3174                cycles_only: true,
3175            },
3176        );
3177
3178        // With cycles_only, only modules in cycles should appear in metrics
3179        for m in &martin_report.metrics {
3180            assert!(
3181                m.in_cycle,
3182                "cycles_only filter should only include cycle modules, got: {:?}",
3183                m.module
3184            );
3185        }
3186    }
3187
3188    #[test]
3189    fn test_project_mode_top_n_limits() {
3190        // Create 5+ modules
3191        let temp = TempDir::new().unwrap();
3192
3193        create_test_file(
3194            &temp,
3195            "a.py",
3196            "from b import fb\n\ndef fa():\n    return fb()\n",
3197        );
3198        create_test_file(
3199            &temp,
3200            "b.py",
3201            "from c import fc\n\ndef fb():\n    return fc()\n",
3202        );
3203        create_test_file(
3204            &temp,
3205            "c.py",
3206            "from d import fd\n\ndef fc():\n    return fd()\n",
3207        );
3208        create_test_file(
3209            &temp,
3210            "d.py",
3211            "from e import fe\n\ndef fd():\n    return fe()\n",
3212        );
3213        create_test_file(&temp, "e.py", "def fe():\n    return 42\n");
3214
3215        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3216        use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3217
3218        let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3219        let martin_report = compute_martin_metrics_from_deps(
3220            &deps_report,
3221            &MartinOptions {
3222                top: 2,
3223                cycles_only: false,
3224            },
3225        );
3226
3227        assert!(
3228            martin_report.metrics.len() <= 2,
3229            "top 2 should limit metrics to at most 2, got {}",
3230            martin_report.metrics.len()
3231        );
3232        // modules_analyzed should still reflect the total count
3233        assert!(
3234            martin_report.modules_analyzed >= 3,
3235            "modules_analyzed should reflect total (not filtered), got {}",
3236            martin_report.modules_analyzed
3237        );
3238    }
3239
3240    #[test]
3241    fn test_pair_mode_unchanged() {
3242        // Pair mode should still work with the new fields present
3243        let temp = TempDir::new().unwrap();
3244
3245        let path_a = create_test_file(&temp, "a.py", "def standalone_a():\n    return 1\n");
3246        let path_b = create_test_file(&temp, "b.py", "def standalone_b():\n    return 2\n");
3247
3248        let args = CouplingArgs {
3249            path_a: path_a.clone(),
3250            path_b: Some(path_b.clone()),
3251            timeout: 30,
3252            project_root: None,
3253            max_pairs: 20,
3254            top: 3,
3255            cycles_only: true,
3256            lang: None,
3257            include_tests: false,
3258        };
3259
3260        // Pair mode should ignore top and cycles_only, and succeed
3261        let result = run(args, OutputFormat::Json);
3262        assert!(
3263            result.is_ok(),
3264            "pair mode with new flags should still work: {:?}",
3265            result.err()
3266        );
3267    }
3268
3269    #[test]
3270    fn test_project_mode_empty_dir() {
3271        // analyze_dependencies errors on a directory with no source files
3272        // (it can't auto-detect a language). Verify we handle this gracefully.
3273        let temp = TempDir::new().unwrap();
3274
3275        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3276        use tldr_core::quality::coupling::MartinMetricsReport;
3277
3278        let deps_result = analyze_dependencies(temp.path(), &DepsOptions::default());
3279        // Empty dir → error is expected from analyze_dependencies
3280        // The empty MartinMetricsReport should format as "No modules found"
3281        match deps_result {
3282            Err(_) => {
3283                let empty_report = MartinMetricsReport::default();
3284                let text = format_martin_text(&empty_report);
3285                assert!(
3286                    text.contains("No modules found"),
3287                    "empty report should say 'No modules found': {}",
3288                    text
3289                );
3290            }
3291            Ok(deps_report) => {
3292                // If analyze_dependencies somehow succeeds with 0 modules, verify that too
3293                use tldr_core::quality::coupling::{
3294                    compute_martin_metrics_from_deps, MartinOptions,
3295                };
3296                let martin_report = compute_martin_metrics_from_deps(
3297                    &deps_report,
3298                    &MartinOptions {
3299                        top: 0,
3300                        cycles_only: false,
3301                    },
3302                );
3303                assert_eq!(
3304                    martin_report.modules_analyzed, 0,
3305                    "empty dir should have 0 modules"
3306                );
3307                let text = format_martin_text(&martin_report);
3308                assert!(
3309                    text.contains("No modules found"),
3310                    "empty dir text should say 'No modules found': {}",
3311                    text
3312                );
3313            }
3314        }
3315    }
3316
3317    #[test]
3318    fn test_project_mode_single_file() {
3319        let temp = TempDir::new().unwrap();
3320
3321        create_test_file(&temp, "only.py", "def lonely():\n    return 1\n");
3322
3323        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3324        use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3325
3326        let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3327        let martin_report = compute_martin_metrics_from_deps(
3328            &deps_report,
3329            &MartinOptions {
3330                top: 0,
3331                cycles_only: false,
3332            },
3333        );
3334
3335        // Single file should show in output (at least 1 module analyzed)
3336        assert!(
3337            martin_report.modules_analyzed >= 1,
3338            "single file should produce at least 1 module, got {}",
3339            martin_report.modules_analyzed
3340        );
3341    }
3342
3343    // =========================================================================
3344    // Phase 4: Edge Case Tests
3345    // =========================================================================
3346
3347    #[test]
3348    fn test_format_martin_json_schema() {
3349        // JSON output should include schema_version field
3350        use serde_json::Value;
3351        use tldr_core::quality::coupling::{
3352            MartinMetricsReport, MartinModuleMetrics, MartinSummary,
3353        };
3354
3355        let report = MartinMetricsReport {
3356            schema_version: "1.0".to_string(),
3357            modules_analyzed: 1,
3358            metrics: vec![MartinModuleMetrics {
3359                module: PathBuf::from("a.py"),
3360                ca: 0,
3361                ce: 0,
3362                instability: 0.0,
3363                in_cycle: false,
3364            }],
3365            cycles: vec![],
3366            summary: MartinSummary {
3367                avg_instability: 0.0,
3368                total_cycles: 0,
3369                most_stable: Some(PathBuf::from("a.py")),
3370                most_unstable: Some(PathBuf::from("a.py")),
3371            },
3372        };
3373
3374        let json_str = serde_json::to_string_pretty(&report).unwrap();
3375        let parsed: Value = serde_json::from_str(&json_str).unwrap();
3376
3377        assert_eq!(
3378            parsed["schema_version"].as_str(),
3379            Some("1.0"),
3380            "JSON should contain schema_version=1.0, got: {}",
3381            json_str
3382        );
3383    }
3384
3385    #[test]
3386    fn test_project_mode_top_and_cycles_combined() {
3387        // --top 2 --cycles-only should show max 2 cycle-participating modules
3388        let temp = TempDir::new().unwrap();
3389
3390        // Create 4 modules: A<->B cycle, B<->C cycle, D standalone
3391        // This gives us A, B, C in cycles
3392        create_test_file(
3393            &temp,
3394            "a.py",
3395            "from b import fb\n\ndef fa():\n    return fb()\n",
3396        );
3397        create_test_file(
3398            &temp,
3399            "b.py",
3400            "from a import fa\nfrom c import fc\n\ndef fb():\n    return fa() + fc()\n",
3401        );
3402        create_test_file(
3403            &temp,
3404            "c.py",
3405            "from b import fb\n\ndef fc():\n    return fb()\n",
3406        );
3407        create_test_file(&temp, "d.py", "def fd():\n    return 42\n");
3408
3409        use tldr_core::analysis::deps::{analyze_dependencies, DepsOptions};
3410        use tldr_core::quality::coupling::{compute_martin_metrics_from_deps, MartinOptions};
3411
3412        let deps_report = analyze_dependencies(temp.path(), &DepsOptions::default()).unwrap();
3413        let martin_report = compute_martin_metrics_from_deps(
3414            &deps_report,
3415            &MartinOptions {
3416                top: 2,
3417                cycles_only: true,
3418            },
3419        );
3420
3421        // With both filters: should show at most 2 modules, and all must be in_cycle
3422        assert!(
3423            martin_report.metrics.len() <= 2,
3424            "top 2 + cycles_only should limit to at most 2 modules, got {}",
3425            martin_report.metrics.len()
3426        );
3427        for m in &martin_report.metrics {
3428            assert!(
3429                m.in_cycle,
3430                "all returned modules should be in_cycle, but {:?} is not",
3431                m.module
3432            );
3433        }
3434    }
3435
3436    #[test]
3437    fn test_coupling_args_lang_flag() {
3438        // Verify CouplingArgs has a lang field of type Option<Language>
3439        let args = CouplingArgs {
3440            path_a: PathBuf::from("src/a.ts"),
3441            path_b: Some(PathBuf::from("src/b.ts")),
3442            timeout: 30,
3443            project_root: None,
3444            max_pairs: 20,
3445            top: 0,
3446            cycles_only: false,
3447            lang: Some(TldrLanguage::TypeScript),
3448            include_tests: false,
3449        };
3450        assert_eq!(args.lang, Some(TldrLanguage::TypeScript));
3451
3452        // Also test None case (auto-detect)
3453        let args_auto = CouplingArgs {
3454            path_a: PathBuf::from("src/a.py"),
3455            path_b: None,
3456            timeout: 30,
3457            project_root: None,
3458            max_pairs: 20,
3459            top: 0,
3460            cycles_only: false,
3461            lang: None,
3462            include_tests: false,
3463        };
3464        assert_eq!(args_auto.lang, None);
3465    }
3466}