1use 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#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
16pub struct FlagAlias {
17 pub short: String,
18 pub long: String,
19}
20
21const CACHE_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
26pub struct FlagCache {
27 pub version: u32,
29 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#[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 let mut seen = std::collections::HashSet::new();
62 aliases.retain(|a| seen.insert(a.long.clone()));
63 aliases
64}
65
66fn parse_flag_line(line: &str) -> Option<FlagAlias> {
68 let trimmed = line.trim();
69
70 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
90fn 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
109fn cache_path() -> Option<PathBuf> {
112 config::home_dir().map(|h| h.join(".rippy/flag-cache.bin"))
113}
114
115#[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 if cache.version != CACHE_VERSION {
129 return None;
130 }
131 Some(cache)
132}
133
134pub 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
168pub 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 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#[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 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
228pub 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 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#[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_cache_to(&cache, &path).unwrap();
494
495 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, ..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}