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