use super::complete::{
active_pads_completer, all_pads_completer, archived_pads_completer, deleted_pads_completer,
};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use once_cell::sync::Lazy;
use standout::cli::{render_help_with_topics, App, CommandGroup, Dispatch, HelpConfig};
use standout::topics::TopicRegistry;
use standout::OutputMode;
use super::handlers;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum CompletionShell {
Bash,
Zsh,
}
fn get_version() -> &'static str {
const VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_HASH: &str = env!("GIT_HASH");
const GIT_COMMIT_DATE: &str = env!("GIT_COMMIT_DATE");
const IS_RELEASE: &str = env!("IS_RELEASE");
use std::sync::OnceLock;
static VERSION_STRING: OnceLock<String> = OnceLock::new();
VERSION_STRING.get_or_init(|| {
if IS_RELEASE == "true" {
format!("v{}", VERSION)
} else {
format!("v{}\ndev: {} {}", VERSION, GIT_HASH, GIT_COMMIT_DATE)
}
})
}
#[derive(Parser, Debug)]
#[command(
name = "padz",
bin_name = "padz",
version = get_version(),
disable_help_subcommand = true,
after_help = "Enable shell completions:\n padz completion install"
)]
#[command(about = "Context-aware command-line note-taking tool", long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(short, long, global = true, conflicts_with = "data")]
pub global: bool,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true, value_name = "PATH", conflicts_with = "global")]
pub data: Option<String>,
}
static HELP_TOPICS: Lazy<TopicRegistry> = Lazy::new(|| {
let mut registry = TopicRegistry::new();
let topic_content = include_str!("topics/scopes.txt");
if let Some(topic) = parse_topic_file("scopes", topic_content) {
registry.add_topic(topic);
}
registry
});
fn parse_topic_file(name: &str, content: &str) -> Option<standout::topics::Topic> {
let lines: Vec<&str> = content.lines().collect();
if lines.len() < 2 {
return None;
}
let title_idx = lines.iter().position(|l| !l.trim().is_empty())?;
let title = lines[title_idx].trim().to_string();
let content_lines = &lines[title_idx + 1..];
let content_start = content_lines
.iter()
.position(|l| !l.trim().is_empty())
.unwrap_or(content_lines.len());
let body = content_lines[content_start..].join("\n");
if body.trim().is_empty() {
return None;
}
Some(standout::topics::Topic::new(
title,
body,
standout::topics::TopicType::Text,
Some(name.to_string()),
))
}
pub fn build_command() -> clap::Command {
Cli::command()
}
pub fn parse_cli() -> (Cli, OutputMode) {
if should_show_custom_help() {
println!("{}", render_custom_help());
std::process::exit(0);
}
let app: App = App::with_registry(HELP_TOPICS.clone());
let matches = app.parse_with(Cli::command());
let output_mode = match matches
.get_one::<String>("_output_mode")
.map(|s| s.as_str())
{
Some("term") => OutputMode::Term,
Some("text") => OutputMode::Text,
Some("term-debug") => OutputMode::TermDebug,
Some("json") => OutputMode::Json,
_ => OutputMode::Auto,
};
let cli = Cli::from_arg_matches(&matches).expect("Failed to parse CLI arguments");
(cli, output_mode)
}
pub fn get_grouped_help() -> String {
render_custom_help()
}
fn should_show_custom_help() -> bool {
let args: Vec<String> = std::env::args().skip(1).collect();
let subcommands = [
"create",
"n",
"list",
"ls",
"search",
"peek",
"pk",
"view",
"v",
"edit",
"e",
"open",
"o",
"delete",
"rm",
"move",
"mv",
"restore",
"archive",
"unarchive",
"pin",
"p",
"unpin",
"u",
"path",
"uuid",
"complete",
"done",
"reopen",
"purge",
"export",
"import",
"tag",
"doctor",
"config",
"init",
"completion",
];
if args.len() == 1 && args[0] == "help" {
return true;
}
for arg in &args {
if arg == "--help" || arg == "-h" {
return true;
}
if subcommands.contains(&arg.as_str()) {
return false;
}
}
false
}
fn command_groups() -> Vec<CommandGroup> {
vec![
CommandGroup {
title: "Commands".into(),
help: None,
commands: vec![
Some("init".into()),
Some("create".into()),
Some("list".into()),
Some("search".into()),
],
},
CommandGroup {
title: "Per Pad(s)".into(),
help: Some("These commands accept one or more pad ids (<id>...)".into()),
commands: vec![
Some("open".into()),
Some("view".into()),
Some("copy".into()),
Some("peek".into()),
Some("move".into()),
Some("delete".into()),
None,
Some("archive".into()),
Some("unarchive".into()),
None,
Some("pin".into()),
Some("unpin".into()),
Some("path".into()),
Some("uuid".into()),
None,
Some("complete".into()),
Some("reopen".into()),
None,
Some("import".into()),
Some("export".into()),
None,
Some("tag".into()),
],
},
CommandGroup {
title: "Misc".into(),
help: None,
commands: vec![
Some("purge".into()),
Some("restore".into()),
None,
Some("completion".into()),
Some("help".into()),
Some("doctor".into()),
Some("config".into()),
],
},
]
}
fn render_custom_help() -> String {
let app = App::with_registry(HELP_TOPICS.clone());
let cmd = app.augment_command(Cli::command());
let config = HelpConfig {
command_groups: Some(command_groups()),
..Default::default()
};
let mut help = render_help_with_topics(&cmd, &HELP_TOPICS, Some(config))
.unwrap_or_else(|e| format!("Help rendering error: {}", e));
help.push_str("\n\nEnable shell completions:\n");
help.push_str(" padz completion install");
help
}
#[derive(Subcommand, Dispatch, Debug)]
#[dispatch(handlers = handlers)]
pub enum Commands {
#[command(alias = "n", display_order = 1)]
#[dispatch(pure, template = "modification_result")]
Create {
#[arg(long, short = 'e', conflicts_with = "no_editor")]
editor: bool,
#[arg(long)]
no_editor: bool,
#[arg(long, short = 'i')]
inside: Option<String>,
#[arg(long, short = 'f')]
format: Option<String>,
#[arg(trailing_var_arg = true)]
title: Vec<String>,
},
#[command(alias = "ls", display_order = 2)]
#[dispatch(pure)]
List {
#[arg(num_args = 0..)]
ids: Vec<String>,
#[arg(short, long)]
search: Option<String>,
#[arg(long, conflicts_with = "all")]
deleted: bool,
#[arg(long, conflicts_with = "all")]
archived: bool,
#[arg(long)]
all: bool,
#[arg(long)]
peek: bool,
#[arg(long, conflicts_with_all = ["completed", "in_progress"])]
planned: bool,
#[arg(long, conflicts_with_all = ["planned", "in_progress"])]
completed: bool,
#[arg(long, conflicts_with_all = ["planned", "completed"])]
in_progress: bool,
#[arg(long = "tag", short = 't', num_args = 1..)]
tags: Vec<String>,
#[arg(long)]
uuid: bool,
},
#[command(display_order = 3)]
#[dispatch(pure, template = "list")]
Search {
term: String,
#[arg(long, conflicts_with = "all")]
deleted: bool,
#[arg(long, conflicts_with = "all")]
archived: bool,
#[arg(long)]
all: bool,
#[arg(long)]
completed: bool,
#[arg(long = "tag", short = 't', num_args = 1..)]
tags: Vec<String>,
#[arg(long)]
uuid: bool,
},
#[command(alias = "pk", display_order = 4)]
#[dispatch(pure, template = "list")]
Peek {
#[arg(num_args = 0..)]
ids: Vec<String>,
#[arg(long = "tag", short = 't', num_args = 1..)]
tags: Vec<String>,
#[arg(long)]
uuid: bool,
},
#[command(alias = "v", display_order = 10)]
#[dispatch(pure, template = "view")]
View {
#[arg(required = true, num_args = 1.., add = all_pads_completer())]
indexes: Vec<String>,
#[arg(long)]
peek: bool,
#[arg(long)]
uuid: bool,
#[arg(long, conflicts_with_all = ["tree", "indented"])]
flat: bool,
#[arg(long, conflicts_with_all = ["flat", "indented"])]
tree: bool,
#[arg(long, conflicts_with_all = ["flat", "tree"])]
indented: bool,
},
#[command(alias = "cp", display_order = 10)]
#[dispatch(pure, template = "messages")]
Copy {
#[arg(required = true, num_args = 1.., add = all_pads_completer())]
indexes: Vec<String>,
#[arg(long)]
peek: bool,
#[arg(long, conflicts_with_all = ["tree", "indented"])]
flat: bool,
#[arg(long, conflicts_with_all = ["flat", "indented"])]
tree: bool,
#[arg(long, conflicts_with_all = ["flat", "tree"])]
indented: bool,
},
#[command(alias = "e", display_order = 11, hide = true)]
#[dispatch(pure, template = "modification_result")]
Edit {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "o", display_order = 12)]
#[dispatch(pure, handler = handlers::edit__handler, template = "modification_result")]
Open {
#[arg(required = true, num_args = 1.., add = all_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "rm", display_order = 13)]
#[dispatch(pure, template = "modification_result")]
Delete {
#[arg(num_args = 1.., add = active_pads_completer(), required_unless_present = "completed")]
indexes: Vec<String>,
#[arg(long = "completed", conflicts_with = "indexes")]
completed: bool,
},
#[command(display_order = 14)]
#[dispatch(pure, template = "modification_result")]
Restore {
#[arg(required = true, num_args = 1.., add = deleted_pads_completer())]
indexes: Vec<String>,
},
#[command(display_order = 15)]
#[dispatch(pure, template = "modification_result")]
Archive {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(display_order = 16)]
#[dispatch(pure, template = "modification_result")]
Unarchive {
#[arg(required = true, num_args = 1.., add = archived_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "p", display_order = 17)]
#[dispatch(pure, template = "modification_result")]
Pin {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "u", display_order = 18)]
#[dispatch(pure, template = "modification_result")]
Unpin {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "mv", display_order = 13)]
#[dispatch(pure, handler = handlers::move_pads__handler, template = "modification_result")]
Move {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
#[arg(long, short = 'r')]
root: bool,
},
#[command(display_order = 17)]
#[dispatch(pure)]
Path {
#[arg(required = true, num_args = 1.., add = all_pads_completer())]
indexes: Vec<String>,
},
#[command(display_order = 17)]
#[dispatch(pure)]
Uuid {
#[arg(required = true, num_args = 1.., add = all_pads_completer())]
indexes: Vec<String>,
},
#[command(alias = "done", display_order = 18)]
#[dispatch(pure, template = "modification_result")]
Complete {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(display_order = 19)]
#[dispatch(pure, template = "modification_result")]
Reopen {
#[arg(required = true, num_args = 1.., add = active_pads_completer())]
indexes: Vec<String>,
},
#[command(display_order = 20)]
#[dispatch(pure, template = "messages")]
Purge {
#[arg(required = false, num_args = 0.., add = deleted_pads_completer())]
indexes: Vec<String>,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long, short = 'r')]
recursive: bool,
},
#[command(display_order = 21)]
#[dispatch(pure, template = "messages")]
Export {
#[arg(long, value_name = "TITLE")]
single_file: Option<String>,
#[arg(required = false, num_args = 0.., add = active_pads_completer())]
indexes: Vec<String>,
#[arg(long, conflicts_with_all = ["tree", "indented"])]
flat: bool,
#[arg(long, conflicts_with_all = ["flat", "indented"])]
tree: bool,
#[arg(long, conflicts_with_all = ["flat", "tree"])]
indented: bool,
},
#[command(display_order = 22)]
#[dispatch(pure, template = "messages")]
Import {
#[arg(required = true, num_args = 1..)]
paths: Vec<String>,
},
#[command(subcommand, display_order = 25)]
#[dispatch(nested)]
Tag(TagCommands),
#[command(display_order = 30)]
#[dispatch(pure, template = "messages")]
Doctor,
#[command(display_order = 31)]
#[dispatch(skip)]
Config {
#[command(subcommand)]
action: Option<ConfigSubcommand>,
},
#[command(display_order = 32)]
#[dispatch(pure, template = "messages")]
Init {
#[arg(long, value_name = "PATH", conflicts_with = "unlink")]
link: Option<String>,
#[arg(long, conflicts_with = "link")]
unlink: bool,
},
#[command(display_order = 34, name = "completion")]
#[dispatch(skip)]
Completion {
#[arg(long, short, value_enum)]
shell: Option<CompletionShell>,
#[command(subcommand)]
action: CompletionAction,
},
}
#[derive(Subcommand, Debug)]
pub enum ConfigSubcommand {
List,
Gen {
#[arg(short = 'o', long = "out")]
file: Option<std::path::PathBuf>,
},
Get {
key: String,
},
Set {
key: String,
value: String,
},
}
#[derive(Subcommand, Dispatch, Debug)]
#[dispatch(handlers = handlers::tag)]
pub enum TagCommands {
#[command(display_order = 25)]
#[dispatch(pure, template = "modification_result")]
Add {
#[arg(required = true, num_args = 1..)]
args: Vec<String>,
},
#[command(display_order = 26)]
#[dispatch(pure, template = "modification_result")]
Remove {
#[arg(required = true, num_args = 1..)]
args: Vec<String>,
},
#[command(alias = "mv", display_order = 27)]
#[dispatch(pure, template = "messages")]
Rename {
old_name: String,
new_name: String,
},
#[command(alias = "rm", display_order = 28)]
#[dispatch(pure, template = "messages")]
Delete {
name: String,
},
#[command(alias = "ls", display_order = 29)]
#[dispatch(pure, template = "messages")]
List {
#[arg(num_args = 0..)]
ids: Vec<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum CompletionAction {
Install,
Print,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use standout::cli::validate_command_groups;
#[test]
fn test_completion_install_no_shell() {
let cli = Cli::try_parse_from(["padz", "completion", "install"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Completion {
shell: None,
action: CompletionAction::Install,
})
));
}
#[test]
fn test_completion_install_with_shell() {
let cli =
Cli::try_parse_from(["padz", "completion", "--shell", "bash", "install"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Completion {
shell: Some(CompletionShell::Bash),
action: CompletionAction::Install,
})
));
}
#[test]
fn test_completion_print() {
let cli = Cli::try_parse_from(["padz", "completion", "print"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Completion {
shell: None,
action: CompletionAction::Print,
})
));
}
#[test]
fn test_completion_print_with_shell() {
let cli = Cli::try_parse_from(["padz", "completion", "--shell", "zsh", "print"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Completion {
shell: Some(CompletionShell::Zsh),
action: CompletionAction::Print,
})
));
}
#[test]
fn test_help_groups_match_commands() {
let app = App::with_registry(HELP_TOPICS.clone());
let cmd = app.augment_command(Cli::command());
validate_command_groups(&cmd, &command_groups()).unwrap();
}
#[test]
fn test_data_option_parses() {
let cli = Cli::try_parse_from(["padz", "--data", "/path/to/.padz", "list"]).unwrap();
assert_eq!(cli.data, Some("/path/to/.padz".to_string()));
assert!(!cli.global);
}
#[test]
fn test_data_option_with_equals() {
let cli = Cli::try_parse_from(["padz", "--data=/custom/data", "list"]).unwrap();
assert_eq!(cli.data, Some("/custom/data".to_string()));
}
#[test]
fn test_data_option_before_command() {
let cli = Cli::try_parse_from(["padz", "--data", "/tmp/.padz", "create", "test"]).unwrap();
assert_eq!(cli.data, Some("/tmp/.padz".to_string()));
assert!(matches!(cli.command, Some(Commands::Create { .. })));
}
#[test]
fn test_data_option_after_command() {
let cli = Cli::try_parse_from(["padz", "list", "--data", "/tmp/.padz"]).unwrap();
assert_eq!(cli.data, Some("/tmp/.padz".to_string()));
}
#[test]
fn test_data_and_global_options_conflict() {
let result = Cli::try_parse_from(["padz", "--data", "/tmp/.padz", "-g", "list"]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("--data") || err.contains("--global"));
}
#[test]
fn test_no_data_option() {
let cli = Cli::try_parse_from(["padz", "list"]).unwrap();
assert_eq!(cli.data, None);
}
#[test]
fn test_data_option_with_worktree_path() {
let cli = Cli::try_parse_from([
"padz",
"--data",
"/home/user/project/.padz",
"create",
"todo",
])
.unwrap();
assert_eq!(cli.data, Some("/home/user/project/.padz".to_string()));
}
}