diff --git a/Cargo.toml b/Cargo.toml index faa4be14..c59cc0f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,7 +145,7 @@ subtle = { version = "2", default-features = false } swagger-ui-redist = { version = "0.1" } syn = { version = "3", default-features = false } sync_wrapper = "1" -tempfile = "3" +tempfile = "3.11" thiserror = "2" time = { version = "0.3.55", default-features = false } tokio = { version = "1.53", default-features = false } diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index ac94f493..ddf52fd6 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -44,6 +44,7 @@ tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +tempfile = { workspace = true, optional = true } wait-timeout.workspace = true [dev-dependencies] @@ -51,8 +52,9 @@ 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 67713fb2..9a9bb8db 100644 --- a/cot-cli/src/args.rs +++ b/cot-cli/src/args.rs @@ -1,11 +1,14 @@ +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 BINARY_FLAG: &str = "--bin"; +pub const BUILD_FLAG: &str = "--build"; #[derive(Debug, Parser)] #[command( @@ -15,6 +18,16 @@ pub const BINARY_FLAG: &str = "--bin"; 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)] @@ -33,6 +46,9 @@ pub enum Commands { /// Manage Cot CLI #[command(subcommand)] Cli(CliCommands), + + #[command(external_subcommand)] + External(Vec), } #[derive(Debug, Args)] @@ -54,6 +70,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)] @@ -123,3 +142,89 @@ 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 arg == "--" { + // all args before the double dash delimeter is used internally per convention + // and any arg after the delimeter is forwarded to the binary, so we + // stop here + return None; + } + 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); + } + + #[test] + fn extract_package_arg_stops_scanning_at_double_dash() { + let raw = args(&["cot", "check", "--", "-p", "package"]); + assert_eq!(extract_package_arg(&raw), None); + } + + #[test] + fn extract_package_arg_found_before_double_dash_ignores_forwarded_content() { + let raw = args(&["cot", "-p", "real", "check", "--", "-p", "forwarded"]); + assert_eq!(extract_package_arg(&raw), Some("real".to_string())); + } +} diff --git a/cot-cli/src/handlers.rs b/cot-cli/src/handlers.rs index 23b34fb9..912b2e56 100644 --- a/cot-cli/src/handlers.rs +++ b/cot-cli/src/handlers.rs @@ -1,7 +1,12 @@ +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::CommandMeta; +use cot::utils::cli::{StatusType, print_status_msg}; use crate::args::{ Cli, CompletionsArgs, ManpagesArgs, MigrationListArgs, MigrationMakeArgs, MigrationNewArgs, @@ -11,6 +16,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 +101,88 @@ 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)); + } +} + fn generate_completions(shell: clap_complete::Shell, writer: &mut impl std::io::Write) { clap_complete::generate(shell, &mut Cli::command(), "cot", writer); } @@ -180,4 +268,193 @@ 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_attr( + miri, + ignore = "unsupported operation: can't call foreign function `execvp` on OS `linux`" + )] + #[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_attr( + miri, + ignore = "unsupported operation: can't call foreign function `execvp` on OS `linux`" + )] + #[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(&[], &[])); + } } diff --git a/cot-cli/src/lib.rs b/cot-cli/src/lib.rs index c7602149..07364dac 100644 --- a/cot-cli/src/lib.rs +++ b/cot-cli/src/lib.rs @@ -5,6 +5,8 @@ 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 c80f9827..be2ef5fb 100644 --- a/cot-cli/src/main.rs +++ b/cot-cli/src/main.rs @@ -1,12 +1,42 @@ #![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, MigrationCommands, RELEASE_FLAG, extract_package_arg, +}; +use cot_cli::{handlers, project}; use tracing_subscriber::util::SubscriberInitExt; +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); + + let cli = Cli::parse_from(cot_args); tracing_subscriber::fmt() .with_env_filter( @@ -26,6 +56,80 @@ 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 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/test_harness.rs b/cot-cli/src/test_harness.rs new file mode 100644 index 00000000..73fdd99f --- /dev/null +++ b/cot-cli/src/test_harness.rs @@ -0,0 +1,897 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU32, Ordering}; + +use anyhow::{Context, Result, bail}; +use heck::ToPascalCase; +use tempfile::TempDir; + +use crate::args::{BUILD_FLAG, RELEASE_FLAG}; + +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 = project_name.to_pascal_case(); + 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#"["default"]"#.to_owned() + } else { + format!( + "[{}]", + features + .iter() + .map(|f| format!(r#""{f}""#)) + .collect::>() + .join(", ") + ) + }; + // normalize path separator + let cot_path = cot_crate_path().display().to_string().replace('\\', "/"); + + 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} +"#, + ) +} + +fn unique_project_name() -> String { + 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-cli-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 = self.name.to_pascal_case(); + + 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 = self.name.to_pascal_case(); + + 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` can find it. + // On Unix we create a symlink, 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 adds the `--release` arg 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 { + // ensure that the binary always exists before invoking it + let mut final_args = vec![BUILD_FLAG]; + if self.release { + final_args.push(RELEASE_FLAG); + } + final_args.extend_from_slice(args); + + self.inner.cot_cmd(&final_args) + } + + /// Build a `cot` CLI command without any automatic flags. + #[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) + } + + /// Symlinks (or copies, on non-Unix) the already-compiled binary into + /// a custom-specified target dir, so that a later `cargo metadata` + /// invocation by the cot-cli will find it. + /// + /// We use this typically in testing various target dir discovery use cases. + pub fn bridge_binary_to(&self, target_root: &Path) -> Result { + let profile = if self.release { "release" } else { "debug" }; + let dir = target_root.join(profile); + std::fs::create_dir_all(&dir) + .context("failed to create target directory for bridged binary")?; + + let binary_name = platform_binary_name(self.name()); + let dest = dir.join(&binary_name); + link_or_copy(&self.binary_path, &dest)?; + Ok(dest) + } +} + +/// 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 +/// +/// ```no_run +/// # use std::path::PathBuf; +/// # use cot_cli::test_harness::standard_project; +/// let project = standard_project(PathBuf::from("path/to/cot/bin")).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) + .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(|_| ()) + } +} diff --git a/cot-cli/tests/cli.rs b/cot-cli/tests/cli.rs index 5c380c51..b33eb474 100644 --- a/cot-cli/tests/cli.rs +++ b/cot-cli/tests/cli.rs @@ -1,3 +1,121 @@ +use cot_cli::test_harness::CotProjectBuilder; +use tempfile::TempDir; + // It's pointless to run miri on UI tests #[cfg(not(miri))] mod snapshot_testing; + +#[cfg(not(miri))] +use snapshot_testing::cot_cli_path; + +#[test] +#[cfg(not(miri))] +fn discovery_honors_cargo_target_dir_env_var() { + let project = CotProjectBuilder::new(cot_cli_path()) + .build() + .unwrap() + .compile() + .unwrap(); + + let override_dir = TempDir::new().unwrap(); + project.bridge_binary_to(override_dir.path()).unwrap(); + + let output = project + .cot_cmd_raw(&["check"]) + .env("CARGO_TARGET_DIR", override_dir.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[cfg(not(miri))] +fn discovery_honors_project_level_cargo_config() { + let project = CotProjectBuilder::new(cot_cli_path()) + .with_file( + ".cargo/config.toml", + "[build]\ntarget-dir = \"custom-target\"\n", + ) + .build() + .unwrap() + .compile() + .unwrap(); + + let custom_target = project.path().join("custom-target"); + project.bridge_binary_to(&custom_target).unwrap(); + + let output = project.cot_cmd_raw(&["check"]).output().unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[cfg(not(miri))] +fn discovery_honors_global_cargo_config() { + let project = CotProjectBuilder::new(cot_cli_path()) + .build() + .unwrap() + .compile() + .unwrap(); + + let fake_cargo_home = TempDir::new().unwrap(); + let custom_target = fake_cargo_home.path().join("shared-target"); + let normalized_target_path = custom_target.display().to_string().replace('\\', "/"); + std::fs::write( + fake_cargo_home.path().join("config.toml"), + format!("[build]\ntarget-dir = \"{normalized_target_path}\"\n"), + ) + .unwrap(); + + project.bridge_binary_to(&custom_target).unwrap(); + + let output = project + .cot_cmd_raw(&["check"]) + .env("CARGO_HOME", fake_cargo_home.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +#[cfg(not(miri))] +fn cargo_target_dir_env_wins_over_project_config() { + let project = CotProjectBuilder::new(cot_cli_path()) + .with_file( + ".cargo/config.toml", + "[build]\ntarget-dir = \"from-config\"\n", + ) + .build() + .unwrap() + .compile() + .unwrap(); + + let env_override = TempDir::new().unwrap(); + project.bridge_binary_to(env_override.path()).unwrap(); + + let output = project + .cot_cmd_raw(&["check"]) + .env("CARGO_TARGET_DIR", env_override.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} 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 00000000..9ea815bc --- /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(&["--build", "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 00000000..be0c6a3e --- /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 00000000..36b3422f --- /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 00000000..bc4b2ae4 --- /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 00000000..9509c4eb --- /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,20 @@ +--- +source: cot-cli/tests/snapshot_testing/external/check.rs +info: + program: cot + args: + - "--build" + - check + - "--" + - "--build" +--- +success: false +exit_code: 2 +----- stdout ----- + +----- stderr ----- +error: unexpected argument '--build' found + +Usage: [PROJECT_NAME] 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 00000000..b3b8803a --- /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/mod.rs b/cot-cli/tests/snapshot_testing/mod.rs index 86dcee55..64079134 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)] = &[