1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3
4use crate::AnalysisResult;
5use crate::analyzer::LanguageAnalyzer;
6use crate::config::Config;
7use crate::fingerprint::Fingerprint;
8use crate::ignore::{self, IgnoreEntry};
9use crate::output::Reporter;
10use crate::output::json::JsonReporter;
11use crate::output::text::TextReporter;
12
13#[derive(Debug)]
19pub enum CliError {
20 Io(io::Error),
22 NoSourceFiles(PathBuf),
24 NoRecognizedFiles,
26 Analysis(crate::error::Error),
28 InvalidFingerprint(String),
30 CheckFailed,
32}
33
34impl CliError {
35 #[must_use]
37 pub const fn exit_code(&self) -> i32 {
38 match self {
39 Self::CheckFailed => 1,
40 Self::Io(_)
41 | Self::NoSourceFiles(_)
42 | Self::NoRecognizedFiles
43 | Self::Analysis(_)
44 | Self::InvalidFingerprint(_) => 2,
45 }
46 }
47}
48
49impl std::fmt::Display for CliError {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 Self::Io(e) => write!(f, "{e}"),
53 Self::NoSourceFiles(path) => {
54 write!(f, "No source files found in {}", path.display())
55 }
56 Self::NoRecognizedFiles => {
57 write!(
58 f,
59 "No recognized source files found. Use --language to specify the language."
60 )
61 }
62 Self::Analysis(e) => write!(f, "{e}"),
63 Self::InvalidFingerprint(fp) => write!(f, "Invalid fingerprint: {fp}"),
64 Self::CheckFailed => write!(f, "Check failed"),
65 }
66 }
67}
68
69impl std::error::Error for CliError {
70 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
71 match self {
72 Self::Io(e) => Some(e),
73 Self::Analysis(e) => Some(e),
74 _ => None,
75 }
76 }
77}
78
79impl From<io::Error> for CliError {
80 fn from(e: io::Error) -> Self {
81 Self::Io(e)
82 }
83}
84
85impl From<crate::error::Error> for CliError {
86 fn from(e: crate::error::Error) -> Self {
87 Self::Analysis(e)
88 }
89}
90
91pub type CliResult<T = ()> = Result<T, CliError>;
93
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
101pub enum OutputFormat {
102 #[default]
103 Text,
104 Json,
105}
106
107#[derive(Debug, Clone)]
109#[cfg_attr(feature = "cli", derive(clap::Subcommand))]
110pub enum Command {
111 Stats,
113 Report,
115 Check {
117 #[cfg_attr(feature = "cli", arg(long))]
119 max_exact: Option<usize>,
120 #[cfg_attr(feature = "cli", arg(long))]
122 max_near: Option<usize>,
123 #[cfg_attr(feature = "cli", arg(long))]
125 max_exact_percent: Option<f64>,
126 #[cfg_attr(feature = "cli", arg(long))]
128 max_near_percent: Option<f64>,
129 },
130 Ignore {
132 fingerprint: String,
134 #[cfg_attr(feature = "cli", arg(long))]
136 reason: Option<String>,
137 },
138 Ignored,
140 Cleanup {
142 #[cfg_attr(feature = "cli", arg(long))]
144 dry_run: bool,
145 },
146}
147
148#[derive(Debug, Clone, Default)]
150pub struct CheckThresholds {
151 pub max_exact: Option<usize>,
152 pub max_near: Option<usize>,
153 pub max_exact_percent: Option<f64>,
154 pub max_near_percent: Option<f64>,
155}
156
157#[derive(Debug, Clone, Default)]
159pub struct CliOverrides {
160 pub min_nodes: Option<usize>,
161 pub min_lines: Option<usize>,
162 pub threshold: Option<f64>,
163 pub exclude: Vec<String>,
164 pub exclude_tests: Option<bool>,
165 pub sub_function: Option<bool>,
166 pub min_sub_nodes: Option<usize>,
167}
168
169pub struct AnalysisOutput {
171 pub config: Config,
172 pub result: AnalysisResult,
173 pub reporter: Box<dyn Reporter>,
174}
175
176pub fn apply_overrides(config: &mut Config, overrides: &CliOverrides) {
184 if let Some(min_nodes) = overrides.min_nodes {
185 config.min_nodes = min_nodes;
186 }
187 if let Some(min_lines) = overrides.min_lines {
188 config.min_lines = min_lines;
189 }
190 if let Some(threshold) = overrides.threshold {
191 config.similarity_threshold = threshold;
192 }
193 if !overrides.exclude.is_empty() {
194 config.exclude.extend(overrides.exclude.iter().cloned());
195 }
196 if let Some(v) = overrides.exclude_tests {
197 config.exclude_tests = v;
198 }
199 if let Some(v) = overrides.sub_function {
200 config.sub_function = v;
201 }
202 if let Some(min_sub_nodes) = overrides.min_sub_nodes {
203 config.min_sub_nodes = min_sub_nodes;
204 }
205}
206
207#[must_use]
209pub fn create_reporter(format: OutputFormat, root: Option<&Path>) -> Box<dyn Reporter> {
210 match format {
211 OutputFormat::Text => Box::new(TextReporter::new(root.map(Path::to_path_buf))),
212 OutputFormat::Json => Box::new(JsonReporter::new(root.map(Path::to_path_buf))),
213 }
214}
215
216pub fn run_analysis(
225 analyzer: &dyn LanguageAnalyzer,
226 root: &Path,
227 format: OutputFormat,
228 overrides: &CliOverrides,
229) -> CliResult<AnalysisOutput> {
230 let mut config = Config::load(root);
231 apply_overrides(&mut config, overrides);
232
233 let scan_config = crate::scanner::ScanConfig::new(config.root.clone())
234 .with_excludes(config.exclude.clone())
235 .with_extensions(
236 analyzer
237 .file_extensions()
238 .iter()
239 .map(std::string::ToString::to_string)
240 .collect(),
241 );
242 let files = crate::scanner::scan_files(&scan_config);
243
244 if files.is_empty() {
245 return Err(CliError::NoSourceFiles(config.root));
246 }
247
248 let result = crate::analyze(analyzer, &files, &config)?;
249 let reporter = create_reporter(format, Some(root));
250
251 Ok(AnalysisOutput {
252 config,
253 result,
254 reporter,
255 })
256}
257
258pub fn cmd_stats(
264 result: &AnalysisResult,
265 reporter: &dyn Reporter,
266 writer: &mut impl Write,
267) -> CliResult {
268 reporter.report_stats(&result.stats, writer)?;
269 Ok(())
270}
271
272pub fn cmd_report(
274 result: &AnalysisResult,
275 reporter: &dyn Reporter,
276 writer: &mut impl Write,
277) -> CliResult {
278 reporter.report_stats(&result.stats, writer)?;
279 writeln!(writer)?;
280 reporter.report_exact(&result.exact_groups, writer)?;
281 if !result.near_groups.is_empty() {
282 reporter.report_near(&result.near_groups, writer)?;
283 }
284 if !result.sub_exact_groups.is_empty() {
285 reporter.report_sub_exact(&result.sub_exact_groups, writer)?;
286 }
287 if !result.sub_near_groups.is_empty() {
288 reporter.report_sub_near(&result.sub_near_groups, writer)?;
289 }
290 Ok(())
291}
292
293pub fn cmd_check(
295 config: &Config,
296 result: &AnalysisResult,
297 reporter: &dyn Reporter,
298 writer: &mut impl Write,
299 thresholds: &CheckThresholds,
300) -> CliResult {
301 let max_exact = thresholds.max_exact.or(config.max_exact_duplicates);
302 let max_near = thresholds.max_near.or(config.max_near_duplicates);
303 let max_exact_pct = thresholds.max_exact_percent.or(config.max_exact_percent);
304 let max_near_pct = thresholds.max_near_percent.or(config.max_near_percent);
305
306 reporter.report_stats(&result.stats, writer)?;
307
308 let mut failed = false;
309
310 if let Some(threshold) = max_exact
311 && result.stats.exact_duplicate_groups > threshold
312 {
313 writeln!(
314 writer,
315 "\nCheck FAILED: {} exact duplicate groups (max: {})",
316 result.stats.exact_duplicate_groups, threshold
317 )?;
318 reporter.report_exact(&result.exact_groups, writer)?;
319 failed = true;
320 }
321
322 if let Some(threshold) = max_near
323 && result.stats.near_duplicate_groups > threshold
324 {
325 writeln!(
326 writer,
327 "\nCheck FAILED: {} near duplicate groups (max: {})",
328 result.stats.near_duplicate_groups, threshold
329 )?;
330 reporter.report_near(&result.near_groups, writer)?;
331 failed = true;
332 }
333
334 if let Some(threshold) = max_exact_pct {
335 let actual = result.stats.exact_duplicate_percent();
336 if actual > threshold {
337 writeln!(
338 writer,
339 "\nCheck FAILED: {actual:.1}% exact duplicate lines (max: {threshold:.1}%)"
340 )?;
341 reporter.report_exact(&result.exact_groups, writer)?;
342 failed = true;
343 }
344 }
345
346 if let Some(threshold) = max_near_pct {
347 let actual = result.stats.near_duplicate_percent();
348 if actual > threshold {
349 writeln!(
350 writer,
351 "\nCheck FAILED: {actual:.1}% near duplicate lines (max: {threshold:.1}%)"
352 )?;
353 reporter.report_near(&result.near_groups, writer)?;
354 failed = true;
355 }
356 }
357
358 if failed {
359 Err(CliError::CheckFailed)
360 } else {
361 writeln!(writer, "\nCheck passed.")?;
362 Ok(())
363 }
364}
365
366pub fn cmd_ignore(
368 root: &Path,
369 fingerprint: &str,
370 reason: Option<String>,
371 writer: &mut impl Write,
372) -> CliResult {
373 let fp = Fingerprint::from_hex(fingerprint)
374 .ok_or_else(|| CliError::InvalidFingerprint(fingerprint.to_string()))?;
375 let mut ignore_file = ignore::load_ignore_file(root);
376 ignore::add_ignore(&mut ignore_file, &fp, reason, vec![]);
377 ignore::save_ignore_file(root, &ignore_file)?;
378 writeln!(writer, "Added {fingerprint} to ignore list.")?;
379 Ok(())
380}
381
382pub fn cmd_ignored(root: &Path, writer: &mut impl Write) -> CliResult {
384 let ignore_file = ignore::load_ignore_file(root);
385 if ignore_file.ignore.is_empty() {
386 writeln!(writer, "No ignored fingerprints.")?;
387 } else {
388 writeln!(writer, "Ignored fingerprints:")?;
389 for entry in &ignore_file.ignore {
390 write_ignore_entry(writer, entry)?;
391 }
392 }
393 Ok(())
394}
395
396pub fn cmd_cleanup(
398 root: &Path,
399 result: &AnalysisResult,
400 writer: &mut impl Write,
401 dry_run: bool,
402) -> CliResult {
403 let mut ignore_file = ignore::load_ignore_file(root);
404
405 if dry_run {
406 let stale = ignore::find_stale_entries(&ignore_file, &result.all_fingerprints);
407 if stale.is_empty() {
408 writeln!(writer, "No stale entries found.")?;
409 } else {
410 writeln!(writer, "Stale entries (dry run):")?;
411 for entry in &stale {
412 write_ignore_entry(writer, entry)?;
413 }
414 writeln!(writer, "\n{} stale entries would be removed.", stale.len())?;
415 }
416 } else {
417 let removed = ignore::remove_stale_entries(&mut ignore_file, &result.all_fingerprints);
418 if removed.is_empty() {
419 writeln!(writer, "No stale entries found.")?;
420 } else {
421 ignore::save_ignore_file(root, &ignore_file)?;
422 writeln!(writer, "Removed stale entries:")?;
423 for entry in &removed {
424 write_ignore_entry(writer, entry)?;
425 }
426 writeln!(writer, "\nRemoved {} stale entries.", removed.len())?;
427 }
428 }
429 Ok(())
430}
431
432fn write_ignore_entry(writer: &mut impl Write, entry: &IgnoreEntry) -> io::Result<()> {
437 write!(writer, " {}", entry.fingerprint)?;
438 if let Some(reason) = &entry.reason {
439 write!(writer, " (reason: {reason})")?;
440 }
441 if !entry.members.is_empty() {
442 write!(writer, " [{}]", entry.members.join(", "))?;
443 }
444 writeln!(writer)
445}