feat(pack): add weft-pack package validator tool

New crate: weft-pack — command-line tool for validating WEFT application
package directories against the app-package-format spec.

src/main.rs:
- check <dir>: loads wapp.toml, validates app ID format, verifies
  package.name is non-empty and <=64 chars, confirms runtime.module and
  ui.entry files exist. Prints 'OK' on success or the list of errors.
- info <dir>: prints all manifest fields to stdout.
- load_manifest(): reads and parses wapp.toml with toml crate.
- is_valid_app_id(): enforces reverse-domain convention (>=3 components,
  each starting with a lowercase letter, digits allowed, no hyphens or
  uppercase).

Tests (5):
- app_id_valid: accepts well-formed reverse-domain IDs.
- app_id_invalid: rejects two-component, uppercase, hyphen, empty IDs.
- check_package_missing_manifest: error when wapp.toml is absent.
- check_package_valid: full happy-path with real temp files.
- check_package_invalid_app_id: error on a hyphenated app ID.

New deps: toml 0.8, serde 1 (derive).
Added weft-pack to workspace Cargo.toml; wsl-test.sh extended.
This commit is contained in:
Marco Allegretti 2026-03-11 09:40:34 +01:00
parent 1e4ced9a39
commit ffae164747
5 changed files with 333 additions and 3 deletions

65
Cargo.lock generated
View file

@ -1310,7 +1310,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.4+spec-1.1.0",
]
[[package]]
@ -1595,6 +1595,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.0.4"
@ -1786,7 +1795,7 @@ dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"toml 0.9.12+spec-1.1.0",
"version-compare",
]
@ -1897,6 +1906,18 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@ -1905,13 +1926,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"serde_spanned 1.0.4",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@ -1930,6 +1960,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
@ -1951,6 +1995,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
@ -2429,6 +2479,15 @@ dependencies = [
"wayland-server",
]
[[package]]
name = "weft-pack"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"toml 0.8.23",
]
[[package]]
name = "weft-runtime"
version = "0.1.0"

View file

@ -3,6 +3,7 @@ members = [
"crates/weft-appd",
"crates/weft-build-meta",
"crates/weft-compositor",
"crates/weft-pack",
"crates/weft-runtime",
"crates/weft-servo-shell",
]

View file

@ -0,0 +1,14 @@
[package]
name = "weft-pack"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[[bin]]
name = "weft-pack"
path = "src/main.rs"
[dependencies]
anyhow = "1.0"
serde = { version = "1", features = ["derive"] }
toml = "0.8"

View file

@ -0,0 +1,252 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Manifest {
package: PackageMeta,
runtime: RuntimeMeta,
ui: UiMeta,
}
#[derive(Debug, Deserialize)]
struct PackageMeta {
id: String,
name: String,
version: String,
description: Option<String>,
author: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RuntimeMeta {
module: String,
}
#[derive(Debug, Deserialize)]
struct UiMeta {
entry: String,
}
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("check") => {
let dir = args.get(2).context("usage: weft-pack check <dir>")?;
let result = check_package(Path::new(dir))?;
println!("{result}");
}
Some("info") => {
let dir = args.get(2).context("usage: weft-pack info <dir>")?;
let manifest = load_manifest(Path::new(dir))?;
print_info(&manifest);
}
_ => {
eprintln!("usage:");
eprintln!(" weft-pack check <dir> validate a package directory");
eprintln!(" weft-pack info <dir> print package metadata");
std::process::exit(1);
}
}
Ok(())
}
fn check_package(dir: &Path) -> anyhow::Result<String> {
let mut errors: Vec<String> = Vec::new();
let manifest = match load_manifest(dir) {
Ok(m) => Some(m),
Err(e) => {
errors.push(format!("wapp.toml: {e}"));
None
}
};
if let Some(ref m) = manifest {
if !is_valid_app_id(&m.package.id) {
errors.push(format!(
"package.id '{}' does not match required pattern",
m.package.id
));
}
if m.package.name.is_empty() {
errors.push("package.name is empty".into());
}
if m.package.name.len() > 64 {
errors.push(format!(
"package.name exceeds 64 characters ({})",
m.package.name.len()
));
}
let wasm_path = dir.join(&m.runtime.module);
if !wasm_path.exists() {
errors.push(format!(
"runtime.module '{}' not found",
wasm_path.display()
));
}
let ui_path = dir.join(&m.ui.entry);
if !ui_path.exists() {
errors.push(format!("ui.entry '{}' not found", ui_path.display()));
}
}
if errors.is_empty() {
Ok("OK".into())
} else {
Err(anyhow::anyhow!("{}", errors.join("\n")))
}
}
fn load_manifest(dir: &Path) -> anyhow::Result<Manifest> {
let manifest_path = dir.join("wapp.toml");
let text = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("read {}", manifest_path.display()))?;
toml::from_str(&text).with_context(|| format!("parse {}", manifest_path.display()))
}
fn print_info(m: &Manifest) {
println!("id: {}", m.package.id);
println!("name: {}", m.package.name);
println!("version: {}", m.package.version);
if let Some(ref d) = m.package.description {
println!("desc: {d}");
}
if let Some(ref a) = m.package.author {
println!("author: {a}");
}
println!("module: {}", m.runtime.module);
println!("ui: {}", m.ui.entry);
}
fn is_valid_app_id(id: &str) -> bool {
let parts: Vec<&str> = id.split('.').collect();
if parts.len() < 3 {
return false;
}
parts.iter().all(|p| {
!p.is_empty()
&& p.chars()
.next()
.map(|c| c.is_ascii_lowercase())
.unwrap_or(false)
&& p.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}
fn _resolve_store_roots() -> Vec<PathBuf> {
if let Ok(explicit) = std::env::var("WEFT_APP_STORE") {
return vec![PathBuf::from(explicit)];
}
let mut roots = Vec::new();
if let Ok(home) = std::env::var("HOME") {
roots.push(
PathBuf::from(home)
.join(".local")
.join("share")
.join("weft")
.join("apps"),
);
}
roots.push(PathBuf::from("/usr/share/weft/apps"));
roots
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_id_valid() {
assert!(is_valid_app_id("com.example.notes"));
assert!(is_valid_app_id("org.weft.calculator"));
assert!(is_valid_app_id("io.github.username.app"));
}
#[test]
fn app_id_invalid() {
assert!(!is_valid_app_id("com.example"));
assert!(!is_valid_app_id("Com.example.notes"));
assert!(!is_valid_app_id("com.example.notes-app"));
assert!(!is_valid_app_id("com..example.notes"));
assert!(!is_valid_app_id(""));
assert!(!is_valid_app_id("com.Example.notes"));
}
#[test]
fn check_package_missing_manifest() {
let tmp = std::env::temp_dir().join("weft_pack_test_empty");
let _ = std::fs::create_dir_all(&tmp);
let result = check_package(&tmp);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn check_package_valid() {
use std::fs;
let tmp = std::env::temp_dir().join("weft_pack_test_valid");
let ui_dir = tmp.join("ui");
let _ = fs::create_dir_all(&ui_dir);
fs::write(tmp.join("app.wasm"), b"\0asm\x01\0\0\0").unwrap();
fs::write(ui_dir.join("index.html"), b"<!DOCTYPE html>").unwrap();
fs::write(
tmp.join("wapp.toml"),
r#"
[package]
id = "com.example.test"
name = "Test App"
version = "1.0.0"
[runtime]
module = "app.wasm"
[ui]
entry = "ui/index.html"
"#,
)
.unwrap();
let result = check_package(&tmp);
assert!(result.is_ok(), "{result:?}");
assert_eq!(result.unwrap(), "OK");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn check_package_invalid_app_id() {
use std::fs;
let tmp = std::env::temp_dir().join("weft_pack_test_invalid_id");
let ui_dir = tmp.join("ui");
let _ = fs::create_dir_all(&ui_dir);
fs::write(tmp.join("app.wasm"), b"\0asm").unwrap();
fs::write(ui_dir.join("index.html"), b"").unwrap();
fs::write(
tmp.join("wapp.toml"),
r#"
[package]
id = "bad-id"
name = "Bad"
version = "0.1.0"
[runtime]
module = "app.wasm"
[ui]
entry = "ui/index.html"
"#,
)
.unwrap();
let result = check_package(&tmp);
assert!(result.is_err());
let _ = fs::remove_dir_all(&tmp);
}
}

View file

@ -34,5 +34,9 @@ echo ""
echo "==> cargo test -p weft-runtime"
cargo test -p weft-runtime 2>&1
echo ""
echo "==> cargo test -p weft-pack"
cargo test -p weft-pack 2>&1
echo ""
echo "ALL DONE"