use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::cli::DiscoverArgs;
use crate::config;
use crate::error::RippyError;
#[derive(Debug, Clone, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct FlagAlias {
pub short: String,
pub long: String,
}
const CACHE_VERSION: u32 = 1;
#[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct FlagCache {
pub version: u32,
pub entries: BTreeMap<String, Vec<FlagAlias>>,
}
impl Default for FlagCache {
fn default() -> Self {
Self {
version: CACHE_VERSION,
entries: BTreeMap::new(),
}
}
}
#[must_use]
pub fn parse_help_output(output: &str) -> Vec<FlagAlias> {
let mut aliases = Vec::new();
for line in output.lines() {
if let Some(alias) = parse_flag_line(line) {
aliases.push(alias);
}
}
let mut seen = std::collections::HashSet::new();
aliases.retain(|a| seen.insert(a.long.clone()));
aliases
}
fn parse_flag_line(line: &str) -> Option<FlagAlias> {
let trimmed = line.trim();
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
for window in tokens.windows(2) {
let a = window[0].trim_end_matches(',');
let b = window[1].trim_end_matches(',');
if let Some(alias) = match_flag_pair(a, b) {
return Some(alias);
}
if let Some(alias) = match_flag_pair(b, a) {
return Some(alias);
}
}
None
}
fn match_flag_pair(a: &str, b: &str) -> Option<FlagAlias> {
let is_short = a.starts_with('-')
&& !a.starts_with("--")
&& a.len() == 2
&& a.as_bytes().get(1).is_some_and(u8::is_ascii_alphabetic);
let is_long = b.starts_with("--") && b.len() > 2 && b.as_bytes()[2].is_ascii_alphabetic();
if is_short && is_long {
Some(FlagAlias {
short: a.to_string(),
long: b.to_string(),
})
} else {
None
}
}
fn cache_path() -> Option<PathBuf> {
config::home_dir().map(|h| h.join(".rippy/flag-cache.bin"))
}
#[must_use]
pub fn load_cache() -> FlagCache {
let Some(path) = cache_path() else {
return FlagCache::default();
};
load_cache_from(&path).unwrap_or_default()
}
fn load_cache_from(path: &Path) -> Option<FlagCache> {
let bytes = std::fs::read(path).ok()?;
let cache = rkyv::from_bytes::<FlagCache, rkyv::rancor::Error>(&bytes).ok()?;
if cache.version != CACHE_VERSION {
return None;
}
Some(cache)
}
pub fn save_cache(cache: &FlagCache) -> Result<(), RippyError> {
let Some(path) = cache_path() else {
return Err(RippyError::Setup(
"could not determine home directory".into(),
));
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
RippyError::Setup(format!("could not create {}: {e}", parent.display()))
})?;
}
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cache)
.map_err(|e| RippyError::Setup(format!("could not serialize flag cache: {e}")))?;
std::fs::write(&path, &bytes)
.map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
Ok(())
}
#[cfg(test)]
fn save_cache_to(cache: &FlagCache, path: &Path) -> Result<(), RippyError> {
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cache)
.map_err(|e| RippyError::Setup(format!("could not serialize flag cache: {e}")))?;
std::fs::write(path, &bytes)
.map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))?;
Ok(())
}
pub fn discover_flags(
command: &str,
subcommand: Option<&str>,
) -> Result<Vec<FlagAlias>, RippyError> {
let mut cmd = std::process::Command::new(command);
if let Some(sub) = subcommand {
cmd.arg(sub);
}
cmd.arg("--help");
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let output = cmd
.output()
.map_err(|e| RippyError::Setup(format!("could not run `{command} --help`: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
Ok(parse_help_output(&combined))
}
#[must_use]
pub fn expand_flags(flags: &[String], cache: &FlagCache, command: Option<&str>) -> Vec<String> {
let mut expanded: Vec<String> = flags.to_vec();
let Some(cmd) = command else {
return expanded;
};
let aliases = cache.entries.get(cmd);
if let Some(alias_list) = aliases {
for flag in flags {
for alias in alias_list {
if flag == &alias.long && !expanded.contains(&alias.short) {
expanded.push(alias.short.clone());
} else if flag == &alias.short && !expanded.contains(&alias.long) {
expanded.push(alias.long.clone());
}
}
}
}
expanded
}
pub fn run(args: &DiscoverArgs) -> Result<ExitCode, RippyError> {
if args.all {
return rediscover_all(args.json);
}
let Some(command) = args.args.first() else {
return Err(RippyError::Setup(
"usage: rippy discover <command> [subcommand]".into(),
));
};
let subcommand = args.args.get(1).map(String::as_str);
let aliases = discover_flags(command, subcommand)?;
if args.json {
print_json(&aliases);
} else {
print_text(command, subcommand, &aliases);
}
let mut cache = load_cache();
let key = cache_key(command, subcommand);
cache.entries.insert(key, aliases);
save_cache(&cache)?;
Ok(ExitCode::SUCCESS)
}
fn cache_key(command: &str, subcommand: Option<&str>) -> String {
subcommand.map_or_else(|| command.to_string(), |sub| format!("{command} {sub}"))
}
fn rediscover_all(json: bool) -> Result<ExitCode, RippyError> {
let cache = load_cache();
let mut new_cache = FlagCache::default();
for key in cache.entries.keys() {
let mut parts = key.split_whitespace();
let Some(cmd) = parts.next() else { continue };
let sub = parts.next();
match discover_flags(cmd, sub) {
Ok(aliases) => {
if !json {
eprintln!("[rippy] discovered {} flags for {key}", aliases.len());
}
new_cache.entries.insert(key.clone(), aliases);
}
Err(e) => {
eprintln!("[rippy] warning: {key}: {e}");
}
}
}
save_cache(&new_cache)?;
if json {
println!("{{\"refreshed\": {}}}", new_cache.entries.len());
} else {
eprintln!("[rippy] Refreshed {} commands", new_cache.entries.len());
}
Ok(ExitCode::SUCCESS)
}
fn print_text(command: &str, subcommand: Option<&str>, aliases: &[FlagAlias]) {
let label = subcommand.map_or_else(|| command.to_string(), |sub| format!("{command} {sub}"));
if aliases.is_empty() {
eprintln!("[rippy] No flag aliases discovered for {label}");
return;
}
println!("Flag aliases for {label}:\n");
for alias in aliases {
println!(" {:<6} {}", alias.short, alias.long);
}
println!("\n{} alias(es) cached.", aliases.len());
}
fn print_json(aliases: &[FlagAlias]) {
let pairs: Vec<serde_json::Value> = aliases
.iter()
.map(|a| {
serde_json::json!({
"short": a.short,
"long": a.long,
})
})
.collect();
let json = serde_json::to_string_pretty(&serde_json::Value::Array(pairs));
if let Ok(j) = json {
println!("{j}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_clap_style() {
let help = " -f, --force Force operation\n -v, --verbose Be verbose\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 2);
assert_eq!(aliases[0].short, "-f");
assert_eq!(aliases[0].long, "--force");
assert_eq!(aliases[1].short, "-v");
assert_eq!(aliases[1].long, "--verbose");
}
#[test]
fn parse_git_manpage_style() {
let help = " -n, --dry-run\n -d, --delete\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 2);
assert_eq!(aliases[0].short, "-n");
assert_eq!(aliases[0].long, "--dry-run");
}
#[test]
fn parse_reverse_order() {
let help = " --force, -f Force operation\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].short, "-f");
assert_eq!(aliases[0].long, "--force");
}
#[test]
fn parse_no_comma() {
let help = " -q --quiet Suppress output\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].short, "-q");
assert_eq!(aliases[0].long, "--quiet");
}
#[test]
fn parse_with_value_placeholder() {
let help = " -o, --output <file> Write to file\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 1);
assert_eq!(aliases[0].short, "-o");
assert_eq!(aliases[0].long, "--output");
}
#[test]
fn parse_ignores_long_only() {
let help = " --verbose Be verbose\n --quiet Quiet\n";
let aliases = parse_help_output(help);
assert!(aliases.is_empty());
}
#[test]
fn parse_ignores_noise() {
let help = "Usage: git push [options]\n\nOptions:\n This is a description.\n";
let aliases = parse_help_output(help);
assert!(aliases.is_empty());
}
#[test]
fn parse_deduplicates() {
let help = " -f, --force Force\n -f, --force Force again\n";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 1);
}
#[test]
fn parse_curl_real_output() {
let help = "\
-d, --data <data> HTTP POST data
-f, --fail Fail fast with no output on HTTP errors
-h, --help <category> Get help for commands
-i, --include Include response headers in output
-o, --output <file> Write to file instead of stdout
-s, --silent Silent mode
-u, --user <user:password> Server user and password";
let aliases = parse_help_output(help);
assert_eq!(aliases.len(), 7);
assert!(
aliases
.iter()
.any(|a| a.short == "-f" && a.long == "--fail")
);
assert!(
aliases
.iter()
.any(|a| a.short == "-s" && a.long == "--silent")
);
}
#[test]
fn expand_flags_with_cache() {
let mut cache = FlagCache::default();
cache.entries.insert(
"git push".into(),
vec![FlagAlias {
short: "-f".into(),
long: "--force".into(),
}],
);
let expanded = expand_flags(&["--force".into()], &cache, Some("git push"));
assert!(expanded.contains(&"--force".to_string()));
assert!(expanded.contains(&"-f".to_string()));
}
#[test]
fn expand_flags_reverse() {
let mut cache = FlagCache::default();
cache.entries.insert(
"curl".into(),
vec![FlagAlias {
short: "-s".into(),
long: "--silent".into(),
}],
);
let expanded = expand_flags(&["-s".into()], &cache, Some("curl"));
assert!(expanded.contains(&"-s".to_string()));
assert!(expanded.contains(&"--silent".to_string()));
}
#[test]
fn expand_flags_no_cache_entry() {
let cache = FlagCache::default();
let expanded = expand_flags(&["--force".into()], &cache, Some("unknown"));
assert_eq!(expanded, vec!["--force".to_string()]);
}
#[test]
fn expand_flags_no_command() {
let cache = FlagCache::default();
let expanded = expand_flags(&["--force".into()], &cache, None);
assert_eq!(expanded, vec!["--force".to_string()]);
}
#[test]
fn cache_round_trip() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("flag-cache.bin");
let mut cache = FlagCache::default();
cache.entries.insert(
"git push".into(),
vec![
FlagAlias {
short: "-f".into(),
long: "--force".into(),
},
FlagAlias {
short: "-n".into(),
long: "--dry-run".into(),
},
],
);
save_cache_to(&cache, &path).unwrap();
let loaded = load_cache_from(&path).unwrap();
assert!(loaded.entries.contains_key("git push"));
let aliases = &loaded.entries["git push"];
assert_eq!(aliases.len(), 2);
assert!(
aliases
.iter()
.any(|a| a.short == "-f" && a.long == "--force")
);
}
#[test]
fn cache_version_mismatch_returns_none() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("flag-cache.bin");
let cache = FlagCache {
version: 999, ..FlagCache::default()
};
save_cache_to(&cache, &path).unwrap();
assert!(load_cache_from(&path).is_none());
}
#[test]
fn cache_key_format() {
assert_eq!(cache_key("git", Some("push")), "git push");
assert_eq!(cache_key("curl", None), "curl");
}
}