Skip to main content

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}