use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Path, PathBuf};
use wasmparser::{Parser, Payload};
#[derive(Debug, Clone)]
pub struct CommandInfo {
pub name: String,
pub path: PathBuf,
}
impl CommandInfo {
pub fn var_name(&self) -> String {
self.name.replace('-', "_")
}
pub fn package_name(&self) -> String {
format!("wacli:cmd-{}", self.name)
}
}
fn is_valid_command_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
return false;
}
}
!name.ends_with('-')
}
fn extract_exports(wasm_bytes: &[u8]) -> Result<Vec<String>> {
let mut exports = Vec::new();
let parser = Parser::new(0);
let mut depth = 0;
for payload in parser.parse_all(wasm_bytes) {
let payload = payload.context("failed to parse WASM")?;
match payload {
Payload::ModuleSection { .. } | Payload::ComponentSection { .. } => {
depth += 1;
}
Payload::End(_) => {
if depth > 0 {
depth -= 1;
}
}
Payload::ComponentExportSection(reader) if depth == 0 => {
for export in reader {
let export = export.context("failed to read component export")?;
exports.push(export.name.0.to_string());
}
}
_ => {}
}
}
Ok(exports)
}
fn exports_command_interface(wasm_bytes: &[u8]) -> Result<bool> {
let exports = extract_exports(wasm_bytes)?;
Ok(exports
.iter()
.any(|e| e == "wacli:cli/command@1.0.0" || e == "wacli:cli/command" || e == "command"))
}
pub fn scan_commands(commands_dir: &Path) -> Result<Vec<CommandInfo>> {
if !commands_dir.exists() {
bail!("commands directory not found: {}", commands_dir.display());
}
if !commands_dir.is_dir() {
bail!(
"commands path is not a directory: {}",
commands_dir.display()
);
}
let mut commands = Vec::new();
let entries = fs::read_dir(commands_dir).with_context(|| {
format!(
"failed to read commands directory: {}",
commands_dir.display()
)
})?;
for entry in entries {
let entry = entry.context("failed to read directory entry")?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = path.file_name().unwrap().to_string_lossy();
if !file_name.ends_with(".component.wasm") {
continue;
}
let name = file_name
.strip_suffix(".component.wasm")
.unwrap()
.to_string();
if !is_valid_command_name(&name) {
bail!(
"invalid command name '{}': must match pattern [a-z][a-z0-9-]* (file: {})",
name,
path.display()
);
}
let wasm_bytes = fs::read(&path)
.with_context(|| format!("failed to read component: {}", path.display()))?;
if !exports_command_interface(&wasm_bytes)? {
bail!(
"'{}' does not export wacli:cli/command interface",
path.display()
);
}
commands.push(CommandInfo { name, path });
}
commands.sort_by(|a, b| a.name.cmp(&b.name));
if commands.is_empty() {
bail!("no commands found in {}", commands_dir.display());
}
Ok(commands)
}
pub fn verify_defaults(defaults_dir: &Path) -> Result<(PathBuf, PathBuf)> {
let host_path = defaults_dir.join("host.component.wasm");
let core_path = defaults_dir.join("core.component.wasm");
if !host_path.exists() {
bail!("defaults/host.component.wasm not found");
}
if !core_path.exists() {
bail!("defaults/core.component.wasm not found");
}
Ok((host_path, core_path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_command_names() {
assert!(is_valid_command_name("help"));
assert!(is_valid_command_name("greet"));
assert!(is_valid_command_name("my-command"));
assert!(is_valid_command_name("cmd123"));
assert!(is_valid_command_name("a"));
assert!(is_valid_command_name("a1"));
assert!(is_valid_command_name("hello-world-test"));
}
#[test]
fn test_invalid_command_names() {
assert!(!is_valid_command_name("")); assert!(!is_valid_command_name("1cmd")); assert!(!is_valid_command_name("-cmd")); assert!(!is_valid_command_name("Cmd")); assert!(!is_valid_command_name("CMD")); assert!(!is_valid_command_name("my_cmd")); assert!(!is_valid_command_name("cmd-")); assert!(!is_valid_command_name("my.cmd")); }
#[test]
fn test_command_info_var_name() {
let cmd = CommandInfo {
name: "my-command".to_string(),
path: PathBuf::from("test.wasm"),
};
assert_eq!(cmd.var_name(), "my_command");
}
#[test]
fn test_command_info_package_name() {
let cmd = CommandInfo {
name: "greet".to_string(),
path: PathBuf::from("test.wasm"),
};
assert_eq!(cmd.package_name(), "wacli:cmd-greet");
}
}