rust_filesearch/
cli.rs

1use crate::errors::{FsError, Result};
2use crate::models::{Column, EntryKind, OutputFormat, SortKey, SortOrder};
3use clap::{Parser, Subcommand, ValueEnum};
4use std::path::PathBuf;
5
6#[derive(Parser, Debug)]
7#[command(name = "fexplorer")]
8#[command(author, version, about = "A fast, modern file system explorer and toolkit", long_about = None)]
9pub struct Cli {
10    #[command(subcommand)]
11    pub command: Commands,
12
13    /// Disable colored output
14    #[arg(long, global = true)]
15    pub no_color: bool,
16
17    /// Quiet mode (suppress warnings)
18    #[arg(long, short = 'q', global = true)]
19    pub quiet: bool,
20
21    /// Verbose mode (show detailed output)
22    #[arg(long, short = 'v', global = true)]
23    pub verbose: bool,
24}
25
26#[derive(Subcommand, Debug)]
27pub enum Commands {
28    /// List entries with metadata and sorting
29    #[command(visible_alias = "ls")]
30    List {
31        /// Root path to list
32        #[arg(default_value = ".")]
33        path: PathBuf,
34
35        /// Sort by key
36        #[arg(long, value_name = "KEY")]
37        sort: Option<String>,
38
39        /// Sort order (asc or desc)
40        #[arg(long, default_value = "asc")]
41        order: String,
42
43        /// Show directories first
44        #[arg(long)]
45        dirs_first: bool,
46
47        #[command(flatten)]
48        common: CommonArgs,
49    },
50
51    /// Display directory tree with ASCII art
52    Tree {
53        /// Root path to display
54        #[arg(default_value = ".")]
55        path: PathBuf,
56
57        /// Show directories first
58        #[arg(long)]
59        dirs_first: bool,
60
61        #[command(flatten)]
62        common: CommonArgs,
63    },
64
65    /// Find files matching criteria
66    Find {
67        /// Root path to search
68        #[arg(default_value = ".")]
69        path: PathBuf,
70
71        /// Name glob patterns (repeatable)
72        #[arg(long = "name")]
73        names: Vec<String>,
74
75        /// Regex pattern for names
76        #[arg(long)]
77        regex: Option<String>,
78
79        /// File extensions (comma-separated)
80        #[arg(long, value_delimiter = ',')]
81        ext: Vec<String>,
82
83        /// Minimum size (e.g., 10KB, 2MiB)
84        #[arg(long)]
85        min_size: Option<String>,
86
87        /// Maximum size (e.g., 10MB, 2GiB)
88        #[arg(long)]
89        max_size: Option<String>,
90
91        /// Modified after date (ISO8601 or YYYY-MM-DD)
92        #[arg(long)]
93        after: Option<String>,
94
95        /// Modified before date (ISO8601 or YYYY-MM-DD)
96        #[arg(long)]
97        before: Option<String>,
98
99        /// Filter by kind (file, dir, symlink)
100        #[arg(long, value_delimiter = ',')]
101        kind: Vec<String>,
102
103        /// Filter by category (source, build, config, docs, media, data, archive, executable)
104        #[arg(long)]
105        category: Option<String>,
106
107        #[command(flatten)]
108        common: CommonArgs,
109    },
110
111    /// Calculate and display sizes
112    Size {
113        /// Root path to analyze
114        #[arg(default_value = ".")]
115        path: PathBuf,
116
117        /// Show top N entries by size
118        #[arg(long)]
119        top: Option<usize>,
120
121        /// Aggregate directory sizes
122        #[arg(long)]
123        aggregate: bool,
124
125        /// Display like 'du' command
126        #[arg(long)]
127        du: bool,
128
129        #[command(flatten)]
130        common: CommonArgs,
131    },
132
133    /// Search file contents (grep functionality)
134    #[cfg(feature = "grep")]
135    Grep {
136        /// Root path to search
137        #[arg(default_value = ".")]
138        path: PathBuf,
139
140        /// Pattern to search for
141        #[arg(value_name = "PATTERN")]
142        pattern: String,
143
144        /// Use regex matching (default is literal)
145        #[arg(long, short = 'e')]
146        regex: bool,
147
148        /// Case insensitive search
149        #[arg(long, short = 'i')]
150        case_insensitive: bool,
151
152        /// File extensions to search (comma-separated)
153        #[arg(long, value_delimiter = ',')]
154        ext: Vec<String>,
155
156        /// Number of context lines to show
157        #[arg(long, short = 'C', default_value = "0")]
158        context: usize,
159
160        /// Show line numbers
161        #[arg(long, short = 'n')]
162        line_numbers: bool,
163
164        #[command(flatten)]
165        common: CommonArgs,
166    },
167
168    /// Find duplicate files by content hash
169    #[cfg(feature = "dedup")]
170    Duplicates {
171        /// Root path to analyze
172        #[arg(default_value = ".")]
173        path: PathBuf,
174
175        /// Minimum file size to check (e.g., 1MB)
176        #[arg(long, default_value = "0")]
177        min_size: String,
178
179        /// Show wasted space summary
180        #[arg(long)]
181        summary: bool,
182
183        #[command(flatten)]
184        common: CommonArgs,
185    },
186
187    /// Git integration - show files with git status
188    #[cfg(feature = "git")]
189    Git {
190        /// Root path (must be in a git repository)
191        #[arg(default_value = ".")]
192        path: PathBuf,
193
194        /// Filter by git status
195        #[arg(long, value_enum)]
196        status: Option<GitStatusFilter>,
197
198        /// Show files changed since ref (branch/commit/tag)
199        #[arg(long)]
200        since: Option<String>,
201
202        #[command(flatten)]
203        common: CommonArgs,
204    },
205
206    /// Interactive file explorer (TUI mode)
207    #[cfg(feature = "tui")]
208    #[command(visible_alias = "tui")]
209    Interactive {
210        /// Root path to explore
211        #[arg(default_value = ".")]
212        path: PathBuf,
213    },
214
215    /// Save a filesystem snapshot for trend analysis
216    #[cfg(feature = "trends")]
217    Snapshot {
218        /// Root path to snapshot
219        #[arg(default_value = ".")]
220        path: PathBuf,
221
222        /// Description for this snapshot
223        #[arg(long)]
224        description: Option<String>,
225    },
226
227    /// Analyze filesystem trends over time
228    #[cfg(feature = "trends")]
229    Trends {
230        /// Root path to analyze
231        #[arg(default_value = ".")]
232        path: PathBuf,
233
234        /// Show trends since date
235        #[arg(long)]
236        since: Option<String>,
237
238        /// Display as ASCII chart
239        #[arg(long)]
240        chart: bool,
241    },
242
243    /// Generate shell completions
244    Completions {
245        /// Shell to generate completions for
246        #[arg(value_enum)]
247        shell: Shell,
248    },
249
250    /// Manage saved query profiles
251    Profiles {
252        #[command(subcommand)]
253        command: ProfileCommand,
254    },
255
256    /// Run a saved query profile
257    Run {
258        /// Profile name to execute
259        profile: String,
260
261        /// Override the path argument
262        #[arg(long)]
263        path: Option<PathBuf>,
264
265        /// Additional arguments to override profile settings
266        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
267        args: Vec<String>,
268    },
269
270    /// Watch for filesystem changes (requires watch feature)
271    #[cfg(feature = "watch")]
272    Watch {
273        /// Root path to watch
274        #[arg(default_value = ".")]
275        path: PathBuf,
276
277        /// Events to monitor (comma-separated: create,modify,remove)
278        #[arg(long, value_delimiter = ',')]
279        events: Vec<String>,
280
281        /// Output format (ndjson recommended for watch)
282        #[arg(long, default_value = "ndjson")]
283        format: String,
284    },
285
286    /// Manage plugins (requires plugins feature)
287    #[cfg(feature = "plugins")]
288    Plugins {
289        #[command(subcommand)]
290        command: PluginCommand,
291    },
292}
293
294/// Profile subcommands
295#[derive(Subcommand, Debug)]
296pub enum ProfileCommand {
297    /// List all saved profiles
298    List,
299
300    /// Show details of a specific profile
301    Show {
302        /// Profile name
303        name: String,
304    },
305
306    /// Initialize config with example profiles
307    Init,
308}
309
310/// Plugin subcommands
311#[derive(Subcommand, Debug)]
312#[cfg(feature = "plugins")]
313pub enum PluginCommand {
314    /// List installed plugins
315    List,
316
317    /// Enable a plugin
318    Enable {
319        /// Plugin name
320        name: String,
321    },
322
323    /// Disable a plugin
324    Disable {
325        /// Plugin name
326        name: String,
327    },
328}
329
330/// Git status filters
331#[derive(Debug, Clone, Copy, ValueEnum)]
332#[cfg(feature = "git")]
333pub enum GitStatusFilter {
334    Untracked,
335    Modified,
336    Staged,
337    Conflict,
338    Clean,
339    Ignored,
340}
341
342/// Shell types for completion generation
343#[derive(Debug, Clone, Copy, ValueEnum)]
344pub enum Shell {
345    Bash,
346    Zsh,
347    Fish,
348    Powershell,
349    Elvish,
350}
351
352/// Common arguments shared across commands
353#[derive(Parser, Debug, Clone)]
354pub struct CommonArgs {
355    /// Maximum depth to traverse
356    #[arg(long)]
357    pub max_depth: Option<usize>,
358
359    /// Include hidden files
360    #[arg(long)]
361    pub hidden: bool,
362
363    /// Disable gitignore filtering
364    #[arg(long)]
365    pub no_gitignore: bool,
366
367    /// Follow symbolic links
368    #[arg(long)]
369    pub follow_symlinks: bool,
370
371    /// Output format (pretty, json, ndjson, csv)
372    #[arg(long, default_value = "pretty")]
373    pub format: String,
374
375    /// Columns to display (comma-separated)
376    #[arg(long, value_delimiter = ',')]
377    pub columns: Vec<String>,
378
379    /// Number of threads for parallel traversal
380    #[cfg(feature = "parallel")]
381    #[arg(long, default_value = "4")]
382    pub threads: usize,
383
384    /// Show progress bar
385    #[cfg(feature = "progress")]
386    #[arg(long)]
387    pub progress: bool,
388
389    /// Export using template (markdown, html)
390    #[cfg(feature = "templates")]
391    #[arg(long)]
392    pub template: Option<String>,
393}
394
395impl Default for CommonArgs {
396    fn default() -> Self {
397        Self {
398            max_depth: None,
399            hidden: false,
400            no_gitignore: false,
401            follow_symlinks: false,
402            format: "pretty".to_string(),
403            columns: Vec::new(),
404            #[cfg(feature = "parallel")]
405            threads: 4,
406            #[cfg(feature = "progress")]
407            progress: false,
408            #[cfg(feature = "templates")]
409            template: None,
410        }
411    }
412}
413
414impl CommonArgs {
415    pub fn output_format(&self) -> Result<OutputFormat> {
416        OutputFormat::from_str(&self.format).ok_or_else(|| FsError::InvalidFormat {
417            format: self.format.clone(),
418        })
419    }
420
421    pub fn columns(&self) -> Result<Vec<Column>> {
422        if self.columns.is_empty() {
423            // Default columns
424            return Ok(vec![
425                Column::Path,
426                Column::Size,
427                Column::Mtime,
428                Column::Kind,
429            ]);
430        }
431
432        self.columns
433            .iter()
434            .map(|s| {
435                Column::from_str(s).ok_or_else(|| FsError::InvalidFormat {
436                    format: format!("Invalid column: {}", s),
437                })
438            })
439            .collect()
440    }
441}
442
443// Helper functions (kept for backwards compatibility)
444
445pub fn parse_sort_key(s: &str) -> Result<SortKey> {
446    match s.to_lowercase().as_str() {
447        "name" => Ok(SortKey::Name),
448        "size" => Ok(SortKey::Size),
449        "mtime" => Ok(SortKey::Mtime),
450        "kind" => Ok(SortKey::Kind),
451        _ => Err(FsError::InvalidFormat {
452            format: format!("Invalid sort key: {}", s),
453        }),
454    }
455}
456
457pub fn parse_sort_order(s: &str) -> Result<SortOrder> {
458    match s.to_lowercase().as_str() {
459        "asc" | "ascending" => Ok(SortOrder::Asc),
460        "desc" | "descending" => Ok(SortOrder::Desc),
461        _ => Err(FsError::InvalidFormat {
462            format: format!("Invalid sort order: {}", s),
463        }),
464    }
465}
466
467pub fn parse_entry_kinds(kinds: &[String]) -> Result<Vec<EntryKind>> {
468    kinds
469        .iter()
470        .map(|s| match s.to_lowercase().as_str() {
471            "file" => Ok(EntryKind::File),
472            "dir" | "directory" => Ok(EntryKind::Dir),
473            "symlink" | "link" => Ok(EntryKind::Symlink),
474            _ => Err(FsError::InvalidFormat {
475                format: format!("Invalid kind: {}", s),
476            }),
477        })
478        .collect()
479}
480
481pub fn determine_sort_order(_asc: bool, desc: bool) -> SortOrder {
482    if desc {
483        SortOrder::Desc
484    } else {
485        SortOrder::Asc
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_parse_sort_key() {
495        assert!(matches!(parse_sort_key("name").unwrap(), SortKey::Name));
496        assert!(matches!(parse_sort_key("size").unwrap(), SortKey::Size));
497        assert!(matches!(parse_sort_key("mtime").unwrap(), SortKey::Mtime));
498        assert!(parse_sort_key("invalid").is_err());
499    }
500
501    #[test]
502    fn test_parse_sort_order() {
503        assert!(matches!(parse_sort_order("asc").unwrap(), SortOrder::Asc));
504        assert!(matches!(parse_sort_order("desc").unwrap(), SortOrder::Desc));
505        assert!(matches!(
506            parse_sort_order("ascending").unwrap(),
507            SortOrder::Asc
508        ));
509        assert!(parse_sort_order("invalid").is_err());
510    }
511
512    #[test]
513    fn test_parse_entry_kinds() {
514        let kinds = parse_entry_kinds(&["file".to_string(), "dir".to_string()]).unwrap();
515        assert_eq!(kinds.len(), 2);
516        assert!(kinds.contains(&EntryKind::File));
517        assert!(kinds.contains(&EntryKind::Dir));
518    }
519
520    #[test]
521    fn test_determine_sort_order() {
522        assert!(matches!(determine_sort_order(false, false), SortOrder::Asc));
523        assert!(matches!(determine_sort_order(false, true), SortOrder::Desc));
524        assert!(matches!(determine_sort_order(true, false), SortOrder::Asc));
525    }
526}