tldr_cli/commands/hubs.rs
1//! Hubs command - Detect high-centrality hub functions
2//!
3//! Identifies "hub" functions that are change amplifiers - modifications to them
4//! affect many other parts of the codebase. Uses graph centrality algorithms
5//! to quantify risk.
6//!
7//! # Algorithms
8//!
9//! - `in_degree`: How many functions call this one (dependencies)
10//! - `out_degree`: How many functions this one calls (complexity)
11//! - `pagerank`: Recursive importance based on caller importance
12//! - `betweenness`: How often this lies on shortest paths (bottleneck)
13//!
14//! # Premortem Mitigations
15//! - T14: CLI registration follows existing pattern
16//! - T16: Small graph (<10 nodes) messaging
17//! - T18: Text formatting follows spec style guide
18
19use std::path::PathBuf;
20
21use anyhow::Result;
22use clap::{Args, ValueEnum};
23
24use tldr_core::analysis::hubs::{
25 compute_hub_report_with_lines, enumerate_function_lines, HubAlgorithm,
26};
27use tldr_core::callgraph::{build_forward_graph, build_reverse_graph, collect_nodes};
28use tldr_core::{build_project_call_graph, Language};
29
30use crate::output::{format_hubs_dot, format_hubs_text, OutputFormat, OutputWriter};
31use crate::path_validation::require_directory;
32
33/// Algorithm selection for CLI (mirrors HubAlgorithm)
34#[derive(Debug, Clone, Copy, Default, ValueEnum)]
35pub enum AlgorithmArg {
36 /// All algorithms: in_degree, out_degree, pagerank, betweenness
37 #[default]
38 All,
39 /// In-degree only (fast)
40 Indegree,
41 /// Out-degree only (fast)
42 Outdegree,
43 /// PageRank only
44 Pagerank,
45 /// Betweenness only (slow for large graphs)
46 Betweenness,
47}
48
49impl From<AlgorithmArg> for HubAlgorithm {
50 fn from(arg: AlgorithmArg) -> Self {
51 match arg {
52 AlgorithmArg::All => HubAlgorithm::All,
53 AlgorithmArg::Indegree => HubAlgorithm::InDegree,
54 AlgorithmArg::Outdegree => HubAlgorithm::OutDegree,
55 AlgorithmArg::Pagerank => HubAlgorithm::PageRank,
56 AlgorithmArg::Betweenness => HubAlgorithm::Betweenness,
57 }
58 }
59}
60
61/// Detect hub functions using centrality analysis
62#[derive(Debug, Args)]
63pub struct HubsArgs {
64 /// Project root directory (default: current directory)
65 #[arg(default_value = ".")]
66 pub path: PathBuf,
67
68 /// Number of top hubs to return
69 #[arg(long, default_value = "10")]
70 pub top: usize,
71
72 /// Centrality algorithm to use
73 #[arg(long, value_enum, default_value = "all")]
74 pub algorithm: AlgorithmArg,
75
76 /// Minimum composite score threshold (0.0-1.0)
77 #[arg(long)]
78 pub threshold: Option<f64>,
79
80 /// Programming language (auto-detect if not specified)
81 #[arg(long, short = 'l')]
82 pub lang: Option<Language>,
83}
84
85impl HubsArgs {
86 /// Run the hubs command
87 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
88 let writer = OutputWriter::new(format, quiet);
89
90 // Validate path exists AND is a directory.
91 // cli-error-clarity-v2 (P2.BUG-4): when given a regular file we used
92 // to print "Path not found" (false). Use the shared helper so the
93 // error explains the user mistake clearly.
94 require_directory(&self.path, "hubs")?;
95
96 // Validate threshold if provided
97 if let Some(thresh) = self.threshold {
98 if !(0.0..=1.0).contains(&thresh) {
99 anyhow::bail!("Threshold must be between 0.0 and 1.0, got {}", thresh);
100 }
101 }
102
103 // Determine language (auto-detect from directory, default to Python)
104 let language = self
105 .lang
106 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
107
108 writer.progress(&format!(
109 "Building call graph for {} ({:?})...",
110 self.path.display(),
111 language
112 ));
113
114 // Build call graph
115 let graph = build_project_call_graph(&self.path, language, None, true)?;
116
117 writer.progress("Computing hub centrality metrics...");
118
119 // Build graph representations
120 let forward = build_forward_graph(&graph);
121 let reverse = build_reverse_graph(&graph);
122 let nodes = collect_nodes(&graph);
123
124 // hubs-line-population-v1: enumerate function definition lines so the
125 // hub report identifies each function by its real AST line instead of
126 // the legacy `0` placeholder produced by the call-graph builder
127 // (graph_utils::collect_nodes constructs FunctionRefs without line
128 // info).
129 let function_lines = enumerate_function_lines(&self.path, language);
130
131 // Compute hub report
132 let report = compute_hub_report_with_lines(
133 &nodes,
134 &forward,
135 &reverse,
136 self.algorithm.into(),
137 self.top,
138 self.threshold,
139 Some(&function_lines),
140 );
141
142 // Output based on format
143 if writer.is_text() {
144 let text = format_hubs_text(&report);
145 writer.write_text(&text)?;
146 } else if writer.is_dot() {
147 // surface-gaps-v1 (BUG-19): hubs DOT — node-only graph of top
148 // hubs labeled with their composite scores.
149 let dot = format_hubs_dot(&report);
150 writer.write_text(&dot)?;
151 } else {
152 writer.write(&report)?;
153 }
154
155 Ok(())
156 }
157}