pub mod builtin;
pub mod git;
pub mod manifest;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use include_dir::Dir;
use crate::generate::Flags;
use crate::generate::emit::Plan;
use crate::new::{TemplateVars, render_template, validate_name};
use git::GitSource;
use manifest::{MANIFEST_FILE, Manifest};
#[derive(Debug, thiserror::Error)]
pub enum StarterError {
#[error("{0}")]
InvalidSource(String),
#[error("a starter ref was given twice (an '@ref' suffix and --starter-ref); supply only one")]
AmbiguousRef,
#[error("--starter-ref only applies to git starters")]
RefNotApplicable,
#[error("directory '{0}' already exists")]
AlreadyExists(String),
#[error("invalid project name '{0}': {1}")]
InvalidName(String, String),
#[error("invalid starter manifest: {0}")]
Manifest(String),
#[error(
"git is required to fetch a community starter but was not found on PATH; \
install git or use a built-in starter (autumn new --list-starters)"
)]
GitNotInstalled,
#[error("{0}")]
CloneFailed(String),
#[error(
"fetching a community starter requires confirmation; re-run with --yes to \
proceed non-interactively"
)]
ConfirmationRequired,
#[error("aborted: starter not applied")]
Aborted,
#[error("{0}")]
Io(#[from] io::Error),
#[error("{0}")]
Emit(#[from] crate::generate::GenerateError),
}
#[derive(Debug)]
pub enum Resolved {
Builtin(&'static builtin::Builtin),
LocalDir(PathBuf),
Git(GitSource),
}
impl Resolved {
#[must_use]
pub const fn requires_confirmation(&self) -> bool {
!matches!(self, Self::Builtin(_))
}
}
pub fn resolve(value: &str, ref_override: Option<&str>) -> Result<Resolved, StarterError> {
if let Some(b) = builtin::find(value) {
if ref_override.is_some() {
return Err(StarterError::RefNotApplicable);
}
return Ok(Resolved::Builtin(b));
}
if Path::new(value).is_dir() {
if ref_override.is_some() {
return Err(StarterError::RefNotApplicable);
}
return Ok(Resolved::LocalDir(PathBuf::from(value)));
}
let source = git::resolve(value, ref_override)?;
Ok(Resolved::Git(source))
}
#[derive(Debug)]
struct StarterContents {
manifest: Manifest,
files: Vec<StarterFile>,
}
#[derive(Debug)]
struct StarterFile {
rel_path: String,
bytes: Vec<u8>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfirmMode {
Proceed,
Prompt,
NeedsYesFlag,
}
#[must_use]
pub const fn confirm_mode(yes: bool, interactive: bool) -> ConfirmMode {
if yes {
ConfirmMode::Proceed
} else if interactive {
ConfirmMode::Prompt
} else {
ConfirmMode::NeedsYesFlag
}
}
fn provenance(resolved: &Resolved) -> String {
match resolved {
Resolved::Builtin(b) => format!("Built-in starter '{}'", b.name),
Resolved::LocalDir(path) => {
let abs = path.canonicalize().unwrap_or_else(|_| path.clone());
format!("Community starter (local path): {}", abs.display())
}
Resolved::Git(source) => {
let reference = source.reference.as_deref().unwrap_or("<default branch>");
format!("Community starter (git): {} (ref: {reference})", source.url)
}
}
}
fn confirm(provenance: &str, yes: bool) -> Result<(), StarterError> {
println!("{provenance}");
match confirm_mode(yes, io::stdin().is_terminal()) {
ConfirmMode::Proceed => Ok(()),
ConfirmMode::NeedsYesFlag => Err(StarterError::ConfirmationRequired),
ConfirmMode::Prompt => {
print!("Proceed? [y/N] ");
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
if matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
Ok(())
} else {
Err(StarterError::Aborted)
}
}
}
}
pub fn print_list() {
println!("Available built-in starters:\n");
let width = builtin::BUILTINS
.iter()
.map(|b| b.name.len())
.max()
.unwrap_or(0);
for b in builtin::BUILTINS {
println!(" {:<width$} {}", b.name, b.description, width = width);
}
println!("\nScaffold one with: autumn new <name> --starter <starter>");
println!("Community starters: autumn new <name> --starter <git-url|owner/repo>[@ref] [--yes]");
}
pub fn run(name: &str, starter: &str, starter_ref: Option<&str>, yes: bool, flags: Flags) {
let cwd = std::env::current_dir().unwrap_or_else(|e| {
eprintln!("Error: cannot determine current directory: {e}");
std::process::exit(1);
});
if let Err(e) = run_inner(name, starter, starter_ref, yes, flags, &cwd) {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
fn run_inner(
name: &str,
starter: &str,
starter_ref: Option<&str>,
yes: bool,
flags: Flags,
parent_dir: &Path,
) -> Result<(), StarterError> {
validate_name(name).map_err(|e| {
StarterError::InvalidName(name.to_owned(), strip_name_error(&e.to_string()))
})?;
let project_dir = parent_dir.join(name);
if project_dir.exists() {
return Err(StarterError::AlreadyExists(name.to_owned()));
}
let resolved = resolve(starter, starter_ref)?;
if resolved.requires_confirmation() {
confirm(&provenance(&resolved), yes)?;
}
let mut clone_guard: Option<tempfile::TempDir> = None;
let contents = match &resolved {
Resolved::Builtin(b) => {
println!("Scaffolding built-in starter '{}'", b.name);
load_from_embedded(b.dir)?
}
Resolved::LocalDir(path) => load_from_dir(path)?,
Resolved::Git(source) => {
let tmp = tempfile::TempDir::new()?;
git::clone_into(source, tmp.path())?;
let contents = load_from_dir(tmp.path())?;
clone_guard = Some(tmp);
contents
}
};
let crate_name = name.replace('-', "_");
let vars = TemplateVars {
project_name: name,
crate_name: &crate_name,
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
println!("\nCreating `{name}`:");
scaffold(&contents, &vars, &project_dir, flags)?;
drop(clone_guard);
if !flags.dry_run {
print_post_scaffold(&contents.manifest, &vars);
}
Ok(())
}
fn strip_name_error(msg: &str) -> String {
msg.rsplit(": ").next().unwrap_or(msg).to_owned()
}
fn collect_embedded(dir: &Dir<'_>, out: &mut Vec<StarterFile>) {
for f in dir.files() {
out.push(StarterFile {
rel_path: f.path().to_string_lossy().replace('\\', "/"),
bytes: f.contents().to_vec(),
});
}
for d in dir.dirs() {
collect_embedded(d, out);
}
}
fn load_from_embedded(dir: &Dir<'_>) -> Result<StarterContents, StarterError> {
let manifest_src = dir
.get_file(MANIFEST_FILE)
.ok_or_else(|| StarterError::Manifest(format!("missing {MANIFEST_FILE}")))?
.contents_utf8()
.ok_or_else(|| StarterError::Manifest(format!("{MANIFEST_FILE} is not valid UTF-8")))?;
let manifest = Manifest::parse(manifest_src).map_err(StarterError::Manifest)?;
let mut all = Vec::new();
collect_embedded(dir, &mut all);
Ok(finish_contents(manifest, all))
}
const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", "build"];
fn collect_dir(root: &Path, cur: &Path, out: &mut Vec<StarterFile>) -> io::Result<()> {
for entry in fs::read_dir(cur)? {
let entry = entry?;
if SKIP_DIRS.contains(&entry.file_name().to_str().unwrap_or("")) {
continue;
}
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
collect_dir(root, &path, out)?;
} else {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
out.push(StarterFile {
rel_path: rel,
bytes: fs::read(&path)?,
});
}
}
Ok(())
}
fn load_from_dir(root: &Path) -> Result<StarterContents, StarterError> {
let manifest_path = root.join(MANIFEST_FILE);
let manifest_src = fs::read_to_string(&manifest_path).map_err(|_| {
StarterError::Manifest(format!(
"missing {MANIFEST_FILE} at the starter root ({})",
root.display()
))
})?;
let manifest = Manifest::parse(&manifest_src).map_err(StarterError::Manifest)?;
let mut all = Vec::new();
collect_dir(root, root, &mut all)?;
Ok(finish_contents(manifest, all))
}
fn finish_contents(manifest: Manifest, mut all: Vec<StarterFile>) -> StarterContents {
all.retain(|f| f.rel_path != MANIFEST_FILE);
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
StarterContents {
manifest,
files: all,
}
}
fn is_verbatim(manifest: &Manifest, rel_path: &str) -> bool {
manifest.starter.verbatim.iter().any(|p| p == rel_path)
}
const TEMPLATE_SUFFIX: &str = ".tmpl";
fn emit_rel_path(rel_path: &str) -> &str {
rel_path.strip_suffix(TEMPLATE_SUFFIX).unwrap_or(rel_path)
}
fn scaffold(
contents: &StarterContents,
vars: &TemplateVars<'_>,
project_dir: &Path,
flags: Flags,
) -> Result<(), StarterError> {
let mut plan = Plan::new(project_dir);
for file in &contents.files {
let rel_path = Path::new(&file.rel_path);
if rel_path.is_absolute()
|| rel_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(StarterError::InvalidSource(format!(
"malicious path in starter: {}",
file.rel_path
)));
}
let target = project_dir.join(emit_rel_path(&file.rel_path));
if is_verbatim(&contents.manifest, &file.rel_path) {
plan.create_bytes(target, file.bytes.clone());
} else {
match std::str::from_utf8(&file.bytes) {
Ok(text) => plan.create(target, render_template(text, vars)),
Err(_) => plan.create_bytes(target, file.bytes.clone()),
}
}
}
plan.execute(flags)?;
Ok(())
}
fn print_post_scaffold(manifest: &Manifest, vars: &TemplateVars<'_>) {
if let Some(notes) = &manifest.starter.post_scaffold_notes {
println!("\n{}", render_template(notes, vars));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_resolves_without_confirmation() {
let r = resolve("saas", None).unwrap();
assert!(matches!(r, Resolved::Builtin(_)));
assert!(!r.requires_confirmation());
}
#[test]
fn emit_rel_path_strips_single_tmpl_suffix() {
assert_eq!(emit_rel_path("Cargo.toml.tmpl"), "Cargo.toml");
assert_eq!(emit_rel_path("src/main.rs"), "src/main.rs");
assert_eq!(emit_rel_path("weird.tmpl.tmpl"), "weird.tmpl");
assert_eq!(emit_rel_path("no-suffix"), "no-suffix");
}
#[test]
fn embedded_saas_emits_a_real_cargo_toml() {
let contents = load_from_embedded(&builtin::SAAS).unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let dest = tmp.path().join("app");
let crate_name = "app".to_owned();
let vars = TemplateVars {
project_name: "app",
crate_name: &crate_name,
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
scaffold(&contents, &vars, &dest, Flags::default()).unwrap();
assert!(dest.join("Cargo.toml").is_file());
assert!(!dest.join("Cargo.toml.tmpl").exists());
let cargo = fs::read_to_string(dest.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"app\""), "got: {cargo}");
assert!(!cargo.contains("{{"), "no tokens should remain: {cargo}");
}
#[test]
fn builtin_rejects_ref() {
assert!(matches!(
resolve("saas", Some("v1")).unwrap_err(),
StarterError::RefNotApplicable
));
}
#[test]
fn local_dir_resolves_as_community() {
let tmp = tempfile::TempDir::new().unwrap();
let r = resolve(tmp.path().to_str().unwrap(), None).unwrap();
assert!(matches!(r, Resolved::LocalDir(_)));
assert!(r.requires_confirmation());
}
#[test]
fn unknown_value_resolves_as_git_shorthand() {
let r = resolve("owner/repo", None).unwrap();
match r {
Resolved::Git(src) => {
assert_eq!(src.url, "https://github.com/owner/repo.git");
assert!(r_requires_confirmation_git());
}
_ => panic!("expected git source"),
}
}
fn r_requires_confirmation_git() -> bool {
Resolved::Git(GitSource {
url: String::new(),
reference: None,
})
.requires_confirmation()
}
#[test]
fn confirm_mode_yes_proceeds() {
assert_eq!(confirm_mode(true, false), ConfirmMode::Proceed);
assert_eq!(confirm_mode(true, true), ConfirmMode::Proceed);
}
#[test]
fn confirm_mode_non_tty_without_yes_needs_flag() {
assert_eq!(confirm_mode(false, false), ConfirmMode::NeedsYesFlag);
}
#[test]
fn confirm_mode_tty_without_yes_prompts() {
assert_eq!(confirm_mode(false, true), ConfirmMode::Prompt);
}
#[test]
fn builtin_list_includes_saas() {
assert!(builtin::BUILTINS.iter().any(|b| b.name == "saas"));
}
fn fixture_minimal() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/starters/minimal")
}
#[test]
fn scaffold_local_fixture_substitutes_and_copies_verbatim() {
let contents = load_from_dir(&fixture_minimal()).unwrap();
assert_eq!(contents.manifest.starter.name, "minimal");
let tmp = tempfile::TempDir::new().unwrap();
let dest = tmp.path().join("acme-app");
let crate_name = "acme_app".to_owned();
let vars = TemplateVars {
project_name: "acme-app",
crate_name: &crate_name,
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
scaffold(&contents, &vars, &dest, Flags::default()).unwrap();
let cargo = fs::read_to_string(dest.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"acme_app\""), "got: {cargo}");
let main_rs = fs::read_to_string(dest.join("src/main.rs")).unwrap();
assert!(main_rs.contains("acme-app"));
assert!(
!main_rs.contains("{{"),
"no tokens should remain: {main_rs}"
);
assert!(!dest.join(MANIFEST_FILE).exists());
let original = fs::read(fixture_minimal().join("static/logo.bin")).unwrap();
let copied = fs::read(dest.join("static/logo.bin")).unwrap();
assert_eq!(
copied, original,
"verbatim binary asset must not be altered"
);
assert!(
std::str::from_utf8(&copied).is_err(),
"fixture asset is binary"
);
}
#[test]
fn missing_manifest_is_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join("file.txt"), "no manifest here").unwrap();
assert!(matches!(
load_from_dir(tmp.path()).unwrap_err(),
StarterError::Manifest(_)
));
}
fn assert_starter_matches_example(starter: &'static Dir<'static>, project_name: &'static str) {
let contents = load_from_embedded(starter).unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let dest = tmp.path().join(project_name);
let crate_name = project_name.replace('-', "_");
let vars = TemplateVars {
project_name,
crate_name: &crate_name,
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
scaffold(&contents, &vars, &dest, Flags::default()).unwrap();
let example_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../examples")
.join(project_name);
for file in &contents.files {
let emitted = emit_rel_path(&file.rel_path);
if emitted == "Cargo.toml" {
continue;
}
let example_path = example_root.join(emitted);
let rendered = fs::read(dest.join(emitted)).unwrap();
let committed = fs::read(&example_path).unwrap_or_else(|_| {
panic!(
"examples/{project_name} is missing {emitted} — regenerate it from the starter"
)
});
assert_eq!(
rendered, committed,
"drift between embedded {project_name} starter and examples/{project_name} at {emitted}"
);
}
let mut starter_paths: std::collections::BTreeSet<String> = contents
.files
.iter()
.map(|f| emit_rel_path(&f.rel_path).to_owned())
.collect();
starter_paths.insert("Cargo.toml".to_owned());
let mut stack = vec![example_root.clone()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let name = entry.file_name();
if name == "target" || name == ".git" {
continue;
}
if path.is_dir() {
stack.push(path);
continue;
}
let rel = path
.strip_prefix(&example_root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if rel == "static/css/app.css"
|| rel == "tests/system/smoke.rs"
|| (rel.starts_with("tests/") && rel.ends_with("_profile.rs"))
{
continue;
}
assert!(
starter_paths.contains(&rel),
"examples/{project_name} has {rel} which the embedded starter does not produce"
);
}
}
}
fn assert_no_hardcoded_crate_name(starter: &'static Dir<'static>, starter_name: &str) {
let contents = load_from_embedded(starter).unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let dest = tmp.path().join("acme-site");
let vars = TemplateVars {
project_name: "acme-site",
crate_name: "acme_site",
autumn_version: env!("CARGO_PKG_VERSION"),
rust_version: option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
};
scaffold(&contents, &vars, &dest, Flags::default()).unwrap();
let needle = format!("{starter_name}::");
for file in &contents.files {
let emitted = emit_rel_path(&file.rel_path);
if std::path::Path::new(emitted).extension() != Some(std::ffi::OsStr::new("rs")) {
continue;
}
let rendered = fs::read_to_string(dest.join(emitted)).unwrap();
for (line_no, line) in rendered.lines().enumerate() {
let Some(at) = line.find(&needle) else {
continue;
};
let preceded_by_ident = line[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == ':');
assert!(
preceded_by_ident,
"{emitted}:{} names the crate `{starter_name}` after rendering as \
`acme-site`; template it as {{{{crate_name}}}}:: or the scaffolded \
project will not compile:\n {line}",
line_no + 1
);
}
}
}
#[test]
fn saas_starter_has_no_hardcoded_crate_name() {
assert_no_hardcoded_crate_name(&builtin::SAAS, "saas");
}
#[test]
fn cms_starter_has_no_hardcoded_crate_name() {
assert_no_hardcoded_crate_name(&builtin::CMS, "cms");
}
#[test]
fn embedded_saas_matches_example_saas() {
assert_starter_matches_example(&builtin::SAAS, "saas");
}
#[test]
fn embedded_cms_matches_example_cms() {
assert_starter_matches_example(&builtin::CMS, "cms");
}
}