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