use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Parser, ValueEnum};
use include_dir::{include_dir, Dir};
use crate::ui;
static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
#[derive(Debug, Clone, ValueEnum)]
pub enum InitTemplate {
Default,
Empty,
}
#[derive(Debug, Parser)]
pub struct InitArgs {
pub name: String,
#[arg(long, value_enum, default_value_t = InitTemplate::Default)]
pub template: InitTemplate,
#[arg(long)]
pub path: Option<PathBuf>,
}
pub fn run(args: InitArgs) -> Result<()> {
ui::header(
"portaki init",
"Scaffold a module crate — buildable, runnable in the sandbox, publishable.",
);
let dest = args
.path
.clone()
.unwrap_or_else(|| PathBuf::from(&args.name));
let template_dir = TEMPLATES
.get_dir(directory(&args.template))
.with_context(|| {
format!(
"template missing from this build: {}",
label(&args.template)
)
})?;
if dest.exists() && !dest.is_dir() {
bail!("destination is not a directory: {}", dest.display());
}
let clashes = clashes(&dest, &planned_paths(template_dir));
if !clashes.is_empty() {
bail!(
"{} already has {} — move them aside, or scaffold elsewhere",
dest.display(),
listed(&clashes)
);
}
let scaffolding = ui::step(format!(
"scaffolding {} from the {} template",
args.name,
label(&args.template)
));
copy_template(template_dir, &dest, &args.name)?;
scaffolding.done(format!("created {}", dest.display()));
describe(&args.template);
let mut next: Vec<(&str, &str)> = Vec::new();
let cd = format!("cd {}", dest.display());
if dest != Path::new(".") {
next.push((cd.as_str(), "everything below runs from the module root"));
}
next.push((
"portaki build",
"compile to wasm32 and assemble the manifest",
));
next.push((
"portaki dev --watch",
"run it in the hosted sandbox on every save",
));
ui::next(&next);
ui::blank();
Ok(())
}
fn describe(template: &InitTemplate) {
let mut rows = vec![
("src/lib.rs", "the module — entity, capability, manifest"),
("src/ids.rs", "typed surface and operation ids"),
];
if matches!(template, InitTemplate::Default) {
rows.push(("src/host/", "surfaces the host dashboard renders"));
rows.push(("src/guest/", "surfaces the guest booklet renders"));
rows.push((
"src/commands.rs",
"updateConfig — what the sheet's Save posts",
));
rows.push((
"src/queries.rs",
"getConfig — what the dashboard reads back",
));
rows.push(("src/config.rs", "the settings blob, in the module's own KV"));
rows.push(("tests/", "the mock host, the settings round-tripped"));
}
rows.push((
"i18n/*.json",
"one file per locale — the keys ids.rs points at",
));
rows.push(("Cargo.toml", "wired to portaki-sdk, cdylib for wasm32"));
rows.push((
"build.rs",
"no build step — it exists so cargo gives the macros an OUT_DIR",
));
rows.push((
"portaki.module.json",
"the catalogue entry — name, author, surfaces, permissions",
));
ui::list("what you got", &rows);
}
fn label(template: &InitTemplate) -> &'static str {
match template {
InitTemplate::Default => "default",
InitTemplate::Empty => "empty",
}
}
fn listed(paths: &[PathBuf]) -> String {
const SHOWN: usize = 3;
let named = paths
.iter()
.take(SHOWN)
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
match paths.len().saturating_sub(SHOWN) {
0 => named,
rest => format!("{named} and {rest} more"),
}
}
fn planned_paths(source: &Dir<'_>) -> Vec<PathBuf> {
let root = source.path();
let mut planned = Vec::new();
collect_paths(source, root, &mut planned);
planned
}
fn collect_paths(source: &Dir<'_>, root: &Path, planned: &mut Vec<PathBuf>) {
for file in source.files() {
let relative = file.path().strip_prefix(root).unwrap_or(file.path());
planned.push(rendered_path(relative));
}
for child in source.dirs() {
collect_paths(child, root, planned);
}
}
fn rendered_path(relative: &Path) -> PathBuf {
let Some(name) = relative
.file_name()
.map(|name| name.to_string_lossy().to_string())
else {
return relative.to_path_buf();
};
let stripped = name.strip_suffix(".template").unwrap_or(&name);
relative.with_file_name(stripped)
}
fn clashes(dest: &Path, planned: &[PathBuf]) -> Vec<PathBuf> {
planned
.iter()
.filter(|path| dest.join(path).exists())
.cloned()
.collect()
}
fn crate_name(module_name: &str) -> String {
module_name.replace('-', "_")
}
fn directory(template: &InitTemplate) -> &'static str {
match template {
InitTemplate::Default => "default-module",
InitTemplate::Empty => "empty-module",
}
}
fn copy_template(source: &Dir<'_>, dest: &Path, module_name: &str) -> Result<()> {
fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
for file in source.files() {
let name = file
.path()
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let name = name.strip_suffix(".template").unwrap_or(&name).to_string();
let target = dest.join(&name);
let text = file
.contents_utf8()
.with_context(|| format!("template {} is not UTF-8", file.path().display()))?;
let rendered = text
.replace("{{MODULE_NAME}}", module_name)
.replace("{{CRATE_NAME}}", &crate_name(module_name))
.replace("{{SDK_VERSION}}", env!("CARGO_PKG_VERSION"));
fs::write(&target, rendered).with_context(|| format!("write {}", target.display()))?;
}
for child in source.dirs() {
let name = child
.path()
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
copy_template(child, &dest.join(name), module_name)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_template_is_embedded() {
for template in [InitTemplate::Default, InitTemplate::Empty] {
let dir = TEMPLATES
.get_dir(directory(&template))
.expect("template embedded");
assert!(dir.files().count() + dir.dirs().count() > 0);
}
}
#[test]
fn what_a_scaffold_would_write_is_known_before_it_writes() {
let planned = planned_paths(TEMPLATES.get_dir("default-module").expect("template"));
assert!(planned.contains(&PathBuf::from("Cargo.toml")));
assert!(planned.contains(&PathBuf::from("portaki.module.json")));
assert!(planned.contains(&PathBuf::from("src/host/mod.rs")));
assert!(planned.contains(&PathBuf::from(".cargo/config.toml")));
assert!(!planned
.iter()
.any(|path| path.to_string_lossy().ends_with(".template")));
}
#[test]
fn a_long_clash_is_three_names_and_a_count() {
let paths: Vec<PathBuf> = ["Cargo.toml", "build.rs", "src/lib.rs", "i18n/en-US.json"]
.iter()
.map(PathBuf::from)
.collect();
assert_eq!(listed(&paths[..2]), "Cargo.toml, build.rs");
assert_eq!(
listed(&paths),
"Cargo.toml, build.rs, src/lib.rs and 1 more"
);
}
#[test]
fn an_existing_directory_is_fine_until_a_file_would_be_overwritten() {
let dest = std::env::temp_dir().join(format!("portaki-clash-{}", std::process::id()));
let _ = fs::remove_dir_all(&dest);
fs::create_dir_all(dest.join(".git")).expect("a clone");
let planned = planned_paths(TEMPLATES.get_dir("default-module").expect("template"));
assert!(clashes(&dest, &planned).is_empty());
fs::write(dest.join("Cargo.toml"), "[package]").expect("an existing crate");
assert_eq!(clashes(&dest, &planned), vec![PathBuf::from("Cargo.toml")]);
fs::remove_dir_all(&dest).ok();
}
#[test]
fn a_kebab_case_module_becomes_a_snake_case_crate() {
assert_eq!(crate_name("pre-arrival-form"), "pre_arrival_form");
assert_eq!(crate_name("trmnl"), "trmnl");
}
#[test]
fn a_scaffold_has_what_build_and_dev_read() {
for template in [InitTemplate::Default, InitTemplate::Empty] {
let dir = TEMPLATES.get_dir(directory(&template)).expect("template");
let names: Vec<String> = dir
.files()
.map(|file| {
file.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
})
.collect();
assert!(names.iter().any(|name| name == "build.rs"), "{names:?}");
assert!(
names
.iter()
.any(|name| name == "portaki.module.json.template"),
"{names:?}"
);
let cargo_config = dir
.get_file(format!(
"{}/.cargo/config.toml.template",
directory(&template)
))
.expect("wasm rustflags");
assert!(cargo_config
.contents_utf8()
.unwrap_or_default()
.contains("getrandom_backend"));
}
}
#[test]
fn a_scaffolded_module_carries_its_name_and_the_sdk_version() {
let dest = std::env::temp_dir().join(format!("portaki-init-{}", std::process::id()));
let _ = fs::remove_dir_all(&dest);
copy_template(
TEMPLATES.get_dir("default-module").expect("template"),
&dest,
"concierge",
)
.expect("scaffold");
let cargo = fs::read_to_string(dest.join("Cargo.toml")).expect("Cargo.toml written");
assert!(cargo.contains("name = \"concierge\""));
assert!(cargo.contains(env!("CARGO_PKG_VERSION")));
assert!(!cargo.contains("{{"));
assert!(dest.join("src/host/mod.rs").exists());
assert!(dest.join(".cargo/config.toml").exists());
assert!(!dest.join("Cargo.toml.template").exists());
let integration =
fs::read_to_string(dest.join("tests/integration.rs")).expect("tests written");
assert!(integration.contains("use concierge::{"));
assert!(!integration.contains("{{"));
let catalog =
fs::read_to_string(dest.join("portaki.module.json")).expect("catalogue written");
assert!(catalog.contains("\"id\": \"concierge\""));
fs::remove_dir_all(&dest).ok();
}
}