From 97be8aae188864b71391e9ca4c7f43e4c050d7ef Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sun, 5 May 2024 14:16:49 -0700 Subject: [PATCH] Update API --- Cargo.lock | 48 +++-- Cargo.toml | 2 +- DELETEME | 1 + src/main.rs | 487 +++++++++++++++++++++++++------------------------- src/viewer.rs | 4 +- 5 files changed, 280 insertions(+), 262 deletions(-) create mode 100644 DELETEME diff --git a/Cargo.lock b/Cargo.lock index 6a1928f..9e656b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,15 +43,16 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.4" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] @@ -212,18 +213,19 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.8" +version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2275f18819641850fa26c89acc84d465c1bf91ce57bc2748b28c420473352f64" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] name = "clap_builder" -version = "4.4.8" +version = "4.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07cdf1b148b25c1e1f7a42225e30a0d99a615cd4637eae7365548dd4529b95bc" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" dependencies = [ "anstream", "anstyle", @@ -231,11 +233,23 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "clap_lex" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" [[package]] name = "colorchoice" @@ -579,6 +593,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.3" @@ -733,6 +753,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + [[package]] name = "itoa" version = "1.0.9" @@ -1514,9 +1540,9 @@ dependencies = [ [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -1533,7 +1559,7 @@ version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "rustversion", diff --git a/Cargo.toml b/Cargo.toml index b223f9f..065c03a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ regex = "1" chrono = "0.4.31" chrono-tz = "0.8.4" colored = "2.0.4" -clap = "4.4.8" +clap = { version = "4.5.4", features = ["derive"] } spinners = "4.1.0" inquire = { version = "0.6.2", features = ["editor"] } rand = "0.8.5" diff --git a/DELETEME b/DELETEME new file mode 100644 index 0000000..4648beb --- /dev/null +++ b/DELETEME @@ -0,0 +1 @@ +DELETEME diff --git a/src/main.rs b/src/main.rs index 7259a25..8171cf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,40 +14,179 @@ mod template; mod test; mod viewer; -use clap::{Arg, ArgMatches, Command}; +use clap::{Parser, Subcommand}; use colored::*; use config::Config; use priority::Priority; use team::{Project, State, Team}; -const APP: &str = "lnr"; +const NAME: &str = "lnr"; const VERSION: &str = env!("CARGO_PKG_VERSION"); const AUTHOR: &str = "Alan Vardy "; const ABOUT: &str = "A tiny unofficial Linear client"; +#[derive(Parser, Clone)] +#[command(name = NAME)] +#[command(version = VERSION)] +#[command(about = ABOUT, long_about = None)] +#[command(author = AUTHOR, version)] +#[command(arg_required_else_help(true))] +struct Cli { + #[arg(short, long)] + /// Absolute path of configuration. Defaults to $XDG_CONFIG_HOME/lnr.cfg + config: Option, + + #[arg(short, long)] + /// You will be prompted at runtime if this isn't provided + org: Option, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug, Clone)] +enum Commands { + #[command(subcommand)] + #[clap(alias = "i")] + /// (i) Commands for issues + Issue(IssueCommands), + + #[command(subcommand)] + #[clap(alias = "o")] + /// (o) Commands for organizations + Org(OrgCommands), + + #[command(subcommand)] + #[clap(alias = "t")] + /// (t) Commands for working with templates + Template(TemplateCommands), +} + +#[derive(Subcommand, Debug, Clone)] +enum IssueCommands { + #[clap(alias = "c")] + /// (c) Create a new issue + Create(IssueCreate), + + #[clap(alias = "e")] + /// (e) Edit the issue for current branch + Edit(IssueEdit), + + #[clap(alias = "v")] + /// (v) View the issue for current branch + View(IssueView), + + #[clap(alias = "l")] + /// (l) List issues, maximum of 50. Returns issues assigned to user that are Todo or In Progress + List(IssueList), +} + +#[derive(Subcommand, Debug, Clone)] +enum OrgCommands { + #[clap(alias = "a")] + /// (a) Add an organization and token to config + Add(OrgAdd), + + #[clap(alias = "r")] + /// (r) Remove an organization and token from config + Remove(OrgRemove), + + #[clap(alias = "l")] + /// (l) List organizations in config + List(OrgList), +} + +#[derive(Parser, Debug, Clone)] +struct OrgAdd {} + +#[derive(Parser, Debug, Clone)] +struct OrgRemove {} + +#[derive(Parser, Debug, Clone)] +struct OrgList {} + +#[derive(Subcommand, Debug, Clone)] +enum TemplateCommands { + #[clap(alias = "e")] + /// (e) Create issues from a TOML file + Evaluate(TemplateEvaluate), +} + +#[derive(Parser, Debug, Clone)] +struct TemplateEvaluate { + #[arg(short, long)] + /// Path to file or directory + path: Option, + + #[arg(short = 't', long)] + /// Team name + team: Option, + + #[arg(short, long, default_value_t = false)] + /// Do not prompt for a project + noproject: bool, +} + +#[derive(Parser, Debug, Clone)] +struct IssueCreate { + #[arg(short, long)] + /// Title for issue + title: Option, + + #[arg(short, long)] + /// Description for issue + description: Option, + + #[arg(short = 't', long)] + /// Team name + team: Option, + + #[arg(short, long, default_value_t = false)] + /// Do not prompt for a project + noproject: bool, +} + +#[derive(Parser, Debug, Clone)] +struct IssueEdit {} + +#[derive(Parser, Debug, Clone)] +struct IssueView { + #[arg(short, long, default_value_t = false)] + /// Select ticket from list view + select: bool, +} + +#[derive(Parser, Debug, Clone)] +struct IssueList { + #[arg(short = 't', long)] + /// Team name + team: Option, + + #[arg(short, long, default_value_t = false)] + /// Don't prompt for project + noproject: bool, + + #[arg(short, long, default_value_t = false)] + /// Don't prompt for team + noteam: bool, +} + #[cfg(not(tarpaulin_include))] fn main() { - let matches = cmd().get_matches(); - - let result = match matches.subcommand() { - Some(("issue", issue_matches)) => match issue_matches.subcommand() { - Some(("create", m)) => issue_create(m), - Some(("view", m)) => issue_view(m), - Some(("edit", m)) => issue_edit(m), - Some(("list", m)) => issue_list(m), - _ => unreachable!(), - }, - Some(("org", issue_matches)) => match issue_matches.subcommand() { - Some(("add", m)) => organization_add(m), - Some(("remove", m)) => organization_remove(m), - Some(("list", m)) => organization_list(m), - _ => unreachable!(), - }, - Some(("template", issue_matches)) => match issue_matches.subcommand() { - Some(("evaluate", m)) => template_evaluate(m), - _ => unreachable!(), - }, - _ => unreachable!(), + let cli = Cli::parse(); + + let result = match &cli.command { + Commands::Issue(IssueCommands::Create(args)) => issue_create(cli.clone(), args), + Commands::Issue(IssueCommands::Edit(args)) => issue_edit(cli.clone(), args), + Commands::Issue(IssueCommands::View(args)) => issue_view(cli.clone(), args), + Commands::Issue(IssueCommands::List(args)) => issue_list(cli.clone(), args), + + Commands::Org(OrgCommands::Add(args)) => org_add(cli.clone(), args), + Commands::Org(OrgCommands::Remove(args)) => org_remove(cli.clone(), args), + Commands::Org(OrgCommands::List(args)) => org_list(cli.clone(), args), + + Commands::Template(TemplateCommands::Evaluate(args)) => { + template_evaluate(cli.clone(), args) + } }; match result { @@ -62,92 +201,28 @@ fn main() { } } -fn cmd() -> Command { - Command::new(APP) - .version(VERSION) - .author(AUTHOR) - .about(ABOUT) - .arg_required_else_help(true) - .propagate_version(true) - .subcommands([ - Command::new("issue") - .arg_required_else_help(true) - .propagate_version(true) - .subcommand_required(true) - .subcommands([ - Command::new("create") - .about("Create a new issue") - .arg(title_arg()) - .arg(team_arg()) - .arg(org_arg()) - .arg(description_arg()) - .arg(flag_arg("noproject", 'n', "Do not prompt for a project")) - .arg(config_arg()), - Command::new("edit") - .about("Edit the issue for current branch") - .arg(org_arg()) - .arg(config_arg()), - Command::new("list") - .about("List issues, maximum of 50. Returns issues assigned to user that are Todo or In Progress") - .arg(org_arg()) - .arg(team_arg()) - .arg(flag_arg("noproject", 'n', "Do not prompt for a project")) - .arg(flag_arg("noteam", 't', "Do not prompt for a team")) - .arg(config_arg()), - Command::new("view") - .about("View the issue for current branch") - .arg(flag_arg("select", 's', "Select ticket from list view")) - .arg(org_arg()) - .arg(config_arg()), - ]), - Command::new("org") - .arg_required_else_help(true) - .propagate_version(true) - .subcommand_required(true) - .subcommands([ - Command::new("add") - .about("Add an organization and token to config") - .arg(config_arg()), - Command::new("remove") - .about("Remove an organization and token from config") - .arg(config_arg()), - Command::new("list") - .about("List organizations and tokens in config") - .arg(config_arg()), - ]), - Command::new("template") - .arg_required_else_help(true) - .propagate_version(true) - .subcommand_required(true) - .subcommands([ - Command::new("evaluate") - .about("create issues from a TOML file") - .arg(team_arg()) - .arg(org_arg()) - .arg(path_arg()) - .arg(flag_arg("noproject", 'n', "Do not prompt for a project")) - .arg(config_arg()), - ]), - ]) -} - // --- ISSUES --- #[cfg(not(tarpaulin_include))] -fn issue_create(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - let token = fetch_token(matches, &config)?; +fn issue_create(cli: Cli, args: &IssueCreate) -> Result { + let IssueCreate { + title, + description, + team, + noproject, + } = args; + let config = fetch_config(&cli)?; + let token = fetch_token(&cli, &config)?; let viewer = viewer::get_viewer(&config, &token)?; - let team_name = fetch_team_name(matches); - let team = viewer::team(&viewer, team_name)?; + let team = viewer::team(&viewer, team)?; let state = get_state(&config, &token, &team)?; let priority = get_priority()?; - let project = match has_flag(matches, "noproject") { + let project = match noproject { true => None, false => get_project(&Some(team.clone()))?, }; - let title = fetch_string(matches, &config, "title", "Title")?; - let description = fetch_editor(matches, &config, "description", "Description")?; + let title = fetch_string(title, &config, "Title")?; + let description = fetch_editor(description, &config, "Description")?; issue::create( &config, @@ -163,10 +238,11 @@ fn issue_create(matches: &ArgMatches) -> Result { } #[cfg(not(tarpaulin_include))] -fn issue_view(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - let token = fetch_token(matches, &config)?; - if has_flag(matches, "select") { +fn issue_view(cli: Cli, args: &IssueView) -> Result { + let IssueView { select } = args; + let config = fetch_config(&cli)?; + let token = fetch_token(&cli, &config)?; + if *select { issue::view(&config, &token, None) } else { let branch = git::get_branch()?; @@ -175,27 +251,31 @@ fn issue_view(matches: &ArgMatches) -> Result { } #[cfg(not(tarpaulin_include))] -fn issue_edit(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - let token = fetch_token(matches, &config)?; +fn issue_edit(cli: Cli, _args: &IssueEdit) -> Result { + let config = fetch_config(&cli)?; + let token = fetch_token(&cli, &config)?; let branch = git::get_branch()?; issue::edit(&config, &token, branch) } #[cfg(not(tarpaulin_include))] -fn issue_list(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - let token = fetch_token(matches, &config)?; +fn issue_list(cli: Cli, args: &IssueList) -> Result { + let IssueList { + team, + noteam, + noproject, + } = args; + let config = fetch_config(&cli)?; + let token = fetch_token(&cli, &config)?; let viewer = viewer::get_viewer(&config, &token)?; - let team_name = fetch_team_name(matches); - let team = match has_flag(matches, "noteam") { + let team = match *noteam { true => None, - false => Some(viewer::team(&viewer, team_name)?), + false => Some(viewer::team(&viewer, team)?), }; - let project = match has_flag(matches, "noproject") { + let project = match *noproject { true => None, false => get_project(&team)?, }; @@ -206,8 +286,8 @@ fn issue_list(matches: &ArgMatches) -> Result { // --- ORGANIZATIONS --- #[cfg(not(tarpaulin_include))] -fn organization_add(matches: &ArgMatches) -> Result { - let mut config = fetch_config(matches)?; +fn org_add(cli: Cli, _args: &OrgAdd) -> Result { + let mut config = fetch_config(&cli)?; let name = input::string("Input organization name", None)?; let token = input::string("Input organization token", None)?; config.add_organization(name, token); @@ -215,8 +295,8 @@ fn organization_add(matches: &ArgMatches) -> Result { } #[cfg(not(tarpaulin_include))] -fn organization_list(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; +fn org_list(cli: Cli, _args: &OrgList) -> Result { + let config = fetch_config(&cli)?; let orgs = config .organizations .into_iter() @@ -234,8 +314,8 @@ fn organization_list(matches: &ArgMatches) -> Result { } #[cfg(not(tarpaulin_include))] -fn organization_remove(matches: &ArgMatches) -> Result { - let mut config = fetch_config(matches)?; +fn org_remove(cli: Cli, _args: &OrgRemove) -> Result { + let mut config = fetch_config(&cli)?; let org_names = config.organization_names(); if org_names.is_empty() { let command = color::cyan_string("org add"); @@ -250,21 +330,20 @@ fn organization_remove(matches: &ArgMatches) -> Result { // --- TEMPLATES --- #[cfg(not(tarpaulin_include))] -fn template_evaluate(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - let token = fetch_token(matches, &config)?; +fn template_evaluate(cli: Cli, args: &TemplateEvaluate) -> Result { + let TemplateEvaluate { + path, + team, + noproject, + } = args; + let config = fetch_config(&cli)?; + let token = fetch_token(&cli, &config)?; let viewer = viewer::get_viewer(&config, &token)?; - let team_name = fetch_team_name(matches); - let team = viewer::team(&viewer, team_name)?; + let team = viewer::team(&viewer, team)?; let priority = get_priority()?; let state = get_state(&config, &token, &team)?; - let path = fetch_string( - matches, - &config, - "path", - "Enter path to TOML file or directory", - )?; - let project = match has_flag(matches, "noproject") { + let path = fetch_string(path, &config, "Enter path to TOML file or directory")?; + let project = match *noproject { true => None, false => get_project(&Some(team.clone()))?, }; @@ -277,16 +356,14 @@ fn template_evaluate(matches: &ArgMatches) -> Result { // --- VALUE HELPERS --- #[cfg(not(tarpaulin_include))] -fn fetch_config(matches: &ArgMatches) -> Result { +fn fetch_config(cli: &Cli) -> Result { check_for_latest_version(); - let config_path = matches.get_one::("config").map(|s| s.to_owned()); - - config::get_or_create(config_path) + config::get_or_create(cli.config.clone()) } #[cfg(not(tarpaulin_include))] -fn fetch_token(matches: &ArgMatches, config: &Config) -> Result { - let org_name = matches.get_one::("org").map(|s| s.to_owned()); +fn fetch_token(cli: &Cli, config: &Config) -> Result { + let org_name = cli.org.clone(); match org_name { Some(string) => config.token(&string), None => { @@ -327,143 +404,57 @@ fn get_priority() -> Result { input::select("Select priority", priorities, None) } -/// Checks if the flag was used -#[cfg(not(tarpaulin_include))] -fn has_flag(matches: &ArgMatches, id: &'static str) -> bool { - matches.get_one::(id) == Some(&String::from("yes")) -} -#[cfg(not(tarpaulin_include))] -fn config_arg() -> Arg { - Arg::new("config") - .short('c') - .long("config") - .num_args(1) - .required(false) - .value_name("CONFIGURATION PATH") - .help("Absolute path of configuration. Defaults to $XDG_CONFIG_HOME/lnr.cfg") -} - -#[cfg(not(tarpaulin_include))] -fn org_arg() -> Arg { - Arg::new("org") - .short('o') - .long("org") - .num_args(1) - .required(false) - .value_name("organization name") - .help("You will be promped at runtime if this isn't provided") -} - -#[cfg(not(tarpaulin_include))] -fn title_arg() -> Arg { - Arg::new("title") - .short('t') - .long("title") - .num_args(1) - .required(false) - .value_name("TITLE TEXT") - .help("Title for issue") -} - -#[cfg(not(tarpaulin_include))] -fn team_arg() -> Arg { - Arg::new("team") - .short('e') - .long("team") - .num_args(1) - .required(false) - .value_name("TEAM NAME") - .help("Team name") -} - -#[cfg(not(tarpaulin_include))] -fn path_arg() -> Arg { - Arg::new("path") - .short('p') - .long("path") - .num_args(1) - .required(false) - .value_name("PATH") - .help("Path to file or directory") -} - -#[cfg(not(tarpaulin_include))] -fn description_arg() -> Arg { - Arg::new("description") - .short('d') - .long("description") - .num_args(1) - .required(false) - .value_name("DESCRIPTION TEXT") - .help("Description for issue") -} - -#[cfg(not(tarpaulin_include))] -fn flag_arg(id: &'static str, short: char, help: &'static str) -> Arg { - Arg::new(id) - .short(short) - .long(id) - .value_parser(["yes", "no"]) - .num_args(0..1) - .default_value("no") - .default_missing_value("yes") - .required(false) - .help(help) -} +// #[cfg(not(tarpaulin_include))] +// fn path_arg() -> Arg { +// Arg::new("path") +// .short('p') +// .long("path") +// .num_args(1) +// .required(false) +// .value_name("PATH") +// .help("Path to file or directory") +// } #[cfg(not(tarpaulin_include))] -fn fetch_string( - matches: &ArgMatches, - config: &Config, - field: &str, - prompt: &str, -) -> Result { - let argument_content = matches.get_one::(field).map(|s| s.to_owned()); - match argument_content { - Some(string) => Ok(string), +fn fetch_string(value: &Option, config: &Config, prompt: &str) -> Result { + match value { + Some(string) => Ok(string.to_owned()), None => input::string(prompt, config.mock_string.clone()), } } #[cfg(not(tarpaulin_include))] -fn fetch_editor( - matches: &ArgMatches, - config: &Config, - field: &str, - prompt: &str, -) -> Result { - let argument_content = matches.get_one::(field).map(|s| s.to_owned()); - match argument_content { - Some(string) => Ok(string), +fn fetch_editor(value: &Option, config: &Config, prompt: &str) -> Result { + match value { + Some(string) => Ok(string.to_owned()), None => input::editor(prompt, "", config.mock_string.clone()), } } -#[cfg(not(tarpaulin_include))] -fn fetch_team_name(matches: &ArgMatches) -> Option { - matches.get_one::("team").map(|s| s.to_owned()) -} - fn check_for_latest_version() { match request::get_latest_version() { Ok(version) if version.as_str() != VERSION => { println!( "Latest {} version is {}, found {}.\nRun {} to update if you installed with Cargo", - APP, + NAME, version, VERSION, - format!("cargo install {APP} --force").bright_cyan() + format!("cargo install {NAME} --force").bright_cyan() ); } Ok(_) => (), Err(err) => println!( "{}, {:?}", - format!("Could not fetch {APP} version from Cargo.io").red(), + format!("Could not fetch {NAME} version from Cargo.io").red(), err ), }; } + #[test] fn verify_cmd() { - cmd().debug_assert(); + use clap::CommandFactory; + // Mostly checks that it is not going to throw an exception because of conflicting short arguments + Cli::try_parse().err(); + Cli::command().debug_assert(); } diff --git a/src/viewer.rs b/src/viewer.rs index 507616d..98f1342 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -117,11 +117,11 @@ pub fn team_by_name(viewer: &Viewer, team_name: &String) -> Result } } -pub fn team(viewer: &Viewer, team_name: Option) -> Result { +pub fn team(viewer: &Viewer, team_name: &Option) -> Result { let mut team_names = team_names(viewer)?; if let Some(name) = team_name { - return team_by_name(viewer, &name); + return team_by_name(viewer, name); } team_names.sort();