From a5bc1d5303387e5ccf8aa31a335f3f2f162b9a16 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 24 Oct 2026 11:12:19 -0400 Subject: [PATCH] feat(scaffolding): add Rust package generator --- Cargo.lock | 4 + Cargo.toml | 1 + tools/scaffolding/BUILD | 27 +++ tools/scaffolding/Cargo.toml | 12 ++ tools/scaffolding/rust.rs | 192 ++++++++++++++++++ tools/scaffolding/rust_main.rs | 29 +++ tools/scaffolding/rust_test.rs | 71 +++++++ tools/scaffolding/templates/BUILD.template | 26 +++ .../scaffolding/templates/Cargo.toml.template | 11 + .../scaffolding/templates/binary.rs.template | 3 + .../scaffolding/templates/library.rs.template | 5 + tools/scaffolding/templates/test.rs.template | 6 + 12 files changed, 387 insertions(+) create mode 100644 tools/scaffolding/BUILD create mode 100644 tools/scaffolding/Cargo.toml create mode 100644 tools/scaffolding/rust.rs create mode 100644 tools/scaffolding/rust_main.rs create mode 100644 tools/scaffolding/rust_test.rs create mode 100644 tools/scaffolding/templates/BUILD.template create mode 100644 tools/scaffolding/templates/Cargo.toml.template create mode 100644 tools/scaffolding/templates/binary.rs.template create mode 100644 tools/scaffolding/templates/library.rs.template create mode 100644 tools/scaffolding/templates/test.rs.template diff --git a/Cargo.lock b/Cargo.lock index 08d07b3..a7a00ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,10 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "scaffolding" +version = "0.1.0" + [[package]] name = "serde" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index c5008af..e64ed73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "src/machoke", "src/monorepo", "src/status", + "tools/scaffolding", ] resolver = "3" diff --git a/tools/scaffolding/BUILD b/tools/scaffolding/BUILD new file mode 100644 index 0000000..60b7d08 --- /dev/null +++ b/tools/scaffolding/BUILD @@ -0,0 +1,27 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +rust_library( + name = "rust", + crate_name = "rust", + srcs = [ + "rust.rs", + "rust_test.rs", + ], + compile_data = glob(["templates/*"]), +) + +rust_test( + name = "rust_test", + crate = ":rust", +) + +rust_binary( + name = "rust_cli", + crate_name = "rust_cli", + srcs = [ + "rust_main.rs", + ], + deps = [ + ":rust", + ], +) diff --git a/tools/scaffolding/Cargo.toml b/tools/scaffolding/Cargo.toml new file mode 100644 index 0000000..19d4612 --- /dev/null +++ b/tools/scaffolding/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "scaffolding" +version = "0.1.0" +edition = "2024" + +[lib] +name = "rust" +path = "rust.rs" + +[[bin]] +name = "rust_cli" +path = "rust_main.rs" diff --git a/tools/scaffolding/rust.rs b/tools/scaffolding/rust.rs new file mode 100644 index 0000000..26929b5 --- /dev/null +++ b/tools/scaffolding/rust.rs @@ -0,0 +1,192 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const BUILD_TEMPLATE: &str = include_str!("templates/BUILD.template"); +const CARGO_TEMPLATE: &str = include_str!("templates/Cargo.toml.template"); +const LIBRARY_TEMPLATE: &str = include_str!("templates/library.rs.template"); +const BINARY_TEMPLATE: &str = include_str!("templates/binary.rs.template"); +const TEST_TEMPLATE: &str = include_str!("templates/test.rs.template"); +const CRATE_NAME_PLACEHOLDER: &str = "{CRATE_NAME}"; + +/// Creates a Rust package at `destination`. +pub fn run(destination: &Path) -> io::Result { + scaffold(&workspace_root()?, destination) +} + +fn scaffold(workspace_root: &Path, destination: &Path) -> io::Result { + let workspace_root = fs::canonicalize(workspace_root)?; + let destination = resolve_destination(&workspace_root, destination)?; + let crate_name = crate_name(&destination)?; + let workspace_member = workspace_member(&workspace_root, &destination)?; + let files = render_files(&crate_name); + let cargo_path = workspace_root.join("Cargo.toml"); + let cargo_manifest = fs::read_to_string(&cargo_path)?; + let cargo_manifest = update_workspace_members(&cargo_manifest, &workspace_member)?; + + fs::create_dir(&destination)?; + for file in files { + fs::write(destination.join(file.name), file.contents)?; + } + fs::write(cargo_path, cargo_manifest)?; + + Ok(destination) +} + +fn workspace_root() -> io::Result { + match env::var_os("BUILD_WORKSPACE_DIRECTORY") { + Some(path) => Ok(PathBuf::from(path)), + None => env::current_dir(), + } +} + +fn resolve_destination(workspace_root: &Path, destination: &Path) -> io::Result { + let destination = if destination.is_absolute() { + destination.to_path_buf() + } else { + workspace_root.join(destination) + }; + let parent = destination.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "the destination must have a parent directory", + ) + })?; + let parent = fs::canonicalize(parent)?; + + if !parent.starts_with(workspace_root) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "the destination is outside the workspace: {}", + destination.display() + ), + )); + } + + let destination = parent.join(destination.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "the destination must name a directory", + ) + })?); + if destination.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("the destination already exists: {}", destination.display()), + )); + } + + Ok(destination) +} + +fn crate_name(destination: &Path) -> io::Result { + let crate_name = destination + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "the destination name must be valid UTF-8", + ) + })?; + + if crate_name.is_empty() + || !crate_name.bytes().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == b'_' + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid crate name: {crate_name}"), + )); + } + + Ok(crate_name.to_owned()) +} + +fn workspace_member(workspace_root: &Path, destination: &Path) -> io::Result { + let member = destination.strip_prefix(workspace_root).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "the destination is outside the workspace", + ) + })?; + let member = member.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "the destination must be valid UTF-8", + ) + })?; + + Ok(member.replace(std::path::MAIN_SEPARATOR, "/")) +} + +fn render_files(crate_name: &str) -> Vec { + vec![ + render_file("BUILD", BUILD_TEMPLATE, crate_name), + render_file("Cargo.toml", CARGO_TEMPLATE, crate_name), + render_file(&format!("{crate_name}.rs"), LIBRARY_TEMPLATE, crate_name), + render_file( + &format!("{crate_name}_main.rs"), + BINARY_TEMPLATE, + crate_name, + ), + render_file(&format!("{crate_name}_test.rs"), TEST_TEMPLATE, crate_name), + ] +} + +fn render_file(name: &str, template: &str, crate_name: &str) -> RenderedFile { + RenderedFile { + name: name.to_owned(), + contents: template.replace(CRATE_NAME_PLACEHOLDER, crate_name), + } +} + +fn update_workspace_members(manifest: &str, member: &str) -> io::Result { + let workspace_start = manifest.find("[workspace]").ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the root Cargo.toml has no [workspace] section", + ) + })?; + let members_start = manifest[workspace_start..] + .find("members = [") + .map(|offset| workspace_start + offset) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the root Cargo.toml has no workspace members array", + ) + })?; + let members_end = manifest[members_start..] + .find(']') + .map(|offset| members_start + offset) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the root Cargo.toml has an unclosed workspace members array", + ) + })?; + + if manifest[members_start..members_end].contains(&format!("\"{member}\"")) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("the workspace already has member: {member}"), + )); + } + + let mut manifest = manifest.to_owned(); + manifest.insert_str(members_end, &format!(" \"{member}\",\n")); + Ok(manifest) +} + +struct RenderedFile { + name: String, + contents: String, +} + +#[cfg(test)] +#[path = "rust_test.rs"] +mod rust_test; diff --git a/tools/scaffolding/rust_main.rs b/tools/scaffolding/rust_main.rs new file mode 100644 index 0000000..a584a3e --- /dev/null +++ b/tools/scaffolding/rust_main.rs @@ -0,0 +1,29 @@ +use std::env; +use std::io; +use std::path::PathBuf; + +fn main() -> io::Result<()> { + let mut arguments = env::args_os(); + let program = arguments.next().unwrap_or_else(|| "rust_cli".into()); + let Some(destination) = arguments.next() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("usage: {} ", PathBuf::from(program).display()), + )); + }; + + if destination == "--help" { + println!("usage: {} ", PathBuf::from(program).display()); + return Ok(()); + } + if arguments.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected exactly one destination", + )); + } + + let destination = rust::run(&PathBuf::from(destination))?; + println!("created {}", destination.display()); + Ok(()) +} diff --git a/tools/scaffolding/rust_test.rs b/tools/scaffolding/rust_test.rs new file mode 100644 index 0000000..31d7faa --- /dev/null +++ b/tools/scaffolding/rust_test.rs @@ -0,0 +1,71 @@ +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{crate_name, render_files, scaffold, update_workspace_members}; + +#[test] +fn accepts_valid_crate_names() { + assert_eq!(crate_name(Path::new("foo_2")).unwrap(), "foo_2"); +} + +#[test] +fn rejects_invalid_crate_names() { + assert!(crate_name(Path::new("Foo")).is_err()); + assert!(crate_name(Path::new("foo-bar")).is_err()); +} + +#[test] +fn renders_crate_name_into_every_file() { + for file in render_files("example") { + assert!(!file.contents.contains("{CRATE_NAME}")); + } +} + +#[test] +fn adds_workspace_member() { + let manifest = "[workspace]\nmembers = [\n \"src/status\",\n]\n"; + let updated = update_workspace_members(manifest, "src/example").unwrap(); + + assert_eq!( + updated, + "[workspace]\nmembers = [\n \"src/status\",\n \"src/example\",\n]\n" + ); +} + +#[test] +fn creates_a_rust_package() { + let workspace = std::env::temp_dir().join(format!( + "scaffolding-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + fs::create_dir_all(workspace.join("src")).unwrap(); + fs::write( + workspace.join("Cargo.toml"), + "[workspace]\nmembers = [\n]\n", + ) + .unwrap(); + + let destination = scaffold(&workspace, &workspace.join("src/example")).unwrap(); + + assert_eq!( + destination, + fs::canonicalize(workspace.join("src/example")).unwrap() + ); + assert!(destination.join("BUILD").exists()); + assert!(destination.join("Cargo.toml").exists()); + assert!(destination.join("example.rs").exists()); + assert!(destination.join("example_main.rs").exists()); + assert!(destination.join("example_test.rs").exists()); + assert!( + fs::read_to_string(workspace.join("Cargo.toml")) + .unwrap() + .contains(" \"src/example\",") + ); + + fs::remove_dir_all(workspace).unwrap(); +} diff --git a/tools/scaffolding/templates/BUILD.template b/tools/scaffolding/templates/BUILD.template new file mode 100644 index 0000000..e51b7ff --- /dev/null +++ b/tools/scaffolding/templates/BUILD.template @@ -0,0 +1,26 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +rust_library( + name = "{CRATE_NAME}", + crate_name = "{CRATE_NAME}", + srcs = [ + "{CRATE_NAME}.rs", + "{CRATE_NAME}_test.rs", + ], +) + +rust_test( + name = "{CRATE_NAME}_test", + crate = ":{CRATE_NAME}", +) + +rust_binary( + name = "{CRATE_NAME}_cli", + crate_name = "{CRATE_NAME}_cli", + srcs = [ + "{CRATE_NAME}_main.rs", + ], + deps = [ + ":{CRATE_NAME}", + ], +) diff --git a/tools/scaffolding/templates/Cargo.toml.template b/tools/scaffolding/templates/Cargo.toml.template new file mode 100644 index 0000000..c83686a --- /dev/null +++ b/tools/scaffolding/templates/Cargo.toml.template @@ -0,0 +1,11 @@ +[package] +name = "{CRATE_NAME}" +version = "0.1.0" +edition = "2024" + +[lib] +path = "{CRATE_NAME}.rs" + +[[bin]] +name = "{CRATE_NAME}_cli" +path = "{CRATE_NAME}_main.rs" diff --git a/tools/scaffolding/templates/binary.rs.template b/tools/scaffolding/templates/binary.rs.template new file mode 100644 index 0000000..d717550 --- /dev/null +++ b/tools/scaffolding/templates/binary.rs.template @@ -0,0 +1,3 @@ +fn main() { + {CRATE_NAME}::run(); +} diff --git a/tools/scaffolding/templates/library.rs.template b/tools/scaffolding/templates/library.rs.template new file mode 100644 index 0000000..54d3d68 --- /dev/null +++ b/tools/scaffolding/templates/library.rs.template @@ -0,0 +1,5 @@ +#[cfg(test)] +#[path = "{CRATE_NAME}_test.rs"] +mod tests; + +pub fn run() {} diff --git a/tools/scaffolding/templates/test.rs.template b/tools/scaffolding/templates/test.rs.template new file mode 100644 index 0000000..55289bb --- /dev/null +++ b/tools/scaffolding/templates/test.rs.template @@ -0,0 +1,6 @@ +use super::run; + +#[test] +fn runs() { + run(); +}