Skip to main content

tldr_cli/commands/
halstead.rs

1//! Halstead metrics command - Calculate Halstead complexity metrics per function
2//!
3//! Exposes Halstead software science metrics as a standalone command with:
4//! - Per-function granularity
5//! - Threshold-based recommendations
6//! - Optional operator/operand listing
7//! - File or directory analysis
8
9use std::path::{Path, PathBuf};
10
11use anyhow::Result;
12use clap::Args;
13use colored::Colorize;
14
15use tldr_core::metrics::halstead::{
16    analyze_halstead, merge_halstead_reports, HalsteadOptions, HalsteadReport, ThresholdStatus,
17};
18use tldr_core::metrics::{walk_source_files, WalkOptions};
19use tldr_core::{detect_or_parse_language, validate_file_path, Language};
20
21use crate::output::{common_path_prefix, strip_prefix_display, OutputFormat, OutputWriter};
22
23/// Calculate Halstead complexity metrics
24#[derive(Debug, Args)]
25pub struct HalsteadArgs {
26    /// File or directory to analyze
27    #[arg(default_value = ".")]
28    pub path: PathBuf,
29
30    /// Specific function to analyze (analyzes all if not specified)
31    #[arg(long)]
32    pub function: Option<String>,
33
34    /// Programming language (auto-detect if not specified)
35    #[arg(long, short = 'l')]
36    pub lang: Option<Language>,
37
38    /// Show list of operators found
39    #[arg(long)]
40    pub show_operators: bool,
41
42    /// Show list of operands found
43    #[arg(long)]
44    pub show_operands: bool,
45
46    /// Volume threshold for warnings (default: 1000)
47    #[arg(long, default_value = "1000")]
48    pub threshold_volume: f64,
49
50    /// Difficulty threshold for warnings (default: 20)
51    #[arg(long, default_value = "20")]
52    pub threshold_difficulty: f64,
53
54    /// Maximum functions to report (0 = all)
55    #[arg(long, default_value = "0")]
56    pub top: usize,
57
58    /// Exclude patterns (glob syntax), can be specified multiple times
59    #[arg(long, short = 'e')]
60    pub exclude: Vec<String>,
61
62    /// Include hidden files (dotfiles)
63    #[arg(long)]
64    pub include_hidden: bool,
65
66    /// Maximum files to process (0 = unlimited)
67    #[arg(long, default_value = "0")]
68    pub max_files: usize,
69}
70
71impl HalsteadArgs {
72    /// Run the halstead command
73    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
74        let writer = OutputWriter::new(format, quiet);
75
76        let options = HalsteadOptions {
77            function: self.function.clone(),
78            volume_threshold: self.threshold_volume,
79            difficulty_threshold: self.threshold_difficulty,
80            show_operators: self.show_operators,
81            show_operands: self.show_operands,
82            top: self.top,
83        };
84
85        let report = if self.path.is_file() {
86            // Single file: preserve exact current behavior.
87            //
88            // BUG-8 (cross-command-consistency-v1): preserve the user-supplied
89            // path in the emitted JSON.  `validate_file_path` is still called
90            // for existence/traversal checks, but we discard its canonicalised
91            // value and feed `self.path` (as typed by the user) to the
92            // analyzer so the `file` field in the report matches the input
93            // (no `/private/tmp/...` rewrite on macOS).
94            let _validated_path =
95                validate_file_path(self.path.to_str().unwrap_or_default(), None)?;
96            let language =
97                detect_or_parse_language(self.lang.as_ref().map(|l| l.as_str()), &self.path)?;
98
99            writer.progress(&format!(
100                "Calculating Halstead metrics for {} ({:?})...",
101                self.path.display(),
102                language
103            ));
104
105            analyze_halstead(&self.path, Some(language), options)?
106        } else if self.path.is_dir() {
107            // Directory: walk -> analyze each -> merge
108            let walk_options = WalkOptions {
109                lang: self.lang,
110                exclude: self.exclude.clone(),
111                include_hidden: self.include_hidden,
112                gitignore: true,
113                max_files: self.max_files,
114            };
115
116            let (files, walk_warnings) = walk_source_files(&self.path, &walk_options)?;
117
118            writer.progress(&format!(
119                "Calculating Halstead metrics for {} files in {}...",
120                files.len(),
121                self.path.display()
122            ));
123
124            let mut reports = Vec::new();
125            let mut extra_warnings = walk_warnings;
126
127            for file in &files {
128                // Detect language per-file (walker already filtered to supported extensions)
129                let language = match Language::from_path(file) {
130                    Some(l) => l,
131                    None => {
132                        extra_warnings
133                            .push(format!("Skipping {}: unsupported language", file.display()));
134                        continue;
135                    }
136                };
137
138                // Clone options because analyze_halstead takes ownership
139                match analyze_halstead(file, Some(language), options.clone()) {
140                    Ok(report) => reports.push(report),
141                    Err(e) => {
142                        extra_warnings.push(format!("Failed to analyze {}: {}", file.display(), e));
143                    }
144                }
145            }
146
147            let mut merged = merge_halstead_reports(reports, &options);
148            let mut all_warnings = extra_warnings;
149            all_warnings.append(&mut merged.warnings);
150            merged.warnings = all_warnings;
151            merged
152        } else {
153            return Err(anyhow::anyhow!(
154                "Path does not exist: {}",
155                self.path.display()
156            ));
157        };
158
159        // Output based on format
160        if writer.is_text() {
161            self.print_text_report(&report, &writer)?;
162        } else {
163            writer.write(&report)?;
164        }
165
166        Ok(())
167    }
168
169    fn print_text_report(&self, report: &HalsteadReport, writer: &OutputWriter) -> Result<()> {
170        // Header
171        writer.write_text(&format!(
172            "\n{}\n",
173            "Halstead Metrics Report".bold().underline()
174        ))?;
175
176        // Summary
177        writer.write_text(&format!(
178            "\n{} ({} functions analyzed)\n",
179            "Summary".bold(),
180            report.summary.total_functions
181        ))?;
182        writer.write_text(&format!(
183            "  Avg Volume:     {:.2}\n",
184            report.summary.avg_volume
185        ))?;
186        writer.write_text(&format!(
187            "  Avg Difficulty: {:.2}\n",
188            report.summary.avg_difficulty
189        ))?;
190        writer.write_text(&format!(
191            "  Avg Effort:     {:.2}\n",
192            report.summary.avg_effort
193        ))?;
194        writer.write_text(&format!(
195            "  Est. Bugs:      {:.3}\n",
196            report.summary.total_estimated_bugs
197        ))?;
198
199        if report.summary.violations_count > 0 {
200            writer.write_text(&format!(
201                "  {}: {}\n",
202                "Violations".red(),
203                report.summary.violations_count
204            ))?;
205        }
206
207        // Functions table
208        writer.write_text(&format!("\n{}\n", "Functions".bold()))?;
209        writer.write_text(&format!(
210            "  {:<30} {:>8} {:>8} {:>10} {:>12} {:>10} {:>8}\n",
211            "Name", "n1", "n2", "Volume", "Difficulty", "Effort", "Status"
212        ))?;
213        writer.write_text(&format!("{}\n", "-".repeat(98)))?;
214
215        for func in &report.functions {
216            let status = format_status(&func.thresholds.volume_status);
217            let name = if func.name.len() > 30 {
218                format!("{}...", &func.name[..27])
219            } else {
220                func.name.clone()
221            };
222
223            writer.write_text(&format!(
224                "  {:<30} {:>8} {:>8} {:>10.2} {:>12.2} {:>10.0} {:>8}\n",
225                name,
226                func.metrics.n1,
227                func.metrics.n2,
228                func.metrics.volume,
229                func.metrics.difficulty,
230                func.metrics.effort,
231                status
232            ))?;
233
234            // Show operators/operands if requested
235            if let Some(ref operators) = func.operators {
236                writer.write_text(&format!(
237                    "    Operators: {}\n",
238                    operators.join(", ").dimmed()
239                ))?;
240            }
241            if let Some(ref operands) = func.operands {
242                writer.write_text(&format!("    Operands: {}\n", operands.join(", ").dimmed()))?;
243            }
244        }
245
246        // Violations with relative path display
247        if !report.violations.is_empty() {
248            // Compute common prefix for relative path display
249            let violation_paths: Vec<&Path> = report
250                .violations
251                .iter()
252                .map(|v| Path::new(v.file.as_str()))
253                .collect();
254            let prefix = if violation_paths.is_empty() {
255                PathBuf::new()
256            } else {
257                common_path_prefix(&violation_paths)
258            };
259
260            writer.write_text(&format!("\n{}\n", "Threshold Violations".red().bold()))?;
261            for violation in &report.violations {
262                let rel_path = strip_prefix_display(Path::new(&violation.file), &prefix);
263                writer.write_text(&format!(
264                    "  {} in {}: {} = {:.2} (threshold: {:.2})\n",
265                    violation.name.yellow(),
266                    rel_path,
267                    violation.metric,
268                    violation.value,
269                    violation.threshold
270                ))?;
271            }
272        }
273
274        // Warnings section
275        if !report.warnings.is_empty() {
276            writer.write_text(&format!("\n{}\n", "Warnings".yellow().bold()))?;
277            for warning in &report.warnings {
278                writer.write_text(&format!("  {}\n", warning))?;
279            }
280        }
281
282        Ok(())
283    }
284}
285
286fn format_status(status: &ThresholdStatus) -> String {
287    match status {
288        ThresholdStatus::Good => "good".green().to_string(),
289        ThresholdStatus::Warning => "warning".yellow().to_string(),
290        ThresholdStatus::Bad => "bad".red().to_string(),
291    }
292}