diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 146651c10..29709f555 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -91,123 +91,6 @@ fn check_global_json_policy() -> Result<()> { Ok(()) } -fn overlay_unity_meta_skeleton(pkg_id: &str) -> Result<()> { - let skeleton_base = Path::new("sdks/csharp/unity-meta-skeleton~"); - let skeleton_root = skeleton_base.join(pkg_id); - if !skeleton_root.exists() { - return Ok(()); - } - - let pkg_root = Path::new("sdks/csharp/packages").join(pkg_id); - if !pkg_root.exists() { - return Ok(()); - } - - // Copy spacetimedb..meta - let pkg_root_meta = skeleton_base.join(format!("{pkg_id}.meta")); - if pkg_root_meta.exists() - && let Some(parent) = pkg_root.parent() - { - let pkg_meta_dst = parent.join(format!("{pkg_id}.meta")); - fs::copy(&pkg_root_meta, &pkg_meta_dst)?; - } - - let versioned_dir = match find_only_subdir(&pkg_root) { - Ok(dir) => dir, - Err(err) => { - log::info!("Skipping Unity meta overlay for {pkg_id}: could not locate restored version dir: {err}"); - return Ok(()); - } - }; - - // If version.meta exists under the skeleton package, rename it to match the restored version dir. - let version_meta_template = skeleton_root.join("version.meta"); - if version_meta_template.exists() - && let Some(parent) = versioned_dir.parent() - { - let version_name = versioned_dir - .file_name() - .expect("versioned directory should have a file name"); - let version_meta_dst = parent.join(format!("{}.meta", version_name.to_string_lossy())); - fs::copy(&version_meta_template, &version_meta_dst)?; - } - - copy_overlay_dir(&skeleton_root, &versioned_dir) -} - -fn clear_restored_package_dirs(pkg_id: &str) -> Result<()> { - let pkg_root = Path::new("sdks/csharp/packages").join(pkg_id); - if !pkg_root.exists() { - return Ok(()); - } - - fs::remove_dir_all(&pkg_root)?; - - Ok(()) -} - -fn find_only_subdir(dir: &Path) -> Result { - let mut subdirs: Vec = vec![]; - - for entry in fs::read_dir(dir)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - subdirs.push(entry.path()); - } - } - - match subdirs.as_slice() { - [] => Err(anyhow::anyhow!( - "Could not find a restored versioned directory under {}", - dir.display() - )), - [only] => Ok(only.clone()), - _ => Err(anyhow::anyhow!( - "Expected exactly one restored versioned directory under {}, found {}", - dir.display(), - subdirs.len() - )), - } -} - -fn copy_overlay_dir(src: &Path, dst: &Path) -> Result<()> { - if !src.exists() { - bail!("Skeleton directory does not exist: {}", src.display()); - } - if !dst.exists() { - bail!("Destination directory does not exist: {}", dst.display()); - } - - for entry in fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if entry.file_type()?.is_dir() { - if dst_path.exists() { - copy_overlay_dir(&src_path, &dst_path)?; - } - } else { - if src_path.extension() == Some(OsStr::new("meta")) { - let asset_path = dst_path - .parent() - .expect("dst_path should have a parent") - .join(dst_path.file_stem().expect(".meta file should have a file stem")); - - if asset_path.exists() { - fs::copy(&src_path, &dst_path)?; - } else if dst_path.exists() { - fs::remove_file(&dst_path)?; - } - continue; - } - - fs::copy(&src_path, &dst_path)?; - } - } - - Ok(()) -} - #[derive(Subcommand)] enum CiCmd { /// Runs tests @@ -225,12 +108,6 @@ enum CiCmd { /// /// Runs tests for the codegen crate and builds a test module with the wasm bindings. WasmBindings, - /// Builds and packs C# DLLs and NuGet packages for local Unity workflows - /// - /// Packs the in-repo C# NuGet packages and restores the C# SDK to populate `sdks/csharp/packages/**`. - /// Then overlays Unity `.meta` skeleton files from `sdks/csharp/unity-meta-skeleton~/**` onto the restored - /// versioned package directory, so Unity can associate stable meta files with the most recently built package. - Dlls, /// Runs smoketests /// /// Executes the smoketests suite with some default exclusions. @@ -308,84 +185,6 @@ fn tracked_rs_files_under(path: &str) -> Result> { .collect()) } -fn run_dlls() -> Result<()> { - ensure_repo_root()?; - - cmd!( - "dotnet", - "pack", - "crates/bindings-csharp/BSATN.Runtime", - "-c", - "Release" - ) - .run()?; - cmd!("dotnet", "pack", "crates/bindings-csharp/Runtime", "-c", "Release").run()?; - - let repo_root = env::current_dir()?; - let bsatn_source = repo_root.join("crates/bindings-csharp/BSATN.Runtime/bin/Release"); - let runtime_source = repo_root.join("crates/bindings-csharp/Runtime/bin/Release"); - - let nuget_config_dir = tempfile::tempdir()?; - let nuget_config_path = nuget_config_dir.path().join("nuget.config"); - let nuget_config_contents = format!( - r#" - - - - - - - - - - - - - - - - - - - - "#, - bsatn_source.display(), - runtime_source.display(), - ); - fs::write(&nuget_config_path, nuget_config_contents)?; - - let nuget_config_path_str = nuget_config_path.to_string_lossy().to_string(); - - clear_restored_package_dirs("spacetimedb.bsatn.runtime")?; - clear_restored_package_dirs("spacetimedb.runtime")?; - - cmd!( - "dotnet", - "restore", - "SpacetimeDB.ClientSDK.csproj", - "--configfile", - &nuget_config_path_str, - ) - .dir("sdks/csharp") - .run()?; - - overlay_unity_meta_skeleton("spacetimedb.bsatn.runtime")?; - overlay_unity_meta_skeleton("spacetimedb.runtime")?; - - cmd!( - "dotnet", - "pack", - "SpacetimeDB.ClientSDK.csproj", - "-c", - "Release", - "--no-restore" - ) - .dir("sdks/csharp") - .run()?; - - Ok(()) -} - fn run_publish_checks() -> Result<()> { cmd!("bash", "-lc", "test -d venv || python3 -m venv venv").run()?; cmd!("venv/bin/pip3", "install", "argparse", "toml").run()?; @@ -622,10 +421,6 @@ fn main() -> Result<()> { cmd!(cli_path, "build", "--module-path", "modules/module-test",).run()?; } - Some(CiCmd::Dlls) => { - run_dlls()?; - } - Some(CiCmd::Smoketests(args)) => { ensure_repo_root()?; smoketest::run(args)?; diff --git a/tools/csharp-tools/src/lib.rs b/tools/csharp-tools/src/lib.rs new file mode 100644 index 000000000..a3ebf24fb --- /dev/null +++ b/tools/csharp-tools/src/lib.rs @@ -0,0 +1,245 @@ +#![allow(clippy::disallowed_macros)] + +use anyhow::{Context, Result}; +use duct::cmd; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn workspace_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("tools/csharp-tools should be two levels below the workspace root") + .to_path_buf() +} + +pub fn sdk_dir() -> PathBuf { + workspace_dir().join("sdks/csharp") +} + +fn cli_manifest() -> PathBuf { + workspace_dir().join("crates/cli/Cargo.toml") +} + +fn standalone_manifest() -> PathBuf { + workspace_dir().join("crates/standalone/Cargo.toml") +} + +fn path_arg(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn canonicalize_existing(path: &Path) -> Result { + path.canonicalize() + .with_context(|| format!("failed to canonicalize {}", path.display())) +} + +fn render_nuget_config(bsatn_source: &Path, runtime_source: &Path) -> String { + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#, + bsatn_source.display(), + runtime_source.display(), + ) +} + +pub fn write_persistent_nuget_configs(spacetimedb_repo_path: Option<&Path>) -> Result<()> { + let spacetimedb_repo_path = match spacetimedb_repo_path { + Some(path) => canonicalize_existing(path)?, + None => workspace_dir(), + }; + + let sdk_config = sdk_dir().join("NuGet.Config"); + let sdk_config_contents = render_nuget_config( + &spacetimedb_repo_path.join("crates/bindings-csharp/BSATN.Runtime/bin/Release"), + &spacetimedb_repo_path.join("crates/bindings-csharp/Runtime/bin/Release"), + ); + fs::write(&sdk_config, sdk_config_contents).with_context(|| format!("failed to write {}", sdk_config.display()))?; + + let repo_config = spacetimedb_repo_path.join("NuGet.Config"); + let repo_config_contents = render_nuget_config( + Path::new("crates/bindings-csharp/BSATN.Runtime/bin/Release"), + Path::new("crates/bindings-csharp/Runtime/bin/Release"), + ); + fs::write(&repo_config, repo_config_contents) + .with_context(|| format!("failed to write {}", repo_config.display()))?; + + println!("Wrote {} contents:", sdk_config.display()); + print!("{}", fs::read_to_string(&sdk_config)?); + + Ok(()) +} + +fn remove_obj_tilde_children(parent: &Path) -> Result<()> { + if !parent.exists() { + return Ok(()); + } + + for entry in fs::read_dir(parent)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let obj_tilde = entry.path().join("obj~"); + if obj_tilde.exists() { + fs::remove_dir_all(&obj_tilde).with_context(|| format!("failed to remove {}", obj_tilde.display()))?; + } + } + } + Ok(()) +} + +fn clean_procedure_obj_tilde_dirs() -> Result<()> { + let procedure_client = sdk_dir().join("examples~/regression-tests/procedure-client"); + println!("Cleanup obj~ folders generated in {}", procedure_client.display()); + remove_obj_tilde_children(&procedure_client)?; + remove_obj_tilde_children(&procedure_client.join("module_bindings"))?; + Ok(()) +} + +pub fn run_regression_tests() -> Result<()> { + let sdk = sdk_dir(); + let workspace = workspace_dir(); + let server_url = env::var("SPACETIMEDB_SERVER_URL").unwrap_or_else(|_| "local".to_string()); + + cmd!("cargo", "regen", "csharp", "regression-tests").run()?; + + cmd!("cargo", "build", "--manifest-path", path_arg(&standalone_manifest())).run()?; + + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "publish", + "-c", + "-y", + "--server", + &server_url, + "-p", + path_arg(&sdk.join("examples~/regression-tests/server")), + "btree-repro", + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "publish", + "-c", + "-y", + "--server", + &server_url, + "-p", + path_arg(&sdk.join("examples~/regression-tests/republishing/server-initial")), + "republish-test", + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "call", + "--server", + &server_url, + "republish-test", + "insert", + "1", + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "publish", + "--server", + &server_url, + "-p", + path_arg(&sdk.join("examples~/regression-tests/republishing/server-republish")), + "--break-clients", + "republish-test", + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "call", + "--server", + &server_url, + "republish-test", + "insert", + "2", + ) + .run()?; + + clean_procedure_obj_tilde_dirs()?; + + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "publish", + "-c", + "-y", + "--server", + &server_url, + "-p", + path_arg(&workspace.join("modules/sdk-test-procedure")), + "procedure-tests", + ) + .run()?; + + cmd!("dotnet", "run", "-c", "Debug") + .dir(sdk.join("examples~/regression-tests/client")) + .run()?; + cmd!("dotnet", "run", "-c", "Debug") + .dir(sdk.join("examples~/regression-tests/republishing/client")) + .run()?; + cmd!("dotnet", "run", "-c", "Debug") + .dir(sdk.join("examples~/regression-tests/procedure-client")) + .run()?; + + Ok(()) +} diff --git a/tools/csharp-tools/src/main.rs b/tools/csharp-tools/src/main.rs new file mode 100644 index 000000000..8088ff0a4 --- /dev/null +++ b/tools/csharp-tools/src/main.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "csharp-tools", bin_name = "cargo csharp", about = "C# SDK maintenance tasks")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Write NuGet.Config files that point at local SpacetimeDB C# packages. + WriteNugetConfig { + /// Path to the SpacetimeDB repository whose C# packages should be used. + spacetimedb_repo_path: Option, + }, + /// Run the C# regression test workflow against a running local SpacetimeDB instance. + RunRegressionTests, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::WriteNugetConfig { spacetimedb_repo_path } => { + csharp_tools::write_persistent_nuget_configs(spacetimedb_repo_path.as_deref())?; + } + Command::RunRegressionTests => { + csharp_tools::run_regression_tests()?; + } + } + + Ok(()) +} diff --git a/tools/regen/src/csharp.rs b/tools/regen/src/csharp.rs new file mode 100644 index 000000000..18cc50bb3 --- /dev/null +++ b/tools/regen/src/csharp.rs @@ -0,0 +1,303 @@ +#![allow(clippy::disallowed_macros)] + +use anyhow::{bail, Result}; +use duct::cmd; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +const BSATN_PACKAGE_ID: &str = "spacetimedb.bsatn.runtime"; +const RUNTIME_PACKAGE_ID: &str = "spacetimedb.runtime"; + +fn workspace_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("tools/regen should be two levels below the workspace root") + .to_path_buf() +} + +fn sdk_dir() -> PathBuf { + workspace_dir().join("sdks/csharp") +} + +fn cli_manifest() -> PathBuf { + workspace_dir().join("crates/cli/Cargo.toml") +} + +fn standalone_manifest() -> PathBuf { + workspace_dir().join("crates/standalone/Cargo.toml") +} + +fn path_arg(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn render_nuget_config(bsatn_source: &Path, runtime_source: &Path) -> String { + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#, + bsatn_source.display(), + runtime_source.display(), + ) +} + +pub fn regen_regression_tests() -> Result<()> { + let sdk = sdk_dir(); + let workspace = workspace_dir(); + + cmd!("cargo", "build", "--manifest-path", path_arg(&standalone_manifest())).run()?; + + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "generate", + "-y", + "-l", + "csharp", + "-o", + path_arg(&sdk.join("examples~/regression-tests/client/module_bindings")), + "--module-path", + path_arg(&sdk.join("examples~/regression-tests/server")), + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "generate", + "-y", + "-l", + "csharp", + "-o", + path_arg(&sdk.join("examples~/regression-tests/republishing/client/module_bindings")), + "--module-path", + path_arg(&sdk.join("examples~/regression-tests/republishing/server-republish")), + ) + .run()?; + cmd!( + "cargo", + "run", + "--manifest-path", + path_arg(&cli_manifest()), + "--", + "generate", + "-y", + "-l", + "csharp", + "-o", + path_arg(&sdk.join("examples~/regression-tests/procedure-client/module_bindings")), + "--module-path", + path_arg(&workspace.join("modules/sdk-test-procedure")), + ) + .run()?; + + Ok(()) +} + +fn overlay_unity_meta_skeleton(pkg_id: &str) -> Result<()> { + let sdk = sdk_dir(); + let skeleton_base = sdk.join("unity-meta-skeleton~"); + let skeleton_root = skeleton_base.join(pkg_id); + if !skeleton_root.exists() { + return Ok(()); + } + + let pkg_root = sdk.join("packages").join(pkg_id); + if !pkg_root.exists() { + return Ok(()); + } + + let pkg_root_meta = skeleton_base.join(format!("{pkg_id}.meta")); + if pkg_root_meta.exists() + && let Some(parent) = pkg_root.parent() + { + let pkg_meta_dst = parent.join(format!("{pkg_id}.meta")); + fs::copy(&pkg_root_meta, &pkg_meta_dst)?; + } + + let versioned_dir = match find_only_subdir(&pkg_root) { + Ok(dir) => dir, + Err(err) => { + eprintln!("Skipping Unity meta overlay for {pkg_id}: could not locate restored version dir: {err}"); + return Ok(()); + } + }; + + let version_meta_template = skeleton_root.join("version.meta"); + if version_meta_template.exists() + && let Some(parent) = versioned_dir.parent() + { + let version_name = versioned_dir + .file_name() + .expect("versioned directory should have a file name"); + let version_meta_dst = parent.join(format!("{}.meta", version_name.to_string_lossy())); + fs::copy(&version_meta_template, &version_meta_dst)?; + } + + copy_overlay_dir(&skeleton_root, &versioned_dir) +} + +fn clear_restored_package_dirs(pkg_id: &str) -> Result<()> { + let pkg_root = sdk_dir().join("packages").join(pkg_id); + if pkg_root.exists() { + fs::remove_dir_all(&pkg_root)?; + } + Ok(()) +} + +fn find_only_subdir(dir: &Path) -> Result { + let mut subdirs = vec![]; + + for entry in fs::read_dir(dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + subdirs.push(entry.path()); + } + } + + match subdirs.as_slice() { + [] => bail!("Could not find a restored versioned directory under {}", dir.display()), + [only] => Ok(only.clone()), + _ => bail!( + "Expected exactly one restored versioned directory under {}, found {}", + dir.display(), + subdirs.len() + ), + } +} + +fn copy_overlay_dir(src: &Path, dst: &Path) -> Result<()> { + if !src.exists() { + bail!("Skeleton directory does not exist: {}", src.display()); + } + if !dst.exists() { + bail!("Destination directory does not exist: {}", dst.display()); + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + if dst_path.exists() { + copy_overlay_dir(&src_path, &dst_path)?; + } + } else { + if src_path.extension().is_some_and(|ext| ext == "meta") { + let asset_path = dst_path + .parent() + .expect("dst_path should have a parent") + .join(dst_path.file_stem().expect(".meta file should have a file stem")); + + if asset_path.exists() { + fs::copy(&src_path, &dst_path)?; + } else if dst_path.exists() { + fs::remove_file(&dst_path)?; + } + continue; + } + + fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +pub fn regen_dlls() -> Result<()> { + let workspace = workspace_dir(); + let sdk = sdk_dir(); + + cmd!( + "dotnet", + "pack", + workspace.join("crates/bindings-csharp/BSATN.Runtime"), + "-c", + "Release" + ) + .run()?; + cmd!( + "dotnet", + "pack", + workspace.join("crates/bindings-csharp/Runtime"), + "-c", + "Release" + ) + .run()?; + + let nuget_config_dir = tempfile::tempdir()?; + let nuget_config_path = nuget_config_dir.path().join("nuget.config"); + fs::write( + &nuget_config_path, + render_nuget_config( + &workspace.join("crates/bindings-csharp/BSATN.Runtime/bin/Release"), + &workspace.join("crates/bindings-csharp/Runtime/bin/Release"), + ), + )?; + + clear_restored_package_dirs(BSATN_PACKAGE_ID)?; + clear_restored_package_dirs(RUNTIME_PACKAGE_ID)?; + + cmd!( + "dotnet", + "restore", + "SpacetimeDB.ClientSDK.csproj", + "--configfile", + path_arg(&nuget_config_path), + ) + .dir(&sdk) + .run()?; + + overlay_unity_meta_skeleton(BSATN_PACKAGE_ID)?; + overlay_unity_meta_skeleton(RUNTIME_PACKAGE_ID)?; + + cmd!( + "dotnet", + "pack", + "SpacetimeDB.ClientSDK.csproj", + "-c", + "Release", + "--no-restore" + ) + .dir(&sdk) + .run()?; + + Ok(()) +} diff --git a/tools/regen/src/main.rs b/tools/regen/src/main.rs new file mode 100644 index 000000000..c8e07dc67 --- /dev/null +++ b/tools/regen/src/main.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +mod csharp; + +#[derive(Parser)] +#[command(name = "regen", bin_name = "cargo regen", about = "Regenerate checked-in artifacts")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Regenerate C# SDK artifacts. + Csharp { + #[command(subcommand)] + command: CsharpCommand, + }, +} + +#[derive(Subcommand)] +enum CsharpCommand { + /// Regenerate C# regression test bindings. + RegressionTests, + /// Regenerate C# DLL and NuGet package artifacts for Unity workflows. + Dlls, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::Csharp { command } => match command { + CsharpCommand::RegressionTests => csharp::regen_regression_tests()?, + CsharpCommand::Dlls => csharp::regen_dlls()?, + }, + } + + Ok(()) +}