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