use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
pub use clap_complete::Shell;
#[derive(Parser)]
#[command(
name = "stash",
about = "Encrypted notes, URLs, and secrets manager",
version
)]
pub struct Cli {
#[arg(long, value_name = "FILE", global = true)]
pub db: Option<PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Auth {
#[command(subcommand)]
action: AuthAction,
},
Add {
item_type: ItemType,
shortname: String,
#[arg(short = 'e', long)]
edit: bool,
#[arg(long)]
stdin: bool,
#[arg(short = 't', long, value_name = "TITLE")]
title: Option<String>,
#[arg(short = 'g', long = "tag", value_name = "TAG")]
tags: Vec<String>,
#[arg(short = 'b', long)]
browser: Option<String>,
text: Option<String>,
},
Show {
#[arg(short = 'v', long)]
verbose: bool,
#[arg(short = 'c', long)]
copy: bool,
#[arg(long, value_name = "SECONDS")]
clear_after: Option<u64>,
shortname: String,
},
History { shortname: String },
Edit {
shortname: String,
#[arg(short = 't', long, value_name = "TITLE")]
title: Option<String>,
},
Web {
#[arg(short = 'p', long)]
private: bool,
#[arg(short = 'b', long)]
browser: Option<String>,
shortname: String,
},
Purge {
#[arg(short = 'f', long)]
force: bool,
shortname: String,
},
List {
#[arg(short = 'g', long = "tag", value_name = "TAG")]
tags: Vec<String>,
#[arg(short = 't', long = "type", value_name = "TYPE")]
item_type: Option<ItemType>,
},
Tag {
shortname: String,
#[arg(required = true)]
tags: Vec<String>,
},
Untag {
shortname: String,
#[arg(required = true)]
tags: Vec<String>,
},
Search {
pattern: Option<String>,
#[arg(short = 'r', long)]
regex: bool,
#[arg(short = 'H', long)]
include_history: bool,
#[arg(short = 'g', long = "tag", value_name = "TAG")]
tag: Option<String>,
#[arg(short = 't', long = "type", value_name = "TYPE")]
item_type: Option<ItemType>,
},
Rename { shortname: String, new_name: String },
Restore {
shortname: String,
#[arg(long, value_name = "N")]
version: Option<i64>,
},
Copy { shortname: String, dest: String },
Browser {
shortname: String,
browser: Option<String>,
#[arg(long)]
clear: bool,
#[arg(long, conflicts_with = "no_private")]
private: bool,
#[arg(long, conflicts_with = "private")]
no_private: bool,
},
Import {
file: Option<PathBuf>,
#[arg(long)]
overwrite: bool,
},
Export {
#[arg(short = 'o', long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
include_history: bool,
},
Migrate,
Completions {
shell: Shell,
},
}
#[derive(Subcommand)]
pub enum AuthAction {
Login {
#[arg(long, value_name = "MINUTES")]
timeout: Option<u64>,
},
Status,
Logout,
Reset,
}
#[derive(ValueEnum, Clone, Debug)]
pub enum ItemType {
Url,
Note,
}
impl std::fmt::Display for ItemType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ItemType::Url => write!(f, "url"),
ItemType::Note => write!(f, "note"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(args)
}
#[test]
fn auth_login_no_timeout() {
let cli = parse(&["stash", "auth", "login"]).unwrap();
if let Commands::Auth {
action: AuthAction::Login { timeout },
} = cli.command
{
assert!(timeout.is_none());
} else {
panic!("wrong variant");
}
}
#[test]
fn auth_login_with_timeout() {
let cli = parse(&["stash", "auth", "login", "--timeout", "60"]).unwrap();
if let Commands::Auth {
action: AuthAction::Login { timeout },
} = cli.command
{
assert_eq!(timeout, Some(60));
} else {
panic!("wrong variant");
}
}
#[test]
fn auth_login_timeout_zero_disables_expiry() {
let cli = parse(&["stash", "auth", "login", "--timeout", "0"]).unwrap();
if let Commands::Auth {
action: AuthAction::Login { timeout },
} = cli.command
{
assert_eq!(timeout, Some(0));
} else {
panic!("wrong variant");
}
}
#[test]
fn auth_status() {
let cli = parse(&["stash", "auth", "status"]).unwrap();
assert!(matches!(
cli.command,
Commands::Auth {
action: AuthAction::Status
}
));
}
#[test]
fn auth_logout() {
let cli = parse(&["stash", "auth", "logout"]).unwrap();
assert!(matches!(
cli.command,
Commands::Auth {
action: AuthAction::Logout
}
));
}
#[test]
fn add_inline_text() {
let cli = parse(&["stash", "add", "note", "k", "hello"]).unwrap();
if let Commands::Add {
item_type,
shortname,
edit,
stdin,
text,
..
} = cli.command
{
assert!(matches!(item_type, ItemType::Note));
assert_eq!(shortname, "k");
assert!(!edit);
assert!(!stdin);
assert_eq!(text.as_deref(), Some("hello"));
} else {
panic!("wrong variant");
}
}
#[test]
fn add_url_positional() {
let cli = parse(&["stash", "add", "url", "gh", "https://x.com"]).unwrap();
if let Commands::Add {
item_type,
shortname,
text,
..
} = cli.command
{
assert!(matches!(item_type, ItemType::Url));
assert_eq!(shortname, "gh");
assert_eq!(text.as_deref(), Some("https://x.com"));
} else {
panic!("wrong variant");
}
}
#[test]
fn add_rejects_invalid_type() {
assert!(parse(&["stash", "add", "secret", "k", "v"]).is_err());
assert!(parse(&["stash", "add", "image", "k"]).is_err());
}
#[test]
fn add_edit_flag() {
let cli = parse(&["stash", "add", "note", "k", "-e"]).unwrap();
if let Commands::Add {
edit, stdin, text, ..
} = cli.command
{
assert!(edit);
assert!(!stdin);
assert!(text.is_none());
} else {
panic!("wrong variant");
}
}
#[test]
fn add_stdin_flag() {
let cli = parse(&["stash", "add", "note", "k", "--stdin"]).unwrap();
if let Commands::Add { edit, stdin, .. } = cli.command {
assert!(!edit);
assert!(stdin);
} else {
panic!("wrong variant");
}
}
#[test]
fn add_requires_type_and_shortname() {
assert!(parse(&["stash", "add"]).is_err());
assert!(parse(&["stash", "add", "note"]).is_err());
}
#[test]
fn show_default_not_verbose() {
let cli = parse(&["stash", "show", "k"]).unwrap();
if let Commands::Show {
shortname, verbose, ..
} = cli.command
{
assert_eq!(shortname, "k");
assert!(!verbose);
} else {
panic!("wrong variant");
}
}
#[test]
fn show_verbose_short() {
let cli = parse(&["stash", "show", "-v", "k"]).unwrap();
assert!(matches!(cli.command, Commands::Show { verbose: true, .. }));
}
#[test]
fn show_verbose_long() {
let cli = parse(&["stash", "show", "--verbose", "k"]).unwrap();
assert!(matches!(cli.command, Commands::Show { verbose: true, .. }));
}
#[test]
fn web_no_private() {
let cli = parse(&["stash", "web", "myurl"]).unwrap();
if let Commands::Web {
private, shortname, ..
} = cli.command
{
assert!(!private);
assert_eq!(shortname, "myurl");
} else {
panic!("wrong variant");
}
}
#[test]
fn web_private_short() {
let cli = parse(&["stash", "web", "-p", "myurl"]).unwrap();
assert!(matches!(cli.command, Commands::Web { private: true, .. }));
}
#[test]
fn web_private_long() {
let cli = parse(&["stash", "web", "--private", "myurl"]).unwrap();
assert!(matches!(cli.command, Commands::Web { private: true, .. }));
}
#[test]
fn list_no_filter() {
let cli = parse(&["stash", "list"]).unwrap();
if let Commands::List { tags, .. } = cli.command {
assert!(tags.is_empty());
} else {
panic!("wrong variant");
}
}
#[test]
fn list_single_tag() {
let cli = parse(&["stash", "list", "--tag", "work"]).unwrap();
if let Commands::List { tags, .. } = cli.command {
assert_eq!(tags, ["work"]);
} else {
panic!("wrong variant");
}
}
#[test]
fn list_multiple_tags() {
let cli = parse(&["stash", "list", "-g", "work", "-g", "personal"]).unwrap();
if let Commands::List { tags, .. } = cli.command {
assert_eq!(tags, ["work", "personal"]);
} else {
panic!("wrong variant");
}
}
#[test]
fn parse_history() {
let cli = parse(&["stash", "history", "k"]).unwrap();
assert!(matches!(cli.command, Commands::History { shortname } if shortname == "k"));
}
#[test]
fn parse_edit() {
let cli = parse(&["stash", "edit", "k"]).unwrap();
assert!(matches!(cli.command, Commands::Edit { shortname, .. } if shortname == "k"));
}
#[test]
fn parse_purge() {
let cli = parse(&["stash", "purge", "k"]).unwrap();
assert!(matches!(cli.command, Commands::Purge { shortname, .. } if shortname == "k"));
}
#[test]
fn add_with_tags() {
let cli = parse(&[
"stash", "add", "note", "k", "--tag", "work", "--tag", "personal", "text",
])
.unwrap();
if let Commands::Add { tags, .. } = cli.command {
assert_eq!(tags, ["work", "personal"]);
} else {
panic!("wrong variant");
}
}
#[test]
fn parse_tag() {
let cli = parse(&["stash", "tag", "myitem", "work", "personal"]).unwrap();
if let Commands::Tag { shortname, tags } = cli.command {
assert_eq!(shortname, "myitem");
assert_eq!(tags, ["work", "personal"]);
} else {
panic!("wrong variant");
}
}
#[test]
fn tag_requires_at_least_one_tag() {
assert!(parse(&["stash", "tag", "myitem"]).is_err());
}
#[test]
fn parse_untag() {
let cli = parse(&["stash", "untag", "myitem", "work"]).unwrap();
if let Commands::Untag { shortname, tags } = cli.command {
assert_eq!(shortname, "myitem");
assert_eq!(tags, ["work"]);
} else {
panic!("wrong variant");
}
}
#[test]
fn untag_requires_at_least_one_tag() {
assert!(parse(&["stash", "untag", "myitem"]).is_err());
}
#[test]
fn search_pattern_only() {
let cli = parse(&["stash", "search", "search term"]).unwrap();
if let Commands::Search { pattern, tag, .. } = cli.command {
assert_eq!(pattern.as_deref(), Some("search term"));
assert!(tag.is_none());
} else {
panic!("wrong variant");
}
}
#[test]
fn search_tag_only() {
let cli = parse(&["stash", "search", "--tag", "work"]).unwrap();
if let Commands::Search { pattern, tag, .. } = cli.command {
assert!(pattern.is_none());
assert_eq!(tag.as_deref(), Some("work"));
} else {
panic!("wrong variant");
}
}
#[test]
fn search_pattern_and_tag() {
let cli = parse(&["stash", "search", "--tag", "work", "term"]).unwrap();
if let Commands::Search { pattern, tag, .. } = cli.command {
assert_eq!(pattern.as_deref(), Some("term"));
assert_eq!(tag.as_deref(), Some("work"));
} else {
panic!("wrong variant");
}
}
#[test]
fn search_regex_flag() {
let cli = parse(&["stash", "search", "--regex", r"\d+"]).unwrap();
if let Commands::Search { pattern, regex, .. } = cli.command {
assert_eq!(pattern.as_deref(), Some(r"\d+"));
assert!(regex);
} else {
panic!("wrong variant");
}
}
#[test]
fn search_include_history_flag() {
let cli = parse(&["stash", "search", "-H", "term"]).unwrap();
if let Commands::Search {
include_history, ..
} = cli.command
{
assert!(include_history);
} else {
panic!("wrong variant");
}
}
#[test]
fn db_flag_before_subcommand() {
let cli = parse(&["stash", "--db", "/tmp/test.db", "list"]).unwrap();
assert_eq!(
cli.db.as_deref(),
Some(std::path::Path::new("/tmp/test.db"))
);
}
#[test]
fn db_flag_after_subcommand() {
let cli = parse(&["stash", "list", "--db", "/tmp/test.db"]).unwrap();
assert_eq!(
cli.db.as_deref(),
Some(std::path::Path::new("/tmp/test.db"))
);
}
#[test]
fn db_flag_absent() {
let cli = parse(&["stash", "list"]).unwrap();
assert!(cli.db.is_none());
}
#[test]
fn browser_set() {
let cli = parse(&["stash", "browser", "myurl", "firefox"]).unwrap();
if let Commands::Browser {
shortname,
browser,
clear,
..
} = cli.command
{
assert_eq!(shortname, "myurl");
assert_eq!(browser.as_deref(), Some("firefox"));
assert!(!clear);
} else {
panic!("wrong variant");
}
}
#[test]
fn browser_clear() {
let cli = parse(&["stash", "browser", "myurl", "--clear"]).unwrap();
if let Commands::Browser {
shortname,
browser,
clear,
..
} = cli.command
{
assert_eq!(shortname, "myurl");
assert!(browser.is_none());
assert!(clear);
} else {
panic!("wrong variant");
}
}
#[test]
fn browser_private_flag() {
let cli = parse(&["stash", "browser", "myurl", "--private"]).unwrap();
if let Commands::Browser {
shortname,
private,
no_private,
..
} = cli.command
{
assert_eq!(shortname, "myurl");
assert!(private);
assert!(!no_private);
} else {
panic!("wrong variant");
}
}
#[test]
fn browser_no_private_flag() {
let cli = parse(&["stash", "browser", "myurl", "--no-private"]).unwrap();
if let Commands::Browser {
private,
no_private,
..
} = cli.command
{
assert!(!private);
assert!(no_private);
} else {
panic!("wrong variant");
}
}
#[test]
fn browser_no_args_is_ok_at_parse_level() {
let cli = parse(&["stash", "browser", "myurl"]).unwrap();
assert!(matches!(cli.command, Commands::Browser { .. }));
}
#[test]
fn import_defaults() {
let cli = parse(&["stash", "import"]).unwrap();
if let Commands::Import { file, overwrite } = cli.command {
assert!(file.is_none());
assert!(!overwrite);
} else {
panic!("wrong variant");
}
}
#[test]
fn import_with_file() {
let cli = parse(&["stash", "import", "/tmp/vault.json"]).unwrap();
if let Commands::Import { file, .. } = cli.command {
assert_eq!(
file.as_deref(),
Some(std::path::Path::new("/tmp/vault.json"))
);
} else {
panic!("wrong variant");
}
}
#[test]
fn import_overwrite() {
let cli = parse(&["stash", "import", "--overwrite", "/tmp/vault.json"]).unwrap();
if let Commands::Import { overwrite, .. } = cli.command {
assert!(overwrite);
} else {
panic!("wrong variant");
}
}
#[test]
fn export_defaults() {
let cli = parse(&["stash", "export"]).unwrap();
if let Commands::Export {
output,
include_history,
} = cli.command
{
assert!(output.is_none());
assert!(!include_history);
} else {
panic!("wrong variant");
}
}
#[test]
fn export_with_output_file() {
let cli = parse(&["stash", "export", "-o", "/tmp/vault.json"]).unwrap();
if let Commands::Export { output, .. } = cli.command {
assert_eq!(
output.as_deref(),
Some(std::path::Path::new("/tmp/vault.json"))
);
} else {
panic!("wrong variant");
}
}
#[test]
fn export_include_history() {
let cli = parse(&["stash", "export", "--include-history"]).unwrap();
if let Commands::Export {
include_history, ..
} = cli.command
{
assert!(include_history);
} else {
panic!("wrong variant");
}
}
#[test]
fn parse_migrate() {
let cli = parse(&["stash", "migrate"]).unwrap();
assert!(matches!(cli.command, Commands::Migrate));
}
#[test]
fn item_type_display() {
assert_eq!(ItemType::Url.to_string(), "url");
assert_eq!(ItemType::Note.to_string(), "note");
}
}