use std::path::{Path, PathBuf};
use crate::templates::{substitute, TEMPLATES};
const FOGLET_GAME_VERSION_REQ: &str = "0.1";
const FOGLET_GAME_DEP_TOKEN: &str = "{foglet_game_dependency}";
#[derive(Debug, Clone)]
struct ScaffoldRenderContext {
foglet_game_dependency_line: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ScaffoldError {
#[error("could not derive a project name from path `{0}` — pass a path whose final component is the project name")]
NameFromPath(PathBuf),
#[error(
"project name `{0}` is not a valid slug — must be lowercase ASCII alphanumeric or `-`, \
must not start or end with `-`, and must be non-empty (e.g. `murder-motel`)"
)]
InvalidName(String),
#[error(
"destination `{0}` already exists and is not empty — pass a fresh path or empty directory"
)]
DestinationNotEmpty(PathBuf),
#[error("filesystem error at `{path}`: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub type ScaffoldResult<T> = Result<T, ScaffoldError>;
pub fn scaffold_project(dest: &Path) -> ScaffoldResult<()> {
let name = name_from_path(dest)?;
scaffold_project_with_name(dest, &name)
}
pub fn scaffold_project_with_name(dest: &Path, name: &str) -> ScaffoldResult<()> {
if !is_valid_slug(name) {
return Err(ScaffoldError::InvalidName(name.to_string()));
}
let render_ctx = ScaffoldRenderContext::auto_detect();
ensure_empty_destination(dest)?;
create_dir_all(dest)?;
for template in TEMPLATES {
let rel = Path::new(template.dest_path);
let abs = dest.join(rel);
if let Some(parent) = abs.parent() {
create_dir_all(parent)?;
}
let body = render_template_body(template.contents, name, &render_ctx);
write_file(&abs, body.as_bytes())?;
}
Ok(())
}
impl ScaffoldRenderContext {
fn auto_detect() -> Self {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let local_path = detect_local_foglet_game_path(manifest_dir);
Self {
foglet_game_dependency_line: render_foglet_game_dependency_line(local_path.as_deref()),
}
}
}
fn render_template_body(contents: &str, name: &str, ctx: &ScaffoldRenderContext) -> String {
let with_name = substitute(contents, name);
with_name.replace(FOGLET_GAME_DEP_TOKEN, &ctx.foglet_game_dependency_line)
}
fn detect_local_foglet_game_path(manifest_dir: &Path) -> Option<PathBuf> {
let candidate = manifest_dir.join("..").join("foglet_game");
if !candidate.join("Cargo.toml").is_file() {
return None;
}
std::fs::canonicalize(&candidate).ok().or(Some(candidate))
}
fn render_foglet_game_dependency_line(local_path: Option<&Path>) -> String {
match local_path {
Some(path) => format!(
"foglet_game = {{ path = \"{}\" }}",
escape_toml_basic_string(&path.display().to_string())
),
None => format!("foglet_game = \"{FOGLET_GAME_VERSION_REQ}\""),
}
}
fn escape_toml_basic_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn name_from_path(dest: &Path) -> ScaffoldResult<String> {
dest.file_name()
.and_then(|os| os.to_str())
.map(|s| s.to_string())
.ok_or_else(|| ScaffoldError::NameFromPath(dest.to_path_buf()))
}
fn is_valid_slug(s: &str) -> bool {
if s.is_empty() {
return false;
}
if s.starts_with('-') || s.ends_with('-') {
return false;
}
s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn ensure_empty_destination(dest: &Path) -> ScaffoldResult<()> {
match std::fs::read_dir(dest) {
Ok(mut entries) => {
if entries.next().is_some() {
Err(ScaffoldError::DestinationNotEmpty(dest.to_path_buf()))
} else {
Ok(())
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(ScaffoldError::Io {
path: dest.to_path_buf(),
source: e,
}),
}
}
fn create_dir_all(path: &Path) -> ScaffoldResult<()> {
std::fs::create_dir_all(path).map_err(|e| ScaffoldError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn write_file(path: &Path, bytes: &[u8]) -> ScaffoldResult<()> {
std::fs::write(path, bytes).map_err(|e| ScaffoldError::Io {
path: path.to_path_buf(),
source: e,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_NAME: &str = "test-game";
fn fresh_dest(td: &tempfile::TempDir, name: &str) -> PathBuf {
td.path().join(name)
}
#[test]
fn slug_validator_accepts_canonical_examples() {
assert!(is_valid_slug("murder-motel"));
assert!(is_valid_slug("door1"));
assert!(is_valid_slug("a"));
assert!(is_valid_slug(SAMPLE_NAME));
}
#[test]
fn slug_validator_rejects_bad_inputs() {
assert!(!is_valid_slug(""));
assert!(!is_valid_slug("-leading"));
assert!(!is_valid_slug("trailing-"));
assert!(!is_valid_slug("Has_Underscore"));
assert!(!is_valid_slug("CapitalCase"));
assert!(!is_valid_slug("with space"));
assert!(!is_valid_slug("dot.path"));
}
#[test]
fn name_from_path_uses_final_component() {
assert_eq!(
name_from_path(Path::new("/tmp/foo/test-game")).unwrap(),
"test-game"
);
assert_eq!(name_from_path(Path::new("test-game")).unwrap(), "test-game");
}
#[test]
fn scaffold_creates_every_template_file() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
scaffold_project(&dest).expect("scaffold should succeed");
for template in TEMPLATES {
let path = dest.join(template.dest_path);
assert!(path.is_file(), "expected file at {}", path.display());
}
}
#[test]
fn scaffold_substitutes_name_in_cargo_toml() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
scaffold_project(&dest).unwrap();
let cargo = std::fs::read_to_string(dest.join("Cargo.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&cargo).unwrap();
let pkg_name = parsed
.get("package")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
.unwrap();
assert_eq!(pkg_name, SAMPLE_NAME);
let deps = parsed
.get("dependencies")
.and_then(|v| v.as_table())
.expect("Cargo.toml should define [dependencies]");
assert!(
deps.get("foglet_game").is_some(),
"scaffold should always render foglet_game dependency"
);
assert!(
!cargo.contains(FOGLET_GAME_DEP_TOKEN),
"Cargo.toml must not contain unsubstituted dependency token"
);
}
#[test]
fn scaffold_produces_game_toml_that_passes_game_config_validation() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
scaffold_project(&dest).unwrap();
let body = std::fs::read_to_string(dest.join("assets/game.toml")).unwrap();
let cfg = foglet_game::GameConfig::from_toml_str(&body)
.expect("scaffolded game.toml must pass GameConfig validation");
assert_eq!(cfg.game.slug, SAMPLE_NAME);
}
#[test]
fn scaffold_into_existing_empty_dir_succeeds() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
std::fs::create_dir_all(&dest).unwrap();
scaffold_project(&dest).expect("empty existing dir is fine");
assert!(dest.join("Cargo.toml").is_file());
}
#[test]
fn scaffold_into_non_empty_dir_is_rejected() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("EXISTING"), "hi").unwrap();
let err = scaffold_project(&dest).unwrap_err();
assert!(
matches!(err, ScaffoldError::DestinationNotEmpty(_)),
"expected DestinationNotEmpty, got {err:?}"
);
assert_eq!(
std::fs::read_to_string(dest.join("EXISTING")).unwrap(),
"hi"
);
}
#[test]
fn scaffold_rejects_invalid_name() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, "Bad_Name");
let err = scaffold_project(&dest).unwrap_err();
assert!(
matches!(err, ScaffoldError::InvalidName(ref n) if n == "Bad_Name"),
"expected InvalidName(\"Bad_Name\"), got {err:?}"
);
assert!(
!dest.exists(),
"scaffolder must not create the dest dir on validation failure"
);
}
#[test]
fn scaffold_creates_intermediate_parent_dirs() {
let td = tempfile::tempdir().unwrap();
let dest = td.path().join("nested/parents/test-game");
scaffold_project(&dest).expect("nested parents should be created");
assert!(dest.join("Cargo.toml").is_file());
}
#[test]
fn scaffold_starter_main_rs_parses_as_rust() {
let td = tempfile::tempdir().unwrap();
let dest = fresh_dest(&td, SAMPLE_NAME);
scaffold_project(&dest).unwrap();
let main_rs = std::fs::read_to_string(dest.join("src/main.rs")).unwrap();
syn::parse_file(&main_rs).expect("scaffolded main.rs must parse");
}
#[test]
fn foglet_game_dependency_line_falls_back_to_crates_io_when_no_local_path() {
let line = render_foglet_game_dependency_line(None);
assert_eq!(line, "foglet_game = \"0.1\"");
}
#[test]
fn foglet_game_dependency_line_uses_path_when_local_checkout_exists() {
let td = tempfile::tempdir().unwrap();
let local = td.path().join("foglet_game");
std::fs::create_dir_all(&local).unwrap();
std::fs::write(
local.join("Cargo.toml"),
"[package]\nname = \"foglet_game\"\n",
)
.unwrap();
let line = render_foglet_game_dependency_line(Some(&local));
assert!(
line.contains("path = "),
"expected a path dependency line, got `{line}`"
);
}
#[test]
fn detect_local_foglet_game_path_finds_sibling_checkout() {
let td = tempfile::tempdir().unwrap();
let crates_dir = td.path().join("crates");
let manifest_dir = crates_dir.join("fgk");
let foglet_game_dir = crates_dir.join("foglet_game");
std::fs::create_dir_all(&manifest_dir).unwrap();
std::fs::create_dir_all(&foglet_game_dir).unwrap();
std::fs::write(
foglet_game_dir.join("Cargo.toml"),
"[package]\nname = \"foglet_game\"\n",
)
.unwrap();
let found = detect_local_foglet_game_path(&manifest_dir)
.expect("sibling foglet_game checkout should be detected");
assert!(
found.ends_with("foglet_game"),
"expected detected path to end with foglet_game, got `{}`",
found.display()
);
}
}