Skip to main content

binocular/cli/
args.rs

1use clap::{ArgAction, Args as ClapArgs, Parser, Subcommand, ValueEnum};
2use std::path::PathBuf;
3
4use crate::search::sources::git::GitSearchScope;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
7pub enum OutputFormat {
8    Plain,
9    Jsonl,
10}
11
12#[derive(Parser, Debug, Clone)]
13#[command(author, version, about, long_about = None)]
14pub struct Cli {
15    #[command(flatten)]
16    pub global: GlobalArgs,
17
18    #[command(subcommand)]
19    pub command: Option<Command>,
20}
21
22#[derive(ClapArgs, Debug, Clone)]
23pub struct GlobalArgs {
24    /// Headless mode: print results to stdout without opening the TUI
25    #[arg(short = 'H', long)]
26    pub headless: bool,
27
28    /// Format used when printing interactive selections to stdout
29    #[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
30    pub output_format: OutputFormat,
31
32    /// Write final selection output to this file instead of stdout
33    #[arg(long, value_name = "FILE")]
34    pub output_file: Option<PathBuf>,
35
36    /// Preview command (supports '{}' for full item, '{0}', '{1}', ... for delimiter-split parts)
37    #[arg(long)]
38    pub preview: Option<String>,
39
40    /// Delimiter used to split result items into numbered parameters for --preview (default: ":")
41    #[arg(long, default_value = ":")]
42    pub delimiter: String,
43
44    /// Split each stdin line into multiple items using this delimiter.
45    /// For example, --split "," turns "a,b,c" into three separate items.
46    #[arg(short = 's', long)]
47    pub split: Option<String>,
48}
49
50#[derive(Subcommand, Debug, Clone)]
51pub enum Command {
52    /// Search full paths (default command)
53    Path(SearchCommandArgs),
54    /// Search file names only
55    Files(SearchCommandArgs),
56    /// Search file contents
57    #[command(alias = "grep")]
58    Content(ContentCommandArgs),
59    /// Search directories only
60    Dirs(SearchCommandArgs),
61    /// Open the structured log viewer from stdin
62    Log(LogCommandArgs),
63    /// Open a direct diff preview for two files
64    Diff(DiffCommandArgs),
65    /// Git-backed search commands
66    Git(GitCommandArgs),
67}
68
69#[derive(ClapArgs, Debug, Clone, Default)]
70pub struct SearchCommandArgs {
71    /// Initial search query (pre-populates the search bar)
72    #[arg(index = 1)]
73    pub query: Option<String>,
74
75    /// Directory to search in (can be specified multiple times for multiple roots)
76    #[arg(short = 'l', long, value_name = "DIR", action = ArgAction::Append)]
77    pub location: Vec<PathBuf>,
78
79    /// Exact match: every search token must appear as a contiguous substring
80    #[arg(short, long)]
81    pub exact: bool,
82
83    /// Skip hidden files and directories (default: hidden files are included)
84    #[arg(long = "no-hidden")]
85    pub no_hidden: bool,
86
87    /// Do not respect .gitignore files (default: .gitignore is respected)
88    #[arg(long = "no-git-ignore")]
89    pub no_git_ignore: bool,
90
91    /// Do not respect .ignore files (default: .ignore is respected)
92    #[arg(long = "no-ignore")]
93    pub no_ignore: bool,
94
95    /// Do not apply the built-in ignore list (node_modules, target, .git, etc.)
96    #[arg(long = "no-default-ignore-dirs")]
97    pub no_default_ignore_dirs: bool,
98}
99
100#[derive(ClapArgs, Debug, Clone, Default)]
101pub struct ContentCommandArgs {
102    #[command(flatten)]
103    pub search: SearchCommandArgs,
104
105    /// In content mode, also extract and search text inside PDF files
106    #[arg(long = "search-pdf")]
107    pub search_pdf: bool,
108}
109
110#[derive(ClapArgs, Debug, Clone, Default)]
111pub struct LogCommandArgs {
112    /// Log file(s) to tail. If omitted, reads from stdin.
113    #[arg(value_name = "FILE")]
114    pub files: Vec<PathBuf>,
115}
116
117#[derive(ClapArgs, Debug, Clone)]
118pub struct DiffCommandArgs {
119    pub left: PathBuf,
120    pub right: PathBuf,
121}
122
123#[derive(ClapArgs, Debug, Clone)]
124pub struct GitCommandArgs {
125    #[command(subcommand)]
126    pub command: GitSubcommand,
127}
128
129#[derive(Subcommand, Debug, Clone)]
130pub enum GitSubcommand {
131    /// Search the committed history of one tracked file
132    History(GitHistoryCommandArgs),
133    /// Search local branches
134    Branches(GitListCommandArgs),
135    /// Search commits on the current branch
136    #[command(alias = "logs")]
137    Commits(GitListCommandArgs),
138}
139
140#[derive(ClapArgs, Debug, Clone)]
141pub struct GitHistoryCommandArgs {
142    pub file: PathBuf,
143
144    /// Initial search query (pre-populates the search bar)
145    #[arg(index = 2)]
146    pub query: Option<String>,
147
148    /// Exact match: every search token must appear as a contiguous substring
149    #[arg(short, long)]
150    pub exact: bool,
151}
152
153#[derive(ClapArgs, Debug, Clone, Default)]
154pub struct GitListCommandArgs {
155    /// Initial search query (pre-populates the search bar)
156    #[arg(index = 1)]
157    pub query: Option<String>,
158
159    /// Exact match: every search token must appear as a contiguous substring
160    #[arg(short, long)]
161    pub exact: bool,
162}
163
164#[derive(Debug, Clone)]
165pub struct Args {
166    pub query: Option<String>,
167    pub location: Vec<PathBuf>,
168    pub dir_only: bool,
169    pub file_name: bool,
170    pub full_path: bool,
171    pub content: bool,
172    pub exact: bool,
173    pub no_hidden: bool,
174    pub no_git_ignore: bool,
175    pub no_ignore: bool,
176    pub no_default_ignore_dirs: bool,
177    pub search_pdf: bool,
178    pub git_history: Option<PathBuf>,
179    pub git_branches: bool,
180    pub git_commits: bool,
181    pub headless: bool,
182    pub diff: Option<Vec<PathBuf>>,
183    pub output_format: OutputFormat,
184    pub output_file: Option<PathBuf>,
185    pub stdin: bool,
186    pub git_search_scope: Option<GitSearchScope>,
187    pub preview: Option<String>,
188    pub delimiter: String,
189    pub split: Option<String>,
190    pub log: bool,
191    pub log_files: Vec<PathBuf>,
192}
193
194impl Default for Args {
195    fn default() -> Self {
196        Self {
197            query: None,
198            location: vec![],
199            dir_only: false,
200            file_name: false,
201            full_path: false,
202            content: false,
203            exact: false,
204            no_hidden: false,
205            no_git_ignore: false,
206            no_ignore: false,
207            no_default_ignore_dirs: false,
208            search_pdf: false,
209            git_history: None,
210            git_branches: false,
211            git_commits: false,
212            headless: false,
213            diff: None,
214            output_format: OutputFormat::Plain,
215            output_file: None,
216            stdin: false,
217            git_search_scope: None,
218            preview: None,
219            delimiter: ":".to_string(),
220            split: None,
221            log: false,
222            log_files: vec![],
223        }
224    }
225}
226
227impl Cli {
228    pub fn into_args(self) -> Args {
229        let mut args = Args {
230            headless: self.global.headless,
231            output_format: self.global.output_format,
232            output_file: self.global.output_file,
233            preview: self.global.preview,
234            delimiter: self.global.delimiter,
235            split: self.global.split,
236            ..Args::default()
237        };
238
239        match self.command {
240            None => {
241                args.full_path = true;
242            }
243            Some(Command::Path(cmd)) => {
244                apply_search_command(&mut args, cmd);
245                args.full_path = true;
246            }
247            Some(Command::Files(cmd)) => {
248                apply_search_command(&mut args, cmd);
249                args.file_name = true;
250            }
251            Some(Command::Content(cmd)) => {
252                apply_search_command(&mut args, cmd.search);
253                args.content = true;
254                args.search_pdf = cmd.search_pdf;
255            }
256            Some(Command::Dirs(cmd)) => {
257                apply_search_command(&mut args, cmd);
258                args.dir_only = true;
259            }
260            Some(Command::Log(cmd)) => {
261                args.log = true;
262                args.log_files = cmd.files;
263            }
264            Some(Command::Diff(cmd)) => {
265                args.diff = Some(vec![cmd.left, cmd.right]);
266            }
267            Some(Command::Git(cmd)) => match cmd.command {
268                GitSubcommand::History(cmd) => {
269                    args.git_history = Some(cmd.file);
270                    args.query = cmd.query;
271                    args.exact = cmd.exact;
272                }
273                GitSubcommand::Branches(cmd) => {
274                    args.git_branches = true;
275                    args.query = cmd.query;
276                    args.exact = cmd.exact;
277                }
278                GitSubcommand::Commits(cmd) => {
279                    args.git_commits = true;
280                    args.query = cmd.query;
281                    args.exact = cmd.exact;
282                }
283            },
284        }
285
286        args
287    }
288}
289
290fn apply_search_command(args: &mut Args, cmd: SearchCommandArgs) {
291    args.query = cmd.query;
292    args.location = cmd.location;
293    args.exact = cmd.exact;
294    args.no_hidden = cmd.no_hidden;
295    args.no_git_ignore = cmd.no_git_ignore;
296    args.no_ignore = cmd.no_ignore;
297    args.no_default_ignore_dirs = cmd.no_default_ignore_dirs;
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use clap::Parser;
304
305    #[test]
306    fn grep_alias_maps_to_content_command() {
307        let cli = Cli::parse_from(["binocular", "grep", "needle"]);
308        let args = cli.into_args();
309        assert!(args.content);
310        assert_eq!(args.query.as_deref(), Some("needle"));
311    }
312
313    #[test]
314    fn git_logs_alias_maps_to_commits_command() {
315        let cli = Cli::parse_from(["binocular", "git", "logs", "needle"]);
316        let args = cli.into_args();
317        assert!(args.git_commits);
318        assert_eq!(args.query.as_deref(), Some("needle"));
319    }
320}