diff --git a/Cargo.lock b/Cargo.lock index 70b752fd3..781f1fe24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -504,9 +504,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64ct" @@ -1006,11 +1006,14 @@ dependencies = [ "proc-macro2", "quote", "rand 0.10.2", + "serde", + "serde_json", "syn 3.0.3", "tempfile", "tracing", "tracing-subscriber", "trybuild", + "wait-timeout", ] [[package]] @@ -1482,7 +1485,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" dependencies = [ - "base64 0.23.0", + "base64 0.23.1", "memchr", ] @@ -1602,7 +1605,7 @@ dependencies = [ name = "example-file-upload" version = "0.1.0" dependencies = [ - "base64 0.23.0", + "base64 0.23.1", "cot", ] @@ -2439,9 +2442,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -2556,13 +2559,13 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lettre" -version = "0.11.22" +version = "0.11.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" dependencies = [ "async-std", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "email-encoding", "email_address", "fastrand", @@ -3398,9 +3401,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4684,9 +4687,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "trybuild" -version = "1.0.118" +version = "1.0.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" dependencies = [ "dissimilar", "glob", diff --git a/Cargo.toml b/Cargo.toml index 55471725c..e8b010e45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ tracing-subscriber = "0.3" tracing-test = "0.2" trybuild = { version = "1", features = ["diff"] } url = "2" +wait-timeout = { version = "0.2", default-features = false } [profile.dev.package] insta.opt-level = 3 diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index 341b564bd..c805ef30a 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -21,6 +21,7 @@ workspace = true [dependencies] anyhow.workspace = true +assert_cmd = {workspace = true, optional = true} cargo_toml.workspace = true chrono.workspace = true clap = { workspace = true, features = ["derive", "env", "wrap_help", "string"] } @@ -41,14 +42,20 @@ quote.workspace = true syn.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true} +wait-timeout = { workspace = true } +tempfile = {workspace = true, optional = true} [dev-dependencies] cot-cli = { path = ".", features = ["test_utils"] } assert_cmd.workspace = true insta.workspace = true insta-cmd.workspace = true -tempfile.workspace = true + trybuild.workspace = true [features] -test_utils = [] +test_utils = [ + "dep:tempfile" +] diff --git a/cot-cli/src/args.rs b/cot-cli/src/args.rs index 1e35ceec8..e5a2011b0 100644 --- a/cot-cli/src/args.rs +++ b/cot-cli/src/args.rs @@ -1,8 +1,17 @@ +use std::ffi::OsString; use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; use clap_verbosity_flag::Verbosity; +pub const PACKAGE_LONG_FLAG: &str = "--package"; +pub const PACKAGE_SHORT_FLAG: &str = "-p"; +pub const RELEASE_FLAG: &str = "--release"; +pub const HELP_LONG_FLAG: &str = "--help"; +pub const HELP_SHORT_FLAG: &str = "-h"; +pub const BINARY_FLAG: &str = "--bin"; +pub const BUILD_FLAG: &str = "--build"; + #[derive(Debug, Parser)] #[command( name = "cot", @@ -11,6 +20,16 @@ use clap_verbosity_flag::Verbosity; long_about = None )] pub struct Cli { + /// Use target/release instead of target/debug when looking for the project + /// binary + #[arg(long, global = true)] + release: bool, + /// Build the binary if it does not exist + #[arg(long, global = true)] + build: bool, + /// Package to use, in case you're running this in a workspace + #[arg(short = 'p', long, global = true, value_name = "PACKAGE")] + pub package: Option, #[command(flatten)] pub verbose: Verbosity, #[command(subcommand)] @@ -29,6 +48,9 @@ pub enum Commands { /// Manage Cot CLI #[command(subcommand)] Cli(CliCommands), + + #[command(external_subcommand)] + External(Vec), } #[derive(Debug, Args)] @@ -50,6 +72,9 @@ pub enum MigrationCommands { Make(MigrationMakeArgs), /// Create a new empty migration New(MigrationNewArgs), + /// External migration subcommands shipped with the cot binary + #[command(external_subcommand)] + External(Vec), } #[derive(Debug, Args)] @@ -119,3 +144,71 @@ pub struct CompletionsArgs { /// Shell to generate completions for pub shell: clap_complete::Shell, } + +/// Pulls `-p ` / `--package ` / `--package=` out of raw +/// argv, before clap has parsed anything. Needed because `project::load` +/// must run before `Cli::parse` for the `--help` interception path. +#[must_use] +pub fn extract_package_arg(raw: &[String]) -> Option { + let mut iter = raw.iter(); + while let Some(arg) = iter.next() { + if let Some(value) = arg.strip_prefix(&format!("{PACKAGE_LONG_FLAG}=")) { + return Some(value.to_string()); + } + if arg == PACKAGE_LONG_FLAG || arg == PACKAGE_SHORT_FLAG { + return iter.next().cloned(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(raw: &[&str]) -> Vec { + raw.iter().map(|arg| (*arg).to_string()).collect() + } + + #[test] + fn extract_package_arg_long_with_separate_value() { + let raw = args(&["cot", "--release", "--package", "blog", "check"]); + + assert_eq!(extract_package_arg(&raw), Some("blog".to_string())); + } + + #[test] + fn extract_package_arg_long_with_equals_value() { + let raw = args(&["cot", "--package=blog", "check"]); + + assert_eq!(extract_package_arg(&raw), Some("blog".to_string())); + } + + #[test] + fn extract_package_arg_short_with_value() { + let raw = args(&["cot", "-p", "blog", "check"]); + + assert_eq!(extract_package_arg(&raw), Some("blog".to_string())); + } + + #[test] + fn extract_package_arg_returns_first_package_flag() { + let raw = args(&["cot", "-p", "first", "--package", "second", "check"]); + + assert_eq!(extract_package_arg(&raw), Some("first".to_string())); + } + + #[test] + fn extract_package_arg_missing_value_returns_none() { + let raw = args(&["cot", "check", "-p"]); + + assert_eq!(extract_package_arg(&raw), None); + } + + #[test] + fn extract_package_arg_absent_returns_none() { + let raw = args(&["cot", "--release", "check"]); + + assert_eq!(extract_package_arg(&raw), None); + } +} diff --git a/cot-cli/src/handlers.rs b/cot-cli/src/handlers.rs index 23b34fb90..7bc737268 100644 --- a/cot-cli/src/handlers.rs +++ b/cot-cli/src/handlers.rs @@ -1,7 +1,13 @@ +use std::collections::HashSet; +use std::ffi::OsString; +#[cfg(unix)] +use std::os::unix::process::CommandExt; use std::path::PathBuf; use anyhow::Context; use clap::CommandFactory; +use cot::metadata::{ArgMeta, CommandMeta}; +use cot::utils::cli::{StatusType, print_status_msg}; use crate::args::{ Cli, CompletionsArgs, ManpagesArgs, MigrationListArgs, MigrationMakeArgs, MigrationNewArgs, @@ -11,6 +17,7 @@ use crate::migration_generator::{ MigrationGeneratorOptions, create_new_migration, list_migrations, make_migrations, }; use crate::new_project::{CotSource, new_project}; +use crate::project::ProjectBinary; pub fn handle_new_project( ProjectNewArgs { path, name, source }: ProjectNewArgs, @@ -95,6 +102,194 @@ pub fn handle_cli_completions(CompletionsArgs { shell }: CompletionsArgs) -> any Ok(()) } +pub fn handle_external( + command_path: &[String], + remaining_args: &[OsString], + project: Option, + _release: bool, +) -> anyhow::Result<()> { + let subcmd = command_path.join(" "); + + let Some(proj) = project else { + anyhow::bail!( + "unknown command `{subcmd}` and no project binary was found in the `target` dir.\n\ + Hint: run `cargo build` first, or pass `cot --build {subcmd}` to build it automatically." + ); + }; + + match &proj.metadata { + Some(meta) if command_path_exists(&meta.commands, command_path) => { + // command is known, proceed to exec + } + Some(_) => { + // metadata found but command is not known + anyhow::bail!( + "unknown command `{subcmd}`. Run `cot --help` to see available commands." + ); + } + None => { + // The metadata retrieval from the binary most likely failed or didnt exist so + // theres no way to validate the command exists here. We forward the command + // unconditionally and let the binary handle it. + print_status_msg( + StatusType::Warning, + &format!( + "could not obtain metadata for `{}`; forwarding `{subcmd}` command directly", + proj.path.display() + ), + ); + } + } + + let full_args: Vec = command_path + .iter() + .map(OsString::from) + .chain(remaining_args.iter().cloned()) + .collect(); + + exec(&proj, &full_args) +} + +fn command_path_exists(commands: &[CommandMeta], path: &[String]) -> bool { + let mut current: &[CommandMeta] = commands; + + for segment in path { + let found = current + .iter() + .find(|c| c.name == *segment || c.aliases.iter().any(|a| a == segment)); + + match found { + Some(cmd) => current = &cmd.subcommands, + None => return false, + } + } + + true +} + +fn exec(proj: &ProjectBinary, args: &[OsString]) -> anyhow::Result<()> { + #[cfg(unix)] + { + let err = std::process::Command::new(&proj.path).args(args).exec(); + anyhow::bail!("Failed to exec {}: {err}", proj.path.display()); + } + + #[cfg(not(unix))] + { + // Windows has no equivalent of POSIX `execve` that replaces the current + // process in place. The best we can do is spawn the binary as a + // child and block here until it exits + let status = std::process::Command::new(&proj.path).args(args).status()?; + std::process::exit(status.code().unwrap_or(1)); + } +} + +/// Build a fresh [`clap::Command`] and inject the project's subcommands into +/// it before printing. +pub fn handle_combined_help( + project: Option<&ProjectBinary>, + path: &[String], +) -> anyhow::Result<()> { + let cmd = combined_help_command(project); + let mut target = navigate_to(cmd, path); + target.print_help()?; + println!(); + Ok(()) +} + +fn navigate_to(mut cmd: clap::Command, path: &[String]) -> clap::Command { + let mut bin_name = cmd.get_name().to_string(); + + for segment in path { + match cmd.find_subcommand(segment) { + Some(sub) => { + bin_name = format!("{bin_name} {segment}"); + cmd = sub.clone(); + } + None => break, + } + } + + cmd.bin_name(bin_name) +} + +fn combined_help_command(project: Option<&ProjectBinary>) -> clap::Command { + let mut cmd = Cli::command(); + + if let Some(proj) = project + && let Some(meta) = &proj.metadata + { + let mut cmd_set: HashSet = cmd + .get_subcommands() + .map(|sc| sc.get_name().to_string()) + .collect(); + + for meta_cmd in &meta.commands { + if cmd_set.insert(meta_cmd.name.clone()) { + cmd = cmd.subcommand(build_clap_subcommand(meta_cmd)); + } else { + // there's an existing command, let's merge them into one. For command + // collisions, metadata(such as name and about) of the command + // present in `cot-cli` will take precedence. + cmd = cmd.mut_subcommand(&meta_cmd.name, |mut sc| { + for sub in &meta_cmd.subcommands { + sc = sc.subcommand(build_clap_subcommand(sub)); + } + sc + }); + } + } + } + + cmd +} + +fn build_clap_subcommand(meta: &CommandMeta) -> clap::Command { + let mut cmd = clap::Command::new(&meta.name); + + if let Some(about) = &meta.about { + cmd = cmd.about(about.clone()); + } + + for alias in &meta.aliases { + cmd = cmd.visible_alias(alias.clone()); + } + + for arg_meta in &meta.args { + cmd = cmd.arg(build_clap_arg(arg_meta)); + } + + for sub in &meta.subcommands { + cmd = cmd.subcommand(build_clap_subcommand(sub)); + } + + cmd +} + +fn build_clap_arg(meta: &ArgMeta) -> clap::Arg { + let mut arg = clap::Arg::new(&meta.name).required(meta.required); + + if meta.is_positional + && let Some(vn) = &meta.value_name + { + arg = arg.value_name(vn.clone()); + } else { + if let Some(long) = &meta.long { + arg = arg.long(long.clone()); + } + if let Some(short) = meta.short { + arg = arg.short(short); + } + if !meta.takes_value { + arg = arg.action(clap::ArgAction::SetTrue); + } + } + if let Some(help) = &meta.help { + arg = arg.help(help.clone()); + } + arg +} + fn generate_completions(shell: clap_complete::Shell, writer: &mut impl std::io::Write) { clap_complete::generate(shell, &mut Cli::command(), "cot", writer); } @@ -180,4 +375,417 @@ mod tests { assert!(!output.is_empty()); } + + #[test] + fn external_command_without_project_reports_build_hint() { + let result = handle_external(&["serve".to_string()], &[], None, false); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("unknown command `serve`")); + assert!(message.contains("run `cargo build` first")); + } + + #[test] + fn external_command_unknown_to_project_reports_unknown_command() { + let project = ProjectBinary { + path: PathBuf::from("target/debug/example"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "check".to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + }), + }; + + let result = handle_external(&["foo".to_string()], &[], Some(project), false); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("unknown command `foo`")); + assert!(message.contains("cot --help")); + } + + #[test] + fn external_command_nested_path_unknown_reports_unknown_command() { + let project = ProjectBinary { + path: PathBuf::from("target/debug/example"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }], + }), + }; + + let result = handle_external( + &["migration".to_string(), "nonexistent".to_string()], + &[], + Some(project), + false, + ); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("unknown command `migration nonexistent`")); + } + + #[test] + #[cfg(unix)] + fn known_nested_command_attempts_exec_and_fails_when_binary_missing() { + let project = ProjectBinary { + path: PathBuf::from("/nonexistent/binary/path"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }], + }), + }; + + let result = handle_external( + &["migration".to_string(), "rollback".to_string()], + &[OsString::from("my_migration"), OsString::from("--dry-run")], + Some(project), + false, + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to exec")); + } + + #[test] + #[cfg(unix)] + fn missing_metadata_forwards_blindly_and_attempts_exec() { + let project = ProjectBinary { + path: PathBuf::from("/nonexistent/binary/path"), + metadata: None, + }; + + let result = handle_external(&["anything".to_string()], &[], Some(project), false); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to exec")); + } + + #[test] + fn command_path_exists_finds_nested_command() { + let commands = vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }]; + + assert!(command_path_exists( + &commands, + &["migration".to_string(), "rollback".to_string()] + )); + } + + #[test] + fn command_path_exists_matches_via_alias() { + let commands = vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec!["mig".to_string()], + subcommands: vec![], + args: vec![], + }]; + + assert!(command_path_exists(&commands, &["mig".to_string()])); + } + + #[test] + fn command_path_exists_rejects_missing_nested_command() { + let commands = vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }]; + + assert!(!command_path_exists( + &commands, + &["migration".to_string(), "nonexistent".to_string()] + )); + } + + #[test] + fn command_path_exists_empty_path_is_true() { + assert!(command_path_exists(&[], &[])); + } + + #[test] + fn build_clap_subcommand_preserves_about_aliases_and_nested_subcommands() { + let meta = CommandMeta { + name: "migration".to_string(), + about: Some("Migration commands".to_string()), + aliases: vec!["database".to_string()], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: Some("Rollback migrations".to_string()), + aliases: vec!["rbk".to_string()], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }; + + let cmd = build_clap_subcommand(&meta); + + assert_eq!(cmd.get_name(), "migration"); + assert_eq!(cmd.get_about().unwrap().to_string(), "Migration commands"); + assert!(cmd.get_all_aliases().any(|alias| alias == "database")); + let nested = cmd + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "rollback") + .unwrap(); + assert_eq!( + nested.get_about().unwrap().to_string(), + "Rollback migrations" + ); + assert!(nested.get_all_aliases().any(|alias| alias == "rbk")); + } + + #[test] + fn build_clap_arg_positional_required() { + let meta = ArgMeta { + name: "migration_name".to_string(), + long: None, + short: None, + help: Some("Migration to roll back to".to_string()), + required: true, + is_positional: true, + takes_value: true, + value_name: Some("MIGRATION_NAME".to_string()), + }; + + let arg = build_clap_arg(&meta); + + assert!(arg.is_required_set()); + assert!(arg.is_positional()); + assert_eq!(arg.get_value_names().unwrap()[0].as_str(), "MIGRATION_NAME"); + } + + #[test] + fn build_clap_arg_boolean_flag_sets_true_action() { + let meta = ArgMeta { + name: "dry-run".to_string(), + long: Some("dry-run".to_string()), + short: None, + help: None, + required: false, + is_positional: false, + takes_value: false, + value_name: None, + }; + + let arg = build_clap_arg(&meta); + + assert_eq!(arg.get_long(), Some("dry-run")); + } + + #[test] + fn build_clap_arg_valued_flag_with_short_and_long() { + let meta = ArgMeta { + name: "app".to_string(), + long: Some("app".to_string()), + short: Some('a'), + help: Some("App name".to_string()), + required: false, + is_positional: false, + takes_value: true, + value_name: None, + }; + + let arg = build_clap_arg(&meta); + + assert_eq!(arg.get_long(), Some("app")); + assert_eq!(arg.get_short(), Some('a')); + } + + #[test] + fn combined_help_command_includes_project_commands_and_builtin_commands() { + let project = ProjectBinary { + path: PathBuf::from("target/debug/example"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "health".to_string(), + about: Some("Check the server health".to_string()), + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + }), + }; + + let cmd = combined_help_command(Some(&project)); + + assert!( + cmd.get_subcommands() + .any(|subcommand| subcommand.get_name() == "new") + ); + let health = cmd + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "health") + .unwrap(); + assert_eq!( + health.get_about().unwrap().to_string(), + "Check the server health" + ); + } + + #[test] + fn combined_help_command_merges_duplicate_subcommand_preserving_builtin_about() { + let project = ProjectBinary { + path: PathBuf::from("target/debug/example"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "migration".to_string(), + about: Some("Should not override cot-cli's about".to_string()), + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: Some("Rollback migrations".to_string()), + aliases: vec![], + subcommands: vec![], + args: vec![], + }], + args: vec![], + }], + }), + }; + + let cmd = combined_help_command(Some(&project)); + + let matches: Vec<_> = cmd + .get_subcommands() + .filter(|sc| sc.get_name() == "migration") + .collect(); + assert_eq!(matches.len(), 1, "migration should not be duplicated"); + + let migration = matches[0]; + assert_eq!( + migration.get_about().unwrap().to_string(), + "Manage migrations for a Cot project" + ); + assert!( + migration + .get_subcommands() + .any(|sc| sc.get_name() == "rollback") + ); + assert!( + migration + .get_subcommands() + .any(|sc| sc.get_name() == "list") + ); + } + + #[test] + fn navigate_to_returns_root_for_empty_path() { + let cmd = combined_help_command(None); + let target = navigate_to(cmd, &[]); + assert_eq!(target.get_name(), "cot"); + } + + #[test] + fn navigate_to_descends_into_known_subcommand() { + let cmd = combined_help_command(None); + let target = navigate_to(cmd, &["migration".to_string()]); + assert_eq!(target.get_name(), "migration"); + assert_eq!(target.get_bin_name(), Some("cot migration")); + } + + #[test] + fn navigate_to_stops_at_first_unknown_segment() { + let cmd = combined_help_command(None); + let target = navigate_to(cmd, &["migration".to_string(), "nonexistent".to_string()]); + assert_eq!(target.get_name(), "migration"); + } + + #[test] + fn navigate_to_descends_into_merged_binary_subcommand_with_args() { + let project = ProjectBinary { + path: PathBuf::from("target/debug/example"), + metadata: Some(cot::metadata::ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: "example".to_string(), + commands: vec![CommandMeta { + name: "migration".to_string(), + about: None, + aliases: vec![], + subcommands: vec![CommandMeta { + name: "rollback".to_string(), + about: Some("Rollback migrations".to_string()), + aliases: vec![], + subcommands: vec![], + args: vec![ArgMeta { + name: "dry-run".to_string(), + long: Some("dry-run".to_string()), + short: None, + help: Some("Print the rollback plan".to_string()), + required: false, + is_positional: false, + takes_value: false, + value_name: None, + }], + }], + args: vec![], + }], + }), + }; + + let cmd = combined_help_command(Some(&project)); + let target = navigate_to(cmd, &["migration".to_string(), "rollback".to_string()]); + + assert_eq!(target.get_name(), "rollback"); + assert_eq!(target.get_bin_name(), Some("cot migration rollback")); + assert!(target.get_arguments().any(|a| a.get_id() == "dry-run")); + } } diff --git a/cot-cli/src/lib.rs b/cot-cli/src/lib.rs index 5c23e7383..07364dac1 100644 --- a/cot-cli/src/lib.rs +++ b/cot-cli/src/lib.rs @@ -4,6 +4,9 @@ pub mod args; pub mod handlers; pub mod migration_generator; pub mod new_project; +pub mod project; +#[cfg(any(test, feature = "test_utils"))] +pub mod test_harness; #[cfg(feature = "test_utils")] pub mod test_utils; mod utils; diff --git a/cot-cli/src/main.rs b/cot-cli/src/main.rs index c80f98271..999354f0b 100644 --- a/cot-cli/src/main.rs +++ b/cot-cli/src/main.rs @@ -1,12 +1,84 @@ #![allow(unreachable_pub)] // triggers false positives because we have both a binary and library +use std::ffi::OsString; + use clap::Parser; -use cot_cli::args::{Cli, CliCommands, Commands, MigrationCommands}; -use cot_cli::handlers; +use cot_cli::args::{ + BUILD_FLAG, Cli, CliCommands, Commands, HELP_LONG_FLAG, HELP_SHORT_FLAG, MigrationCommands, + PACKAGE_LONG_FLAG, PACKAGE_SHORT_FLAG, RELEASE_FLAG, extract_package_arg, +}; +use cot_cli::{handlers, project}; use tracing_subscriber::util::SubscriberInitExt; +fn resolve_help_request(args: &[String]) -> Option> { + if !args + .iter() + .any(|a| a == HELP_LONG_FLAG || a == HELP_SHORT_FLAG) + { + return None; + } + + let mut path = Vec::new(); + let mut iter = args.iter().skip(1).peekable(); + + while let Some(arg) = iter.next() { + match arg.as_str() { + // short-circuit once we find a help flag + HELP_LONG_FLAG | HELP_SHORT_FLAG => return Some(path), + RELEASE_FLAG | BUILD_FLAG => {} + PACKAGE_SHORT_FLAG | PACKAGE_LONG_FLAG => match iter.peek() { + Some(v) if !v.starts_with('-') => { + iter.next(); + } + _ => return None, + }, + other if other.starts_with('-') => return None, + other => path.push(other.to_string()), + } + } + + None +} + +fn forwarded_args( + clap_captured_args: &[OsString], + args_after_double_dash: &[String], +) -> Vec { + clap_captured_args + .iter() + .cloned() + .chain(args_after_double_dash.iter().map(OsString::from)) + .collect() +} + +fn split_on_double_dash(raw: &[String]) -> (&[String], &[String]) { + match raw.iter().position(|a| a == "--") { + Some(i) => (&raw[..i], &raw[i + 1..]), + None => (raw, &[]), + } +} + fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); + let raw: Vec = std::env::args().collect(); + + let (cot_args, forwarded_remaining_args) = split_on_double_dash(&raw); + + let release = cot_args.iter().any(|a| a == RELEASE_FLAG); + let build = cot_args.iter().any(|b| b == BUILD_FLAG); + let package = extract_package_arg(cot_args); + + if let Some(path) = resolve_help_request(cot_args) { + let project = project::load( + &std::env::current_dir()?, + release, + package.as_deref(), + build, + )?; + handlers::handle_combined_help(project.as_ref(), &path)?; + return Ok(()); + } + + let cli = Cli::parse_from(cot_args); tracing_subscriber::fmt() .with_env_filter( @@ -26,6 +98,149 @@ fn main() -> anyhow::Result<()> { MigrationCommands::List(args) => handlers::handle_migration_list(args), MigrationCommands::Make(args) => handlers::handle_migration_make(args), MigrationCommands::New(args) => handlers::handle_migration_new(args), + MigrationCommands::External(args) => { + let project = project::load( + &std::env::current_dir()?, + release, + package.as_deref(), + build, + )?; + let path = vec![ + "migration".to_string(), + args[0].to_string_lossy().into_owned(), + ]; + let remaining = forwarded_args(&args[1..], forwarded_remaining_args); + handlers::handle_external(&path, &remaining, project, release) + } }, + Commands::External(args) => { + let project = project::load( + &std::env::current_dir()?, + release, + package.as_deref(), + build, + )?; + let path = vec![args[0].to_string_lossy().into_owned()]; + let remaining = forwarded_args(&args[1..], forwarded_remaining_args); + handlers::handle_external(&path, &remaining, project, release) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(raw: &[&str]) -> Vec { + raw.iter().map(|arg| (*arg).to_string()).collect() + } + + #[test] + fn top_level_help_returns_empty_path() { + assert_eq!( + resolve_help_request(&args(&["cot", "--help"])), + Some(vec![]) + ); + assert_eq!(resolve_help_request(&args(&["cot", "-h"])), Some(vec![])); + } + + #[test] + fn top_level_help_accepts_global_flags_before_help() { + assert_eq!( + resolve_help_request(&args(&["cot", "--release", "-p", "blog", "--help"])), + Some(vec![]) + ); + assert_eq!( + resolve_help_request(&args(&["cot", "--package", "blog", "-h", "--release"])), + Some(vec![]) + ); + } + + #[test] + fn help_flag_short_circuits_ignoring_trailing_tokens() { + assert_eq!( + resolve_help_request(&args(&["cot", "--help", "foo"])), + Some(vec![]) + ); + assert_eq!( + resolve_help_request(&args(&["cot", "migration", "-h", "rollback"])), + Some(vec!["migration".to_string()]) + ); + } + + #[test] + fn subcommand_help_returns_path() { + assert_eq!( + resolve_help_request(&args(&["cot", "migration", "--help"])), + Some(vec!["migration".to_string()]) + ); + assert_eq!( + resolve_help_request(&args(&["cot", "migration", "rollback", "-h"])), + Some(vec!["migration".to_string(), "rollback".to_string()]) + ); + } + + #[test] + fn non_help_invocations_return_none() { + assert_eq!(resolve_help_request(&args(&["cot"])), None); + assert_eq!(resolve_help_request(&args(&["cot", "serve"])), None); + assert_eq!(resolve_help_request(&args(&["cot", "--version"])), None); + } + + #[test] + fn missing_package_value_returns_none() { + assert_eq!(resolve_help_request(&args(&["cot", "-p", "--help"])), None); + assert_eq!( + resolve_help_request(&args(&["cot", "--package", "-h"])), + None + ); + } + + #[test] + fn unknown_flag_before_help_returns_none() { + assert_eq!( + resolve_help_request(&args(&["cot", "--unknown", "--help"])), + None + ); + } + + #[test] + fn forwarded_args_combines_captured_and_double_dash_tail() { + let captured = vec![OsString::from("--dry-run")]; + let tail = vec!["--app".to_string(), "blog".to_string()]; + + let result = forwarded_args(&captured, &tail); + + assert_eq!( + result, + vec![ + OsString::from("--dry-run"), + OsString::from("--app"), + OsString::from("blog"), + ] + ); + } + + #[test] + fn forwarded_args_empty_inputs_produce_empty_vec() { + assert!(forwarded_args(&[], &[]).is_empty()); + } + + #[test] + fn split_on_double_dash_splits_at_delimiter() { + let raw = args(&["cot", "check", "--", "--dry-run", "x"]); + let (before, after) = split_on_double_dash(&raw); + + assert_eq!(before, &args(&["cot", "check"])[..]); + assert_eq!(after, &args(&["--dry-run", "x"])[..]); + } + + #[test] + fn split_on_double_dash_without_delimiter_returns_all_before() { + let raw = args(&["cot", "check"]); + let (before, after) = split_on_double_dash(&raw); + + assert_eq!(before, &raw[..]); + assert!(after.is_empty()); } } diff --git a/cot-cli/src/project.rs b/cot-cli/src/project.rs new file mode 100644 index 000000000..2513bef06 --- /dev/null +++ b/cot-cli/src/project.rs @@ -0,0 +1,894 @@ +use std::fmt::Write; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::SystemTime; + +use anyhow::{Context, bail}; +use cargo_toml::Manifest; +use cot::metadata::{METADATA_FLAG, ProjectMetadata}; +use cot::utils::cli::{StatusType, print_status_msg}; +use serde::{Deserialize, Serialize}; +use wait_timeout::ChildExt; + +use crate::args::{BINARY_FLAG, PACKAGE_SHORT_FLAG, RELEASE_FLAG}; +use crate::utils::{CargoTomlManager, PackageManager, WorkspaceManager}; + +const RELEASE_PROFILE: &str = "release"; +const DEBUG_PROFILE: &str = "debug"; +const METADATA_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(5); + +#[derive(Serialize, Deserialize)] +struct Cache { + binary_mtime_secs: u64, + metadata: ProjectMetadata, +} + +const COT_DIR_NAME: &str = ".cot"; +const CACHE_FILE_NAME: &str = "command-cache.json"; + +fn command_cache_path(project_dir: &Path) -> PathBuf { + project_dir.join(COT_DIR_NAME).join(CACHE_FILE_NAME) +} + +#[derive(Debug)] +pub struct ProjectBinary { + pub path: PathBuf, + pub metadata: Option, +} + +/// Find and load the project binary and its metadata. +/// +/// `package` corresponds to `cot -p ...` or `--package `. +/// It's required when run from a workspace root +/// (or any directory that doesn't unambiguously belong to one package) and +/// the workspace has more than one member. +pub fn load( + path: &Path, + release: bool, + package: Option<&str>, + build: bool, +) -> anyhow::Result> { + let Some(manager) = CargoTomlManager::from_path(path)? else { + return Ok(None); + }; + + let (package_manager, target_dir_root): (&PackageManager, PathBuf) = match &manager { + CargoTomlManager::Package(pm) => { + let dir = pm.get_package_path().to_path_buf(); + (pm, dir) + } + CargoTomlManager::Workspace(wm) => { + let pm = resolve_workspace_package(wm, package)?; + (pm, wm.get_workspace_root().to_path_buf()) + } + }; + + let project_dir = package_manager.get_package_path(); + let binary_name = resolve_binary_name(package_manager)?; + let target_dir = resolve_target_dir(&target_dir_root); + let profile = if release { + RELEASE_PROFILE + } else { + DEBUG_PROFILE + }; + + #[cfg(target_os = "windows")] + let binary_name = format!("{binary_name}.exe"); + + let binary_path = target_dir.join(profile).join(&binary_name); + + if !binary_path.exists() { + if !build { + return Ok(None); + } + + build_binary(package_manager.get_package_name(), &binary_name, release)?; + if !binary_path.exists() { + bail!( + "`cargo build` succeeded but `{}` still wasn't found at the expected path, \ + this may mean the binary name `cot` resolved doesn't match what cargo built.", + binary_path.display(), + ); + } + } + + // Guard against the `cot` CLI resolving to itself. This can happen when + // running from within the `cot-cli` package or a workspace package whose + // binary is the current executable. Querying it for `--metadata` would + // either recurse or fail: only cot application binaries implement that + // flag, not the CLI proxy. + if is_current_executable(&binary_path) { + return Ok(None); + } + + let cache_path = command_cache_path(project_dir); + let metadata = match load_or_refresh_metadata(&binary_path, &cache_path) { + Ok(meta) => meta, + Err(e) => { + print_status_msg( + StatusType::Warning, + &format!( + "could not determine `{}`'s cli commands, so they won't be \ + listed when you run `cot --help`: {e:#}", + binary_path.display(), + ), + ); + None + } + }; + + Ok(Some(ProjectBinary { + path: binary_path, + metadata, + })) +} + +fn build_binary(package_name: &str, binary_name: &str, release: bool) -> anyhow::Result<()> { + print_status_msg( + StatusType::Notice, + &format!("no existing binary found for `{binary_name}`, building it now"), + ); + + let mut cmd = std::process::Command::new("cargo"); + cmd.args([ + "build", + PACKAGE_SHORT_FLAG, + package_name, + BINARY_FLAG, + binary_name, + ]); + if release { + cmd.arg(RELEASE_FLAG); + } + + let status = cmd.status().context("failed to spawn `cargo build`")?; + + anyhow::ensure!( + status.success(), + "`cargo build` failed for `{package_name}`" + ); + Ok(()) +} + +fn is_current_executable(binary_path: &Path) -> bool { + let Ok(current_exe) = std::env::current_exe() else { + return false; + }; + + let Ok(binary_path) = binary_path.canonicalize() else { + return false; + }; + let Ok(current_exe) = current_exe.canonicalize() else { + return false; + }; + + binary_path == current_exe +} + +fn resolve_workspace_package<'a>( + wm: &'a WorkspaceManager, + package: Option<&str>, +) -> anyhow::Result<&'a PackageManager> { + if let Some(name) = package { + return wm.get_package_manager(name).with_context(|| { + format!( + "package `{name}` not found in workspace.\nAvailable packages: {}", + available_packages(wm) + ) + }); + } + + if let Some(pm) = wm.get_current_package_manager() { + return Ok(pm); + } + + bail!( + "multiple packages found in the workspace; specify which one to use with `-p `.\n\n\ + Available packages: {}", + available_packages(wm) + ) +} + +fn available_packages(wm: &WorkspaceManager) -> String { + wm.get_packages() + .iter() + .map(|p| p.get_package_name()) + .collect::>() + .join(", ") +} + +/// Resolve the binary name for a package: +/// +/// 1. If the package has a `[package.metadata.cot.binary]` entry (typically as +/// a result of disambiguating multiple binaries), use that. +/// 2. If the package has a single `[[bin]]` explicitly in `Cargo.toml`, use +/// that. +/// 3. Otherwise, use the package name. +fn resolve_binary_name(package_manager: &PackageManager) -> anyhow::Result { + let manifest: &Manifest = package_manager.get_manifest(); + + if let Some(package) = &manifest.package + && let Some(metadata) = &package.metadata + && let Some(name) = metadata + .get("cot") + .and_then(|c| c.get("binary")) + .and_then(|b| b.as_str()) + { + return Ok(name.to_string()); + } + + let named_bins: Vec<&str> = manifest + .bin + .iter() + .filter_map(|b| b.name.as_deref()) + .collect(); + + match named_bins.len() { + 0 => {} + 1 => return Ok(named_bins[0].to_string()), + _ => { + // if a default-run field exists lets use that + // https://doc.rust-lang.org/cargo/reference/manifest.html#the-default-run-field + if let Some(default_run) = manifest + .package + .as_ref() + .and_then(|p| p.default_run.as_deref()) + { + return Ok(default_run.to_string()); + } + + bail!( + "package `{}` has multiple [[bin]] targets.\n\ + Specify which one `cot` should use by adding to its Cargo.toml:\n\ + \n\ + [package.metadata.cot]\n\ + binary = \"your-binary-name\"", + package_manager.get_package_name(), + ) + } + } + + manifest + .package + .as_ref() + .map(|p| p.name.clone()) + .context("Cargo.toml has no [package] section and no [[bin]] targets") +} + +fn resolve_target_dir(start_dir: &Path) -> PathBuf { + if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") { + return PathBuf::from(dir); + } + + let mut dir = start_dir; + loop { + let candidate = dir.join("target"); + if candidate.exists() { + return candidate; + } + match dir.parent() { + Some(parent) => dir = parent, + None => break, + } + } + start_dir.join("target") +} + +fn load_or_refresh_metadata( + binary_path: &Path, + cache_path: &Path, +) -> anyhow::Result> { + let current_mtime_secs = mtime_secs(binary_path)?; + + // Fast path if we hit the cache + if let Ok(bytes) = std::fs::read(cache_path) + && let Ok(cache) = serde_json::from_slice::(&bytes) + && cache.binary_mtime_secs == current_mtime_secs + { + return Ok(Some(cache.metadata)); + } + + // slow path + // Both stderr and stdout are piped, to avoid deadlock. Pipe buffers + // are a fixed OS size, so if the child fills one while we're still + // waiting to read the other, its write call blocks and it can never + // finish producing output (or exit) for us to read. To avoid this we + // drain stdout and stderr on separate threads concurrently. + // https://doc.rust-lang.org/std/process/index.html#handling-io + // https://docs.rs/os_pipe/latest/os_pipe/#common-deadlocks-related-to-pipes + let mut child = std::process::Command::new(binary_path) + .arg(METADATA_FLAG) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn {}", binary_path.display()))?; + + let mut std_err_piped = child.stderr.take().expect("Stderr should be piped"); + let mut std_out_piped = child.stdout.take().expect("Stdout should be piped"); + + let std_err_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + std_err_piped + .read_to_end(&mut buf) + .expect("reading to buffer should not fail"); + buf + }); + + let std_out_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + std_out_piped + .read_to_end(&mut buf) + .expect("reading to buffer should not fail"); + buf + }); + + let Some(status) = child + .wait_timeout(METADATA_TIMEOUT) + .with_context(|| format!("Failed to wait on {}", binary_path.display()))? + else { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "the `{}` binary did not respond within {:?} when queried for metadata.", + binary_path.display(), + METADATA_TIMEOUT + ); + }; + + let stdout = std_out_thread + .join() + .expect("joining thread handle should not fail"); + let stderr = std_err_thread + .join() + .expect("joining stderr thread should not fail"); + + if !status.success() { + let stderr_str = String::from_utf8_lossy(&stderr); + + let is_legacy_binary = status.code() == Some(2) + && stderr_str.contains(&format!("unexpected argument '{METADATA_FLAG}'")); + + if is_legacy_binary { + print_status_msg( + StatusType::Warning, + &format!( + "the `{}` binary doesn't recognize a flag `cot` uses to discover the binary's cli commands, \ + so they won't be listed in `cot --help`. This usually means the binary \ + was built against an older version of `cot`. To fix this, update your `cot`version", + binary_path.display(), + ), + ); + return Ok(None); + } + + let mut msg = format!( + "the `{}` binary exited unexpectedly while `cot` was trying to determine the binary's cli commands.", + binary_path.display(), + ); + if !stderr_str.trim().is_empty() { + let _ = write!(msg, "\n\nstderr:\n{}", stderr_str.trim()); + } + let stdout_str = String::from_utf8_lossy(&stdout); + if !stdout_str.trim().is_empty() { + let _ = write!(msg, "\n\nstdout:\n{}", stdout_str.trim()); + } + bail!(msg); + } + + if stdout.is_empty() { + // The binary ran but the metadata flag was ignored + bail!( + "the `{}` binary produced no output for {METADATA_FLAG}", + binary_path.display(), + ); + } + + let metadata = parse_metadata(&stdout, binary_path)?; + + write_cache( + cache_path, + &Cache { + binary_mtime_secs: current_mtime_secs, + metadata: metadata.clone(), + }, + )?; + + Ok(Some(metadata)) +} + +#[derive(Deserialize)] +struct MetadataVersionProbe { + version: u32, +} + +fn parse_metadata(bytes: &[u8], binary_path: &Path) -> anyhow::Result { + // check the version first before attempting to deserialize so we can show a + // clearer error message instead of the generic serde error message + let probe: MetadataVersionProbe = serde_json::from_slice(bytes).with_context(|| { + format!( + "the `{}` binary returned metadata with no readable version field.", + binary_path.display() + ) + })?; + + anyhow::ensure!( + probe.version == cot::metadata::METADATA_SCHEMA_VERSION, + "the `{}` binary was built against a `cot` version with metadata schema v{}, \ + but this `cot-cli` expects v{}. Try updating cot-cli (`cargo install --locked cot-cli`) \ + or rebuilding the project.", + binary_path.display(), + probe.version, + cot::metadata::METADATA_SCHEMA_VERSION, + ); + + serde_json::from_slice(bytes).with_context(|| { + format!( + "Binary `{}` returned invalid JSON for {METADATA_FLAG}\n\nstdout:\n{}", + binary_path.display(), + String::from_utf8_lossy(bytes).trim(), + ) + }) +} + +fn mtime_secs(path: &Path) -> anyhow::Result { + let metadata = path.metadata()?; + Ok(metadata + .modified()? + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs()) +} + +fn write_cache(cache_path: &Path, cache: &Cache) -> anyhow::Result<()> { + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(cache_path, serde_json::to_string(cache)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + use cot::metadata::CommandMeta; + use tempfile::TempDir; + + use super::*; + + fn write_package_manifest(package_dir: &Path, package_name: &str, extra: &str) { + fs::create_dir_all(package_dir).unwrap(); + fs::write( + package_dir.join("Cargo.toml"), + format!( + r#"[package] +name = "{package_name}" +version = "0.1.0" +edition = "2024" + +{extra}"# + ), + ) + .unwrap(); + } + + fn write_workspace_manifest(workspace_dir: &Path, members: &[&str]) { + fs::write( + workspace_dir.join("Cargo.toml"), + format!( + "[workspace]\nresolver = \"3\"\nmembers = [{}]\n", + members + .iter() + .map(|member| format!("\"{member}\"")) + .collect::>() + .join(", ") + ), + ) + .unwrap(); + } + + fn command(name: &str) -> CommandMeta { + CommandMeta { + name: name.to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + } + } + + fn metadata(binary_name: &str, command_names: &[&str]) -> ProjectMetadata { + ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: binary_name.to_string(), + commands: command_names.iter().map(|name| command(name)).collect(), + } + } + + #[cfg(unix)] + fn write_metadata_script(path: &Path, metadata: &ProjectMetadata) { + let json = serde_json::to_string(metadata).unwrap(); + write_shell_script(path, &format!("printf '%s\\n' '{json}'\n")); + } + + #[cfg(unix)] + fn write_shell_script(path: &Path, body: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, format!("#!/bin/sh\n{body}")).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + + #[test] + fn load_returns_none_without_cargo_manifest() { + let temp_dir = TempDir::new().unwrap(); + + let result = load(temp_dir.path(), false, None, true).unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn load_errors_when_start_path_does_not_exist() { + let temp_dir = TempDir::new().unwrap(); + + let result = load(&temp_dir.path().join("missing"), false, None, true); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("path does not exist") + ); + } + + #[test] + fn load_returns_none_when_expected_binary_is_missing() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + + let result = load(temp_dir.path(), false, None, false).unwrap(); + + assert!(result.is_none()); + } + + #[test] + #[cfg(unix)] + fn load_reads_debug_binary_metadata_and_writes_cache() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/debug/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["serve"])); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + + let metadata = project.metadata.unwrap(); + + assert_eq!(metadata.binary_name, "demo"); + assert_eq!(metadata.commands[0].name, "serve"); + assert!(command_cache_path(temp_dir.path()).exists()); + } + + #[test] + #[cfg(unix)] + fn load_uses_release_profile_when_requested() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/release/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["serve"])); + + let project = load(temp_dir.path(), true, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + } + + #[test] + #[cfg(unix)] + fn load_uses_single_named_bin_target() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest( + temp_dir.path(), + "demo", + r#"[[bin]] +name = "server" +path = "src/server.rs" +"#, + ); + let binary_path = temp_dir.path().join("target/debug/server"); + write_metadata_script(&binary_path, &metadata("server", &["serve"])); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "server"); + } + + #[test] + #[cfg(unix)] + fn load_uses_metadata_binary_override_before_bin_targets() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest( + temp_dir.path(), + "demo", + r#"[package.metadata.cot] +binary = "api" + +[[bin]] +name = "api" +path = "src/api.rs" + +[[bin]] +name = "worker" +path = "src/worker.rs" +"#, + ); + let binary_path = temp_dir.path().join("target/debug/api"); + write_metadata_script(&binary_path, &metadata("api", &["serve"])); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "api"); + } + + #[test] + fn load_errors_on_multiple_bin_targets_without_override() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest( + temp_dir.path(), + "demo", + r#"[[bin]] +name = "api" +path = "src/api.rs" + +[[bin]] +name = "worker" +path = "src/worker.rs" +"#, + ); + + let result = load(temp_dir.path(), false, None, true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("multiple [[bin]] targets")); + assert!(message.contains("[package.metadata.cot]")); + } + + #[test] + #[cfg(unix)] + fn load_falls_back_to_no_metadata_on_command_failure() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/debug/demo"); + write_shell_script( + &binary_path, + "echo stdout message\necho stderr message >&2\nexit 42\n", + ); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_none()); + } + + #[test] + #[cfg(unix)] + fn load_falls_back_to_no_metadata_on_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/debug/demo"); + write_shell_script(&binary_path, "echo 'not json'\n"); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_none()); + } + + #[test] + #[cfg(unix)] + fn load_or_refresh_metadata_reports_command_failure_with_output() { + let temp_dir = TempDir::new().unwrap(); + let binary_path = temp_dir.path().join("demo"); + write_shell_script( + &binary_path, + "echo stdout message\necho stderr message >&2\nexit 42\n", + ); + let cache_path = command_cache_path(temp_dir.path()); + + let result = load_or_refresh_metadata(&binary_path, &cache_path); + + assert!(result.is_err()); + let message = format!("{:#}", result.unwrap_err()); + assert!(message.contains("exited unexpectedly")); + assert!(message.contains("stdout message")); + assert!(message.contains("stderr message")); + } + + #[test] + #[cfg(unix)] + fn load_or_refresh_metadata_reports_invalid_json() { + let temp_dir = TempDir::new().unwrap(); + let binary_path = temp_dir.path().join("demo"); + write_shell_script(&binary_path, "echo 'not json'\n"); + let cache_path = command_cache_path(temp_dir.path()); + + let result = load_or_refresh_metadata(&binary_path, &cache_path); + + assert!(result.is_err()); + let message = format!("{:#}", result.unwrap_err()); + assert!(message.contains("no readable version field")); + } + + #[test] + #[cfg(unix)] + fn load_or_refresh_metadata_returns_none_for_legacy_binary() { + let temp_dir = TempDir::new().unwrap(); + let binary_path = temp_dir.path().join("demo"); + write_shell_script( + &binary_path, + &format!("echo \"error: unexpected argument '{METADATA_FLAG}'\" >&2\nexit 2\n"), + ); + let cache_path = command_cache_path(temp_dir.path()); + + let result = load_or_refresh_metadata(&binary_path, &cache_path).unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn parse_metadata_reports_schema_version_mismatch() { + let bytes = br#"{"version":999,"binary_name":"demo","commands":[]}"#; + + let result = parse_metadata(bytes, &PathBuf::from("target/debug/demo")); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("metadata schema v999")); + assert!(message.contains("cargo install --locked cot-cli")); + } + + #[test] + fn parse_metadata_succeeds_on_matching_shape() { + let meta = metadata("demo", &["serve"]); + let bytes = serde_json::to_vec(&meta).unwrap(); + + let result = parse_metadata(&bytes, &PathBuf::from("target/debug/demo")); + + assert!(result.is_ok()); + } + + #[test] + fn workspace_root_requires_package_when_ambiguous() { + let temp_dir = TempDir::new().unwrap(); + write_workspace_manifest(temp_dir.path(), &["api", "web"]); + write_package_manifest(&temp_dir.path().join("api"), "api", ""); + write_package_manifest(&temp_dir.path().join("web"), "web", ""); + + let result = load(temp_dir.path(), false, None, true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("multiple packages found")); + assert!(message.contains("api")); + assert!(message.contains("web")); + } + + #[test] + fn workspace_package_flag_must_match_member() { + let temp_dir = TempDir::new().unwrap(); + write_workspace_manifest(temp_dir.path(), &["api", "web"]); + write_package_manifest(&temp_dir.path().join("api"), "api", ""); + write_package_manifest(&temp_dir.path().join("web"), "web", ""); + + let result = load(temp_dir.path(), false, Some("missing"), true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("package `missing` not found")); + assert!(message.contains("api")); + assert!(message.contains("web")); + } + + #[test] + #[cfg(unix)] + fn workspace_root_uses_selected_package_and_workspace_target_dir() { + let temp_dir = TempDir::new().unwrap(); + write_workspace_manifest(temp_dir.path(), &["api", "web"]); + write_package_manifest(&temp_dir.path().join("api"), "api", ""); + write_package_manifest(&temp_dir.path().join("web"), "web", ""); + let binary_path = temp_dir.path().join("target/debug/api"); + write_metadata_script(&binary_path, &metadata("api", &["check"])); + + let project = load(temp_dir.path(), false, Some("api"), true) + .unwrap() + .unwrap(); + + assert_eq!(project.path, binary_path); + assert!(temp_dir.path().join("api").exists()); + } + + #[test] + #[cfg(unix)] + fn workspace_member_directory_uses_current_package_without_flag() { + let temp_dir = TempDir::new().unwrap(); + write_workspace_manifest(temp_dir.path(), &["api", "web"]); + write_package_manifest(&temp_dir.path().join("api"), "api", ""); + write_package_manifest(&temp_dir.path().join("web"), "web", ""); + let binary_path = temp_dir.path().join("target/debug/web"); + write_metadata_script(&binary_path, &metadata("web", &["check"])); + + let project = load(&temp_dir.path().join("web"), false, None, true) + .unwrap() + .unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "web"); + } + + #[test] + #[cfg(unix)] + fn load_reuses_valid_cache_without_spawning_binary() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/debug/demo"); + write_shell_script( + &binary_path, + "echo 'binary should not be queried' >&2\nexit 42\n", + ); + let cache = Cache { + binary_mtime_secs: mtime_secs(&binary_path).unwrap(), + metadata: metadata("demo", &["cached"]), + }; + write_cache(&command_cache_path(temp_dir.path()), &cache).unwrap(); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().commands[0].name, "cached"); + } + + #[test] + #[cfg(unix)] + fn load_refreshes_stale_cache() { + let temp_dir = TempDir::new().unwrap(); + write_package_manifest(temp_dir.path(), "demo", ""); + let binary_path = temp_dir.path().join("target/debug/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["fresh"])); + let cache = Cache { + binary_mtime_secs: 0, + metadata: metadata("demo", &["stale"]), + }; + write_cache(&command_cache_path(temp_dir.path()), &cache).unwrap(); + + let project = load(temp_dir.path(), false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().commands[0].name, "fresh"); + } + + #[test] + fn current_executable_matches_current_process() { + let current_exe = std::env::current_exe().unwrap(); + + assert!(is_current_executable(¤t_exe)); + } + + #[test] + fn current_executable_does_not_match_missing_path() { + let missing = std::env::temp_dir().join("cot-cli-missing-test-binary"); + + assert!(!is_current_executable(&missing)); + } +} diff --git a/cot-cli/src/project_template/.gitignore b/cot-cli/src/project_template/.gitignore index a611abcd7..6e99f71d2 100644 --- a/cot-cli/src/project_template/.gitignore +++ b/cot-cli/src/project_template/.gitignore @@ -3,6 +3,9 @@ debug/ target/ +# Cot related auto generated files +.cot/ + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/cot-cli/src/test_harness.rs b/cot-cli/src/test_harness.rs new file mode 100644 index 000000000..9a94a97bb --- /dev/null +++ b/cot-cli/src/test_harness.rs @@ -0,0 +1,887 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +use anyhow::{Context, Result, bail}; +use tempfile::TempDir; + +pub const FROBNICATE_TASK_SOURCE: &str = r#" +struct Frobnicate; + +#[async_trait(?Send)] +impl CliTask for Frobnicate { + fn subcommand(&self) -> Command { + Command::new("frobnicate") + .about("Frobnicates the target") + .arg(Arg::new("target").required(true).help("What to frobnicate")) + .arg(Arg::new("intensity").long("intensity").help("How hard to frobnicate")) + .arg( + Arg::new("build") + .long("build") + .action(ArgAction::SetTrue) + .help("Simulated flag colliding with cot-cli's own --build"), + ) + } + + async fn execute( + &mut self, + matches: &ArgMatches, + _bootstrapper: Bootstrapper, + ) -> cot::Result<()> { + let target = matches.get_one::("target").expect("required"); + println!("frobnicating {target}"); + if matches.get_flag("build") { + println!("(received forwarded --build flag)"); + } + Ok(()) + } +} +"#; + +pub const FROBNICATE_REGISTER: &str = "cli.add_task(Frobnicate);"; + +pub const GROUPED_TASK_SOURCE: &str = r#" +struct SubA; + +#[async_trait(?Send)] +impl CliTask for SubA { + fn subcommand(&self) -> Command { + Command::new("sub-a").about("Fixture sub-task A") + } + + async fn execute( + &mut self, + _matches: &ArgMatches, + _bootstrapper: Bootstrapper, + ) -> cot::Result<()> { + println!("ran sub-a"); + Ok(()) + } +} +"#; + +pub const GROUPED_REGISTER: &str = r#" + let mut group = cot::cli::CliTaskGroup::new("fixture-group").about("Fixture task group"); + group.add_task(SubA); + cli.add_task(group); +"#; + +fn workspace() -> &'static Path { + static ROOT: OnceLock = OnceLock::new(); + ROOT.get_or_init(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("cot-cli should be in a workspace") + .to_path_buf() + }) +} + +fn cot_crate_path() -> PathBuf { + workspace().join("cot") +} + +fn workspace_target_dir() -> PathBuf { + workspace().join("target") +} + +fn default_main_rs( + project_name: &str, + extra_code: &str, + register_calls: &[String], + apps: &[CotApp], +) -> String { + let struct_name = to_pascal_case(project_name); + let register_tasks_body = register_calls.join("\n\t\t"); + let app_definitions = apps + .iter() + .map(CotApp::render) + .collect::>() + .join("\n"); + + let register_apps_body = apps + .iter() + .map(CotApp::render_registration) + .collect::>() + .join("\n"); + + format!( + r"mod migrations; + +use cot::Project; +use cot::Bootstrapper; +use cot::db::{{Auto, Model, model}}; +use cot::cli::{{Cli, CliMetadata, CliTask}}; +use cot::cli::clap::{{Arg, ArgAction, ArgMatches, Command}}; +use cot::config::ProjectConfig; +use cot::project::{{AppBuilder, RegisterAppsContext, WithConfig}}; +use async_trait::async_trait; + +#[model] +#[derive(Debug, Clone)] +struct DefaultTestModel {{ + #[model(primary_key)] + id: Auto, + title: String, +}} + +{app_definitions} + +{extra_code} + +struct {struct_name}Project; + +impl Project for {struct_name}Project {{ + fn cli_metadata(&self) -> CliMetadata {{ + cot::cli::metadata!() + }} + + fn config(&self, _config_name: &str) -> cot::Result {{ + Ok(ProjectConfig::dev_default()) + }} + + fn register_tasks(&self, cli: &mut Cli) {{ + {register_tasks_body} + }} + + fn register_apps( + &self, + apps: &mut AppBuilder, + _context: &RegisterAppsContext, + ) {{ +{register_apps_body} + }} +}} + +#[cot::main] +fn main() -> impl Project {{ + {struct_name}Project +}} +" + ) +} + +fn default_migrations_rs() -> String { + r"pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[];".to_string() +} + +fn render_cargo_toml(project_name: &str, features: &[String], extra: &str) -> String { + let features_str = if features.is_empty() { + r#"["db", "json", "sqlite"]"#.to_owned() + } else { + format!( + "[{}]", + features + .iter() + .map(|f| format!(r#""{f}""#)) + .collect::>() + .join(", ") + ) + }; + format!( + r#"[package] +name = "{project_name}" +version = "0.1.0" +edition = "2024" + +[dependencies] +cot = {{ path = "{cot_path}", features = {features_str} }} +async-trait = "0.1" +{extra} +"#, + cot_path = cot_crate_path().display(), + ) +} + +fn unique_project_name() -> String { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + // Use process ID + counter so parallel test processes don't collide. + format!("cot-test-{}-{count}", std::process::id()) +} + +/// Builder for a generated Cot application. +/// +/// The builder mirrors the methods available on Cot's [`cot::App`] trait. +#[derive(Debug, Clone)] +pub struct CotAppBuilder { + name: String, + init: Option, + router: Option, + migrations: Option, + admin_model_managers: Option, + static_files: Option, +} + +impl CotAppBuilder { + /// Creates a new App builder. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + init: None, + router: None, + migrations: None, + admin_model_managers: None, + static_files: None, + } + } + + /// Sets the implementation of `App::init`. + #[must_use] + pub fn init(mut self, body: impl Into) -> Self { + self.init = Some(body.into()); + self + } + + /// Sets the implementation of `App::router`. + #[must_use] + pub fn router(mut self, code_block: impl Into) -> Self { + self.router = Some(code_block.into()); + self + } + + /// Sets the implementation of `App::migrations`. + #[must_use] + pub fn migrations(mut self, code_block: impl Into) -> Self { + self.migrations = Some(code_block.into()); + self + } + + /// Sets the implementation of `App::admin_model_managers`. + #[must_use] + pub fn admin_model_managers(mut self, code_block: impl Into) -> Self { + self.admin_model_managers = Some(code_block.into()); + self + } + + /// Sets the implementation of `App::static_files`. + #[must_use] + pub fn static_files(mut self, code_block: impl Into) -> Self { + self.static_files = Some(code_block.into()); + self + } + + /// Builds the application definition. + /// + /// The returned `CotApp` is what gets registered with a project builder. + #[must_use] + pub fn build(self) -> CotApp { + assert!(!self.name.trim().is_empty(), "Cot app name cannot be empty"); + + CotApp { + name: self.name, + init: self.init, + router: self.router, + migrations: self.migrations, + admin_model_managers: self.admin_model_managers, + static_files: self.static_files, + } + } +} + +/// A fully-built generated Cot App +#[derive(Debug, Clone)] +pub struct CotApp { + name: String, + init: Option, + router: Option, + migrations: Option, + admin_model_managers: Option, + static_files: Option, +} + +impl CotApp { + /// Returns the app's name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Render this app as Rust source implementing `cot::App`. + #[must_use] + pub fn render(&self) -> String { + let struct_name = format!("{}App", to_pascal_case(&self.name)); + + let init = self.render_init(); + let router = self.render_router(); + let migrations = self.render_migrations(); + let admin_model_managers = self.render_admin_model_managers(); + let static_files = self.render_static_files(); + + format!( + r" +struct {struct_name}; + +#[async_trait] +impl cot::App for {struct_name} {{ + fn name(&self) -> &str {{ + {name:?} + }} + +{init} + +{router} + +{migrations} + +{admin_model_managers} + +{static_files} +}} +", + name = self.name, + ) + } + + fn render_init(&self) -> String { + match &self.init { + Some(body) => format!( + r" async fn init( + &self, + _context: &mut cot::project::ProjectContext, + ) -> cot::Result<()> {{ + {body} + }}" + ), + + None => r" async fn init( + &self, + _context: &mut cot::project::ProjectContext, + ) -> cot::Result<()> { + Ok(()) + }" + .to_owned(), + } + } + + fn render_router(&self) -> String { + match &self.router { + Some(code_block) => { + format!( + r" fn router(&self) -> cot::router::Router {{ + {code_block} + }}" + ) + } + + None => r" fn router(&self) -> cot::router::Router { + cot::router::Router::empty() + }" + .to_owned(), + } + } + + fn render_migrations(&self) -> String { + match &self.migrations { + Some(code_block) => { + format!( + r#" #[cfg(feature = "db")] + fn migrations(&self) -> Vec> {{ + {code_block} + }}"# + ) + } + + None => r#" #[cfg(feature = "db")] + fn migrations(&self) -> Vec> { + vec![] + }"# + .to_owned(), + } + } + + fn render_admin_model_managers(&self) -> String { + match &self.admin_model_managers { + Some(code_block) => { + format!( + r" fn admin_model_managers(&self) -> Vec> {{ + {code_block} + }}" + ) + } + + None => r" fn admin_model_managers(&self) -> Vec> { + vec![] + }" + .to_owned(), + } + } + + fn render_static_files(&self) -> String { + match &self.static_files { + Some(code_block) => { + format!( + r" fn static_files(&self) -> Vec {{ + {code_block} + }}" + ) + } + + None => r" fn static_files(&self) -> Vec { + vec![] + }" + .to_owned(), + } + } + + /// Returns the code string used to register this app with the generated + /// project. + #[must_use] + pub fn render_registration(&self) -> String { + let struct_name = format!("{}App", to_pascal_case(&self.name)); + + format!("\t\tapps.register({struct_name});") + } +} + +#[derive(Debug)] +pub struct CotProjectBuilder { + project_name: String, + cot_binary: PathBuf, + features: Vec, + main_rs: Option, + migrations_rs: Option, + extra_files: Vec<(PathBuf, String)>, + extra_cargo_toml: String, + extra_code: String, + register_calls: Vec, + apps: Vec, +} + +impl CotProjectBuilder { + #[must_use] + pub fn new(cot_binary: PathBuf) -> Self { + Self { + project_name: unique_project_name(), + features: Vec::new(), + main_rs: None, + migrations_rs: None, + extra_files: Vec::new(), + extra_cargo_toml: String::new(), + extra_code: String::new(), + register_calls: Vec::new(), + apps: Vec::new(), + cot_binary, + } + } + + #[must_use] + pub fn project_name(mut self, name: impl Into) -> Self { + self.project_name = name.into(); + self + } + + #[must_use] + pub fn features(mut self, features: impl IntoIterator>) -> Self { + self.features = features.into_iter().map(Into::into).collect(); + self + } + + #[must_use] + pub fn main_rs(mut self, content: impl Into) -> Self { + self.main_rs = Some(content.into()); + self + } + + #[must_use] + pub fn migrations_rs(mut self, content: impl Into) -> Self { + self.migrations_rs = Some(content.into()); + self + } + + /// Append raw TOML to the generated `Cargo.toml`. + #[must_use] + pub fn cargo_toml_extra(mut self, toml: impl Into) -> Self { + self.extra_cargo_toml = toml.into(); + self + } + + /// Insert raw Rust code at the top level of the generated `main.rs`, + /// above the `Project` impl. + #[must_use] + pub fn extra_code(mut self, code: impl Into) -> Self { + self.extra_code.push_str(&code.into()); + self.extra_code.push('\n'); + self + } + + /// Add a code block`Project::register_tasks` body. + #[must_use] + pub fn register_task(mut self, code_block: impl Into) -> Self { + self.register_calls.push(code_block.into()); + self + } + + /// Register an already-built app with this project. + #[must_use] + pub fn app(mut self, app: CotApp) -> Self { + self.apps.push(app); + self + } + + /// Register multiple already-built apps with this project. + #[must_use] + pub fn apps(mut self, apps: impl IntoIterator) -> Self { + self.apps.extend(apps); + self + } + + /// Add a file to the project, relative to the project root. + #[must_use] + pub fn with_file( + mut self, + relative_path: impl Into, + content: impl Into, + ) -> Self { + self.extra_files + .push((relative_path.into(), content.into())); + self + } + + /// Write all project files to a temporary directory. + /// + /// Returns a [`CotProject`] that can be used to run commands which + /// don't require a compiled binary (e.g. `cot migration list`), or can + /// be compiled via [`CotProject::compile`]. + pub fn build(self) -> Result { + let tempdir = TempDir::with_prefix("cot-test-harness-") + .context("failed to create temporary directory for test project")?; + + let project_dir = tempdir.path().join(&self.project_name); + std::fs::create_dir_all(project_dir.join("src")) + .context("failed to create project src/ directory")?; + + std::fs::write( + project_dir.join("Cargo.toml"), + render_cargo_toml(&self.project_name, &self.features, &self.extra_cargo_toml), + ) + .context("failed to write Cargo.toml")?; + + let main_rs = self.main_rs.clone().unwrap_or_else(|| { + default_main_rs( + &self.project_name, + &self.extra_code, + &self.register_calls, + &self.apps, + ) + }); + std::fs::write(project_dir.join("src").join("main.rs"), main_rs) + .context("failed to write src/main.rs")?; + + let migrations_rs = self + .migrations_rs + .clone() + .unwrap_or_else(default_migrations_rs); + std::fs::write(project_dir.join("src").join("migrations.rs"), migrations_rs) + .context("failed to write src/migrations.rs")?; + + for (rel, content) in &self.extra_files { + let abs = project_dir.join(rel); + if let Some(parent) = abs.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory for {}", rel.display()))?; + } + std::fs::write(&abs, content) + .with_context(|| format!("failed to write {}", rel.display()))?; + } + + Ok(CotProject { + _tempdir: tempdir, + project_dir, + project_name: self.project_name, + cot_binary: self.cot_binary, + }) + } +} + +/// A temporary Cot project with all files written to disk, but no binary built. +/// +/// Suitable for testing CLI commands that operate on source code +/// +/// Call [`CotProject::compile`] to build the binary and unlock proxy +/// command testing. +#[derive(Debug)] +pub struct CotProject { + _tempdir: TempDir, + project_dir: PathBuf, + project_name: String, + cot_binary: PathBuf, +} + +impl CotProject { + /// The absolute path to the project root directory. + #[must_use] + pub fn path(&self) -> &Path { + &self.project_dir + } + + /// The project name (also the Cargo package name and binary name). + #[must_use] + pub fn name(&self) -> &str { + &self.project_name + } + + /// Build a `cot` CLI command configured to run in this project's directory. + /// + /// Uses the test binary (respects `COT_CLI_TEST_CMD`) and does not require + /// a compiled project binary. + #[must_use] + pub fn cot_cmd(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(&self.cot_binary); + cmd.current_dir(&self.project_dir); + cmd.args(args); + cmd + } + + /// Build a raw `cargo` command configured to run in this project's + /// directory. + /// + /// The `CARGO_TARGET_DIR` is set to the workspace target so dependencies + /// are shared across all test project builds. + #[must_use] + pub fn cargo_cmd(&self, subcommand: &str, args: &[&str]) -> Command { + let mut cmd = cargo_bin_command(); + cmd.current_dir(&self.project_dir) + .env("CARGO_TARGET_DIR", workspace_target_dir()) + .arg(subcommand) + .args(args); + cmd + } + + /// Compile the project binary in debug mode. + pub fn compile(self) -> Result { + self.compile_inner(false) + } + + /// Compile the project binary in release mode. + pub fn compile_release(self) -> Result { + self.compile_inner(true) + } + + fn compile_inner(self, release: bool) -> Result { + let mut extra_args = vec![]; + if release { + extra_args.push("--release"); + } + + let status = self + .cargo_cmd("build", &extra_args) + .status() + .context("failed to spawn `cargo build`")?; + + if !status.success() { + bail!( + "`cargo build` failed for project `{}` at `{}`", + self.project_name, + self.project_dir.display() + ); + } + + let profile = if release { "release" } else { "debug" }; + let binary_name = platform_binary_name(&self.project_name); + + // The binary was compiled into the workspace target dir. + let workspace_binary = workspace_target_dir().join(profile).join(&binary_name); + + if !workspace_binary.exists() { + bail!( + "expected compiled binary at `{}` but it was not found", + workspace_binary.display() + ); + } + + // Bridge the binary into the project's own target tree so that + // `cot-cli`'s `resolve_target_dir` (which walks up from CWD) can find + // it. On Unix we symlink (zero-cost); on Windows we copy. + let project_target_dir = self.project_dir.join("target").join(profile); + std::fs::create_dir_all(&project_target_dir) + .context("failed to create project target directory")?; + + let project_binary = project_target_dir.join(&binary_name); + link_or_copy(&workspace_binary, &project_binary) + .context("failed to link binary into project target dir")?; + + Ok(CompiledCotProject { + inner: self, + binary_path: project_binary, + release, + }) + } +} + +/// A temporary Cot project with a compiled binary. +#[derive(Debug)] +pub struct CompiledCotProject { + inner: CotProject, + binary_path: PathBuf, + release: bool, +} + +impl CompiledCotProject { + /// The absolute path to the project root directory. + #[must_use] + pub fn path(&self) -> &Path { + self.inner.path() + } + + /// The project name. + #[must_use] + pub fn name(&self) -> &str { + self.inner.name() + } + + /// The absolute path to the compiled binary. + #[must_use] + pub fn binary_path(&self) -> &Path { + &self.binary_path + } + + /// Whether this is a release build. + #[must_use] + pub fn is_release(&self) -> bool { + self.release + } + + /// Build a `cot` CLI proxy command configured to run in this project's + /// directory. + /// + /// Automatically appends `--release` if the project was compiled in release + /// mode so `cot-cli` resolves the correct binary. + #[must_use] + pub fn cot_cmd(&self, args: &[&str]) -> Command { + let mut cmd = self.inner.cot_cmd(args); + if self.release { + cmd.arg("--release"); + } + cmd + } + + /// Build a `cot` CLI command *without* any automatic flags. + /// + /// Use this when you want to control `--release` manually or test + /// the error path where the wrong profile binary is specified. + #[must_use] + pub fn cot_cmd_raw(&self, args: &[&str]) -> Command { + self.inner.cot_cmd(args) + } + + /// Run the project binary directly, bypassing the `cot` CLI proxy. + /// + /// Useful for verifying that the binary itself behaves correctly, + /// independent of proxy machinery. + #[must_use] + pub fn binary_cmd(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(&self.binary_path); + cmd.current_dir(self.path()).args(args); + cmd + } + + /// Build a `cargo` command in the project directory. + #[must_use] + pub fn cargo_cmd(&self, subcommand: &str, args: &[&str]) -> Command { + self.inner.cargo_cmd(subcommand, args) + } +} + +/// A lazily-compiled standard project cot project. +/// +/// Compiling the same project for every test function would be prohibitively +/// slow. For tests that don't need a custom project structure, use this +/// instead. +/// +/// # Examples +/// +/// ``` +/// # use cot_cli::test_harness::standard_project; +/// let project = standard_project().unwrap(); +/// let output = project.cot_cmd(&["check"]).output().unwrap(); +/// ``` +pub fn standard_project(cot_binary: PathBuf) -> Result<&'static CompiledCotProject> { + static PROJECT: OnceLock = OnceLock::new(); + static ERROR: OnceLock = OnceLock::new(); + + if let Some(err) = ERROR.get() { + bail!("standard project failed to compile: {err}"); + } + + if let Some(proj) = PROJECT.get() { + return Ok(proj); + } + + let extra_code = format!("{FROBNICATE_TASK_SOURCE}\n{GROUPED_TASK_SOURCE}"); + + let standard_app = CotAppBuilder::new("cot_test_standard") + .migrations("cot::db::migrations::wrap_migrations(migrations::MIGRATIONS)") + .build(); + + match CotProjectBuilder::new(cot_binary) + .project_name("cot_test_standard") + .app(standard_app) + .extra_code(extra_code) + .register_task(FROBNICATE_REGISTER) + .register_task(GROUPED_REGISTER) + .build() + .and_then(CotProject::compile) + { + Ok(proj) => { + let _ = PROJECT.set(proj); + Ok(PROJECT.get().unwrap()) + } + + Err(e) => { + let msg = format!("{e:#}"); + let _ = ERROR.set(msg.clone()); + bail!("standard project failed to compile: {msg}"); + } + } +} + +fn cargo_bin_command() -> Command { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut cmd = Command::new(cargo); + // Strip RUSTFLAGS that may have been set by the outer cargo invocation + // (e.g. instrument-coverage flags), they may conflict with the inner build. + cmd.env_remove("RUSTFLAGS").env("CARGO_INCREMENTAL", "0"); + cmd +} + +fn platform_binary_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + } +} + +fn link_or_copy(src: &Path, dst: &Path) -> Result<()> { + // Remove stale link/copy from a previous test run. + if dst.exists() || dst.symlink_metadata().is_ok() { + std::fs::remove_file(dst).context("failed to remove stale binary")?; + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(src, dst) + .with_context(|| format!("failed to symlink {} → {}", src.display(), dst.display())) + } + + #[cfg(not(unix))] + { + std::fs::copy(src, dst) + .with_context(|| format!("failed to copy {} → {}", src.display(), dst.display())) + .map(|_| ()) + } +} + +fn to_pascal_case(s: &str) -> String { + s.split(['-', '_']) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }) + .collect() +} diff --git a/cot-cli/src/utils.rs b/cot-cli/src/utils.rs index b4aa09ca6..81e1e72b5 100644 --- a/cot-cli/src/utils.rs +++ b/cot-cli/src/utils.rs @@ -189,6 +189,10 @@ impl WorkspaceManager { self.package_manifests.get(package_name) } + pub(crate) fn get_workspace_root(&self) -> &Path { + self.workspace_root.as_path() + } + #[cfg(test)] pub(crate) fn get_package_manager_by_path( &self, @@ -226,7 +230,6 @@ impl PackageManager { path.to_owned() } - #[cfg(test)] pub(crate) fn get_manifest(&self) -> &Manifest { &self.manifest } diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap index 7b57c92dd..bd54c3cfb 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap @@ -116,12 +116,20 @@ _cot() { case "${cmd}" in cot) - opts="-v -q -h -V --verbose --quiet --help --version new migration cli help" + opts="-p -v -q -h -V --release --build --package --verbose --quiet --help --version new migration cli help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -130,12 +138,20 @@ _cot() { return 0 ;; cot__subcmd__cli) - opts="-v -q -h --verbose --quiet --help manpages completions help" + opts="-p -v -q -h --release --build --package --verbose --quiet --help manpages completions help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -144,12 +160,20 @@ _cot() { return 0 ;; cot__subcmd__cli__subcmd__completions) - opts="-v -q -h --verbose --quiet --help bash elvish fish powershell zsh" + opts="-p -v -q -h --release --build --package --verbose --quiet --help bash elvish fish powershell zsh" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -214,7 +238,7 @@ _cot() { return 0 ;; cot__subcmd__cli__subcmd__manpages) - opts="-o -c -v -q -h --output-dir --create --verbose --quiet --help" + opts="-o -c -p -v -q -h --output-dir --create --release --build --package --verbose --quiet --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -228,6 +252,14 @@ _cot() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -376,12 +408,20 @@ _cot() { return 0 ;; cot__subcmd__migration) - opts="-v -q -h --verbose --quiet --help list make new help" + opts="-p -v -q -h --release --build --package --verbose --quiet --help list make new help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -460,12 +500,20 @@ _cot() { return 0 ;; cot__subcmd__migration__subcmd__list) - opts="-v -q -h --verbose --quiet --help" + opts="-p -v -q -h --release --build --package --verbose --quiet --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -474,7 +522,7 @@ _cot() { return 0 ;; cot__subcmd__migration__subcmd__make) - opts="-v -q -h --app-name --output-dir --verbose --quiet --help" + opts="-p -v -q -h --app-name --output-dir --release --build --package --verbose --quiet --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -488,6 +536,14 @@ _cot() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -496,7 +552,7 @@ _cot() { return 0 ;; cot__subcmd__migration__subcmd__new) - opts="-v -q -h --app-name --verbose --quiet --help" + opts="-p -v -q -h --app-name --release --build --package --verbose --quiet --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -506,6 +562,14 @@ _cot() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; @@ -514,7 +578,7 @@ _cot() { return 0 ;; cot__subcmd__new) - opts="-v -q -h --name --use-git --cot-path --verbose --quiet --help" + opts="-p -v -q -h --name --use-git --cot-path --release --build --package --verbose --quiet --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -528,6 +592,14 @@ _cot() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -p) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; *) COMPREPLY=() ;; diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap index 66217fe41..cb2f5c200 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap @@ -30,6 +30,10 @@ set edit:completion:arg-completer[cot] = {|@words| } var completions = [ &'cot'= { + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -46,7 +50,11 @@ set edit:completion:arg-completer[cot] = {|@words| &'cot;new'= { cand --name 'Set the resulting crate name [default: the directory name]' cand --cot-path 'Use `cot` from the specified path instead of a published crate' + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' cand --use-git 'Use the latest `cot` version from git instead of a published crate' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -55,6 +63,10 @@ set edit:completion:arg-completer[cot] = {|@words| cand --help 'Print help' } &'cot;migration'= { + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -67,6 +79,10 @@ set edit:completion:arg-completer[cot] = {|@words| cand help 'Print this message or the help of the given subcommand(s)' } &'cot;migration;list'= { + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -77,6 +93,10 @@ set edit:completion:arg-completer[cot] = {|@words| &'cot;migration;make'= { cand --app-name 'Name of the app to use in the migration [default: crate name]' cand --output-dir 'Directory to write the migrations to [default: the migrations/ directory in the crate''s src/ directory]' + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -86,6 +106,10 @@ set edit:completion:arg-completer[cot] = {|@words| } &'cot;migration;new'= { cand --app-name 'Name of the app to use in the migration (default: crate name)' + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -108,6 +132,10 @@ set edit:completion:arg-completer[cot] = {|@words| &'cot;migration;help;help'= { } &'cot;cli'= { + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -121,8 +149,12 @@ set edit:completion:arg-completer[cot] = {|@words| &'cot;cli;manpages'= { cand -o 'Directory to write the manpages to [default: current directory]' cand --output-dir 'Directory to write the manpages to [default: current directory]' + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' cand -c 'Create the directory if it doesn''t exist' cand --create 'Create the directory if it doesn''t exist' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' @@ -131,6 +163,10 @@ set edit:completion:arg-completer[cot] = {|@words| cand --help 'Print help' } &'cot;cli;completions'= { + cand -p 'Package to use, in case you''re running this in a workspace' + cand --package 'Package to use, in case you''re running this in a workspace' + cand --release 'Use target/release instead of target/debug when looking for the project binary' + cand --build 'Build the binary if it does not exist' cand -v 'Increase logging verbosity' cand --verbose 'Increase logging verbosity' cand -q 'Decrease logging verbosity' diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap index bfd28c5cf..a3b63c6e3 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap @@ -12,7 +12,7 @@ exit_code: 0 ----- stdout ----- # Print an optspec for argparse to handle cmd's options that are independent of any subcommand. function __fish_cot_global_optspecs - string join \n v/verbose q/quiet h/help V/version + string join \n release build p/package= v/verbose q/quiet h/help V/version end function __fish_cot_needs_command @@ -36,6 +36,9 @@ function __fish_cot_using_subcommand contains -- $cmd[1] $argv end +complete -c cot -n "__fish_cot_needs_command" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_needs_command" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_needs_command" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_needs_command" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_needs_command" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_needs_command" -s h -l help -d 'Print help' @@ -46,10 +49,16 @@ complete -c cot -n "__fish_cot_needs_command" -f -a "cli" -d 'Manage Cot CLI' complete -c cot -n "__fish_cot_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c cot -n "__fish_cot_using_subcommand new" -l name -d 'Set the resulting crate name [default: the directory name]' -r complete -c cot -n "__fish_cot_using_subcommand new" -l cot-path -d 'Use `cot` from the specified path instead of a published crate' -r -F +complete -c cot -n "__fish_cot_using_subcommand new" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r complete -c cot -n "__fish_cot_using_subcommand new" -l use-git -d 'Use the latest `cot` version from git instead of a published crate' +complete -c cot -n "__fish_cot_using_subcommand new" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand new" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand new" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand new" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand new" -s h -l help -d 'Print help' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s h -l help -d 'Print help' @@ -57,15 +66,24 @@ complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_s complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "make" -d 'Generate migrations for a Cot project' complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "new" -d 'Create a new empty migration' complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -l app-name -d 'Name of the app to use in the migration [default: crate name]' -r complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -l output-dir -d 'Directory to write the migrations to [default: the migrations/ directory in the crate\'s src/ directory]' -r -F +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s h -l help -d 'Print help' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -l app-name -d 'Name of the app to use in the migration (default: crate name)' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s h -l help -d 'Print help' @@ -73,6 +91,9 @@ complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subco complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "make" -d 'Generate migrations for a Cot project' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "new" -d 'Create a new empty migration' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s h -l help -d 'Print help' @@ -80,10 +101,16 @@ complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcomm complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -f -a "completions" -d 'Generate completions for the Cot CLI' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s o -l output-dir -d 'Directory to write the manpages to [default: current directory]' -r -F +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s c -l create -d 'Create the directory if it doesn\'t exist' +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from manpages" -s h -l help -d 'Print help' +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -s p -l package -d 'Package to use, in case you\'re running this in a workspace' -r +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -l release -d 'Use target/release instead of target/debug when looking for the project binary' +complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -l build -d 'Build the binary if it does not exist' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and __fish_seen_subcommand_from completions" -s h -l help -d 'Print help' diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap index fc9bf6565..39ad874ea 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap @@ -33,6 +33,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { $completions = @(switch ($command) { 'cot' { + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -50,7 +54,11 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;new' { [CompletionResult]::new('--name', '--name', [CompletionResultType]::ParameterName, 'Set the resulting crate name [default: the directory name]') [CompletionResult]::new('--cot-path', '--cot-path', [CompletionResultType]::ParameterName, 'Use `cot` from the specified path instead of a published crate') + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') [CompletionResult]::new('--use-git', '--use-git', [CompletionResultType]::ParameterName, 'Use the latest `cot` version from git instead of a published crate') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -60,6 +68,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { break } 'cot;migration' { + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -73,6 +85,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { break } 'cot;migration;list' { + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -84,6 +100,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;migration;make' { [CompletionResult]::new('--app-name', '--app-name', [CompletionResultType]::ParameterName, 'Name of the app to use in the migration [default: crate name]') [CompletionResult]::new('--output-dir', '--output-dir', [CompletionResultType]::ParameterName, 'Directory to write the migrations to [default: the migrations/ directory in the crate''s src/ directory]') + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -94,6 +114,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { } 'cot;migration;new' { [CompletionResult]::new('--app-name', '--app-name', [CompletionResultType]::ParameterName, 'Name of the app to use in the migration (default: crate name)') + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -122,6 +146,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { break } 'cot;cli' { + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -136,8 +164,12 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;cli;manpages' { [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'Directory to write the manpages to [default: current directory]') [CompletionResult]::new('--output-dir', '--output-dir', [CompletionResultType]::ParameterName, 'Directory to write the manpages to [default: current directory]') + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Create the directory if it doesn''t exist') [CompletionResult]::new('--create', '--create', [CompletionResultType]::ParameterName, 'Create the directory if it doesn''t exist') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') @@ -147,6 +179,10 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { break } 'cot;cli;completions' { + [CompletionResult]::new('-p', '-p', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--package', '--package', [CompletionResultType]::ParameterName, 'Package to use, in case you''re running this in a workspace') + [CompletionResult]::new('--release', '--release', [CompletionResultType]::ParameterName, 'Use target/release instead of target/debug when looking for the project binary') + [CompletionResult]::new('--build', '--build', [CompletionResultType]::ParameterName, 'Build the binary if it does not exist') [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap index 1332d6345..aaa92e885 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap @@ -27,6 +27,10 @@ _cot() { local context curcontext="$curcontext" state line _arguments "${_arguments_options[@]}" : \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -48,7 +52,11 @@ _cot() { _arguments "${_arguments_options[@]}" : \ '--name=[Set the resulting crate name \[default\: the directory name\]]:NAME:_default' \ '--cot-path=[Use \`cot\` from the specified path instead of a published crate]:COT_PATH:_files' \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ '--use-git[Use the latest \`cot\` version from git instead of a published crate]' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -60,6 +68,10 @@ _arguments "${_arguments_options[@]}" : \ ;; (migration) _arguments "${_arguments_options[@]}" : \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -78,6 +90,10 @@ _arguments "${_arguments_options[@]}" : \ case $line[1] in (list) _arguments "${_arguments_options[@]}" : \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -91,6 +107,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ '--app-name=[Name of the app to use in the migration \[default\: crate name\]]:APP_NAME:_default' \ '--output-dir=[Directory to write the migrations to \[default\: the migrations/ directory in the crate'\''s src/ directory\]]:OUTPUT_DIR:_files' \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -103,6 +123,10 @@ _arguments "${_arguments_options[@]}" : \ (new) _arguments "${_arguments_options[@]}" : \ '--app-name=[Name of the app to use in the migration (default\: crate name)]:APP_NAME:_default' \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -151,6 +175,10 @@ esac ;; (cli) _arguments "${_arguments_options[@]}" : \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -171,8 +199,12 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ '-o+[Directory to write the manpages to \[default\: current directory\]]:OUTPUT_DIR:_files' \ '--output-dir=[Directory to write the manpages to \[default\: current directory\]]:OUTPUT_DIR:_files' \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ '-c[Create the directory if it doesn'\''t exist]' \ '--create[Create the directory if it doesn'\''t exist]' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ @@ -183,6 +215,10 @@ _arguments "${_arguments_options[@]}" : \ ;; (completions) _arguments "${_arguments_options[@]}" : \ +'-p+[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--package=[Package to use, in case you'\''re running this in a workspace]:PACKAGE:_default' \ +'--release[Use target/release instead of target/debug when looking for the project binary]' \ +'--build[Build the binary if it does not exist]' \ '*-v[Increase logging verbosity]' \ '*--verbose[Increase logging verbosity]' \ '(-v --verbose)*-q[Decrease logging verbosity]' \ diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__no_args.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__no_args.snap index 9fc482ef1..361ac6d40 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__no_args.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__no_args.snap @@ -20,6 +20,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help diff --git a/cot-cli/tests/snapshot_testing/external/check.rs b/cot-cli/tests/snapshot_testing/external/check.rs new file mode 100644 index 000000000..e4186733a --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/check.rs @@ -0,0 +1,40 @@ +use cot_cli::test_harness::standard_project; +use insta_cmd::assert_cmd_snapshot; + +use crate::snapshot_testing::{GENERIC_FILTERS, TEMP_PATH_FILTERS, cot_cli_path, cot_cmd_in}; + +#[test] +fn check_forwards_to_project_binary() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["check"])) } + ); +} + +#[test] +fn double_dash_delimiter_fails_with_unsupported_flag_name() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["check", "--", "--build"])) } + ); +} + +#[test] +fn unrecognized_command_reports_unknown_command() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["banana"])) } + ); +} + +#[test] +fn check_with_no_project_binary_reports_build_hint() { + let tempdir = tempfile::TempDir::new().unwrap(); + insta::with_settings!( + { filters => [GENERIC_FILTERS, TEMP_PATH_FILTERS].concat() }, + { assert_cmd_snapshot!(cot_cmd_in(&["check"], tempdir.path())) } + ); +} diff --git a/cot-cli/tests/snapshot_testing/external/mod.rs b/cot-cli/tests/snapshot_testing/external/mod.rs new file mode 100644 index 000000000..be0c6a3ea --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/mod.rs @@ -0,0 +1 @@ +mod check; diff --git a/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_forwards_to_project_binary.snap b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_forwards_to_project_binary.snap new file mode 100644 index 000000000..36b3422f7 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_forwards_to_project_binary.snap @@ -0,0 +1,13 @@ +--- +source: cot-cli/tests/snapshot_testing/external/check.rs +info: + program: cot + args: + - check +--- +success: true +exit_code: 0 +----- stdout ----- +Success verifying the configuration + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_with_no_project_binary_reports_build_hint.snap b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_with_no_project_binary_reports_build_hint.snap new file mode 100644 index 000000000..bc4b2ae4e --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__check_with_no_project_binary_reports_build_hint.snap @@ -0,0 +1,14 @@ +--- +source: cot-cli/tests/snapshot_testing/external/check.rs +info: + program: cot + args: + - check +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +Error: unknown command `check` and no project binary was found in the `target` dir. +Hint: run `cargo build` first, or pass `cot --build check` to build it automatically. diff --git a/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__double_dash_delimiter_fails_with_unsupported_flag_name.snap b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__double_dash_delimiter_fails_with_unsupported_flag_name.snap new file mode 100644 index 000000000..1de74f97e --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__double_dash_delimiter_fails_with_unsupported_flag_name.snap @@ -0,0 +1,19 @@ +--- +source: cot-cli/tests/snapshot_testing/external/check.rs +info: + program: cot + args: + - check + - "--" + - "--build" +--- +success: false +exit_code: 2 +----- stdout ----- + +----- stderr ----- +error: unexpected argument '--build' found + +Usage: cot_test_standard check + +For more information, try '--help'. diff --git a/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__unrecognized_command_reports_unknown_command.snap b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__unrecognized_command_reports_unknown_command.snap new file mode 100644 index 000000000..b3b8803a5 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/external/snapshots/cli__snapshot_testing__external__check__unrecognized_command_reports_unknown_command.snap @@ -0,0 +1,13 @@ +--- +source: cot-cli/tests/snapshot_testing/external/check.rs +info: + program: cot + args: + - banana +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +Error: unknown command `banana`. Run `cot --help` to see available commands. diff --git a/cot-cli/tests/snapshot_testing/help/external.rs b/cot-cli/tests/snapshot_testing/help/external.rs new file mode 100644 index 000000000..edd777578 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/external.rs @@ -0,0 +1,76 @@ +use cot_cli::test_harness::standard_project; +use insta_cmd::assert_cmd_snapshot; + +use crate::snapshot_testing::{GENERIC_FILTERS, cot_cli_path}; + +#[test] +fn top_level_help_merges_real_project_commands() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["--help"])) } + ); +} + +#[test] +fn migration_help_merges_real_rollback_subcommand() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["migration", "--help"])) } + ); +} + +#[test] +fn migration_rollback_help_succeeds_proving_command_is_reachable() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["migration", "rollback", "--help"])) } + ); +} + +#[test] +fn migration_unknown_subcommand_fails_cleanly() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["migration", "unknown", "--help"])) } + ); +} + +#[test] +fn check_help_shows_real_task() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["check", "--help"])) } + ); +} + +#[test] +fn custom_registered_task_appears_in_top_level_help() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["--help"])) } + ); +} + +#[test] +fn custom_task_help_shows_reconstructed_args() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["frobnicate", "--help"])) } + ); +} + +#[test] +fn nested_custom_group_help_merges_correctly() { + let project = standard_project(cot_cli_path()).unwrap(); + insta::with_settings!( + { filters => GENERIC_FILTERS.to_owned() }, + { assert_cmd_snapshot!(project.cot_cmd(&["fixture-group", "--help"])) } + ); +} diff --git a/cot-cli/tests/snapshot_testing/help/mod.rs b/cot-cli/tests/snapshot_testing/help/mod.rs index 1a4d05202..cd0b00640 100644 --- a/cot-cli/tests/snapshot_testing/help/mod.rs +++ b/cot-cli/tests/snapshot_testing/help/mod.rs @@ -1,3 +1,5 @@ +mod external; + use super::*; #[test] diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__check_help_shows_real_task.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__check_help_shows_real_task.snap new file mode 100644 index 000000000..10c40dc24 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__check_help_shows_real_task.snap @@ -0,0 +1,20 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - check + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Verifies the configuration, including connections to the database and other services + +Usage: cot check + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_registered_task_appears_in_top_level_help.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_registered_task_appears_in_top_level_help.snap new file mode 100644 index 000000000..ead6201b2 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_registered_task_appears_in_top_level_help.snap @@ -0,0 +1,37 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Command-line interface for the Cot web framework + +Usage: cot [OPTIONS] + +Commands: + new Create a new Cot project + migration Manage migrations for a Cot project + cli Manage Cot CLI + check Verifies the configuration, including connections to the database and other + services + collect-static Collects all static files into a static directory + frobnicate Frobnicates the target + fixture-group Fixture task group + help Print this message or the help of the given subcommand(s) + +Options: + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_task_help_shows_reconstructed_args.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_task_help_shows_reconstructed_args.snap new file mode 100644 index 000000000..4b98aed1c --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__custom_task_help_shows_reconstructed_args.snap @@ -0,0 +1,17 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - frobnicate + - "--help" +--- +success: false +exit_code: 101 +----- stdout ----- + +----- stderr ----- + +thread 'main' (6047256) panicked at /Users/eli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/clap_builder-4.6.5/src/builder/debug_asserts.rs:746:9: +Argument 'target' is positional and it must take a value but action is SetTrue +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__check_help_shows_real_task.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__check_help_shows_real_task.snap new file mode 100644 index 000000000..cf24cbf35 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__check_help_shows_real_task.snap @@ -0,0 +1,20 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - check + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Verifies the configuration, including connections to the database and other services + +Usage: cot check + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_registered_task_appears_in_top_level_help.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_registered_task_appears_in_top_level_help.snap new file mode 100644 index 000000000..bb4d92252 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_registered_task_appears_in_top_level_help.snap @@ -0,0 +1,37 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Command-line interface for the Cot web framework + +Usage: cot [OPTIONS] + +Commands: + new Create a new Cot project + migration Manage migrations for a Cot project + cli Manage Cot CLI + check Verifies the configuration, including connections to the database and other + services + collect-static Collects all static files into a static directory + frobnicate Frobnicates the target + fixture-group Fixture task group + help Print this message or the help of the given subcommand(s) + +Options: + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_task_help_shows_reconstructed_args.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_task_help_shows_reconstructed_args.snap new file mode 100644 index 000000000..31c85fbc0 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__custom_task_help_shows_reconstructed_args.snap @@ -0,0 +1,17 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - frobnicate + - "--help" +--- +success: false +exit_code: 101 +----- stdout ----- + +----- stderr ----- + +thread 'main' (6739824) panicked at /Users/eli/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/clap_builder-4.6.5/src/builder/debug_asserts.rs:746:9: +Argument 'target' is positional and it must take a value but action is SetTrue +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_help_merges_real_rollback_subcommand.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_help_merges_real_rollback_subcommand.snap new file mode 100644 index 000000000..96fdec830 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_help_merges_real_rollback_subcommand.snap @@ -0,0 +1,27 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - migration + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Manage migrations for a Cot project + +Usage: cot migration + +Commands: + list List all migrations for a Cot project + make Generate migrations for a Cot project + new Create a new empty migration + rollback Rollback migrations up to the specified migration file + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_rollback_help_succeeds_proving_command_is_reachable.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_rollback_help_succeeds_proving_command_is_reachable.snap new file mode 100644 index 000000000..e063df720 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_rollback_help_succeeds_proving_command_is_reachable.snap @@ -0,0 +1,26 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - migration + - rollback + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Rollback migrations up to the specified migration file + +Usage: cot migration rollback [OPTIONS] + +Arguments: + The migration name to roll back to (e.g. m_0001_initial, 0001, or zero) + +Options: + --app The name of the app to rollback migrations for + --dry-run Print the Rollback Plan without changing the database + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_unknown_subcommand_fails_cleanly.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_unknown_subcommand_fails_cleanly.snap new file mode 100644 index 000000000..c3abd2057 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__migration_unknown_subcommand_fails_cleanly.snap @@ -0,0 +1,28 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - migration + - unknown + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Manage migrations for a Cot project + +Usage: cot migration + +Commands: + list List all migrations for a Cot project + make Generate migrations for a Cot project + new Create a new empty migration + rollback Rollback migrations up to the specified migration file + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__nested_custom_group_help_merges_correctly.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__nested_custom_group_help_merges_correctly.snap new file mode 100644 index 000000000..7c951463f --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__nested_custom_group_help_merges_correctly.snap @@ -0,0 +1,24 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - fixture-group + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Fixture task group + +Usage: cot fixture-group [COMMAND] + +Commands: + sub-a Fixture sub-task A + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__top_level_help_merges_real_project_commands.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__top_level_help_merges_real_project_commands.snap new file mode 100644 index 000000000..bb4d92252 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__external__top_level_help_merges_real_project_commands.snap @@ -0,0 +1,37 @@ +--- +source: cot-cli/tests/snapshot_testing/help/external.rs +info: + program: cot + args: + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Command-line interface for the Cot web framework + +Usage: cot [OPTIONS] + +Commands: + new Create a new Cot project + migration Manage migrations for a Cot project + cli Manage Cot CLI + check Verifies the configuration, including connections to the database and other + services + collect-static Collects all static files into a static directory + frobnicate Frobnicates the target + fixture-group Fixture task group + help Print this message or the help of the given subcommand(s) + +Options: + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help.snap index 710c81f20..0bcc121f5 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help.snap @@ -19,9 +19,13 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help - -V, --version Print version + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_completions.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_completions.snap index 8e362c49a..2fd713769 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_completions.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_completions.snap @@ -18,8 +18,12 @@ Arguments: Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_manpages.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_manpages.snap index 8229dced9..afb4b5e63 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_manpages.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_cli_manpages.snap @@ -16,8 +16,12 @@ Usage: cot cli manpages [OPTIONS] Options: -o, --output-dir Directory to write the manpages to [default: current directory] - -v, --verbose... Increase logging verbosity + --release Use target/release instead of target/debug when looking for the + project binary + --build Build the binary if it does not exist -c, --create Create the directory if it doesn't exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity -q, --quiet... Decrease logging verbosity -h, --help Print help diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap index eeceba2ac..58c707f51 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap @@ -20,8 +20,12 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_list.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_list.snap index f87813e2e..20f2358ea 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_list.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_list.snap @@ -18,8 +18,12 @@ Arguments: [PATH] Path to the crate directory to list migrations for [default: current directory] Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_make.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_make.snap index 90eb57e0e..1c002cb34 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_make.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration_make.snap @@ -19,9 +19,13 @@ Arguments: Options: --app-name Name of the app to use in the migration [default: crate name] - -v, --verbose... Increase logging verbosity + --release Use target/release instead of target/debug when looking for the + project binary + --build Build the binary if it does not exist --output-dir Directory to write the migrations to [default: the migrations/ directory in the crate's src/ directory] + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity -q, --quiet... Decrease logging verbosity -h, --help Print help diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_new.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_new.snap index 4e4d857e8..d86092bd9 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_new.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_new.snap @@ -18,10 +18,14 @@ Arguments: Options: --name Set the resulting crate name [default: the directory name] - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist --use-git Use the latest `cot` version from git instead of a published crate --cot-path Use `cot` from the specified path instead of a published crate + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity -h, --help Print help ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__long_help.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__long_help.snap index 0f46b54d1..0a8a6c660 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__long_help.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__long_help.snap @@ -19,9 +19,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help - -V, --version Print version + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_help_merges_real_rollback_subcommand.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_help_merges_real_rollback_subcommand.snap new file mode 100644 index 000000000..832bdf66e --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_help_merges_real_rollback_subcommand.snap @@ -0,0 +1,27 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - migration + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Manage migrations for a Cot project + +Usage: cot migration + +Commands: + list List all migrations for a Cot project + make Generate migrations for a Cot project + new Create a new empty migration + rollback Rollback migrations up to the specified migration file + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_rollback_help_succeeds_proving_command_is_reachable.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_rollback_help_succeeds_proving_command_is_reachable.snap new file mode 100644 index 000000000..58065d28e --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_rollback_help_succeeds_proving_command_is_reachable.snap @@ -0,0 +1,26 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - migration + - rollback + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Rollback migrations up to the specified migration file + +Usage: cot migration rollback [OPTIONS] + +Arguments: + The migration name to roll back to (e.g. m_0001_initial, 0001, or zero) + +Options: + --app The name of the app to rollback migrations for + --dry-run Print the Rollback Plan without changing the database + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_unknown_subcommand_fails_cleanly.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_unknown_subcommand_fails_cleanly.snap new file mode 100644 index 000000000..9484586d4 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__migration_unknown_subcommand_fails_cleanly.snap @@ -0,0 +1,28 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - migration + - unknown + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Manage migrations for a Cot project + +Usage: cot migration + +Commands: + list List all migrations for a Cot project + make Generate migrations for a Cot project + new Create a new empty migration + rollback Rollback migrations up to the specified migration file + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__nested_custom_group_help_merges_correctly.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__nested_custom_group_help_merges_correctly.snap new file mode 100644 index 000000000..a7d08c7c2 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__nested_custom_group_help_merges_correctly.snap @@ -0,0 +1,24 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - fixture-group + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Fixture task group + +Usage: cot fixture-group [COMMAND] + +Commands: + sub-a Fixture sub-task A + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__no_args.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__no_args.snap index 6cd3549f4..8518a72c3 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__no_args.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__no_args.snap @@ -20,7 +20,11 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help - -V, --version Print version + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__short_help.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__short_help.snap index d2cc816d2..0aaa4d151 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__short_help.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__short_help.snap @@ -19,9 +19,14 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase logging verbosity - -q, --quiet... Decrease logging verbosity - -h, --help Print help - -V, --version Print version + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + ----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__top_level_help_merges_real_project_commands.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__top_level_help_merges_real_project_commands.snap new file mode 100644 index 000000000..ead6201b2 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__top_level_help_merges_real_project_commands.snap @@ -0,0 +1,37 @@ +--- +source: cot-cli/tests/snapshot_testing/help/mod.rs +info: + program: cot + args: + - "--help" +--- +success: true +exit_code: 0 +----- stdout ----- +Command-line interface for the Cot web framework + +Usage: cot [OPTIONS] + +Commands: + new Create a new Cot project + migration Manage migrations for a Cot project + cli Manage Cot CLI + check Verifies the configuration, including connections to the database and other + services + collect-static Collects all static files into a static directory + frobnicate Frobnicates the target + fixture-group Fixture task group + help Print this message or the help of the given subcommand(s) + +Options: + --release Use target/release instead of target/debug when looking for the project + binary + --build Build the binary if it does not exist + -p, --package Package to use, in case you're running this in a workspace + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help + -V, --version Print version + + +----- stderr ----- diff --git a/cot-cli/tests/snapshot_testing/mod.rs b/cot-cli/tests/snapshot_testing/mod.rs index 86dcee554..640791349 100644 --- a/cot-cli/tests/snapshot_testing/mod.rs +++ b/cot-cli/tests/snapshot_testing/mod.rs @@ -1,3 +1,4 @@ +use std::path::{Path, PathBuf}; use std::process::Command; pub(crate) use insta_cmd::assert_cmd_snapshot; @@ -5,6 +6,7 @@ pub(crate) use insta_cmd::assert_cmd_snapshot; pub(crate) use crate::cot_cli; mod cli; +mod external; mod help; mod migration; mod new; @@ -46,6 +48,14 @@ macro_rules! cot_cli { } } +pub(crate) fn cot_cli_path() -> PathBuf { + if let Ok(path) = std::env::var("COT_CLI_TEST_CMD") { + PathBuf::from(path) + } else { + assert_cmd::cargo::cargo_bin!("cot").to_path_buf() + } +} + /// Get the command for the Cot CLI binary under test. /// /// By default, this is the binary defined in this crate. @@ -59,11 +69,16 @@ macro_rules! cot_cli { /// /// COT_CLI_TEST_CMD="$PWD"/custom-cot-cli cargo test --test cli pub(crate) fn cot_cli_cmd() -> Command { - if let Ok(np) = std::env::var("COT_CLI_TEST_CMD") { - Command::new(np) - } else { - Command::new(assert_cmd::cargo::cargo_bin!("cot")) - } + Command::new(cot_cli_path()) +} + +/// Convenience: build a `cot` command in an arbitrary directory. +/// +/// Useful for testing behaviour outside any Cot project. +pub(crate) fn cot_cmd_in(args: &[&str], dir: &Path) -> Command { + let mut cmd = cot_cli_cmd(); + cmd.current_dir(dir).args(args); + cmd } const GENERIC_FILTERS: &[(&str, &str)] = &[ diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 345dac818..8d8e0030d 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -55,7 +55,7 @@ schemars = { workspace = true, optional = true, features = ["derive"] } sea-query = { workspace = true, optional = true } sea-query-sqlx = { workspace = true, features = ["with-chrono"], optional = true } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true, optional = true } +serde_json.workspace = true sqlx = { workspace = true, features = ["runtime-tokio", "chrono"], optional = true } subtle = { workspace = true, features = ["std"] } swagger-ui-redist = { workspace = true, optional = true } @@ -113,7 +113,7 @@ sqlite = ["db", "sea-query/backend-sqlite", "sea-query-sqlx/sqlx-sqlite", "sqlx/ postgres = ["db", "sea-query/backend-postgres", "sea-query-sqlx/sqlx-postgres", "sqlx/postgres"] mysql = ["db", "sea-query/backend-mysql", "sea-query-sqlx/sqlx-mysql", "sqlx/mysql"] redis = ["cache", "dep:deadpool-redis", "dep:redis", "json"] -json = ["dep:serde_json", "cot_core/json"] +json = ["cot_core/json"] openapi = ["json", "cot_core/schemars", "dep:aide", "dep:schemars"] swagger-ui = ["openapi", "dep:swagger-ui-redist"] live-reload = ["dep:tower-livereload"] diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 1c7232ccc..44e194ad6 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -188,6 +188,10 @@ impl Cli { self.tasks.insert(Some(name), Box::new(task)); } + pub(crate) fn command(&self) -> &Command { + &self.command + } + #[must_use] pub(crate) fn common_options(&mut self) -> CommonOptions { let matches = self.command.get_matches_mut(); diff --git a/cot/src/lib.rs b/cot/src/lib.rs index c34512fba..df93dbf55 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -69,6 +69,7 @@ pub mod config; #[cfg(feature = "email")] pub mod email; mod error_page; +pub mod metadata; pub mod middleware; #[cfg(feature = "openapi")] pub mod openapi; diff --git a/cot/src/metadata.rs b/cot/src/metadata.rs new file mode 100644 index 000000000..9ef9079d8 --- /dev/null +++ b/cot/src/metadata.rs @@ -0,0 +1,245 @@ +//! Metadata exported by Cot project binaries for the proxying `cot` CLI. + +use clap::{Arg, Command}; +use serde::{Deserialize, Serialize}; + +/// The current version of the `ProjectMetadata` JSON schema. +pub const METADATA_SCHEMA_VERSION: u32 = 1; + +/// Flag used to ask a Cot project binary to print its CLI metadata as JSON. +pub const METADATA_FLAG: &str = "--cot-internal-cli-metadata"; + +/// Metadata describing the commands exposed by a Cot project binary. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProjectMetadata { + /// Schema version this metadata was serialized with. + pub version: u32, + /// Name of the project binary that produced the metadata. + pub binary_name: String, + /// Top-level commands exposed by the project binary. + pub commands: Vec, +} + +impl ProjectMetadata { + /// Create new Project metadata + pub fn new(cmd: &Command) -> Self { + ProjectMetadata { + version: METADATA_SCHEMA_VERSION, + binary_name: cmd.get_name().to_string(), + commands: cmd + .get_subcommands() + .filter(|subcmd| !subcmd.is_hide_set()) + .map(CommandMeta::from) + .collect(), + } + } +} + +impl From<&Command> for ProjectMetadata { + fn from(cmd: &Command) -> Self { + Self::new(cmd) + } +} + +/// Arguments for a CLI command +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgMeta { + /// Argument Name. + pub name: String, + /// long option name. + pub long: Option, + /// short option name. + pub short: Option, + /// Help text for the argument. + pub help: Option, + /// Whether the argument is required. + pub required: bool, + /// Whether the argument is a positional argument. + pub is_positional: bool, + /// Whether the argument takes a value. + pub takes_value: bool, + /// The value name for this argument. + pub value_name: Option, +} + +impl From<&Arg> for ArgMeta { + fn from(arg: &Arg) -> Self { + Self { + name: arg.get_id().to_string(), + long: arg.get_long().map(str::to_string), + short: arg.get_short(), + help: arg.get_help().map(ToString::to_string), + required: arg.is_required_set(), + is_positional: arg.is_positional(), + takes_value: arg.get_num_args().is_some_and(|n| n.takes_values()), + value_name: arg + .get_value_names() + .and_then(|v| v.first()) + .map(ToString::to_string), + } + } +} + +/// Metadata for a single CLI command. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CommandMeta { + /// Command name. + pub name: String, + /// Optional command description. + pub about: Option, + /// Visible aliases accepted by the command. + pub aliases: Vec, + /// Nested subcommands exposed by this command. + pub subcommands: Vec, + /// Arguments supported by the command. + pub args: Vec, +} + +impl From<&Command> for CommandMeta { + fn from(cmd: &Command) -> Self { + CommandMeta { + name: cmd.get_name().to_string(), + about: cmd.get_about().map(ToString::to_string), + aliases: cmd.get_all_aliases().map(ToString::to_string).collect(), + subcommands: cmd + .get_subcommands() + .filter(|subcmd| !subcmd.is_hide_set()) + .map(CommandMeta::from) + .collect(), + args: cmd + .get_arguments() + .filter(|a| a.get_id() != "help" && a.get_id() != "version") + .map(ArgMeta::from) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_metadata_from() { + let command = Command::new("demo") + .subcommand(Command::new("serve").about("Serve requests")) + .subcommand(Command::new("secret").hide(true)); + + let metadata = ProjectMetadata::from(&command); + + assert_eq!(metadata.binary_name, "demo"); + assert_eq!(metadata.commands.len(), 1); + assert_eq!(metadata.commands[0].name, "serve"); + assert_eq!( + metadata.commands[0].about.as_deref(), + Some("Serve requests") + ); + } + + #[test] + fn test_from_command_with_visible_aliases() { + let command = Command::new("demo").subcommand( + Command::new("database") + .visible_alias("db") + .subcommand(Command::new("migrate").visible_alias("mig")) + .subcommand(Command::new("internal").hide(true)), + ); + + let metadata = ProjectMetadata::from(&command); + let database = &metadata.commands[0]; + + assert_eq!(database.name, "database"); + assert_eq!(database.aliases, vec!["db"]); + assert_eq!(database.subcommands.len(), 1); + assert_eq!(database.subcommands[0].name, "migrate"); + assert_eq!(database.subcommands[0].aliases, vec!["mig"]); + } + + #[test] + fn test_from_command_with_no_about() { + let command = Command::new("demo").subcommand(Command::new("plain")); + + let metadata = ProjectMetadata::from(&command); + + assert_eq!(metadata.commands[0].about, None); + } + + #[test] + fn command_meta_from_captures_args() { + let command = Command::new("demo").subcommand( + Command::new("rollback") + .arg( + Arg::new("migration_name") + .value_name("MIGRATION_NAME") + .required(true), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue), + ), + ); + + let metadata = ProjectMetadata::from(&command); + let rollback = &metadata.commands[0]; + + assert_eq!(rollback.args.len(), 2); + + let positional = rollback + .args + .iter() + .find(|a| a.name == "migration_name") + .unwrap(); + assert!(positional.is_positional); + assert!(positional.required); + assert_eq!(positional.value_name.as_deref(), Some("MIGRATION_NAME")); + + let flag = rollback.args.iter().find(|a| a.name == "dry-run").unwrap(); + assert!(!flag.is_positional); + assert_eq!(flag.long.as_deref(), Some("dry-run")); + assert!(!flag.takes_value); + } + + #[test] + fn command_meta_from_excludes_help_and_version_ids() { + let command = Command::new("demo").subcommand( + Command::new("sub") + .arg(Arg::new("help").long("help")) + .arg(Arg::new("version").long("version")) + .arg(Arg::new("real").long("real")), + ); + + let metadata = ProjectMetadata::from(&command); + let sub = &metadata.commands[0]; + + assert_eq!(sub.args.len(), 1); + assert_eq!(sub.args[0].name, "real"); + } + + #[test] + fn arg_meta_from_flag_arg() { + let arg = Arg::new("verbose") + .short('v') + .long("verbose") + .action(clap::ArgAction::SetTrue); + + let meta = ArgMeta::from(&arg); + + assert_eq!(meta.name, "verbose"); + assert_eq!(meta.short, Some('v')); + assert_eq!(meta.long.as_deref(), Some("verbose")); + assert!(!meta.takes_value); + assert!(!meta.is_positional); + } + + #[test] + fn arg_meta_from_positional_arg() { + let arg = Arg::new("path").value_name("PATH").required(true); + + let meta = ArgMeta::from(&arg); + + assert!(meta.is_positional); + assert!(meta.required); + assert_eq!(meta.value_name.as_deref(), Some("PATH")); + } +} diff --git a/cot/src/project.rs b/cot/src/project.rs index e71f57597..ef573effc 100644 --- a/cot/src/project.rs +++ b/cot/src/project.rs @@ -60,6 +60,7 @@ use crate::error::UncaughtPanic; use crate::error::handler::{DynErrorPageHandler, RequestOuterError}; use crate::error_page::Diagnostics; use crate::html::Html; +use crate::metadata::{METADATA_FLAG, ProjectMetadata}; use crate::middleware::{IntoCotError, IntoCotErrorLayer, IntoCotResponse, IntoCotResponseLayer}; use crate::request::{Request, RequestExt, RequestHead}; use crate::response::{IntoResponse, Response}; @@ -939,6 +940,13 @@ impl Bootstrapper { cli.set_metadata(self.project.cli_metadata()); self.project.register_tasks(&mut cli); + if std::env::args().any(|arg| arg == METADATA_FLAG) { + let meta = ProjectMetadata::from(cli.command()); + + println!("{}", serde_json::to_string_pretty(&meta).unwrap()); + std::process::exit(0); + } + let common_options = cli.common_options(); let self_with_context = self.with_config_name(common_options.config())?;