Compare commits

...
1 Commits
Author SHA1 Message Date
alexion a5bc1d5303 feat(scaffolding): add Rust package generator 2026-10-24 11:12:19 -04:00
12 changed files with 387 additions and 0 deletions

No files matched your search

Generated
+4
View File
@@ -164,6 +164,10 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "scaffolding"
version = "0.1.0"
[[package]]
name = "serde"
version = "1.0.229"
+1
View File
@@ -4,6 +4,7 @@ members = [
"src/machoke",
"src/monorepo",
"src/status",
"tools/scaffolding",
]
resolver = "3"
+27
View File
@@ -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",
],
)
+12
View File
@@ -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"
+192
View File
@@ -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<PathBuf> {
scaffold(&workspace_root()?, destination)
}
fn scaffold(workspace_root: &Path, destination: &Path) -> io::Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> {
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<String> {
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<RenderedFile> {
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<String> {
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;
+29
View File
@@ -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: {} <destination>", PathBuf::from(program).display()),
));
};
if destination == "--help" {
println!("usage: {} <destination>", 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(())
}
+71
View File
@@ -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();
}
@@ -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}",
],
)
@@ -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"
@@ -0,0 +1,3 @@
fn main() {
{CRATE_NAME}::run();
}
@@ -0,0 +1,5 @@
#[cfg(test)]
#[path = "{CRATE_NAME}_test.rs"]
mod tests;
pub fn run() {}
@@ -0,0 +1,6 @@
use super::run;
#[test]
fn runs() {
run();
}