use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmManifest {
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub url_patterns: Vec<String>,
}
impl WasmManifest {
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
bail!("WASM manifest 'name' must not be empty");
}
if self.version.is_empty() {
bail!("WASM manifest '{}': 'version' must not be empty", self.name);
}
if self.url_patterns.is_empty() {
bail!(
"WASM manifest '{}': 'url_patterns' must not be empty (provider would never match)",
self.name
);
}
for pattern in &self.url_patterns {
regex::Regex::new(pattern).with_context(|| {
format!(
"WASM manifest '{}': invalid url_pattern '{pattern}'",
self.name
)
})?;
}
Ok(())
}
}
pub fn wasm_providers_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("nab")
.join("wasm_providers")
}
pub fn provider_dir(base: &Path, name: &str) -> PathBuf {
base.join(name)
}
pub fn manifest_path(provider_dir: &Path) -> PathBuf {
provider_dir.join("manifest.toml")
}
pub fn wasm_path(provider_dir: &Path) -> PathBuf {
provider_dir.join("provider.wasm")
}
#[derive(Debug, Clone)]
pub struct InstalledProvider {
pub manifest: WasmManifest,
pub wasm_path: PathBuf,
}
pub fn load_installed_providers(base_dir: &Path) -> Vec<InstalledProvider> {
let Ok(entries) = std::fs::read_dir(base_dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let dir = entry.path();
if !dir.is_dir() {
return None;
}
match load_single_provider(&dir) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!("Skipping WASM provider at {}: {e}", dir.display());
None
}
}
})
.collect()
}
pub fn load_single_provider(dir: &Path) -> Result<InstalledProvider> {
let mpath = manifest_path(dir);
let wpath = wasm_path(dir);
let toml_str = std::fs::read_to_string(&mpath)
.with_context(|| format!("missing manifest.toml in {}", dir.display()))?;
let manifest: WasmManifest = toml::from_str(&toml_str)
.with_context(|| format!("invalid manifest.toml in {}", dir.display()))?;
manifest.validate()?;
if !wpath.exists() {
bail!("provider.wasm not found in {}", dir.display());
}
Ok(InstalledProvider {
manifest,
wasm_path: wpath,
})
}
pub fn write_manifest(dir: &Path, manifest: &WasmManifest) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("cannot create provider directory {}", dir.display()))?;
let toml_str = toml::to_string_pretty(manifest)
.with_context(|| format!("cannot serialise manifest for '{}'", manifest.name))?;
let path = manifest_path(dir);
std::fs::write(&path, toml_str).with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_manifest() -> WasmManifest {
WasmManifest {
name: "test-provider".to_string(),
version: "1.0.0".to_string(),
description: "A test provider".to_string(),
author: "test@example.com".to_string(),
url_patterns: vec![r"example\.com/.*".to_string()],
}
}
#[test]
fn validate_accepts_complete_manifest() {
let m = valid_manifest();
assert!(m.validate().is_ok());
}
#[test]
fn validate_rejects_empty_name() {
let m = WasmManifest {
name: String::new(),
..valid_manifest()
};
let err = m.validate().unwrap_err();
assert!(err.to_string().contains("name"));
}
#[test]
fn validate_rejects_empty_version() {
let m = WasmManifest {
version: String::new(),
..valid_manifest()
};
let err = m.validate().unwrap_err();
assert!(err.to_string().contains("version"));
}
#[test]
fn validate_rejects_empty_url_patterns() {
let m = WasmManifest {
url_patterns: vec![],
..valid_manifest()
};
let err = m.validate().unwrap_err();
assert!(err.to_string().contains("url_patterns"));
}
#[test]
fn validate_rejects_invalid_regex_pattern() {
let m = WasmManifest {
url_patterns: vec![r"[invalid".to_string()],
..valid_manifest()
};
assert!(m.validate().is_err());
}
#[test]
fn validate_accepts_multiple_valid_patterns() {
let m = WasmManifest {
url_patterns: vec![
r"foo\.com/.*".to_string(),
r"bar\.org/article/\d+".to_string(),
],
..valid_manifest()
};
assert!(m.validate().is_ok());
}
#[test]
fn manifest_round_trips_through_toml() {
let original = valid_manifest();
let toml_str = toml::to_string_pretty(&original).expect("serialise");
let parsed: WasmManifest = toml::from_str(&toml_str).expect("deserialise");
assert_eq!(parsed.name, original.name);
assert_eq!(parsed.version, original.version);
assert_eq!(parsed.url_patterns, original.url_patterns);
}
#[test]
fn manifest_defaults_description_and_author() {
let toml_str = r#"
name = "minimal"
version = "0.1.0"
url_patterns = ["minimal\\.com"]
"#;
let m: WasmManifest = toml::from_str(toml_str).expect("parse");
assert_eq!(m.description, "");
assert_eq!(m.author, "");
}
#[test]
fn load_installed_providers_returns_empty_for_missing_dir() {
let dir = PathBuf::from("/tmp/nab_wasm_test_nonexistent_xyz");
let providers = load_installed_providers(&dir);
assert!(providers.is_empty());
}
#[test]
fn load_installed_providers_discovers_valid_provider() {
let base = tempfile::tempdir().expect("tempdir");
let pdir = base.path().join("my-provider");
std::fs::create_dir_all(&pdir).expect("mkdir");
write_manifest(&pdir, &valid_manifest()).expect("write manifest");
std::fs::write(wasm_path(&pdir), b"(module)").expect("write wasm");
let providers = load_installed_providers(base.path());
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].manifest.name, "test-provider");
}
#[test]
fn load_installed_providers_skips_dir_without_wasm() {
let base = tempfile::tempdir().expect("tempdir");
let pdir = base.path().join("no-wasm");
write_manifest(&pdir, &valid_manifest()).expect("write manifest");
let providers = load_installed_providers(base.path());
assert!(providers.is_empty());
}
#[test]
fn load_installed_providers_skips_dir_without_manifest() {
let base = tempfile::tempdir().expect("tempdir");
let pdir = base.path().join("no-manifest");
std::fs::create_dir_all(&pdir).expect("mkdir");
std::fs::write(wasm_path(&pdir), b"(module)").expect("write wasm");
let providers = load_installed_providers(base.path());
assert!(providers.is_empty());
}
#[test]
fn load_installed_providers_skips_invalid_manifest() {
let base = tempfile::tempdir().expect("tempdir");
let pdir = base.path().join("bad-manifest");
std::fs::create_dir_all(&pdir).expect("mkdir");
std::fs::write(
manifest_path(&pdir),
r#"name = ""
version = "1.0.0"
url_patterns = ["x\\.com"]
"#,
)
.expect("write");
std::fs::write(wasm_path(&pdir), b"(module)").expect("write wasm");
let providers = load_installed_providers(base.path());
assert!(providers.is_empty());
}
#[test]
fn load_single_provider_returns_error_for_missing_manifest() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(load_single_provider(dir.path()).is_err());
}
#[test]
fn write_manifest_creates_directory_and_file() {
let base = tempfile::tempdir().expect("tempdir");
let pdir = base.path().join("new-provider");
let m = valid_manifest();
write_manifest(&pdir, &m).expect("write");
let path = manifest_path(&pdir);
assert!(path.exists());
let read_back: WasmManifest =
toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
assert_eq!(read_back.name, m.name);
}
#[test]
fn wasm_providers_dir_ends_with_nab_wasm_providers() {
let d = wasm_providers_dir();
assert!(d.ends_with("nab/wasm_providers"));
}
#[test]
fn provider_dir_appends_name_to_base() {
let base = PathBuf::from("/tmp/base");
assert_eq!(provider_dir(&base, "foo"), PathBuf::from("/tmp/base/foo"));
}
}