use {
serde::{Deserialize, Serialize},
sha3::{Digest, Sha3_256},
std::path::{Path, PathBuf},
};
pub type CommandId = String;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommandsSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SourceMeta>,
pub entries: Vec<UserCommand>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceMeta {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserCommand {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub script: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<ArgDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub only_on: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<Shell>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub only_in_dir: Vec<String>,
#[serde(default)]
pub risky: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_run: Option<AfterRun>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(skip)]
pub source_path: Option<PathBuf>,
#[serde(skip)]
pub project_dir: Option<PathBuf>,
#[serde(skip)]
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum AfterRun {
#[default]
#[serde(alias = "auto")]
Auto,
#[serde(alias = "wait")]
Wait,
#[serde(alias = "delay")]
Delay(u64),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArgDef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default, rename = "type")]
pub arg_type: ArgType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_transform: Option<String>,
#[serde(default)]
pub secret: bool,
#[serde(default = "default_true")]
pub remember: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum ArgType {
#[default]
#[serde(alias = "text")]
Text,
#[serde(alias = "select")]
Select,
#[serde(alias = "multi-select", alias = "multi_select")]
MultiSelect,
}
impl UserCommand {
pub fn resolve_id(&self) -> CommandId {
if let Some(ref id) = self.id {
return id.clone();
}
self.generate_hash_id()
}
fn generate_hash_id(&self) -> CommandId {
let mut hasher = Sha3_256::new();
hasher.update(self.script.as_bytes());
let hash = hasher.finalize();
base16ct::lower::encode_string(&hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Shell {
Predefined(PredefinedShell),
Custom {
command: String,
#[serde(default = "default_sh_extension")]
extension: String,
#[serde(default)]
shebang: Option<String>,
},
}
fn default_sh_extension() -> String {
".sh".to_string()
}
impl Default for Shell {
#[cfg(unix)]
fn default() -> Self {
Self::Predefined(PredefinedShell::Sh)
}
#[cfg(target_os = "windows")]
fn default() -> Self {
Self::Predefined(PredefinedShell::Cmd)
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Shell::Predefined(p) => write!(f, "{p}"),
Shell::Custom { command, .. } => write!(f, "{command}"),
}
}
}
impl std::fmt::Display for PredefinedShell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PredefinedShell::Sh => write!(f, "sh"),
PredefinedShell::Bash => write!(f, "bash"),
PredefinedShell::Zsh => write!(f, "zsh"),
PredefinedShell::Fish => write!(f, "fish"),
PredefinedShell::Cmd => write!(f, "cmd"),
PredefinedShell::Powershell => write!(f, "powershell"),
}
}
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub enum PredefinedShell {
#[serde(alias = "sh")]
Sh,
#[serde(alias = "bash")]
Bash,
#[serde(alias = "zsh")]
Zsh,
#[serde(alias = "fish")]
Fish,
#[serde(alias = "cmd")]
Cmd,
#[serde(alias = "powershell")]
Powershell,
}
pub fn filter_only_in_dir(current_dir: &Path, command: &UserCommand) -> bool {
if command.only_in_dir.is_empty() {
return true;
}
command
.only_in_dir
.iter()
.any(|dir_glob| match glob::Pattern::new(dir_glob) {
Ok(p) => p.matches_path(current_dir),
Err(e) => {
eprintln!(
"Warning: \"{}\" has invalid `only_in_dir` glob \"{dir_glob}\": {e}",
command.name
);
false
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd_with(only_in_dir: Vec<&str>) -> UserCommand {
UserCommand {
id: None,
name: "test".into(),
description: None,
script: "echo hi".into(),
args: vec![],
tags: vec![],
only_on: None,
shell: None,
only_in_dir: only_in_dir.into_iter().map(String::from).collect(),
risky: false,
after_run: None,
working_dir: None,
source_path: None,
project_dir: None,
domain: None,
}
}
#[test]
fn filter_empty_matches_everything() {
let cmd = cmd_with(vec![]);
assert!(filter_only_in_dir(Path::new("/anywhere"), &cmd));
}
#[test]
fn filter_single_matching_glob() {
let cmd = cmd_with(vec!["/home/user/project*"]);
assert!(filter_only_in_dir(Path::new("/home/user/project"), &cmd));
}
#[test]
fn filter_single_non_matching_glob() {
let cmd = cmd_with(vec!["/home/user/project*"]);
assert!(!filter_only_in_dir(Path::new("/other/path"), &cmd));
}
#[test]
fn filter_multiple_globs_or_semantics() {
let cmd = cmd_with(vec!["/a/*", "/b/*"]);
assert!(filter_only_in_dir(Path::new("/b/foo"), &cmd));
}
#[test]
fn filter_multiple_globs_none_match() {
let cmd = cmd_with(vec!["/a/*", "/b/*"]);
assert!(!filter_only_in_dir(Path::new("/c/foo"), &cmd));
}
#[test]
fn filter_invalid_glob_no_panic() {
let cmd = cmd_with(vec!["[invalid"]);
assert!(!filter_only_in_dir(Path::new("/any"), &cmd));
}
#[test]
fn resolve_id_explicit() {
let mut cmd = cmd_with(vec![]);
cmd.id = Some("my-custom-id".into());
assert_eq!(cmd.resolve_id(), "my-custom-id");
}
#[test]
fn resolve_id_hash_fallback() {
let cmd = cmd_with(vec![]);
let id = cmd.resolve_id();
assert!(!id.is_empty());
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn resolve_id_same_script_same_hash() {
let a = cmd_with(vec![]);
let mut b = cmd_with(vec![]);
b.name = "different name".into();
assert_eq!(a.resolve_id(), b.resolve_id());
}
#[test]
fn resolve_id_different_script_different_hash() {
let a = cmd_with(vec![]);
let mut b = cmd_with(vec![]);
b.script = "echo bye".into();
assert_ne!(a.resolve_id(), b.resolve_id());
}
#[derive(Deserialize)]
struct ShellWrap {
shell: Shell,
}
#[derive(Deserialize)]
struct AfterRunWrap {
after_run: AfterRun,
}
#[test]
fn shell_predefined() {
let w: ShellWrap = toml::from_str(r#"shell = "Sh""#).unwrap();
assert!(matches!(w.shell, Shell::Predefined(PredefinedShell::Sh)));
}
#[test]
fn shell_alias() {
let w: ShellWrap = toml::from_str(r#"shell = "bash""#).unwrap();
assert!(matches!(w.shell, Shell::Predefined(PredefinedShell::Bash)));
}
#[test]
fn shell_custom() {
let w: ShellWrap =
toml::from_str(r#"shell = { command = "nushell", extension = ".nu" }"#).unwrap();
match w.shell {
Shell::Custom {
command,
extension,
shebang,
} => {
assert_eq!(command, "nushell");
assert_eq!(extension, ".nu");
assert!(shebang.is_none());
}
_ => panic!("expected Custom"),
}
}
#[test]
fn shell_custom_with_shebang() {
let w: ShellWrap =
toml::from_str(r##"shell = { command = "x", shebang = "#!/usr/bin/env x" }"##).unwrap();
match w.shell {
Shell::Custom { shebang, .. } => {
assert_eq!(shebang.as_deref(), Some("#!/usr/bin/env x"));
}
_ => panic!("expected Custom"),
}
}
#[test]
fn after_run_auto() {
let w: AfterRunWrap = toml::from_str(r#"after_run = "auto""#).unwrap();
assert!(matches!(w.after_run, AfterRun::Auto));
}
#[test]
fn after_run_wait() {
let w: AfterRunWrap = toml::from_str(r#"after_run = "wait""#).unwrap();
assert!(matches!(w.after_run, AfterRun::Wait));
}
#[test]
fn after_run_delay() {
let w: AfterRunWrap = toml::from_str(r#"after_run = { Delay = 5 }"#).unwrap();
assert!(matches!(w.after_run, AfterRun::Delay(5)));
}
#[test]
fn argdef_text_defaults() {
let a: ArgDef = toml::from_str(r#"name = "MSG""#).unwrap();
assert_eq!(a.name, "MSG");
assert!(matches!(a.arg_type, ArgType::Text));
assert!(a.remember);
assert!(!a.secret);
assert!(a.post_transform.is_none());
}
#[test]
fn argdef_select_with_choices() {
let a: ArgDef = toml::from_str(
r#"
name = "ENV"
type = "select"
choices = ["dev", "prod"]
"#,
)
.unwrap();
assert!(matches!(a.arg_type, ArgType::Select));
assert_eq!(a.choices, vec!["dev", "prod"]);
}
#[test]
fn argdef_multi_select_with_source() {
let a: ArgDef = toml::from_str(
r#"
name = "BRANCHES"
type = "multi-select"
source = "git branch"
post_transform = "tr '\n' ' '"
"#,
)
.unwrap();
assert!(matches!(a.arg_type, ArgType::MultiSelect));
assert_eq!(a.source.as_deref(), Some("git branch"));
assert!(a.post_transform.is_some());
}
}