use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::Args;
use crate::theme::Theme;
const SUPPORTED_KINDS: &[&str] = &["tool"];
#[derive(Args, Debug, Clone)]
pub(crate) struct NewArgs {
pub name: String,
#[arg(long, default_value = "tool")]
pub kind: String,
#[arg(long)]
pub path: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub install_target: bool,
}
pub(crate) fn run(args: &NewArgs) -> Result<ExitCode> {
if !SUPPORTED_KINDS.contains(&args.kind.as_str()) {
eprintln!(
"{}",
Theme::error(&format!(
"unsupported capsule kind '{}'. Supported kinds: {}",
args.kind,
SUPPORTED_KINDS.join(", ")
))
);
return Ok(ExitCode::from(1));
}
if let Err(reason) = validate_name(&args.name) {
eprintln!(
"{}",
Theme::error(&format!("invalid capsule name '{}': {reason}", args.name))
);
return Ok(ExitCode::from(1));
}
let parent = args.path.clone().unwrap_or_else(|| PathBuf::from("."));
let target = parent.join(&args.name);
if dir_is_non_empty(&target) {
if args.force {
eprintln!(
"{}",
Theme::warning(&format!(
"overwriting existing files in {}",
target.display()
))
);
} else {
eprintln!(
"{}",
Theme::error(&format!(
"target directory {} already exists and is not empty. \
Use --force to overwrite.",
target.display()
))
);
return Ok(ExitCode::from(1));
}
}
preflight::check(args.install_target);
scaffold(&target, &args.name)
.with_context(|| format!("failed to scaffold capsule into {}", target.display()))?;
print_next_steps(&target, &args.name);
Ok(ExitCode::SUCCESS)
}
mod preflight {
use std::io::IsTerminal;
use std::process::Command;
use crate::theme::Theme;
const WASM_TARGET: &str = "wasm32-unknown-unknown";
pub(super) fn check(install_target: bool) {
let have_cargo = tool_present("cargo");
let have_rustc = tool_present("rustc");
if !have_cargo || !have_rustc {
warn_no_rust_toolchain(have_cargo, have_rustc);
return;
}
if wasm_target_installed() {
return;
}
if rustup_present() {
if install_target || prompt_install() {
if run_target_add() {
eprintln!(
"{}",
Theme::success(&format!("installed the {WASM_TARGET} target"))
);
return;
}
eprintln!(
"{}",
Theme::warning(&format!(
"could not install the {WASM_TARGET} target automatically — \
run `rustup target add {WASM_TARGET}` yourself."
))
);
} else {
guide_rustup_target_add();
}
} else {
guide_rustup_target_add();
}
}
fn tool_present(tool: &str) -> bool {
Command::new(tool)
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
fn rustup_present() -> bool {
tool_present("rustup")
}
fn wasm_target_installed() -> bool {
if rustup_present()
&& let Ok(out) = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
&& out.status.success()
{
return String::from_utf8_lossy(&out.stdout)
.lines()
.any(|line| line.trim() == WASM_TARGET);
}
Command::new("rustc")
.args(["--target", WASM_TARGET, "--print", "cfg"])
.output()
.is_ok_and(|o| o.status.success())
}
fn run_target_add() -> bool {
eprintln!(
"{}",
Theme::dimmed(&format!("running `rustup target add {WASM_TARGET}`..."))
);
Command::new("rustup")
.args(["target", "add", WASM_TARGET])
.status()
.is_ok_and(|s| s.success())
}
fn prompt_install() -> bool {
if !std::io::stdin().is_terminal() {
return false;
}
eprintln!(
"{}",
Theme::warning(&format!(
"the {WASM_TARGET} target (required to build capsules) is not installed."
))
);
eprint!("Install it now with `rustup target add {WASM_TARGET}`? [Y/n] ");
if std::io::Write::flush(&mut std::io::stderr()).is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).unwrap_or(0) == 0 {
return false;
}
let input = input.trim();
input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes")
}
fn guide_rustup_target_add() {
eprintln!(
"{}",
Theme::warning(&format!(
"the {WASM_TARGET} target is not installed — capsules cannot build without it."
))
);
eprintln!(" Install it with:");
eprintln!(" rustup target add {WASM_TARGET}");
eprintln!(
"{}",
Theme::dimmed(
" (or pass --install-target to have `astrid capsule new` add it for you.)"
)
);
}
fn warn_no_rust_toolchain(have_cargo: bool, have_rustc: bool) {
let missing = match (have_cargo, have_rustc) {
(false, false) => "cargo and rustc were",
(false, true) => "cargo was",
(true, false) => "rustc was",
(true, true) => unreachable!("warn only called when one is missing"),
};
eprintln!(
"{}",
Theme::warning(&format!(
"{missing} not found on PATH — you need a Rust toolchain to build this capsule."
))
);
eprintln!(" Install Rust (rustup) with:");
eprintln!(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
eprintln!(" then add the capsule build target:");
eprintln!(" rustup target add {WASM_TARGET}");
}
}
fn validate_name(name: &str) -> std::result::Result<(), String> {
if name.is_empty() {
return Err("name must not be empty".into());
}
let first = name.chars().next().expect("non-empty checked above");
if !first.is_ascii_lowercase() {
return Err("name must start with a lowercase letter".into());
}
if name.ends_with('-') {
return Err("name must not end with a hyphen".into());
}
if name.contains("--") {
return Err("name must not contain consecutive hyphens".into());
}
for ch in name.chars() {
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
return Err(format!(
"'{ch}' is not allowed (use lowercase letters, digits, and hyphens)"
));
}
}
Ok(())
}
fn dir_is_non_empty(path: &Path) -> bool {
match std::fs::read_dir(path) {
Ok(mut entries) => entries.next().is_some(),
Err(_) => false,
}
}
fn scaffold(target: &Path, name: &str) -> Result<()> {
use super::new_templates as t;
let crate_ident = name.replace('-', "_");
write_file(&target.join(".cargo/config.toml"), &t::cargo_config_toml())?;
write_file(
&target.join("rust-toolchain.toml"),
&t::rust_toolchain_toml(),
)?;
write_file(&target.join("Cargo.toml"), &t::cargo_toml(name))?;
write_file(
&target.join("Capsule.toml"),
&t::capsule_toml(name, &crate_ident),
)?;
write_file(&target.join("src/lib.rs"), &t::lib_rs())?;
write_file(&target.join("README.md"), &t::readme_md(name))?;
write_file(
&target.join("AUTHORING.md"),
&t::authoring_md(name, &crate_ident),
)?;
Ok(())
}
fn write_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(path, contents)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn print_next_steps(target: &Path, name: &str) {
eprintln!(
"{}",
Theme::success(&format!("Created capsule '{name}' at {}", target.display()))
);
eprintln!();
eprintln!("{}", Theme::header("Next steps"));
eprintln!(" cd {}", target.display());
eprintln!(" astrid capsule build");
eprintln!(" astrid capsule install ./dist/{name}.capsule");
eprintln!();
eprintln!(
"{}",
Theme::dimmed("Edit src/lib.rs to replace the hello tool with your own.")
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_validation_accepts_good_names() {
for ok in ["a", "hello", "my-tool", "tool42", "a1-b2-c3"] {
assert!(validate_name(ok).is_ok(), "expected '{ok}' to be valid");
}
}
#[test]
fn name_validation_rejects_bad_names() {
for bad in [
"",
"1tool",
"-tool",
"tool-",
"tool--name",
"Tool",
"my_tool",
"my tool",
"tool!",
] {
assert!(
validate_name(bad).is_err(),
"expected '{bad}' to be invalid"
);
}
}
#[test]
fn dir_is_non_empty_distinguishes_states() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("missing");
assert!(!dir_is_non_empty(&missing), "missing dir is not non-empty");
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(!dir_is_non_empty(&empty), "empty dir is not non-empty");
std::fs::write(empty.join("x"), "y").unwrap();
assert!(dir_is_non_empty(&empty), "dir with a file is non-empty");
}
#[test]
fn scaffold_writes_expected_files() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("hello-cap");
scaffold(&target, "hello-cap").unwrap();
for rel in [
".cargo/config.toml",
"rust-toolchain.toml",
"Cargo.toml",
"Capsule.toml",
"src/lib.rs",
"README.md",
"AUTHORING.md",
] {
assert!(
target.join(rel).is_file(),
"expected scaffolded file {rel} to exist"
);
}
}
}