portaki-cli 5.1.0

Portaki module CLI (portaki) — init, build, lint, test, and OCI publish
//! `portaki init` — scaffold a module from templates.

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;

/// The scaffolding, compiled into the binary.
///
/// Read from disk, it resolved against this crate's source directory — a path that exists in a
/// checkout of this repository and nowhere else, so `cargo install portaki-cli` produced a
/// command that could not scaffold anything.
static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");

#[derive(Debug, Clone, ValueEnum)]
/// Template kind for `portaki init`.
pub enum InitTemplate {
    /// Default module with entity, surfaces, and i18n bundles.
    Default,
    /// Minimal empty module skeleton.
    Empty,
}

#[derive(Debug, Parser)]
/// Arguments for `portaki init`.
pub struct InitArgs {
    /// Module name (kebab-case recommended).
    pub name: String,
    /// Template to use.
    #[arg(long, value_enum, default_value_t = InitTemplate::Default)]
    pub template: InitTemplate,
    /// Output directory (defaults to `./{name}`).
    #[arg(long)]
    pub path: Option<PathBuf>,
}

/// Runs `portaki init`.
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());
    }

    // A cloned repository is the usual starting point — the directory is there, and holds a
    // `.git` and maybe a licence. Only a file the scaffold would overwrite is a reason to stop.
    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());
    // Scaffolded in place — `cd .` would be a step that does nothing.
    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(())
}

/// Ce qui vient d'être écrit, et à quoi chaque morceau sert.
///
/// Un squelette qu'on découvre fichier par fichier se lit mal : `ids.rs` et `i18n/` n'ont de
/// sens que l'un par rapport à l'autre, et rien dans leur nom ne le dit.
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",
    }
}

/// Names a few of the clashing paths and counts the rest.
///
/// Scaffolding over an existing module clashes on every file; seventeen paths on one line say
/// less than three and a number.
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"),
    }
}

/// Every path this scaffold would write, relative to the destination.
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);
    }
}

/// The same `.template` strip `copy_template` applies, on a whole path.
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)
}

/// Which of those already exist — the only reason to refuse a directory that is already there.
fn clashes(dest: &Path, planned: &[PathBuf]) -> Vec<PathBuf> {
    planned
        .iter()
        .filter(|path| dest.join(path).exists())
        .cloned()
        .collect()
}

/// What `use` statements have to spell: cargo turns a kebab-case package into a snake_case lib.
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",
    }
}

/// Writes an embedded directory out, rendering each file on the way.
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();
        // `Cargo.toml.template` would otherwise make the scaffolded crate a cargo package the
        // moment it is written, and cargo would read it while it still holds placeholders.
        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()))?;
        // The CLI's version is the SDK it was published with: a scaffolded module compiles
        // against the SDK this command knows, not against whatever is newest.
        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::*;

    /// Both templates have to be in the binary, or `init` only fails for whoever installed it.
    #[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"));

        // Rendered names, not template ones — that is what a clash has to be checked against.
        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"
        );
    }

    /// The point of #115: a cloned repository is a directory that already exists.
    #[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");
    }

    /// What the two commands `init` recommends need in order to run at all.
    #[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();

            // `portaki build` reads emissions from OUT_DIR, which only a build script creates.
            assert!(names.iter().any(|name| name == "build.rs"), "{names:?}");
            // `portaki dev`, `ci info` and `publish` all read the catalogue manifest.
            assert!(
                names
                    .iter()
                    .any(|name| name == "portaki.module.json.template"),
                "{names:?}"
            );
            // Without the custom getrandom backend, the wasm32 build stops inside getrandom.
            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("{{"));
        // Nested and dot directories come out too — the wasm rustflags live in one of them.
        assert!(dest.join("src/host/mod.rs").exists());
        assert!(dest.join(".cargo/config.toml").exists());
        assert!(!dest.join("Cargo.toml.template").exists());
        // A crate name is not a module id: `use` statements need the snake_case spelling.
        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();
    }
}