use include_dir::{include_dir, Dir};
use minijinja::{context, Environment};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::io::Read as IoRead;
use std::path::Path;
use std::process::Command;
const TEMPLATE_URL: &str =
"https://github.com/cdcgov/cfa-simulator/archive/refs/heads/latest.tar.gz";
const TEMPLATE_PREFIX: &str = "cfa-simulator-latest/cfasim/src/templates/";
const SHARED_DIR: &str = "_shared";
static TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/templates");
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Template {
Python,
Rust,
}
impl Template {
fn dir_name(&self) -> &str {
match self {
Template::Python => "python",
Template::Rust => "rust",
}
}
fn runtime(&self) -> &str {
match self {
Template::Python => "python",
Template::Rust => "rust",
}
}
}
impl fmt::Display for Template {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Template::Python => write!(f, "Python"),
Template::Rust => write!(f, "Rust (WASM)"),
}
}
}
type TemplateFiles = HashMap<String, String>;
fn download_templates(template: &Template) -> Result<TemplateFiles, Box<dyn std::error::Error>> {
let response = ureq::get(TEMPLATE_URL).call()?;
let reader = response.into_body().into_reader();
let decoder = flate2::read::GzDecoder::new(reader);
let mut archive = tar::Archive::new(decoder);
let shared_prefix = format!("{}{}/", TEMPLATE_PREFIX, SHARED_DIR);
let template_prefix = format!("{}{}/", TEMPLATE_PREFIX, template.dir_name());
let mut shared = TemplateFiles::new();
let mut specific = TemplateFiles::new();
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_string_lossy().to_string();
if !entry.header().entry_type().is_file() {
continue;
}
let (bucket, prefix) = if path.starts_with(&shared_prefix) {
(&mut shared, &shared_prefix)
} else if path.starts_with(&template_prefix) {
(&mut specific, &template_prefix)
} else {
continue;
};
let relative = path.strip_prefix(prefix).unwrap().to_string();
eprintln!(" fetched: {}", relative);
let mut content = String::new();
entry.read_to_string(&mut content)?;
bucket.insert(relative, content);
}
if specific.is_empty() {
return Err("No template files found in the downloaded archive".into());
}
Ok(merge(shared, specific))
}
fn collect_files(
dir: &Dir,
base: &Path,
out: &mut TemplateFiles,
) -> Result<(), Box<dyn std::error::Error>> {
for file in dir.files() {
let relative = file
.path()
.strip_prefix(base)
.unwrap()
.to_string_lossy()
.to_string();
let content = file
.contents_utf8()
.ok_or_else(|| format!("Template file is not valid UTF-8: {}", relative))?;
out.insert(relative, content.to_string());
}
for sub in dir.dirs() {
collect_files(sub, base, out)?;
}
Ok(())
}
fn embedded_templates(template: &Template) -> Result<TemplateFiles, Box<dyn std::error::Error>> {
let mut shared = TemplateFiles::new();
if let Some(shared_dir) = TEMPLATES.get_dir(SHARED_DIR) {
collect_files(shared_dir, shared_dir.path(), &mut shared)?;
}
let template_subdir = TEMPLATES
.get_dir(template.dir_name())
.ok_or_else(|| format!("Embedded template directory not found for {}", template))?;
let mut specific = TemplateFiles::new();
collect_files(template_subdir, template_subdir.path(), &mut specific)?;
if specific.is_empty() {
return Err("No template files found in embedded directory".into());
}
Ok(merge(shared, specific))
}
fn merge(mut shared: TemplateFiles, specific: TemplateFiles) -> TemplateFiles {
shared.extend(specific);
shared
}
fn to_module_name(name: &str) -> String {
name.replace('-', "_")
}
fn build_env() -> Environment<'static> {
let mut env = Environment::new();
env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
env.set_keep_trailing_newline(true);
env
}
fn render(
env: &Environment,
template: &str,
name: &str,
runtime: &str,
) -> Result<String, minijinja::Error> {
env.render_str(
template,
context! {
project_name => name,
module_name => to_module_name(name),
cfasim_version => env!("CARGO_PKG_VERSION"),
runtime => runtime,
},
)
}
fn write_file(base: &Path, relative: &str, content: &str) -> std::io::Result<()> {
let path = base.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)
}
fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".into());
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err("Name must contain only letters, numbers, hyphens, and underscores".into());
}
Ok(())
}
fn scaffold(
project_dir: &Path,
name: &str,
template: &Template,
local: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let templates = if local {
embedded_templates(template)?
} else {
download_templates(template)?
};
if project_dir.exists() && project_dir.read_dir()?.next().is_some() {
return Err(format!(
"Directory '{}' already exists and is not empty",
project_dir.display()
)
.into());
}
let env = build_env();
let runtime = template.runtime();
for (relative_path, content) in &templates {
let output_path = render(&env, relative_path, name, runtime)
.map_err(|e| format!("rendering path {relative_path}: {e}"))?;
let rendered = render(&env, content, name, runtime)
.map_err(|e| format!("rendering {relative_path}: {e}"))?;
write_file(project_dir, &output_path, &rendered)?;
}
Ok(())
}
fn init_git_repo(project_dir: &Path) -> bool {
if project_dir.join(".git").exists() {
return false;
}
let status = Command::new("git")
.arg("init")
.arg("--quiet")
.current_dir(project_dir)
.status();
matches!(status, Ok(s) if s.success())
}
fn resolve_directory(
directory: &str,
) -> Result<(std::path::PathBuf, String), Box<dyn std::error::Error>> {
let path = Path::new(directory);
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else if directory == "." {
std::env::current_dir()?
} else {
std::env::current_dir()?.join(path)
};
let name = abs_path
.file_name()
.ok_or("Cannot derive project name from directory")?
.to_str()
.ok_or("Directory name is not valid UTF-8")?
.to_string();
validate_name(&name).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
Ok((abs_path, name))
}
pub fn run(
dir: Option<String>,
template: Option<Template>,
local: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let interactive = dir.is_none();
if interactive {
cliclack::intro("Create a new cfasim project")?;
}
let dir = match dir {
Some(d) => d,
None => {
let input: String = cliclack::input("Project directory")
.placeholder("./my-project")
.validate(|input: &String| {
if input.is_empty() {
Err("Directory cannot be empty")
} else {
Ok(())
}
})
.interact()?;
input
}
};
let (project_dir, name) = resolve_directory(&dir)?;
let template = match template {
Some(t) => t,
None => cliclack::select("Template")
.item(
Template::Python,
"Python",
"Python package built as a wheel for Pyodide",
)
.item(
Template::Rust,
"Rust",
"Compiles to WebAssembly via wasm-bindgen",
)
.interact()?,
};
let spinner_msg = if local {
"Scaffolding project from local templates..."
} else {
"Downloading templates and scaffolding project..."
};
let spinner = interactive.then(|| {
let s = cliclack::spinner();
s.start(spinner_msg);
s
});
scaffold(&project_dir, &name, &template, local)?;
let git_initialized = init_git_repo(&project_dir);
if let Some(spinner) = spinner {
spinner.stop(if git_initialized {
"Project created (git repo initialized)"
} else {
"Project created"
});
} else {
println!("Created project: {}", name);
if git_initialized {
println!("Initialized git repository");
}
}
if interactive {
println!();
if let Err(e) = crate::tools::run_checks_only(true) {
eprintln!("Note: tool check skipped: {e}");
}
println!();
}
println!("Next steps:\n cd {dir}\n pnpm install\n pnpm run dev");
Ok(())
}