Skip to main content

rippy_cli/
discover.rs

1//! Flag alias discovery — parse `--help` output to find short/long flag pairs.
2//!
3//! Enables auto-expansion: a rule with `flags = ["--force"]` also matches `-f`
4//! if the flag cache knows `--force` aliases to `-f`.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::process::ExitCode;
9
10use crate::cli::DiscoverArgs;
11use crate::config;
12use crate::error::RippyError;
13
14/// A short ↔ long flag pair discovered from help output.
15#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
16pub struct FlagAlias {
17    pub short: String,
18    pub long: String,
19}
20
21/// Current cache format version. Bump to force re-discovery on upgrade.
22const CACHE_VERSION: u32 = 1;
23
24/// Cached flag aliases keyed by command (e.g. "git push", "curl").
25#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
26pub struct FlagCache {
27    /// Cache format version — mismatched versions are discarded.
28    pub version: u32,
29    /// Map from command key to list of flag aliases.
30    pub entries: BTreeMap<String, Vec<FlagAlias>>,
31}
32
33impl Default for FlagCache {
34    fn default() -> Self {
35        Self {
36            version: CACHE_VERSION,
37            entries: BTreeMap::new(),
38        }
39    }
40}
41
42// ── Help output parser ─────────────────────────────────────────────────
43
44/// Parse help output text and extract short/long flag pairs.
45///
46/// Recognizes common CLI framework formats:
47/// - `  -f, --force          description`  (clap, cobra, argparse)
48/// - `  --force, -f          description`  (reverse order)
49/// - `       -n, --dry-run`                (git man pages)
50#[must_use]
51pub fn parse_help_output(output: &str) -> Vec<FlagAlias> {
52    let mut aliases = Vec::new();
53
54    for line in output.lines() {
55        if let Some(alias) = parse_flag_line(line) {
56            aliases.push(alias);
57        }
58    }
59
60    // Deduplicate by long flag (keep first occurrence).
61    let mut seen = std::collections::HashSet::new();
62    aliases.retain(|a| seen.insert(a.long.clone()));
63    aliases
64}
65
66/// Try to extract a flag alias from a single line.
67fn parse_flag_line(line: &str) -> Option<FlagAlias> {
68    let trimmed = line.trim();
69
70    // Find positions of short flag (-X) and long flag (--word).
71    // Pattern 1: -X, --long or -X --long
72    // Pattern 2: --long, -X or --long -X
73    let tokens: Vec<&str> = trimmed.split_whitespace().collect();
74
75    for window in tokens.windows(2) {
76        let a = window[0].trim_end_matches(',');
77        let b = window[1].trim_end_matches(',');
78
79        if let Some(alias) = match_flag_pair(a, b) {
80            return Some(alias);
81        }
82        if let Some(alias) = match_flag_pair(b, a) {
83            return Some(alias);
84        }
85    }
86
87    None
88}
89
90/// Check if two tokens form a short/long flag pair.
91fn match_flag_pair(a: &str, b: &str) -> Option<FlagAlias> {
92    let is_short = a.starts_with('-')
93        && !a.starts_with("--")
94        && a.len() == 2
95        && a.as_bytes().get(1).is_some_and(u8::is_ascii_alphabetic);
96
97    let is_long = b.starts_with("--") && b.len() > 2 && b.as_bytes()[2].is_ascii_alphabetic();
98
99    if is_short && is_long {
100        Some(FlagAlias {
101            short: a.to_string(),
102            long: b.to_string(),
103        })
104    } else {
105        None
106    }
107}
108
109// ── Flag cache ─────────────────────────────────────────────────────────
110
111fn cache_path() -> Option<PathBuf> {
112    config::home_dir().map(|h| h.join(".rippy/flag-cache.bin"))
113}
114
115/// Load the flag cache from `~/.rippy/flag-cache.bin`.
116#[must_use]
117pub fn load_cache() -> FlagCache {
118    let Some(path) = cache_path() else {
119        return FlagCache::default();
120    };
121    load_cache_from(&path).unwrap_or_default()
122}
123
124fn load_cache_from(path: &Path) -> Option<FlagCache> {
125    let bytes = std::fs::read(path).ok()?;
126    let cache = rkyv::from_bytes::<FlagCache, rkyv::rancor::Error>(&bytes).ok()?;
127    // Discard cache if version doesn't match (forces re-discovery on upgrade).
128    if cache.version != CACHE_VERSION {
129        return None;
130    }
131    Some(cache)
132}
133
134/// Save the flag cache to `~/.rippy/flag-cache.bin`.
135///
136/// # Errors
137///
138/// Returns `RippyError::Setup` if the file cannot be written.
139pub fn save_cache(cache: &FlagCache) -> Result<(), RippyError> {
140    let Some(path) = cache_path() else {
141        return Err(RippyError::Setup(
142            "could not determine home directory".into(),
143        ));
144    };
145
146    if let Some(parent) = path.parent() {
147        std::fs::create_dir_all(parent).map_err(|e| {
148            RippyError::Setup(format!("could not create {}: {e}", parent.display()))
149        })?;
150    }
151
152    let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cache)
153        .map_err(|e| RippyError::Setup(format!("could not serialize flag cache: {e}")))?;
154    std::fs::write(&path, &bytes)
155        .map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
156    Ok(())
157}
158
159#[cfg(test)]
160fn save_cache_to(cache: &FlagCache, path: &Path) -> Result<(), RippyError> {
161    let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cache)
162        .map_err(|e| RippyError::Setup(format!("could not serialize flag cache: {e}")))?;
163    std::fs::write(path, &bytes)
164        .map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
165    Ok(())
166}
167
168// ── Discovery ──────────────────────────────────────────────────────────
169
170/// Run a command with `--help` and parse the output for flag aliases.
171///
172/// # Errors
173///
174/// Returns `RippyError::Setup` if the command cannot be executed.
175pub fn discover_flags(
176    command: &str,
177    subcommand: Option<&str>,
178) -> Result<Vec<FlagAlias>, RippyError> {
179    let mut cmd = std::process::Command::new(command);
180    if let Some(sub) = subcommand {
181        cmd.arg(sub);
182    }
183    cmd.arg("--help");
184    cmd.stdout(std::process::Stdio::piped());
185    cmd.stderr(std::process::Stdio::piped());
186
187    let output = cmd
188        .output()
189        .map_err(|e| RippyError::Setup(format!("could not run `{command} --help`: {e}")))?;
190
191    // Some tools print help to stdout, others to stderr.
192    let stdout = String::from_utf8_lossy(&output.stdout);
193    let stderr = String::from_utf8_lossy(&output.stderr);
194    let combined = format!("{stdout}\n{stderr}");
195
196    Ok(parse_help_output(&combined))
197}
198
199/// Expand a list of flags with their aliases from the cache.
200///
201/// Given `["--force"]` and a cache with `--force → -f`, returns `["--force", "-f"]`.
202#[must_use]
203pub fn expand_flags(flags: &[String], cache: &FlagCache, command: Option<&str>) -> Vec<String> {
204    let mut expanded: Vec<String> = flags.to_vec();
205
206    let Some(cmd) = command else {
207        return expanded;
208    };
209
210    // Try exact command key and command-only key.
211    let aliases = cache.entries.get(cmd);
212
213    if let Some(alias_list) = aliases {
214        for flag in flags {
215            for alias in alias_list {
216                if flag == &alias.long && !expanded.contains(&alias.short) {
217                    expanded.push(alias.short.clone());
218                } else if flag == &alias.short && !expanded.contains(&alias.long) {
219                    expanded.push(alias.long.clone());
220                }
221            }
222        }
223    }
224
225    expanded
226}
227
228// ── CLI entry point ────────────────────────────────────────────────────
229
230/// Run the `rippy discover` command.
231///
232/// # Errors
233///
234/// Returns `RippyError::Setup` if discovery or cache writing fails.
235pub fn run(args: &DiscoverArgs) -> Result<ExitCode, RippyError> {
236    if args.all {
237        return rediscover_all(args.json);
238    }
239
240    let Some(command) = args.args.first() else {
241        return Err(RippyError::Setup(
242            "usage: rippy discover <command> [subcommand]".into(),
243        ));
244    };
245
246    let subcommand = args.args.get(1).map(String::as_str);
247    let aliases = discover_flags(command, subcommand)?;
248
249    if args.json {
250        print_json(&aliases);
251    } else {
252        print_text(command, subcommand, &aliases);
253    }
254
255    // Update cache.
256    let mut cache = load_cache();
257    let key = cache_key(command, subcommand);
258    cache.entries.insert(key, aliases);
259    save_cache(&cache)?;
260
261    Ok(ExitCode::SUCCESS)
262}
263
264fn cache_key(command: &str, subcommand: Option<&str>) -> String {
265    subcommand.map_or_else(|| command.to_string(), |sub| format!("{command} {sub}"))
266}
267
268fn rediscover_all(json: bool) -> Result<ExitCode, RippyError> {
269    let cache = load_cache();
270    let mut new_cache = FlagCache::default();
271
272    for key in cache.entries.keys() {
273        let mut parts = key.split_whitespace();
274        let Some(cmd) = parts.next() else { continue };
275        let sub = parts.next();
276        match discover_flags(cmd, sub) {
277            Ok(aliases) => {
278                if !json {
279                    eprintln!("[rippy] discovered {} flags for {key}", aliases.len());
280                }
281                new_cache.entries.insert(key.clone(), aliases);
282            }
283            Err(e) => {
284                eprintln!("[rippy] warning: {key}: {e}");
285            }
286        }
287    }
288
289    save_cache(&new_cache)?;
290    if json {
291        println!("{{\"refreshed\": {}}}", new_cache.entries.len());
292    } else {
293        eprintln!("[rippy] Refreshed {} commands", new_cache.entries.len());
294    }
295    Ok(ExitCode::SUCCESS)
296}
297
298fn print_text(command: &str, subcommand: Option<&str>, aliases: &[FlagAlias]) {
299    let label = subcommand.map_or_else(|| command.to_string(), |sub| format!("{command} {sub}"));
300    if aliases.is_empty() {
301        eprintln!("[rippy] No flag aliases discovered for {label}");
302        return;
303    }
304    println!("Flag aliases for {label}:\n");
305    for alias in aliases {
306        println!("  {:<6} {}", alias.short, alias.long);
307    }
308    println!("\n{} alias(es) cached.", aliases.len());
309}
310
311fn print_json(aliases: &[FlagAlias]) {
312    let pairs: Vec<serde_json::Value> = aliases
313        .iter()
314        .map(|a| {
315            serde_json::json!({
316                "short": a.short,
317                "long": a.long,
318            })
319        })
320        .collect();
321    let json = serde_json::to_string_pretty(&serde_json::Value::Array(pairs));
322    if let Ok(j) = json {
323        println!("{j}");
324    }
325}
326
327// ── Tests ──────────────────────────────────────────────────────────────
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn parse_clap_style() {
336        let help = "  -f, --force          Force operation\n  -v, --verbose        Be verbose\n";
337        let aliases = parse_help_output(help);
338        assert_eq!(aliases.len(), 2);
339        assert_eq!(aliases[0].short, "-f");
340        assert_eq!(aliases[0].long, "--force");
341        assert_eq!(aliases[1].short, "-v");
342        assert_eq!(aliases[1].long, "--verbose");
343    }
344
345    #[test]
346    fn parse_git_manpage_style() {
347        let help = "       -n, --dry-run\n       -d, --delete\n";
348        let aliases = parse_help_output(help);
349        assert_eq!(aliases.len(), 2);
350        assert_eq!(aliases[0].short, "-n");
351        assert_eq!(aliases[0].long, "--dry-run");
352    }
353
354    #[test]
355    fn parse_reverse_order() {
356        let help = "  --force, -f          Force operation\n";
357        let aliases = parse_help_output(help);
358        assert_eq!(aliases.len(), 1);
359        assert_eq!(aliases[0].short, "-f");
360        assert_eq!(aliases[0].long, "--force");
361    }
362
363    #[test]
364    fn parse_no_comma() {
365        let help = "  -q --quiet           Suppress output\n";
366        let aliases = parse_help_output(help);
367        assert_eq!(aliases.len(), 1);
368        assert_eq!(aliases[0].short, "-q");
369        assert_eq!(aliases[0].long, "--quiet");
370    }
371
372    #[test]
373    fn parse_with_value_placeholder() {
374        let help = "  -o, --output <file>  Write to file\n";
375        let aliases = parse_help_output(help);
376        assert_eq!(aliases.len(), 1);
377        assert_eq!(aliases[0].short, "-o");
378        assert_eq!(aliases[0].long, "--output");
379    }
380
381    #[test]
382    fn parse_ignores_long_only() {
383        let help = "  --verbose            Be verbose\n  --quiet              Quiet\n";
384        let aliases = parse_help_output(help);
385        assert!(aliases.is_empty());
386    }
387
388    #[test]
389    fn parse_ignores_noise() {
390        let help = "Usage: git push [options]\n\nOptions:\n  This is a description.\n";
391        let aliases = parse_help_output(help);
392        assert!(aliases.is_empty());
393    }
394
395    #[test]
396    fn parse_deduplicates() {
397        let help = "  -f, --force   Force\n  -f, --force   Force again\n";
398        let aliases = parse_help_output(help);
399        assert_eq!(aliases.len(), 1);
400    }
401
402    #[test]
403    fn parse_curl_real_output() {
404        let help = "\
405 -d, --data <data>           HTTP POST data
406 -f, --fail                  Fail fast with no output on HTTP errors
407 -h, --help <category>       Get help for commands
408 -i, --include               Include response headers in output
409 -o, --output <file>         Write to file instead of stdout
410 -s, --silent                Silent mode
411 -u, --user <user:password>  Server user and password";
412        let aliases = parse_help_output(help);
413        assert_eq!(aliases.len(), 7);
414        assert!(
415            aliases
416                .iter()
417                .any(|a| a.short == "-f" && a.long == "--fail")
418        );
419        assert!(
420            aliases
421                .iter()
422                .any(|a| a.short == "-s" && a.long == "--silent")
423        );
424    }
425
426    #[test]
427    fn expand_flags_with_cache() {
428        let mut cache = FlagCache::default();
429        cache.entries.insert(
430            "git push".into(),
431            vec![FlagAlias {
432                short: "-f".into(),
433                long: "--force".into(),
434            }],
435        );
436
437        let expanded = expand_flags(&["--force".into()], &cache, Some("git push"));
438        assert!(expanded.contains(&"--force".to_string()));
439        assert!(expanded.contains(&"-f".to_string()));
440    }
441
442    #[test]
443    fn expand_flags_reverse() {
444        let mut cache = FlagCache::default();
445        cache.entries.insert(
446            "curl".into(),
447            vec![FlagAlias {
448                short: "-s".into(),
449                long: "--silent".into(),
450            }],
451        );
452
453        let expanded = expand_flags(&["-s".into()], &cache, Some("curl"));
454        assert!(expanded.contains(&"-s".to_string()));
455        assert!(expanded.contains(&"--silent".to_string()));
456    }
457
458    #[test]
459    fn expand_flags_no_cache_entry() {
460        let cache = FlagCache::default();
461        let expanded = expand_flags(&["--force".into()], &cache, Some("unknown"));
462        assert_eq!(expanded, vec!["--force".to_string()]);
463    }
464
465    #[test]
466    fn expand_flags_no_command() {
467        let cache = FlagCache::default();
468        let expanded = expand_flags(&["--force".into()], &cache, None);
469        assert_eq!(expanded, vec!["--force".to_string()]);
470    }
471
472    #[test]
473    fn cache_round_trip() {
474        let dir = tempfile::TempDir::new().unwrap();
475        let path = dir.path().join("flag-cache.bin");
476
477        let mut cache = FlagCache::default();
478        cache.entries.insert(
479            "git push".into(),
480            vec![
481                FlagAlias {
482                    short: "-f".into(),
483                    long: "--force".into(),
484                },
485                FlagAlias {
486                    short: "-n".into(),
487                    long: "--dry-run".into(),
488                },
489            ],
490        );
491
492        // Save via save_cache_to (same serialization as save_cache)
493        save_cache_to(&cache, &path).unwrap();
494
495        // Load
496        let loaded = load_cache_from(&path).unwrap();
497        assert!(loaded.entries.contains_key("git push"));
498        let aliases = &loaded.entries["git push"];
499        assert_eq!(aliases.len(), 2);
500        assert!(
501            aliases
502                .iter()
503                .any(|a| a.short == "-f" && a.long == "--force")
504        );
505    }
506
507    #[test]
508    fn cache_version_mismatch_returns_none() {
509        let dir = tempfile::TempDir::new().unwrap();
510        let path = dir.path().join("flag-cache.bin");
511
512        let cache = FlagCache {
513            version: 999, // Wrong version
514            ..FlagCache::default()
515        };
516        save_cache_to(&cache, &path).unwrap();
517
518        assert!(load_cache_from(&path).is_none());
519    }
520
521    #[test]
522    fn cache_key_format() {
523        assert_eq!(cache_key("git", Some("push")), "git push");
524        assert_eq!(cache_key("curl", None), "curl");
525    }
526}