diff --git a/cot-cli/src/handlers.rs b/cot-cli/src/handlers.rs index 23b34fb90..c5c965456 100644 --- a/cot-cli/src/handlers.rs +++ b/cot-cli/src/handlers.rs @@ -9,6 +9,7 @@ use crate::args::{ }; use crate::migration_generator::{ MigrationGeneratorOptions, create_new_migration, list_migrations, make_migrations, + try_list_project_migrations, }; use crate::new_project::{CotSource, new_project}; @@ -37,6 +38,9 @@ pub fn handle_new_project( pub fn handle_migration_list(MigrationListArgs { path }: MigrationListArgs) -> anyhow::Result<()> { let path = path.unwrap_or(PathBuf::from(".")); + if try_list_project_migrations(&path).with_context(|| "unable to list project migrations")? { + return Ok(()); + } let migrations = list_migrations(&path).with_context(|| "unable to list migrations")?; for (app_name, migs) in migrations { for mig in migs { diff --git a/cot-cli/src/migration_generator.rs b/cot-cli/src/migration_generator.rs index 5d5c4ebe6..dcd208ee8 100644 --- a/cot-cli/src/migration_generator.rs +++ b/cot-cli/src/migration_generator.rs @@ -4,6 +4,7 @@ use std::fmt::{Debug, Display}; use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; use anyhow::{Context, bail}; use cot::db::migrations::{DynMigration, MigrationEngine}; @@ -21,6 +22,8 @@ use tracing::{debug, trace}; use crate::utils::{CargoTomlManager, PackageManager}; +const RUNTIME_MIGRATION_LIST_ENV: &str = "COT_RUNTIME_MIGRATION_LIST"; + pub fn make_migrations(path: &Path, options: MigrationGeneratorOptions) -> anyhow::Result<()> { let Some(manager) = CargoTomlManager::from_path(path)? else { bail!("Cargo.toml not found in the specified directory or any parent directory.") @@ -141,6 +144,57 @@ pub fn list_migrations(path: &Path) -> anyhow::Result anyhow::Result { + try_list_project_migrations_with(path, Command::status) +} + +fn try_list_project_migrations_with( + path: &Path, + run: impl FnOnce(&mut Command) -> std::io::Result, +) -> anyhow::Result { + if std::env::var_os(RUNTIME_MIGRATION_LIST_ENV).is_some() { + return Ok(false); + } + + let Some(manager) = CargoTomlManager::from_path(path)? else { + bail!("Cargo.toml not found in the specified directory or any parent directory.") + }; + + let package = match &manager { + CargoTomlManager::Workspace(workspace) => workspace.get_current_package_manager(), + CargoTomlManager::Package(package) => Some(package), + }; + let Some(package) = package.filter(|package| package.is_runnable_cot_project()) else { + return Ok(false); + }; + + let status = run(&mut runtime_migration_list_command(package)) + .with_context(|| "unable to run the Cot project to list migrations")?; + if !status.success() { + bail!("Cot project exited with {status} while listing migrations"); + } + + Ok(true) +} + +fn runtime_migration_list_command(package: &PackageManager) -> Command { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(package.get_package_path()) + .env(RUNTIME_MIGRATION_LIST_ENV, "1") + .arg("run") + .arg("--quiet") + .arg("--manifest-path") + .arg(package.get_manifest_path()) + .arg("--package") + .arg(package.get_package_name()) + .arg("--") + .arg("migration") + .arg("list"); + command +} + #[derive(Debug, Clone, Default)] pub struct MigrationGeneratorOptions { pub app_name: Option, @@ -1552,6 +1606,120 @@ mod tests { use super::*; + fn runtime_list_project(dependencies: &str) -> tempfile::TempDir { + let temp_dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp_dir.path().join("src")).unwrap(); + std::fs::write( + temp_dir.path().join("Cargo.toml"), + format!( + r#" + [package] + name = "runtime-list-test" + version = "0.1.0" + edition = "2024" + + {dependencies} + "# + ), + ) + .unwrap(); + std::fs::write(temp_dir.path().join("src").join("main.rs"), "fn main() {}").unwrap(); + temp_dir + } + + #[test] + fn runtime_migration_list_runs_the_project_command() { + let temp_dir = runtime_list_project( + r#" + [dependencies] + cot = "0.7" + "#, + ); + + let CargoTomlManager::Package(package) = CargoTomlManager::from_path(temp_dir.path()) + .unwrap() + .unwrap() + else { + panic!("expected a package"); + }; + + assert!(package.is_runnable_cot_project()); + let command = runtime_migration_list_command(&package); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + assert_eq!( + args, + [ + "run", + "--quiet", + "--manifest-path", + package.get_manifest_path().to_string_lossy().as_ref(), + "--package", + "runtime-list-test", + "--", + "migration", + "list", + ] + ); + assert!(command.get_envs().any(|(name, value)| { + name == RUNTIME_MIGRATION_LIST_ENV && value == Some(std::ffi::OsStr::new("1")) + })); + } + + #[cfg(unix)] + #[test] + fn runtime_migration_list_handles_process_outcomes_and_fallbacks() { + use std::os::unix::process::ExitStatusExt; + + let project = runtime_list_project( + r#" + [dependencies] + cot = "0.7" + "#, + ); + assert!( + try_list_project_migrations_with(project.path(), |_| Ok(ExitStatus::from_raw(0))) + .unwrap() + ); + + let error = + try_list_project_migrations_with(project.path(), |_| Ok(ExitStatus::from_raw(1 << 8))) + .unwrap_err(); + assert!(error.to_string().contains("exited with")); + + let non_cot_project = runtime_list_project(""); + assert!( + !try_list_project_migrations_with(non_cot_project.path(), |_| { + panic!("non-Cot projects must not be run") + }) + .unwrap() + ); + + let missing_manifest = tempfile::tempdir().unwrap(); + assert!( + try_list_project_migrations_with(missing_manifest.path(), |_| { + panic!("projects without a manifest must not be run") + }) + .is_err() + ); + + let workspace = tempfile::tempdir().unwrap(); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[workspace]\nresolver = \"3\"\nmembers = []\n", + ) + .unwrap(); + assert!( + !try_list_project_migrations_with(workspace.path(), |_| { + panic!("virtual workspaces must use the source fallback") + }) + .unwrap() + ); + } + fn remove_whitespace>(s: &T) -> String { s.as_ref().chars().filter(|c| !c.is_whitespace()).collect() } diff --git a/cot-cli/src/utils.rs b/cot-cli/src/utils.rs index b4aa09ca6..bdba28b73 100644 --- a/cot-cli/src/utils.rs +++ b/cot-cli/src/utils.rs @@ -221,6 +221,18 @@ impl PackageManager { self.package_root.as_path() } + pub(crate) fn is_runnable_cot_project(&self) -> bool { + let depends_on_cot = self.manifest.dependencies.iter().any(|(name, dependency)| { + name == "cot" + || dependency + .detail() + .and_then(|detail| detail.package.as_deref()) + == Some("cot") + }); + + depends_on_cot && self.package_root.join("src").join("main.rs").is_file() + } + pub(crate) fn get_manifest_path(&self) -> PathBuf { let path = &self.get_package_path().join("Cargo.toml"); path.to_owned() @@ -542,6 +554,39 @@ mod tests { assert_eq!(manager.get_package_name(), package_name); } + #[test] + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `OPENSSL_init_ssl` on OS `linux`" + )] + fn runnable_cot_project_with_renamed_dependency() { + let (temp_dir, _) = get_package(); + std::fs::write( + temp_dir.path().join("Cargo.toml"), + r#" + [package] + name = "renamed-cot-dependency" + version = "0.1.0" + edition = "2024" + + [dependencies] + web-framework = { package = "cot", version = "0.7" } + "#, + ) + .unwrap(); + + let CargoTomlManager::Package(manager) = CargoTomlManager::from_path(temp_dir.path()) + .unwrap() + .unwrap() + else { + panic!("expected a package"); + }; + assert!(manager.is_runnable_cot_project()); + + std::fs::remove_file(temp_dir.path().join("src").join("main.rs")).unwrap(); + assert!(!manager.is_runnable_cot_project()); + } + #[test] #[cfg_attr( miri, diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 1c7232ccc..1482f6407 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -1,6 +1,8 @@ //! A command line interface for Cot-based applications. use std::collections::HashMap; +#[cfg(feature = "db")] +use std::io::Write; use std::path::PathBuf; use std::str::FromStr; @@ -20,6 +22,7 @@ const CHECK_SUBCOMMAND: &str = "check"; const LISTEN_PARAM: &str = "listen"; const COLLECT_STATIC_DIR_PARAM: &str = "dir"; const MIGRATION_GROUP_SUBCOMMAND: &str = "migration"; +const MIGRATION_LIST_SUBCOMMAND: &str = "list"; const MIGRATION_ROLLBACK_SUBCOMMAND: &str = "rollback"; /// A central point for configuring the default Command Line Interface (CLI) for @@ -100,6 +103,7 @@ impl Cli { { let mut migration_group = CliTaskGroup::new(MIGRATION_GROUP_SUBCOMMAND).about("Database migration commands"); + migration_group.add_task(MigrationList); migration_group.add_task(MigrationRollback); cli.add_task(migration_group); @@ -573,6 +577,44 @@ impl CliTask for CliTaskGroup { } } +#[cfg(feature = "db")] +struct MigrationList; + +#[cfg(feature = "db")] +impl MigrationList { + fn write_migrations( + apps: &[Box], + mut writer: impl Write, + ) -> std::io::Result<()> { + for app in apps { + for migration in app.migrations() { + writeln!(writer, "{}\t{}", migration.app_name(), migration.name())?; + } + } + Ok(()) + } +} + +#[cfg(feature = "db")] +#[async_trait(?Send)] +impl CliTask for MigrationList { + fn subcommand(&self) -> Command { + Command::new(MIGRATION_LIST_SUBCOMMAND) + .about("List all migrations registered by the project") + } + + async fn execute( + &mut self, + _matches: &ArgMatches, + bootstrapper: Bootstrapper, + ) -> Result<()> { + let bootstrapper = bootstrapper.with_apps(); + Self::write_migrations(bootstrapper.context().apps(), std::io::stdout().lock()) + .map_err(Error::internal)?; + Ok(()) + } +} + #[cfg(feature = "db")] struct MigrationRollback; @@ -678,6 +720,8 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use clap::Command; + #[cfg(feature = "db")] + use cot::db::migrations::{Migration, MigrationDependency, Operation, SyncDynMigration}; use cot::test::serial_guard; use tempfile::tempdir; @@ -745,7 +789,7 @@ mod tests { } #[test] - fn cli_new_includes_migration_rollback_group() { + fn cli_new_includes_migration_commands() { let cli = Cli::new(); let migration_group = cli @@ -754,13 +798,100 @@ mod tests { .find(|command| command.get_name() == MIGRATION_GROUP_SUBCOMMAND) .expect("migration group is registered"); - assert!( - migration_group - .get_subcommands() - .any(|command| command.get_name() == MIGRATION_ROLLBACK_SUBCOMMAND) + let subcommands: Vec<_> = migration_group + .get_subcommands() + .map(clap::Command::get_name) + .collect(); + assert!(subcommands.contains(&MIGRATION_ROLLBACK_SUBCOMMAND)); + assert!(subcommands.contains(&"list")); + } + + #[test] + #[cfg(feature = "db")] + fn migration_list_writes_migrations_from_every_registered_app() { + struct ProjectMigration; + impl Migration for ProjectMigration { + const APP_NAME: &'static str = "project"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[]; + } + + struct DependencyMigration; + impl Migration for DependencyMigration { + const APP_NAME: &'static str = "dependency"; + const MIGRATION_NAME: &'static str = "m_0002_dependency"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[]; + } + + struct ProjectApp; + impl App for ProjectApp { + fn name(&self) -> &'static str { + "project" + } + + fn migrations(&self) -> Vec> { + vec![Box::new(ProjectMigration)] + } + } + + struct DependencyApp; + impl App for DependencyApp { + fn name(&self) -> &'static str { + "dependency" + } + + fn migrations(&self) -> Vec> { + vec![Box::new(DependencyMigration)] + } + } + + let apps: Vec> = vec![Box::new(ProjectApp), Box::new(DependencyApp)]; + let mut output = Vec::new(); + MigrationList::write_migrations(&apps, &mut output).unwrap(); + + assert_eq!( + String::from_utf8(output).unwrap(), + "project\tm_0001_initial\ndependency\tm_0002_dependency\n" ); } + #[cot::test] + #[cfg(feature = "db")] + async fn migration_list_execute_initializes_registered_apps() { + struct TestMigration; + impl Migration for TestMigration { + const APP_NAME: &'static str = "test"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[]; + } + + struct TestApp; + impl App for TestApp { + fn name(&self) -> &'static str { + "test" + } + + fn migrations(&self) -> Vec> { + vec![Box::new(TestMigration)] + } + } + + struct TestProject; + impl cot::Project for TestProject { + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register(TestApp); + } + } + + let matches = MigrationList.subcommand().get_matches_from(["list"]); + let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); + + assert!(MigrationList.execute(&matches, bootstrapper).await.is_ok()); + } + #[cot::test] async fn cli_task_group_dispatches_nested_task() { struct NestedTask;