use std::fs;
use std::path::Path;
use anyhow::{bail, Context};
fn cargo_toml_template(project_name: &str, volter_version: &str) -> String {
format!(
r#"[package]
name = "{project_name}"
version = "0.1.0"
edition = "2021"
rust-version = "1.79"
[dependencies]
volter = "{volter_version}"
tokio = {{ version = "1", features = ["full"] }}
"#,
)
}
fn main_rs_template(project_name: &str) -> String {
format!(
r#"//! {project_name} — created by `volter new`.
use tokio::net::TcpListener;
use volter::{{get, serve, Router}};
/// Root handler.
async fn root() -> &'static str {{
"Hello, World!"
}}
#[tokio::main]
async fn main() -> Result<(), volter::BoxError> {{
let app = Router::new().route("/", get(root));
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
let listener = TcpListener::bind(addr).await?;
eprintln!("Listening on http://{{addr}}");
serve(listener, app).await
}}
"#,
)
}
pub fn run_new_in(name: &str, parent_dir: &Path) -> anyhow::Result<()> {
if name.is_empty() {
bail!("project name cannot be empty");
}
if !is_valid_crate_name(name) {
bail!(
"`{name}` is not a valid Rust crate name. \
Use only lowercase letters, digits, and hyphens."
);
}
let project_dir = parent_dir.join(name);
if project_dir.exists() {
bail!("directory `{name}` already exists");
}
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).with_context(|| format!("failed to create `{}/src`", name))?;
let cargo_toml = cargo_toml_template(name, env!("CARGO_PKG_VERSION"));
let cargo_path = project_dir.join("Cargo.toml");
fs::write(&cargo_path, &cargo_toml)
.with_context(|| format!("failed to write `{}`", cargo_path.display()))?;
let main_rs = main_rs_template(name);
let main_path = src_dir.join("main.rs");
fs::write(&main_path, &main_rs)
.with_context(|| format!("failed to write `{}`", main_path.display()))?;
eprintln!("Created project `{name}`");
Ok(())
}
pub fn run_new(name: &str) -> anyhow::Result<()> {
run_new_in(
name,
&std::env::current_dir().context("failed to get current directory")?,
)
}
fn is_valid_crate_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let first = name.chars().next();
if !first.is_some_and(|c| c.is_ascii_lowercase()) {
return false;
}
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TempDir {
path: std::path::PathBuf,
}
impl TempDir {
fn new() -> Self {
let count = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("volter-test-{count}"));
fs::create_dir_all(&path).expect("failed to create temp dir");
Self { path }
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn cargo_toml_contains_project_name() {
let result = cargo_toml_template("my-app", "0.1.0");
assert!(result.contains(r#"name = "my-app""#));
assert!(result.contains(r#"volter = "0.1.0""#));
assert!(result.contains(r#"tokio = { version = "1", features = ["full"] }"#));
}
#[test]
fn cargo_toml_contains_rust_version() {
let result = cargo_toml_template("x", "0.1.0");
assert!(result.contains("rust-version = \"1.79\""));
assert!(result.contains("edition = \"2021\""));
}
#[test]
fn main_rs_contains_imports_and_boilerplate() {
let result = main_rs_template("test-app");
assert!(result.contains("use tokio::net::TcpListener;"));
assert!(result.contains("use volter::{get, serve, Router};"));
assert!(result.contains("async fn root()"));
assert!(result.contains("Router::new()"));
assert!(result.contains("get(root)"));
assert!(result.contains("serve(listener, app)"));
assert!(result.contains("#[tokio::main]"));
}
#[test]
fn creates_project() {
let temp = TempDir::new();
let name = "test-proj";
let project_path = temp.path().join(name);
let result = run_new_in(name, temp.path());
assert!(result.is_ok(), "run_new_in failed: {:?}", result.err());
assert!(project_path.exists(), "project directory not created");
assert!(
project_path.join("Cargo.toml").exists(),
"Cargo.toml not created"
);
assert!(
project_path.join("src/main.rs").exists(),
"src/main.rs not created"
);
}
#[test]
fn refuses_existing_directory() {
let temp = TempDir::new();
let name = "existing-dir";
let project_path = temp.path().join(name);
fs::create_dir(&project_path).unwrap();
let result = run_new_in(name, temp.path());
assert!(result.is_err(), "expected error for existing directory");
let err = format!("{}", result.err().unwrap());
assert!(
err.contains("already exists"),
"expected 'already exists' error, got: {err}"
);
}
#[test]
fn generated_cargo_toml_looks_correct() {
let temp = TempDir::new();
let name = "check-cargo";
let project_path = temp.path().join(name);
run_new_in(name, temp.path()).unwrap();
let cargo = fs::read_to_string(project_path.join("Cargo.toml")).unwrap();
assert!(cargo.contains(&format!("name = \"{name}\"")));
assert!(cargo.contains("version = \"0.1.0\""));
assert!(cargo.contains("edition = \"2021\""));
assert!(cargo.contains("rust-version = \"1.79\""));
assert!(cargo.contains("volter = "));
assert!(cargo.contains("tokio = "));
}
#[test]
fn generated_main_rs_looks_correct() {
let temp = TempDir::new();
let name = "check-main";
let project_path = temp.path().join(name);
run_new_in(name, temp.path()).unwrap();
let main_rs = fs::read_to_string(project_path.join("src/main.rs")).unwrap();
assert!(main_rs.contains("use tokio::net::TcpListener;"));
assert!(main_rs.contains("use volter::{get, serve, Router};"));
assert!(main_rs.contains("async fn root()"));
assert!(main_rs.contains("Router::new()"));
assert!(main_rs.contains("get(root)"));
assert!(main_rs.contains("serve(listener, app)"));
}
#[test]
fn rejects_invalid_names() {
let temp = TempDir::new();
let cases = &[
("UpperCase", "uppercase"),
("has spaces", "spaces"),
("", "empty"),
("123-starts-with-digit", "starts with digit"),
];
for (name, _label) in cases {
let result = run_new_in(name, temp.path());
assert!(result.is_err(), "expected error for name `{name}`");
}
}
}