Skip to main content

tldr_cli/commands/
smells.rs

1//! Smells command - Detect code smells
2//!
3//! Identifies common code smells like God Class, Long Method, etc.
4//! Auto-routes through daemon when available for ~35x speedup.
5
6use std::path::PathBuf;
7
8use anyhow::Result;
9use clap::Args;
10
11use tldr_core::{
12    analyze_smells_aggregated_with_walker_opts, detect_smells_with_walker_opts, Language,
13    SmellType, SmellsReport, SmellsWalkerOpts, ThresholdPreset,
14};
15
16use crate::commands::daemon_router::{params_for_smells, try_daemon_route};
17use crate::output::{format_smells_text, OutputFormat, OutputWriter};
18
19/// Detect code smells
20#[derive(Debug, Args)]
21pub struct SmellsArgs {
22    /// Path to analyze (file or directory)
23    #[arg(default_value = ".")]
24    pub path: PathBuf,
25
26    /// Programming language to filter by (auto-detected if omitted)
27    #[arg(long, short = 'l')]
28    pub lang: Option<Language>,
29
30    /// Threshold preset
31    #[arg(long, short = 't', default_value = "default")]
32    pub threshold: ThresholdPresetArg,
33
34    /// Filter by smell type
35    #[arg(long, short = 's')]
36    pub smell_type: Option<SmellTypeArg>,
37
38    /// Include suggestions for fixing
39    #[arg(long)]
40    pub suggest: bool,
41
42    /// Deep analysis: aggregate findings from cohesion, coupling, dead code,
43    /// similarity, and cognitive complexity analyzers in addition to the
44    /// standard smell detectors
45    #[arg(long)]
46    pub deep: bool,
47
48    /// Walk vendored/build dirs (node_modules, target, dist, etc.) that would normally be skipped.
49    #[arg(long)]
50    pub no_default_ignore: bool,
51
52    /// Limit the scan to specific files (repeatable; EXACT-PATH-ONLY, no glob expansion).
53    /// Each entry is validated via `validate_file_path` (rejects path traversal /
54    /// non-existent files). When set, the path argument becomes a project-root
55    /// anchor for output ordering only and the walker is bypassed. Implies
56    /// `--include-tests` (caller picked the list).
57    #[arg(long)]
58    pub files: Vec<PathBuf>,
59
60    /// Include findings from test files. Default: test-file findings are excluded
61    /// (PR-review default). Implicit `true` when `--files` is non-empty.
62    #[arg(long)]
63    pub include_tests: bool,
64}
65
66/// CLI wrapper for threshold preset
67#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
68pub enum ThresholdPresetArg {
69    /// Strict thresholds for high-quality codebases
70    Strict,
71    /// Default thresholds (recommended)
72    #[default]
73    Default,
74    /// Relaxed thresholds for legacy code
75    Relaxed,
76}
77
78impl From<ThresholdPresetArg> for ThresholdPreset {
79    fn from(arg: ThresholdPresetArg) -> Self {
80        match arg {
81            ThresholdPresetArg::Strict => ThresholdPreset::Strict,
82            ThresholdPresetArg::Default => ThresholdPreset::Default,
83            ThresholdPresetArg::Relaxed => ThresholdPreset::Relaxed,
84        }
85    }
86}
87
88/// CLI wrapper for smell type
89#[derive(Debug, Clone, Copy, clap::ValueEnum)]
90pub enum SmellTypeArg {
91    /// God Class (>20 methods or >500 LOC)
92    GodClass,
93    /// Long Method (>50 LOC or cyclomatic >10)
94    LongMethod,
95    /// Long Parameter List (>5 parameters)
96    LongParameterList,
97    /// Feature Envy
98    FeatureEnvy,
99    /// Data Clumps
100    DataClumps,
101    /// Low Cohesion (LCOM4 >= 2) -- requires --deep
102    LowCohesion,
103    /// Tight Coupling (score >= 0.6) -- requires --deep
104    TightCoupling,
105    /// Dead Code (unreachable functions) -- requires --deep
106    DeadCode,
107    /// Code Clone (similar functions) -- requires --deep
108    CodeClone,
109    /// High Cognitive Complexity (>= 15) -- requires --deep
110    HighCognitiveComplexity,
111    /// Deep Nesting (nesting depth >= 5)
112    DeepNesting,
113    /// Data Class (many fields, few/no methods)
114    DataClass,
115    /// Lazy Element (class with only 1 method and 0-1 fields)
116    LazyElement,
117    /// Message Chain (long method call chains > 3)
118    MessageChain,
119    /// Primitive Obsession (many primitive-typed parameters)
120    PrimitiveObsession,
121    /// Middle Man (>60% delegation) -- requires --deep
122    MiddleMan,
123    /// Refused Bequest (<33% inherited usage) -- requires --deep
124    RefusedBequest,
125    /// Inappropriate Intimacy (bidirectional coupling) -- requires --deep
126    InappropriateIntimacy,
127}
128
129impl From<SmellTypeArg> for SmellType {
130    fn from(arg: SmellTypeArg) -> Self {
131        match arg {
132            SmellTypeArg::GodClass => SmellType::GodClass,
133            SmellTypeArg::LongMethod => SmellType::LongMethod,
134            SmellTypeArg::LongParameterList => SmellType::LongParameterList,
135            SmellTypeArg::FeatureEnvy => SmellType::FeatureEnvy,
136            SmellTypeArg::DataClumps => SmellType::DataClumps,
137            SmellTypeArg::LowCohesion => SmellType::LowCohesion,
138            SmellTypeArg::TightCoupling => SmellType::TightCoupling,
139            SmellTypeArg::DeadCode => SmellType::DeadCode,
140            SmellTypeArg::CodeClone => SmellType::CodeClone,
141            SmellTypeArg::HighCognitiveComplexity => SmellType::HighCognitiveComplexity,
142            SmellTypeArg::DeepNesting => SmellType::DeepNesting,
143            SmellTypeArg::DataClass => SmellType::DataClass,
144            SmellTypeArg::LazyElement => SmellType::LazyElement,
145            SmellTypeArg::MessageChain => SmellType::MessageChain,
146            SmellTypeArg::PrimitiveObsession => SmellType::PrimitiveObsession,
147            SmellTypeArg::MiddleMan => SmellType::MiddleMan,
148            SmellTypeArg::RefusedBequest => SmellType::RefusedBequest,
149            SmellTypeArg::InappropriateIntimacy => SmellType::InappropriateIntimacy,
150        }
151    }
152}
153
154impl SmellsArgs {
155    /// Run the smells command
156    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
157        let writer = OutputWriter::new(format, quiet);
158
159        // v0.2.3 (#1.D): when `--files` is non-empty, the caller explicitly named
160        // each path. Trust them and force `include_tests=true` so user-listed
161        // test files are not silently filtered.
162        let include_tests = self.include_tests || !self.files.is_empty();
163
164        // v0.2.3 (#1.D): each `--files` entry MUST go through the CORE
165        // validator (`tldr_core::validation::validate_file_path`) — same one
166        // the daemon uses. We pass the smells `path` argument as the project
167        // root so path-traversal attempts (`/etc/passwd`, `../../etc/...`)
168        // produce a hard error rather than a silent skip. Failures bubble up
169        // as a CLI error (non-zero exit), NOT a silent skip.
170        let project_root = if self.path.is_dir() {
171            // Try to canonicalize the path (so traversal checks work). Fall
172            // back to the literal path on canonicalize error (e.g. tmpdir
173            // shenanigans on macOS where /var -> /private/var).
174            dunce::canonicalize(&self.path).unwrap_or_else(|_| self.path.clone())
175        } else {
176            // For file paths, use the parent dir (or "." if none).
177            self.path
178                .parent()
179                .map(|p| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()))
180                .unwrap_or_else(|| PathBuf::from("."))
181        };
182        let mut validated_files: Vec<PathBuf> = Vec::with_capacity(self.files.len());
183        for f in &self.files {
184            let f_str = f.to_str().ok_or_else(|| {
185                anyhow::anyhow!("--files entry contains non-UTF8 bytes: {:?}", f)
186            })?;
187            let canonical =
188                tldr_core::validation::validate_file_path(f_str, Some(&project_root))
189                    .map_err(|e| anyhow::anyhow!("--files {}: {}", f.display(), e))?;
190            validated_files.push(canonical);
191        }
192
193        // Try daemon first for cached result
194        if let Some(report) = try_daemon_route::<SmellsReport>(
195            &self.path,
196            "smells",
197            params_for_smells(Some(&self.path), &validated_files, include_tests),
198        ) {
199            // Output based on format
200            if writer.is_text() {
201                let text = format_smells_text(&report);
202                writer.write_text(&text)?;
203                return Ok(());
204            } else {
205                writer.write(&report)?;
206                return Ok(());
207            }
208        }
209
210        // Fallback to direct compute
211        writer.progress(&format!(
212            "Scanning for code smells in {}{}...",
213            self.path.display(),
214            if self.deep { " (deep analysis)" } else { "" }
215        ));
216
217        // Detect smells - use aggregated analysis when --deep is set
218        let walker_opts = SmellsWalkerOpts {
219            no_default_ignore: self.no_default_ignore,
220            lang: self.lang,
221            files: validated_files,
222            include_tests,
223        };
224        let report = if self.deep {
225            analyze_smells_aggregated_with_walker_opts(
226                &self.path,
227                self.threshold.into(),
228                self.smell_type.map(|s| s.into()),
229                self.suggest,
230                walker_opts,
231            )?
232        } else {
233            detect_smells_with_walker_opts(
234                &self.path,
235                self.threshold.into(),
236                self.smell_type.map(|s| s.into()),
237                self.suggest,
238                walker_opts,
239            )?
240        };
241
242        // Output based on format
243        if writer.is_text() {
244            let text = format_smells_text(&report);
245            writer.write_text(&text)?;
246        } else {
247            writer.write(&report)?;
248        }
249
250        Ok(())
251    }
252}