waos-cli 0.1.0

cargo waos: writes a new program, builds a program as a component, runs it in the WaOS on this machine, and packages it as a signed .wapk.
//! `cargo waos new`: a new program in a folder of its name, a Waft app over
//! `waft-embedder-wasdk` in one widget kit's look, as `cargo waft new` writes a Waft app:
//!
//! ```text
//! hello/
//!   Cargo.toml            the SDK, Waft and the kit by version, and [package.metadata.waos]
//!   rust-toolchain.toml   WaOS's toolchain, with wasm32-wasip2
//!   icon.png              1024 pixels on a side, which a package carries
//!   src/main.rs           the app
//!   .gitignore
//! ```
//!
//! The invariant: a new program is run by `cargo waos run` and packaged by `cargo waos package`
//! as it is written, with no edits: its metadata holds every field `package` reads, its icon
//! passes `waos-package`'s check, and its toolchain is WaOS's own. `tests/new.rs` builds each
//! template as a component.

mod icon;
mod manifest;
mod program_name;
mod template;

use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

use self::program_name::ProgramName;
pub use self::template::Template;

/// The toolchain a program is built with: WaOS's own, the nightly Waft needs at its pinned date,
/// with `wasm32-wasip2`, so rustup installs both by itself.
const TOOLCHAIN: &str = r#"# Nightly because Waft needs `arbitrary_self_types`, pinned to the date WaOS is built with.
# `wasm32-wasip2` is what a program is built for.
[toolchain]
channel = "nightly-2026-09-02"
components = ["rustfmt", "clippy"]
targets = ["wasm32-wasip2"]
"#;

/// What git leaves out: cargo's build.
const GITIGNORE: &str = "/target\n";

/// Writes a new program named `name`, with `template`'s kit, in a folder of that name in
/// `parent`, and answers the folder. A name that is not a package name, or a folder there
/// already, is refused before anything is written.
pub fn create(name: &str, template: Template, parent: &Path) -> Result<PathBuf> {
    let name: ProgramName = name.parse()?;
    let folder = parent.join(name.as_str());
    make_folder(&folder)?;
    write_program(&folder, &name, template)?;
    Ok(folder)
}

/// Makes `folder`, which must not be there yet.
fn make_folder(folder: &Path) -> Result<()> {
    match fs::create_dir(folder) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == ErrorKind::AlreadyExists => {
            bail!("{} already exists", folder.display())
        }
        Err(error) => Err(error).with_context(|| format!("making {}", folder.display())),
    }
}

/// Writes the program's files into `folder`.
fn write_program(folder: &Path, name: &ProgramName, template: Template) -> Result<()> {
    write(folder, "Cargo.toml", manifest::cargo_toml(name, template))?;
    write(folder, "rust-toolchain.toml", TOOLCHAIN)?;
    write(folder, icon::FILE, icon::placeholder())?;
    write(folder, ".gitignore", GITIGNORE)?;
    let source = folder.join("src");
    fs::create_dir(&source).with_context(|| format!("making {}", source.display()))?;
    write(folder, "src/main.rs", template.main_rs())
}

/// Writes `contents` to the file `file` names in `folder`.
fn write(folder: &Path, file: &str, contents: impl AsRef<[u8]>) -> Result<()> {
    let path = folder.join(file);
    fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
}

#[cfg(test)]
mod tests {
    use cargo_metadata::MetadataCommand;
    use waos_package::{Manifest, SigningKey};

    use super::*;
    use crate::metadata::WaosMetadata;

    #[test]
    fn a_new_program_has_its_manifest_toolchain_icon_ignores_and_main() {
        let scratch = tempfile::tempdir().unwrap();
        let folder = create("hello", Template::Winui, scratch.path()).unwrap();
        assert_eq!(folder, scratch.path().join("hello"));
        let mut written: Vec<String> = walk(&folder)
            .iter()
            .map(|path| path.strip_prefix(&folder).unwrap().display().to_string())
            .collect();
        written.sort();
        assert_eq!(
            written,
            [
                ".gitignore",
                "Cargo.toml",
                "icon.png",
                "rust-toolchain.toml",
                "src/main.rs"
            ]
        );
        let main_rs = fs::read_to_string(folder.join("src/main.rs")).unwrap();
        assert_eq!(main_rs, Template::Winui.main_rs());
    }

    #[test]
    fn each_template_runs_a_waft_app_in_its_kit() {
        for (template, app) in [
            (Template::Cupertino, "CupertinoApp"),
            (Template::Winui, "WidgetsApp"),
        ] {
            let main_rs = template.main_rs();
            assert!(main_rs.contains("#[wasdk::main]\nasync fn main()"));
            assert!(main_rs.contains("waft_embedder_wasdk::run("));
            assert!(main_rs.contains(app), "{template:?}");
            assert!(main_rs.contains(&template.kit().replace('-', "_")));
        }
    }

    #[test]
    fn a_folder_there_already_is_left_as_it_is() {
        let scratch = tempfile::tempdir().unwrap();
        let folder = scratch.path().join("hello");
        fs::create_dir(&folder).unwrap();
        fs::write(folder.join("notes.txt"), "mine").unwrap();
        let error = create("hello", Template::Cupertino, scratch.path()).unwrap_err();
        assert!(error.to_string().contains("already exists"), "{error}");
        assert_eq!(walk(&folder), [folder.join("notes.txt")]);
    }

    #[test]
    fn a_name_that_is_not_a_package_name_writes_nothing() {
        let scratch = tempfile::tempdir().unwrap();
        let error = create("My App", Template::Cupertino, scratch.path()).unwrap_err();
        assert!(error.to_string().contains("not a package name"), "{error}");
        assert!(walk(scratch.path()).is_empty());
    }

    /// What `cargo waos package` reads of a new program, through cargo as it reads it, makes a
    /// package: its metadata and version give a manifest, and its icon passes the check.
    #[test]
    fn a_new_program_has_what_a_package_needs() {
        let scratch = tempfile::tempdir().unwrap();
        let folder = create("my-app", Template::Cupertino, scratch.path()).unwrap();
        let metadata = MetadataCommand::new()
            .manifest_path(folder.join("Cargo.toml"))
            .no_deps()
            .exec()
            .unwrap();
        let package = metadata.root_package().unwrap();
        let waos = WaosMetadata::of(package).unwrap();
        assert_eq!(waos.id.as_str(), "dev.example.myapp");
        let manifest = Manifest::new(waos.id, waos.name, package.version.clone()).unwrap();
        let component = scratch.path().join("program.wasm");
        fs::write(&component, b"\0asm").unwrap();
        let written = scratch.path().join("my-app.wapk");
        waos_package::write(
            &written,
            &manifest,
            &component,
            &waos.icon,
            &SigningKey::generate(),
        )
        .unwrap();
    }

    #[test]
    fn the_toolchain_is_waos_own() {
        let waos = concat!(env!("CARGO_MANIFEST_DIR"), "/../../rust-toolchain.toml");
        let waos: toml::Table = fs::read_to_string(waos).unwrap().parse().unwrap();
        let program: toml::Table = TOOLCHAIN.parse().unwrap();
        assert_eq!(program, waos);
    }

    /// Every file under `folder`, at any depth.
    fn walk(folder: &Path) -> Vec<PathBuf> {
        let mut files = Vec::new();
        for entry in fs::read_dir(folder).unwrap() {
            let path = entry.unwrap().path();
            if path.is_dir() {
                files.extend(walk(&path));
            } else {
                files.push(path);
            }
        }
        files
    }
}