1use 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#[derive(Debug, Args)]
25pub struct HalsteadArgs {
26 #[arg(default_value = ".")]
28 pub path: PathBuf,
29
30 #[arg(long)]
32 pub function: Option<String>,
33
34 #[arg(long, short = 'l')]
36 pub lang: Option<Language>,
37
38 #[arg(long)]
40 pub show_operators: bool,
41
42 #[arg(long)]
44 pub show_operands: bool,
45
46 #[arg(long, default_value = "1000")]
48 pub threshold_volume: f64,
49
50 #[arg(long, default_value = "20")]
52 pub threshold_difficulty: f64,
53
54 #[arg(long, default_value = "0")]
56 pub top: usize,
57
58 #[arg(long, short = 'e')]
60 pub exclude: Vec<String>,
61
62 #[arg(long)]
64 pub include_hidden: bool,
65
66 #[arg(long, default_value = "0")]
68 pub max_files: usize,
69}
70
71impl HalsteadArgs {
72 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 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 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 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 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 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 writer.write_text(&format!(
172 "\n{}\n",
173 "Halstead Metrics Report".bold().underline()
174 ))?;
175
176 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 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 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 if !report.violations.is_empty() {
248 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 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}