use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, UNIX_EPOCH};
use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BinSubcommand {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinSchema {
pub binary: String,
pub bin_mtime: u64,
pub schema_version: u32,
pub subcommands: Vec<BinSubcommand>,
}
const SCHEMA_VERSION: u32 = 1;
const HELP_TIMEOUT: Duration = Duration::from_millis(500);
const SKIP_HELP_PROBE: &[&str] = &[
"alacritty", "sccache", "searchboxd", "trunk", "nu_plugin_query", "rustup", "samply", "hotpath", "hotpath-samply", "hotpath-crashtest", "wgslfmt", "kani", "cargo-kani", ];
pub fn schema_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from(".config"))
.join("xtui")
.join("bin-schema")
}
pub fn load_cached(dir: &std::path::Path, binary_name: &str) -> Option<BinSchema> {
let file = dir.join(format!("{binary_name}.json"));
let contents = fs::read_to_string(&file).ok()?;
let schema: BinSchema = serde_json::from_str(&contents).ok()?;
if schema.schema_version != SCHEMA_VERSION {
return None;
}
let current_mtime = mtime_of_binary(binary_name)?;
if schema.bin_mtime != current_mtime {
return None;
}
Some(schema)
}
pub fn save_schema(dir: &std::path::Path, schema: &BinSchema) -> Result<()> {
fs::create_dir_all(dir)?;
let file = dir.join(format!("{}.json", schema.binary));
let json = serde_json::to_string_pretty(schema)?;
fs::write(file, json)?;
Ok(())
}
pub fn probe_and_cache(dir: &std::path::Path, binary_name: &str) -> BinSchema {
let bin_mtime = mtime_of_binary(binary_name).unwrap_or(0);
let subcommands = if SKIP_HELP_PROBE.contains(&binary_name) {
vec![]
} else {
probe_subcommands(binary_name).unwrap_or_default()
};
let schema = BinSchema {
binary: binary_name.to_string(),
bin_mtime,
schema_version: SCHEMA_VERSION,
subcommands,
};
let _ = save_schema(dir, &schema);
schema
}
pub fn get_schema(dir: &std::path::Path, binary_name: &str) -> BinSchema {
if let Some(cached) = load_cached(dir, binary_name) {
return cached;
}
probe_and_cache(dir, binary_name)
}
fn mtime_of_binary(name: &str) -> Option<u64> {
let path = dirs::home_dir()?.join(".cargo").join("bin").join(name);
let meta = fs::metadata(path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
Some(mtime)
}
fn probe_subcommands(binary_name: &str) -> Option<Vec<BinSubcommand>> {
let bin_path = dirs::home_dir()?
.join(".cargo")
.join("bin")
.join(binary_name);
let bin_path_clone = bin_path.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = Command::new(&bin_path_clone).arg("--help").output();
let _ = tx.send(result);
});
let output = rx.recv_timeout(HELP_TIMEOUT).ok()?.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
let stderr_text = String::from_utf8_lossy(&output.stderr);
let help_text = if text.trim().is_empty() {
&stderr_text
} else {
&text
};
let subs = parse_help_subcommands(help_text);
Some(subs)
}
pub fn parse_help_subcommands(help: &str) -> Vec<BinSubcommand> {
let section_re =
Regex::new(r"(?i)^\s*(commands|subcommands|available commands|subcommand)[:\s]*$").unwrap();
let cmd_re = Regex::new(r"^ {1,8}(\S+)\s{2,}(.+)$").unwrap();
let bare_re = Regex::new(r"^ {1,8}(\S+)\s*$").unwrap();
let mut in_section = false;
let mut results: Vec<BinSubcommand> = Vec::new();
for line in help.lines() {
if section_re.is_match(line) {
in_section = true;
continue;
}
if in_section && !line.starts_with(' ') && !line.trim().is_empty() {
in_section = false;
}
if in_section {
if let Some(caps) = cmd_re.captures(line) {
let name = caps[1].to_string();
let desc = caps[2].trim().to_string();
if !name.starts_with('-') && name != "help" {
results.push(BinSubcommand {
name,
description: Some(desc),
});
}
} else if let Some(caps) = bare_re.captures(line) {
let name = caps[1].to_string();
if !name.starts_with('-') && name != "help" {
results.push(BinSubcommand {
name,
description: None,
});
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
fn subs(help: &str) -> Vec<String> {
parse_help_subcommands(help)
.into_iter()
.map(|s| s.name)
.collect()
}
#[test]
fn parses_commands_section() {
let help = "
Usage: mytool <command>
Commands:
build Compile the project
test Run the test suite
clean Remove build artifacts
Options:
--help Print this help
";
assert_eq!(subs(help), vec!["build", "test", "clean"]);
}
#[test]
fn parses_subcommands_section() {
let help = "
SUBCOMMANDS:
run Run a task
list List tasks
help Print help
";
assert_eq!(subs(help), vec!["run", "list"]);
}
#[test]
fn ignores_flags_in_section() {
let help = "
Commands:
build Build
--verbose Enable verbose output
test Test
";
assert_eq!(subs(help), vec!["build", "test"]);
}
#[test]
fn returns_empty_when_no_section() {
let help = "Usage: tool [OPTIONS]\n --help\n --version\n";
assert!(subs(help).is_empty());
}
#[test]
fn parses_bare_subcommands_no_description() {
let help = "
Commands:
init
run
";
assert_eq!(subs(help), vec!["init", "run"]);
}
#[test]
fn skip_list_is_not_empty() {
#[allow(clippy::const_is_empty)]
let not_empty = !SKIP_HELP_PROBE.is_empty();
assert!(not_empty);
assert!(SKIP_HELP_PROBE.contains(&"alacritty"));
assert!(SKIP_HELP_PROBE.contains(&"sccache"));
}
}