feat(workout): create standalone app path

Add a workout new PATH command that copies the Workout exemplar into a standalone app with local hemx path dependencies, verifies its manifest in xtask tests, and documents the create/check path.

req: examples/001

req: examples/006
This commit is contained in:
slhx agent
2026-06-12 12:46:06 +02:00
parent d4e99b6e73
commit f8f1a9aee5
3 changed files with 163 additions and 5 deletions
+150 -4
View File
@@ -9,7 +9,11 @@ fn main() -> ExitCode {
match args.next().as_deref() {
Some("test") | None => run_test_plan(),
Some("bench") => run_bench_plan(),
Some("workout") => run_workout(args.next().as_deref()),
Some("workout") => {
let subcommand = args.next();
let operand = args.next();
run_workout(subcommand.as_deref(), operand.as_deref())
}
Some("workout-mobile") => run_workout_mobile(args.next().as_deref()),
Some("help") | Some("--help") | Some("-h") => {
print_help();
@@ -25,13 +29,14 @@ fn main() -> ExitCode {
fn print_help() {
println!(
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
);
}
fn run_workout(command: Option<&str>) -> ExitCode {
fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode {
// req: examples/001 req: examples/006
match command.unwrap_or("dev") {
"new" | "create" => run_workout_new(operand),
"dev" | "run" => Step::new(
"workout-dev-server",
["run", "--bin", "hemx-workout-example"],
@@ -64,6 +69,126 @@ fn run_workout(command: Option<&str>) -> ExitCode {
}
}
fn run_workout_new(destination: Option<&str>) -> ExitCode {
// req: examples/001 req: examples/006
let Some(destination) = destination else {
eprintln!("usage: cargo run -p hemx-xtask -- workout new PATH");
return ExitCode::from(2);
};
let destination = PathBuf::from(destination);
if destination.exists() {
eprintln!(
"{} already exists; choose an empty path",
destination.display()
);
return ExitCode::from(2);
}
match create_workout_app(&destination) {
Ok(()) => {
println!("workout-new\tpath={}", destination.display());
println!(
"next\tcargo run --manifest-path {}/Cargo.toml --bin workout-app",
destination.display()
);
ExitCode::SUCCESS
}
Err(err) => {
eprintln!(
"failed to create Workout app at {}: {err}",
destination.display()
);
ExitCode::FAILURE
}
}
}
fn create_workout_app(destination: &Path) -> std::io::Result<()> {
let root = repo_root()?;
copy_dir(&root.join("examples/workout"), destination)?;
let cargo_toml = destination.join("Cargo.toml");
let manifest = fs::read_to_string(&cargo_toml)?;
fs::write(
&cargo_toml,
manifest
.replace(
"name = \"hemx-workout-example\"",
"name = \"hemx-workout-app\"",
)
.replace(
"[[bin]]\nname = \"hemx-workout-app\"",
"[[bin]]\nname = \"workout-app\"",
)
.replace("version.workspace = true", "version = \"0.1.0\"")
.replace("edition.workspace = true", "edition = \"2021\"")
.replace(
"path = \"../../../hemplate/hemplate\"",
&format!("path = \"{}\"", root.join("../hemplate/hemplate").display()),
)
.replace(
"path = \"../../hemx\"",
&format!("path = \"{}\"", root.join("hemx").display()),
)
.replace(
"path = \"../../hemx-axum\"",
&format!("path = \"{}\"", root.join("hemx-axum").display()),
)
.replace(
"path = \"../../hemx-host\"",
&format!("path = \"{}\"", root.join("hemx-host").display()),
)
.replace(
"path = \"../../hemx-test\"",
&format!("path = \"{}\"", root.join("hemx-test").display()),
)
.replace(
"path = \"../../hemx-build\"",
&format!("path = \"{}\"", root.join("hemx-build").display()),
)
.replace("hemx_workout_example", "hemx_workout_app"),
)?;
replace_in_file(
&destination.join("src/main.rs"),
"hemx_workout_example",
"hemx_workout_app",
)?;
fs::write(
destination.join("CREATED.md"),
"# Created Workout app\n\nRun locally with:\n\n```sh\ncargo run --manifest-path Cargo.toml --bin workout-app\n```\n\nBuild for production with:\n\n```sh\ncargo build --manifest-path Cargo.toml --release --bin workout-app\n```\n\nThis app owns command/event/projection state and keeps Android/iOS signing outside the repo. req: examples/006\n",
)?;
Ok(())
}
fn repo_root() -> std::io::Result<PathBuf> {
let cwd = env::current_dir()?;
if cwd.join("examples/workout").exists() {
return Ok(cwd);
}
Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf())
}
fn replace_in_file(path: &Path, from: &str, to: &str) -> std::io::Result<()> {
let contents = fs::read_to_string(path)?;
fs::write(path, contents.replace(from, to))
}
fn copy_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if source_path.is_dir() {
copy_dir(&source_path, &destination_path)?;
} else {
fs::copy(&source_path, &destination_path)?;
}
}
Ok(())
}
fn run_workout_mobile(command: Option<&str>) -> ExitCode {
match command.unwrap_or("release") {
"release" => run_workout_mobile_release(),
@@ -744,7 +869,7 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
#[cfg(test)]
mod tests {
use super::{
android_twa_release_json, mobile_external_blockers, origin_host,
android_twa_release_json, create_workout_app, mobile_external_blockers, origin_host,
verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release,
Budget, WorkoutMobileConfig,
};
@@ -782,6 +907,27 @@ mod tests {
assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]);
}
#[test]
fn workout_new_creates_standalone_app_manifest() {
// req: examples/001 req: examples/006
let destination = PathBuf::from("target/test-workout-new-app");
let _ = fs::remove_dir_all(&destination);
create_workout_app(&destination).expect("create app");
let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest");
assert!(manifest.contains("name = \"hemx-workout-app\""));
assert!(manifest.contains("name = \"workout-app\""));
assert!(manifest.contains("edition = \"2021\""));
assert!(manifest.contains("hemx-build"));
let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs");
assert!(main_rs.contains("hemx_workout_app"));
assert!(!main_rs.contains("hemx_workout_example"));
assert!(destination.join("src/lib.rs").exists());
assert!(destination.join("templates/workout.heml").exists());
let _ = fs::remove_dir_all(&destination);
}
#[test]
fn workout_mobile_manifest_names_production_boundaries() {
// req: examples/001 req: local/001 req: host/002