intelli_shell/
cli.rs

1use std::str::FromStr;
2
3use clap::{
4    Args, Command, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
5    builder::{ValueParser, styling::Style},
6};
7use clap_stdin::{FileOrStdin, MaybeStdin};
8use color_eyre::{Result, eyre::eyre};
9use itertools::Itertools;
10use regex::Regex;
11use reqwest::{
12    Method,
13    header::{HeaderName, HeaderValue},
14};
15use tracing::instrument;
16
17use crate::model::SearchMode;
18
19/// Like IntelliSense, but for shells
20///
21/// Interactive commands are best used with the default shell bindings:
22/// - `ctrl + space` to search for commands
23/// - `ctrl + b` to bookmark a new command
24/// - `ctrl + l` to replace variables from a command
25#[derive(Parser)]
26#[cfg_attr(debug_assertions, derive(Debug))]
27#[command(
28    author,
29    version,
30    verbatim_doc_comment,
31    infer_subcommands = true,
32    after_long_help = include_str!("_examples/cli.txt")
33)]
34pub struct Cli {
35    /// Whether to skip the execution of the command
36    ///
37    /// Primarily used by shell integrations capable of running the command themselves
38    #[arg(long, hide = true)]
39    pub skip_execution: bool,
40
41    /// Whether to add an extra line when rendering the inline TUI
42    ///
43    /// Primarily used by shell integrations (e.g., Readline keybindings) to skip and preserve the shell prompt
44    #[arg(long, hide = true)]
45    pub extra_line: bool,
46
47    /// Path of the file to write the final textual output to (defaults to stdout)
48    ///
49    /// Primarily used by shell integrations (e.g., Readline/PSReadLine keybindings) to capture the result of an
50    /// interactive TUI session
51    #[arg(long, hide = true)]
52    pub file_output: Option<String>,
53
54    /// Command to be executed
55    #[command(name = "command", subcommand)]
56    pub process: CliProcess,
57}
58
59#[derive(Subcommand)]
60#[cfg_attr(debug_assertions, derive(Debug))]
61pub enum CliProcess {
62    #[cfg(debug_assertions)]
63    /// Runs an sql query against the database
64    Query(QueryProcess),
65
66    /// Generates the integration shell script
67    #[command(after_long_help = include_str!("_examples/init.txt"))]
68    Init(InitProcess),
69
70    /// Bookmarks a new command
71    #[command(after_long_help = include_str!("_examples/new.txt"))]
72    New(Interactive<BookmarkCommandProcess>),
73
74    /// Search stored commands
75    #[command(after_long_help = include_str!("_examples/search.txt"))]
76    Search(Interactive<SearchCommandsProcess>),
77
78    /// Replace the variables of a command
79    ///
80    /// Anything enclosed in double brackets is considered a variable: echo {{message}}
81    #[command(after_long_help = include_str!("_examples/replace.txt"))]
82    Replace(Interactive<VariableReplaceProcess>),
83
84    /// Exports stored user commands to a file
85    ///
86    /// Commands fetched from tldr are not exported
87    #[command(after_long_help = include_str!("_examples/export.txt"))]
88    Export(ExportCommandsProcess),
89
90    /// Imports user commands from a file or stdin
91    #[command(after_long_help = include_str!("_examples/import.txt"))]
92    Import(ImportCommandsProcess),
93
94    /// Manages tldr integration
95    #[command(name = "tldr", subcommand)]
96    Tldr(TldrProcess),
97}
98
99#[derive(Subcommand)]
100#[cfg_attr(debug_assertions, derive(Debug))]
101pub enum TldrProcess {
102    /// Fetches command examples from tldr pages and imports them
103    ///
104    /// Imported commands will reside on a dfferent category and can be excluded when querying
105    #[command(after_long_help = include_str!("_examples/tldr_fetch.txt"))]
106    Fetch(TldrFetchProcess),
107
108    /// Clear command examples imported from tldr pages
109    #[command(after_long_help = include_str!("_examples/tldr_clear.txt"))]
110    Clear(TldrClearProcess),
111}
112
113/// A generic struct that combines process-specific arguments with common interactive mode options.
114///
115/// This struct is used to wrap processes that can be run in both interactive and non-interactive modes.
116#[derive(Args, Debug)]
117pub struct Interactive<T: FromArgMatches + Args> {
118    /// Options for the process
119    #[command(flatten)]
120    pub process: T,
121
122    /// Options for interactive display mode
123    #[command(flatten)]
124    pub opts: InteractiveOptions,
125}
126
127/// Options common to interactive processes
128#[derive(Args, Debug)]
129pub struct InteractiveOptions {
130    /// Open an interactive interface
131    #[arg(short = 'i', long)]
132    pub interactive: bool,
133
134    /// Force the interactive interface to render inline (takes less space)
135    #[arg(short = 'l', long, requires = "interactive", conflicts_with = "full_screen")]
136    pub inline: bool,
137
138    /// Force the interactive interface to render in full screen
139    #[arg(short = 'f', long, requires = "interactive", conflicts_with = "inline")]
140    pub full_screen: bool,
141}
142
143#[cfg(debug_assertions)]
144/// Runs an SQL query against the database
145#[derive(Args, Debug)]
146pub struct QueryProcess {
147    /// The query to run (reads from stdin if '-')
148    #[arg(default_value = "-")]
149    pub sql: FileOrStdin,
150}
151
152/// Generates the integration shell script
153#[derive(Args, Debug)]
154pub struct InitProcess {
155    /// The shell to generate the script for
156    #[arg(value_enum)]
157    pub shell: Shell,
158}
159
160#[derive(ValueEnum, Copy, Clone, PartialEq, Eq, Debug)]
161pub enum Shell {
162    Bash,
163    Zsh,
164    Fish,
165    Powershell,
166}
167
168/// Bookmarks a new command
169#[derive(Args, Debug)]
170pub struct BookmarkCommandProcess {
171    /// Command to be stored (mandatory when non-interactive)
172    ///
173    /// Take into consideration shell expansion and quote any special character intended to be stored
174    #[arg(required_unless_present = "interactive")]
175    pub command: Option<String>,
176
177    /// Alias for the command
178    #[arg(short = 'a', long)]
179    pub alias: Option<String>,
180
181    /// Description of the command
182    #[arg(short = 'd', long)]
183    pub description: Option<String>,
184}
185
186/// Search stored commands
187#[derive(Args, Debug)]
188pub struct SearchCommandsProcess {
189    /// Initial search query to filter commands
190    pub query: Option<String>,
191
192    /// Search mode, overwriting the default one on the config
193    #[arg(short = 'm', long)]
194    pub mode: Option<SearchMode>,
195
196    /// Whether to search for user commands only (ignoring tldr), overwriting the config
197    #[arg(short = 'u', long)]
198    pub user_only: bool,
199}
200
201/// Replace the variables of a command
202#[derive(Args, Debug)]
203pub struct VariableReplaceProcess {
204    /// Command to replace variables from (reads from stdin if '-')
205    ///
206    /// Take into consideration shell expansion and quote any special character that must be kept
207    #[arg(default_value = "-")]
208    pub command: MaybeStdin<String>,
209
210    /// Values for the variables, can be specified multiple times
211    ///
212    /// If only `KEY` is given (e.g., `--env api-token`), its value is read from the `API_TOKEN` environment variable
213    #[arg(short = 'e', long = "env", value_name = "KEY[=VALUE]", value_parser = ValueParser::new(parse_env_var))]
214    pub values: Vec<(String, Option<String>)>,
215
216    /// Automatically populates remaining unspecified variables from environment variables
217    ///
218    /// Unlike `--env` this flag will provide access to any environment variable, not only those explicitly listed
219    ///
220    /// Variable names are converted to SCREAMING_SNAKE_CASE to find matching variables (e.g., `{{http-header}}` checks
221    /// env var `HTTP_HEADER`)
222    ///
223    /// When run in interactive mode, env variables will be always suggested if found
224    #[arg(short = 'E', long)]
225    pub use_env: bool,
226}
227
228/// Exports stored user commands
229#[derive(Args, Debug)]
230pub struct ExportCommandsProcess {
231    /// Location to export commands to (writes to stdout if '-')
232    ///
233    /// The location type will be auto detected based on the content, if no type is specified
234    #[arg(default_value = "-")]
235    pub location: String,
236    /// Treat the location as a file path
237    #[arg(long, group = "location_type")]
238    pub file: bool,
239    /// Treat the location as a generic http(s) URL
240    #[arg(long, group = "location_type")]
241    pub http: bool,
242    /// Treat the location as a GitHub Gist URL or ID
243    #[arg(long, group = "location_type")]
244    pub gist: bool,
245    /// Export commands matching the given regular expression only
246    ///
247    /// The regular expression will be checked against both the command and the description
248    #[arg(long, value_name = "REGEX")]
249    pub filter: Option<Regex>,
250    /// Custom headers to include in the request
251    ///
252    /// This argument can be specified multiple times to add more than one header, but it will be only used for HTTP
253    /// locations
254    #[arg(short = 'H', long = "header", value_name = "KEY: VALUE", value_parser = ValueParser::new(parse_header))]
255    pub headers: Vec<(HeaderName, HeaderValue)>,
256    /// HTTP method to use for the request
257    ///
258    /// It will be only used for HTTP locations
259    #[arg(short = 'X', long = "request", value_enum, default_value_t = HttpMethod::PUT)]
260    pub method: HttpMethod,
261}
262
263/// Imports user commands
264#[derive(Args, Debug)]
265pub struct ImportCommandsProcess {
266    /// Location to import commands from (reads from stdin if '-')
267    ///
268    /// The location type will be auto detected based on the content, if no type is specified
269    #[arg(default_value = "-")]
270    pub location: String,
271    /// Treat the location as a file path
272    #[arg(long, group = "location_type")]
273    pub file: bool,
274    /// Treat the location as a generic http(s) URL
275    #[arg(long, group = "location_type")]
276    pub http: bool,
277    /// Treat the location as a GitHub Gist URL or ID
278    #[arg(long, group = "location_type")]
279    pub gist: bool,
280    /// Import commands matching the given regular expression only
281    ///
282    /// The regular expression will be checked against both the command and the description
283    #[arg(long, value_name = "REGEX")]
284    pub filter: Option<Regex>,
285    /// Do not import the commands, just output them
286    ///
287    /// This is useful when we're not sure about the format of the location we're importing
288    #[arg(long)]
289    pub dry_run: bool,
290    /// Add hastags to imported commands
291    ///
292    /// This argument can be specified multiple times to add more than one, hastags will be included at the end of the
293    /// description
294    #[arg(short = 't', long = "add-tag", value_name = "TAG")]
295    pub tags: Vec<String>,
296    /// Custom headers to include in the request
297    ///
298    /// This argument can be specified multiple times to add more than one header, but it will be only used for http
299    /// locations
300    #[arg(short = 'H', long = "header", value_name = "KEY: VALUE", value_parser = ValueParser::new(parse_header))]
301    pub headers: Vec<(HeaderName, HeaderValue)>,
302    /// HTTP method to use for the request
303    ///
304    /// It will be only used for http locations
305    #[arg(short = 'X', long = "request", value_enum, default_value_t = HttpMethod::GET)]
306    pub method: HttpMethod,
307}
308
309#[derive(ValueEnum, Copy, Clone, PartialEq, Eq, Debug)]
310pub enum HttpMethod {
311    GET,
312    POST,
313    PUT,
314    PATCH,
315}
316impl From<HttpMethod> for Method {
317    fn from(value: HttpMethod) -> Self {
318        match value {
319            HttpMethod::GET => Method::GET,
320            HttpMethod::POST => Method::POST,
321            HttpMethod::PUT => Method::PUT,
322            HttpMethod::PATCH => Method::PATCH,
323        }
324    }
325}
326
327/// Fetches command examples from tldr pages and imports them
328#[derive(Args, Debug)]
329pub struct TldrFetchProcess {
330    /// Category to fetch, skip to fetch for current platform (e.g., `common`, `linux`, `osx`, `windows`)
331    ///
332    /// For a full list of available categories, see: https://github.com/tldr-pages/tldr/tree/main/pages
333    pub category: Option<String>,
334
335    /// Fetches examples only for the specified command(s) (e.g., `git`, `docker`, `tar`)
336    ///
337    /// Command names should match their corresponding filenames (without the `.md` extension)
338    /// as found in the tldr pages repository
339    #[arg(short = 'c', long = "command", value_name = "COMMAND_NAME")]
340    pub commands: Vec<String>,
341
342    /// Fetches examples only for the command(s) from the file specified (reads from stdin if '-')
343    ///
344    /// The file or stdin must contain the command names as found in the tldr pages repository separated by newlines
345    #[arg(short = 'C', long, value_name = "FILE_OR_STDIN", num_args = 0..=1, default_missing_value = "-")]
346    pub filter_commands: Option<FileOrStdin>,
347}
348
349/// Clear command examples from tldr pages
350#[derive(Args, Debug)]
351pub struct TldrClearProcess {
352    /// Category to clear, skip to clear all categories
353    ///
354    /// For a full list of available categories, see: https://github.com/tldr-pages/tldr/tree/main/pages
355    pub category: Option<String>,
356}
357
358impl Cli {
359    /// Parses the [Cli] command, with any runtime extension required
360    #[instrument]
361    pub fn parse_extended() -> Self {
362        // Command definition
363        let mut cmd = Self::command_for_update();
364
365        // Update after_long_help to match the style, if present
366        let style = cmd.get_styles().clone();
367        let dimmed = style.get_placeholder().dimmed();
368        let plain_examples_header = "Examples:";
369        let styled_examples_header = format!(
370            "{}Examples:{}",
371            style.get_usage().render(),
372            style.get_usage().render_reset()
373        );
374        style_after_long_help(&mut cmd, &dimmed, plain_examples_header, &styled_examples_header);
375
376        // Parse the arguments
377        let matches = cmd.get_matches();
378
379        // Convert the argument matches back into the strongly typed `Cli` struct
380        match Cli::from_arg_matches(&matches) {
381            Ok(args) => args,
382            Err(err) => err.exit(),
383        }
384    }
385}
386
387fn style_after_long_help(
388    command_ref: &mut Command,
389    dimmed: &Style,
390    plain_examples_header: &str,
391    styled_examples_header: &str,
392) {
393    let mut command = std::mem::take(command_ref);
394    if let Some(after_long_help) = command.get_after_long_help() {
395        let current_help_text = after_long_help.to_string();
396        let modified_help_text = current_help_text
397            // Replace the examples header to match the same usage style
398            .replace(plain_examples_header, styled_examples_header)
399            // Style the comment lines to be dimmed
400            .lines()
401            .map(|line| {
402                if line.trim_start().starts_with('#') {
403                    format!("{}{}{}", dimmed.render(), line, dimmed.render_reset())
404                } else {
405                    line.to_string()
406                }
407            }).join("\n");
408        command = command.after_long_help(modified_help_text);
409    }
410    for subcommand_ref in command.get_subcommands_mut() {
411        style_after_long_help(subcommand_ref, dimmed, plain_examples_header, styled_examples_header);
412    }
413    *command_ref = command;
414}
415
416fn parse_env_var(env: &str) -> Result<(String, Option<String>)> {
417    if let Some((var, value)) = env.split_once('=') {
418        Ok((var.to_owned(), Some(value.to_owned())))
419    } else {
420        Ok((env.to_owned(), None))
421    }
422}
423
424fn parse_header(env: &str) -> Result<(HeaderName, HeaderValue)> {
425    if let Some((name, value)) = env.split_once(':') {
426        Ok((HeaderName::from_str(name)?, HeaderValue::from_str(value.trim_start())?))
427    } else {
428        Err(eyre!("Missing a colon between the header name and value"))
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_cli_asserts() {
438        Cli::command().debug_assert()
439    }
440}