use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use wasmparser::{Parser, Payload};
#[derive(Debug, Clone)]
pub struct CommandInfo {
pub name: String,
pub path: PathBuf,
pub imports: Vec<String>,
}
impl CommandInfo {
pub fn var_name(&self) -> String {
self.name.replace('-', "_")
}
pub fn package_name(&self) -> String {
format!("wacli:cmd-{}", self.name)
}
pub fn import_name(&self, base: &str) -> String {
let fqn = format!("wacli:cli/{base}@2.0.0");
if self.imports.iter().any(|i| i == &fqn) {
return fqn;
}
let pkg = format!("wacli:cli/{base}");
if self.imports.iter().any(|i| i == &pkg) {
return pkg;
}
if self.imports.iter().any(|i| i == base) {
return base.to_string();
}
fqn
}
}
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('-')
}
enum WasmKind {
Component {
exports: Vec<String>,
imports: Vec<String>,
},
CoreModule,
}
fn analyze_wasm(wasm_bytes: &[u8]) -> Result<WasmKind> {
let parser = Parser::new(0);
let mut is_component = false;
let mut exports = Vec::new();
let mut imports = Vec::new();
let mut depth = 0;
for payload in parser.parse_all(wasm_bytes) {
let payload = payload.context("failed to parse WASM")?;
match payload {
Payload::Version { encoding, .. } => {
is_component = encoding == wasmparser::Encoding::Component;
}
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());
}
}
Payload::ComponentImportSection(reader) if depth == 0 => {
for import in reader {
let import = import.context("failed to read component import")?;
imports.push(import.name.0.to_string());
}
}
_ => {}
}
}
if is_component {
Ok(WasmKind::Component { exports, imports })
} else {
Ok(WasmKind::CoreModule)
}
}
fn exports_command_interface(exports: &[String]) -> bool {
exports
.iter()
.any(|e| e == "wacli:cli/command@2.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 mut seen = HashMap::new();
collect_commands(commands_dir, &mut commands, &mut seen)?;
commands.sort_by(|a, b| a.name.cmp(&b.name));
if commands.is_empty() {
bail!("no commands found in {}", commands_dir.display());
}
Ok(commands)
}
fn collect_commands(
dir: &Path,
out: &mut Vec<CommandInfo>,
seen: &mut HashMap<String, PathBuf>,
) -> Result<()> {
let entries = fs::read_dir(dir)
.with_context(|| format!("failed to read commands directory: {}", dir.display()))?;
for entry in entries {
let entry = entry.context("failed to read directory entry")?;
let path = entry.path();
if path.is_dir() {
collect_commands(&path, out, seen)?;
continue;
}
if !path.is_file() {
continue;
}
let file_name = match path.file_name() {
Some(name) => name.to_string_lossy(),
None => continue,
};
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()
);
}
if let Some(prev) = seen.get(&name) {
bail!(
"duplicate command name '{}':\n {}\n {}",
name,
prev.display(),
path.display()
);
}
seen.insert(name.clone(), path.clone());
let wasm_bytes = fs::read(&path)
.with_context(|| format!("failed to read component: {}", path.display()))?;
let (exports, imports) = match analyze_wasm(&wasm_bytes)? {
WasmKind::CoreModule => {
bail!(
"'{}' is a core WebAssembly module, not a component.\n\
Hint: run `wasm-tools component new {} -o {}`",
path.display(),
path.display(),
path.display()
);
}
WasmKind::Component { exports, imports } => (exports, imports),
};
if !exports_command_interface(&exports) {
bail!(
"'{}' does not export wacli:cli/command interface",
path.display()
);
}
out.push(CommandInfo {
name,
path,
imports,
});
}
Ok(())
}
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"),
imports: Vec::new(),
};
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"),
imports: Vec::new(),
};
assert_eq!(cmd.package_name(), "wacli:cmd-greet");
}
}