use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use pinto::i18n::Localizer;
fn parse_utc_datetime(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
const DT_FORMATS: [&str; 3] = ["%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S"];
for fmt in DT_FORMATS {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
return Ok(Utc.from_utc_datetime(&dt));
}
}
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let midnight = d.and_hms_opt(0, 0, 0).expect("midnight is always valid");
return Ok(Utc.from_utc_datetime(&midnight));
}
Err(format!(
"invalid date/time: {s:?} (expected `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`)"
))
}
fn parse_positive_usize(s: &str) -> Result<usize, String> {
let value = s
.parse::<usize>()
.map_err(|_| format!("invalid positive integer: {s:?}"))?;
if value == 0 {
return Err("must be at least 1".to_string());
}
Ok(value)
}
#[derive(Debug, Parser)]
#[command(name = "pinto", version, about, long_about = None)]
pub(super) struct Cli {
#[command(subcommand)]
pub(super) command: Command,
}
pub(super) fn localized_command(localizer: &Localizer) -> clap::Command {
localize_command(Cli::command(), localizer, "pinto")
}
pub(super) fn try_parse_localized(localizer: &Localizer) -> Result<Cli, clap::Error> {
let matches = localized_command(localizer).try_get_matches()?;
Cli::from_arg_matches(&matches)
}
fn localize_command(command: clap::Command, localizer: &Localizer, path: &str) -> clap::Command {
let about_key = format!("cli-command-{path}");
let arguments_heading = localizer
.lookup("cli-heading-arguments")
.unwrap_or_else(|| "Arguments".to_string());
let options_heading = localizer
.lookup("cli-heading-options")
.unwrap_or_else(|| "Options".to_string());
let commands_heading = localizer
.lookup("cli-heading-commands")
.unwrap_or_else(|| "Commands".to_string());
let command = if let Some(about) = localizer.lookup(&about_key) {
command.about(about.clone()).long_about(about)
} else {
command
};
let about = command
.get_about()
.map(|text| terminate_help(&text.to_string()));
let long_about = command
.get_long_about()
.map(|text| terminate_help(&text.to_string()));
let command = match about {
Some(about) => command.about(about),
None => command,
};
let command = match long_about {
Some(long_about) => command.long_about(long_about),
None => command,
};
let command = command
.subcommand_help_heading(commands_heading)
.mut_args(|arg| {
let key = format!("cli-arg-{path}-{}", arg.get_id());
let heading = if arg.is_positional() {
arguments_heading.clone()
} else {
options_heading.clone()
};
let arg = if let Some(help) = localizer.lookup(&key) {
arg.help(help.clone()).long_help(help)
} else {
arg
};
let help = arg.get_help().map(|text| terminate_help(&text.to_string()));
let long_help = arg
.get_long_help()
.map(|text| terminate_help(&text.to_string()));
let arg = match help {
Some(help) => arg.help(help),
None => arg,
};
let arg = match long_help {
Some(long_help) => arg.long_help(long_help),
None => arg,
};
arg.help_heading(heading)
});
command.mut_subcommands(|subcommand| {
let child_path = format!("{path}-{}", subcommand.get_name());
localize_command(subcommand, localizer, &child_path)
})
}
fn terminate_help(text: &str) -> String {
let mut text = text.trim_end().to_string();
if !text.ends_with(['.', '!', '?', '。', '!', '?']) {
text.push('.');
}
text
}
#[derive(Debug, Subcommand)]
pub(super) enum Command {
Init,
#[command(visible_alias = "a")]
Add(AddArgs),
#[command(visible_alias = "ls")]
List(ListArgs),
#[command(visible_alias = "s")]
Show(ShowArgs),
#[command(visible_alias = "mv")]
Move(MoveArgs),
#[command(visible_alias = "ro")]
Reorder(ReorderArgs),
#[command(visible_alias = "e")]
Edit(EditArgs),
#[command(visible_alias = "rm")]
Remove(RemoveArgs),
#[command(visible_alias = "d")]
Dep(DepArgs),
#[command(visible_alias = "ln")]
Link(LinkArgs),
#[command(visible_alias = "dd")]
Dod(DodArgs),
#[command(visible_alias = "sp")]
Sprint(SprintArgs),
#[command(visible_alias = "b")]
Board(BoardArgs),
#[command(name = "cycletime", visible_alias = "ct")]
CycleTime(CycleTimeArgs),
#[command(visible_alias = "reb")]
Rebalance(RebalanceArgs),
#[command(visible_alias = "mig")]
Migrate(MigrateArgs),
#[command(visible_alias = "auto")]
Automate(AutomateArgs),
Shell,
#[command(visible_alias = "k")]
Kanban(KanbanArgs),
Completion(CompletionArgs),
}
#[derive(Debug, Args)]
pub(super) struct KanbanArgs {
#[arg(long, short = 'c', num_args = 1..)]
pub(super) column: Option<Vec<String>>,
#[arg(long, short = 'm')]
pub(super) maximize: bool,
#[arg(long, short = 'F')]
pub(super) search: Option<String>,
#[arg(long, short = 'R', requires = "search")]
pub(super) regex: bool,
}
#[derive(Debug, Args)]
pub(super) struct CompletionArgs {
pub(super) shell: Shell,
}
#[derive(Debug, Args)]
pub(super) struct AutomateArgs {
#[arg(
long,
short = 'p',
value_name = "JSON|PATH|-",
help = "Inline JSON, a plan file path, or `-` to read the JSON plan from standard input."
)]
pub(super) plan: String,
#[arg(long, short = 'n')]
pub(super) dry_run: bool,
#[arg(long, short = 'j')]
pub(super) json: bool,
}
#[derive(Debug, Args)]
pub(super) struct CycleTimeArgs {
#[arg(long, short = 'S')]
pub(super) sprint: Option<String>,
#[arg(long, short = 's', value_parser = parse_utc_datetime)]
pub(super) since: Option<chrono::DateTime<chrono::Utc>>,
#[arg(long, short = 'u', value_parser = parse_utc_datetime)]
pub(super) until: Option<chrono::DateTime<chrono::Utc>>,
#[arg(long, short = 'j')]
pub(super) json: bool,
}
#[derive(Debug, Args)]
pub(super) struct RebalanceArgs {
#[arg(long, short = 'n')]
pub(super) dry_run: bool,
}
#[derive(Debug, Args)]
pub(super) struct MigrateArgs {
#[arg(long = "to", short = 't', value_enum)]
pub(super) to: MigrateTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(super) enum MigrateTarget {
File,
Git,
Sqlite,
}
#[derive(Debug, Args)]
pub(super) struct BoardArgs {
#[arg(long, short = 'P')]
pub(super) roots_only: bool,
#[arg(long, short = 'S', num_args = 0..=1)]
pub(super) sprint: Option<Option<String>>,
#[arg(long, short = 'L', num_args = 0..)]
pub(super) label: Option<Vec<String>>,
#[arg(long = "all-labels", short = 'a', requires = "label")]
pub(super) all_labels: bool,
#[arg(long, short = 's', num_args = 1..)]
pub(super) status: Vec<String>,
#[arg(long, short = 'o', value_enum)]
pub(super) sort: Option<SortArg>,
#[arg(long, short = 'r')]
pub(super) reverse: bool,
#[arg(long, short = 'l')]
pub(super) long: bool,
#[arg(long = "no-truncate", short = 'f', visible_alias = "full")]
pub(super) no_truncate: bool,
#[arg(long, short = 'j')]
pub(super) json: bool,
#[arg(long = "no-wip-check", short = 'w')]
pub(super) no_wip_check: bool,
#[arg(long, short = 'F')]
pub(super) search: Option<String>,
#[arg(long, short = 'R', requires = "search")]
pub(super) regex: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(super) enum SortArg {
Rank,
Done,
Created,
}
#[derive(Debug, Args)]
pub(super) struct SprintArgs {
#[command(subcommand)]
pub(super) command: SprintCommand,
}
#[derive(Debug, Subcommand)]
pub(super) enum SprintCommand {
#[command(visible_alias = "n")]
New {
id: String,
title: String,
#[arg(long, short = 'g', conflicts_with = "template")]
goal: Option<String>,
#[arg(long, short = 't', conflicts_with = "goal")]
template: Option<String>,
#[arg(long, short = 's', requires = "end", value_parser = parse_utc_datetime)]
start: Option<chrono::DateTime<chrono::Utc>>,
#[arg(long, short = 'e', requires = "start", value_parser = parse_utc_datetime)]
end: Option<chrono::DateTime<chrono::Utc>>,
},
#[command(visible_alias = "e")]
Edit {
id: String,
#[arg(long, short = 't')]
title: Option<String>,
#[arg(long, short = 'g')]
goal: Option<String>,
#[arg(long, short = 's', requires = "end", value_parser = parse_utc_datetime)]
start: Option<chrono::DateTime<chrono::Utc>>,
#[arg(long, short = 'e', requires = "start", value_parser = parse_utc_datetime)]
end: Option<chrono::DateTime<chrono::Utc>>,
},
#[command(visible_alias = "rm")]
Remove {
id: String,
},
#[command(visible_alias = "s")]
Start {
id: String,
},
#[command(visible_alias = "c")]
Close {
id: String,
},
#[command(visible_alias = "a")]
Add {
sprint_id: String,
#[arg(required_unless_present = "status", conflicts_with = "status")]
item_id: Option<String>,
#[arg(long, short = 's', conflicts_with = "item_id")]
status: Option<String>,
#[arg(long, short = 'n', requires = "status", value_parser = parse_positive_usize)]
limit: Option<usize>,
},
#[command(visible_alias = "u")]
Unassign {
sprint_id: String,
item_id: String,
},
#[command(visible_alias = "ls")]
List {
#[arg(long, short = 'j')]
json: bool,
},
#[command(visible_alias = "bd")]
Burndown {
id: String,
#[arg(long, short = 'j')]
json: bool,
},
#[command(visible_alias = "v")]
Velocity {
#[arg(long, short = 'n', default_value_t = 5, value_parser = parse_positive_usize)]
recent: usize,
},
#[command(visible_alias = "cap")]
Capacity {
id: String,
#[arg(long, short = 'H', requires_all = ["holidays", "deduction_factor"])]
daily_hours: Option<f64>,
#[arg(long, short = 'd', requires_all = ["daily_hours", "deduction_factor"])]
holidays: Option<u32>,
#[arg(long, short = 'f', requires_all = ["daily_hours", "holidays"])]
deduction_factor: Option<f64>,
#[arg(long, short = 'j')]
json: bool,
},
}
#[derive(Debug, Args)]
pub(super) struct DepArgs {
#[command(subcommand)]
pub(super) command: DepCommand,
}
#[derive(Debug, Subcommand)]
pub(super) enum DepCommand {
#[command(visible_alias = "a")]
Add {
id: String,
depends_on: String,
},
#[command(visible_alias = "r")]
Rm {
id: String,
depends_on: String,
},
}
#[derive(Debug, Args)]
pub(super) struct LinkArgs {
#[command(subcommand)]
pub(super) command: LinkCommand,
}
#[derive(Debug, Subcommand)]
pub(super) enum LinkCommand {
#[command(visible_alias = "a")]
Add {
id: String,
#[arg(required = true)]
shas: Vec<String>,
},
#[command(visible_alias = "r")]
Rm {
id: String,
#[arg(required = true)]
shas: Vec<String>,
},
#[command(visible_alias = "s")]
Scan {
#[arg(long, short = 's')]
since: Option<String>,
},
}
#[derive(Debug, Args)]
pub(super) struct DodArgs {
#[command(subcommand)]
pub(super) command: Option<DodCommand>,
}
#[derive(Debug, Subcommand)]
pub(super) enum DodCommand {
#[command(visible_alias = "s")]
Set {
#[arg(allow_hyphen_values = true)]
text: String,
},
#[command(visible_alias = "c")]
Clear,
}
#[derive(Debug, Args)]
pub(super) struct ShowArgs {
#[arg(value_name = "ID", num_args = 1..)]
pub(super) ids: Vec<String>,
#[arg(long, short = 'j')]
pub(super) json: bool,
#[arg(long, short = 'p')]
pub(super) plain: bool,
}
#[derive(Debug, Args)]
#[command(group(clap::ArgGroup::new("target").required(true).multiple(false)))]
pub(super) struct ReorderArgs {
pub(super) id: String,
#[arg(long, short = 'b', group = "target", value_name = "ID")]
pub(super) before: Option<String>,
#[arg(long, short = 'a', group = "target", value_name = "ID")]
pub(super) after: Option<String>,
#[arg(long, short = 't', group = "target")]
pub(super) top: bool,
#[arg(long, short = 'B', group = "target")]
pub(super) bottom: bool,
}
#[derive(Debug, Args)]
pub(super) struct MoveArgs {
#[arg(required = true, num_args = 2.., value_name = "ID_OR_STATUS")]
pub(super) operands: Vec<String>,
#[arg(long = "no-wip-check", short = 'w')]
pub(super) no_wip_check: bool,
}
impl MoveArgs {
pub(super) fn destination_and_ids(&self) -> Option<(&str, &[String])> {
self.operands
.split_last()
.map(|(status, ids)| (status.as_str(), ids))
}
}
#[derive(Debug, Args)]
pub(super) struct ListArgs {
#[arg(long, short = 'P')]
pub(super) roots_only: bool,
#[arg(long, short = 's', num_args = 1..)]
pub(super) status: Vec<String>,
#[arg(long, short = 'S', num_args = 0..=1)]
pub(super) sprint: Option<Option<String>>,
#[arg(long, short = 'L', num_args = 0..)]
pub(super) label: Option<Vec<String>>,
#[arg(long = "all-labels", short = 'a', requires = "label")]
pub(super) all_labels: bool,
#[arg(long, short = 'l')]
pub(super) long: bool,
#[arg(long, short = 'j')]
pub(super) json: bool,
#[arg(long, short = 'F')]
pub(super) search: Option<String>,
#[arg(long, short = 'R', requires = "search")]
pub(super) regex: bool,
}
#[derive(Debug, Args)]
pub(super) struct AddArgs {
pub(super) title: String,
#[arg(long, short = 'p')]
pub(super) points: Option<u32>,
#[arg(long = "label", short = 'l')]
pub(super) labels: Vec<String>,
#[arg(long, short = 'S')]
pub(super) sprint: Option<String>,
#[arg(long, short = 'P')]
pub(super) parent: Option<String>,
#[arg(long = "depends-on", short = 'd', num_args = 1..)]
pub(super) depends_on: Vec<String>,
#[arg(long, short = 'b', conflicts_with = "edit")]
pub(super) body: Option<String>,
#[arg(long, short = 'E')]
pub(super) edit: bool,
#[arg(long, short = 't')]
pub(super) template: Option<String>,
}
#[cfg(test)]
mod add_tests {
use super::*;
#[test]
fn add_parses_long_and_short_edit_flags() {
for flag in ["--edit", "-E"] {
let cli =
Cli::try_parse_from(["pinto", "add", "Task", flag]).expect("add edit flag parses");
let Command::Add(args) = cli.command else {
panic!("expected add command");
};
assert!(args.edit);
}
}
#[test]
fn add_rejects_body_with_edit() {
let error = Cli::try_parse_from(["pinto", "add", "Task", "--body", "initial", "--edit"])
.expect_err("--body and --edit must conflict");
let message = error.to_string();
assert!(
message.contains("--body"),
"error mentions --body: {message}"
);
assert!(
message.contains("--edit"),
"error mentions --edit: {message}"
);
}
}
#[cfg(test)]
mod help_punctuation_tests {
use super::*;
fn is_sentence_terminated(text: &str) -> bool {
text.trim_end().ends_with(['.', '!', '?', '。', '!', '?'])
}
fn assert_help_is_terminated(command: &clap::Command, path: &str) {
if let Some(about) = command.get_about() {
assert!(
is_sentence_terminated(&about.to_string()),
"command `{path}` has unterminated help: {about}"
);
}
for arg in command.get_arguments() {
if let Some(help) = arg.get_help() {
assert!(
is_sentence_terminated(&help.to_string()),
"argument `{path} {}` has unterminated help: {help}",
arg.get_id()
);
}
if let Some(help) = arg.get_long_help() {
assert!(
is_sentence_terminated(&help.to_string()),
"argument `{path} {}` has unterminated long help: {help}",
arg.get_id()
);
}
}
for child in command.get_subcommands() {
assert_help_is_terminated(child, &format!("{path} {}", child.get_name()));
}
}
#[test]
fn every_localized_command_and_argument_help_ends_as_a_sentence() {
for locale in ["en-US", "ja-JP"] {
let localizer = pinto::i18n::localizer_from(Some(locale), None);
let command = localized_command(&localizer);
assert_help_is_terminated(&command, &format!("pinto ({locale})"));
}
}
}
#[cfg(test)]
mod abbreviation_tests {
use super::*;
fn assert_nested_subcommands_have_aliases(command: &clap::Command, path: &str) {
for subcommand in command.get_subcommands() {
if subcommand.get_name() == "help" {
continue;
}
let child_path = format!("{path} {}", subcommand.get_name());
assert!(
subcommand.get_visible_aliases().next().is_some(),
"subcommand `{child_path}` has no visible alias"
);
assert_nested_subcommands_have_aliases(subcommand, &child_path);
}
}
fn assert_long_options_have_short_forms(command: &clap::Command, path: &str) {
for argument in command.get_arguments() {
let Some(long) = argument.get_long() else {
continue;
};
if matches!(long, "help" | "version") {
continue;
}
assert!(
argument.get_short().is_some(),
"option `{path} --{long}` has no short form"
);
}
for subcommand in command.get_subcommands() {
if subcommand.get_name() == "help" {
continue;
}
assert_long_options_have_short_forms(
subcommand,
&format!("{path} {}", subcommand.get_name()),
);
}
}
#[test]
fn every_non_top_level_subcommand_has_a_visible_alias() {
let command = Cli::command();
for subcommand in command.get_subcommands() {
if subcommand.get_name() == "help" {
continue;
}
assert_nested_subcommands_have_aliases(
subcommand,
&format!("pinto {}", subcommand.get_name()),
);
}
}
#[test]
fn every_pinto_long_option_has_a_short_form() {
assert_long_options_have_short_forms(&Cli::command(), "pinto");
}
}
#[derive(Debug, Args)]
pub(super) struct EditArgs {
pub(super) id: String,
#[arg(long, short = 't')]
pub(super) title: Option<String>,
#[arg(long, short = 'p')]
pub(super) points: Option<u32>,
#[arg(long = "label", short = 'l')]
pub(super) labels: Vec<String>,
#[arg(long, short = 'a')]
pub(super) assignee: Option<String>,
#[arg(long, short = 'S')]
pub(super) sprint: Option<String>,
#[arg(long, short = 'b')]
pub(super) body: Option<String>,
#[arg(long, short = 'P', conflicts_with = "no_parent")]
pub(super) parent: Option<String>,
#[arg(long, short = 'N')]
pub(super) no_parent: bool,
}
#[derive(Debug, Args)]
pub(super) struct RemoveArgs {
#[arg(required = true, value_name = "ID")]
pub(super) ids: Vec<String>,
#[arg(long, short = 'f')]
pub(super) force: bool,
}