use std::collections::{HashMap, HashSet};
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::cache::PluginCache;
use crate::download;
use crate::error::CompileError;
use crate::spec_parser::ApiSpec;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct PluginToml {
plugin: PluginMeta,
#[serde(default)]
capabilities: PluginTomlCapabilities,
}
#[derive(Debug, Deserialize)]
struct PluginMeta {
version: String,
#[serde(rename = "type")]
plugin_type: String,
}
#[derive(Debug, Default, Deserialize)]
struct PluginTomlCapabilities {
#[serde(default)]
body_access: bool,
}
struct PluginMetadata {
version: String,
plugin_type: String,
body_access: bool,
}
fn parse_plugin_metadata(content: &str) -> Option<PluginMetadata> {
let parsed: PluginToml = toml::from_str(content).ok()?;
Some(PluginMetadata {
version: parsed.plugin.version,
plugin_type: parsed.plugin.plugin_type,
body_access: parsed.capabilities.body_access,
})
}
fn read_plugin_metadata(wasm_path: &Path) -> Option<PluginMetadata> {
let plugin_toml_path = wasm_path.parent()?.join("plugin.toml");
let content = std::fs::read_to_string(&plugin_toml_path).ok()?;
parse_plugin_metadata(&content)
}
fn resolve_wasm_path(path_source: &PathSource, base_path: &Path) -> std::path::PathBuf {
if Path::new(&path_source.path).is_absolute() {
Path::new(&path_source.path).to_path_buf()
} else {
base_path.join(&path_source.path)
}
}
fn resolve_plugin(
name: &str,
source: &PluginSource,
base_path: &Path,
no_cache: bool,
) -> Result<ResolvedPlugin, CompileError> {
let (wasm_bytes, plugin_toml_content) = match source {
PluginSource::Path(path_source) => {
let wasm_path = resolve_wasm_path(path_source, base_path);
let bytes = std::fs::read(&wasm_path).map_err(|e| {
CompileError::PluginResolution(format!(
"failed to read plugin '{}' from {}: {}",
name,
wasm_path.display(),
e
))
})?;
(bytes, None)
}
PluginSource::Url(url_source) => resolve_url_plugin(name, url_source, no_cache)?,
};
if wasm_bytes.len() < 8
|| wasm_bytes[0..4] != [0x00, 0x61, 0x73, 0x6d]
|| wasm_bytes[4..8] != [0x01, 0x00, 0x00, 0x00]
{
return Err(CompileError::PluginResolution(format!(
"plugin '{}' is not a valid WASM file (invalid magic number)",
name
)));
}
let metadata = match source {
PluginSource::Path(path_source) => {
let wasm_path = resolve_wasm_path(path_source, base_path);
read_plugin_metadata(&wasm_path)
}
PluginSource::Url(_) => plugin_toml_content
.as_deref()
.and_then(parse_plugin_metadata),
};
Ok(ResolvedPlugin {
name: name.to_string(),
source: source.description(),
wasm_bytes,
version: metadata.as_ref().map(|m| m.version.clone()),
plugin_type: metadata.as_ref().map(|m| m.plugin_type.clone()),
body_access: metadata.as_ref().is_some_and(|m| m.body_access),
})
}
fn resolve_url_plugin(
name: &str,
url_source: &UrlSource,
no_cache: bool,
) -> Result<(Vec<u8>, Option<String>), CompileError> {
let url = &url_source.url;
if !url.starts_with("https://") {
return Err(CompileError::PluginResolution(format!(
"plugin '{name}' URL must use HTTPS: {url}"
)));
}
if !no_cache {
let cache = PluginCache::new()?;
if let Some(cached) = cache.get(url, url_source.sha256.as_deref()) {
tracing::info!(name, url, "using cached plugin");
return Ok((cached.wasm_bytes, cached.plugin_toml));
}
}
let downloaded = download::download_plugin(url)?;
if let Some(expected) = &url_source.sha256 {
let actual = hex::encode(Sha256::digest(&downloaded.wasm_bytes));
if actual != *expected {
return Err(CompileError::PluginResolution(format!(
"plugin '{name}' checksum mismatch: expected {expected}, got {actual}"
)));
}
}
if !no_cache {
let cache = PluginCache::new()?;
cache.put(
url,
&downloaded.wasm_bytes,
downloaded.plugin_toml.as_deref(),
)?;
tracing::info!(name, url, "cached remote plugin");
}
Ok((downloaded.wasm_bytes, downloaded.plugin_toml))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectManifest {
#[serde(default)]
pub plugins: HashMap<String, PluginSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub specs: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PluginSource {
Path(PathSource),
Url(UrlSource),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathSource {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlSource {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
impl PluginSource {
pub fn description(&self) -> String {
match self {
PluginSource::Path(p) => format!("path: {}", p.path),
PluginSource::Url(u) => format!("url: {}", u.url),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedPlugin {
pub name: String,
pub source: String,
pub wasm_bytes: Vec<u8>,
pub version: Option<String>,
pub plugin_type: Option<String>,
pub body_access: bool,
}
impl ProjectManifest {
pub fn load(path: &Path) -> Result<Self, CompileError> {
let content = std::fs::read_to_string(path).map_err(|e| {
CompileError::ManifestError(format!("failed to read {}: {}", path.display(), e))
})?;
Self::parse(&content, path)
}
pub fn parse(content: &str, path: &Path) -> Result<Self, CompileError> {
serde_yaml::from_str(content).map_err(|e| {
CompileError::ManifestError(format!("failed to parse {}: {}", path.display(), e))
})
}
pub fn discover_spec_files(
&self,
base_path: &Path,
) -> Result<Vec<std::path::PathBuf>, CompileError> {
let specs_dir_str = match &self.specs {
Some(s) => s,
None => return Ok(vec![]),
};
let specs_dir = if Path::new(specs_dir_str).is_absolute() {
std::path::PathBuf::from(specs_dir_str)
} else {
base_path.join(specs_dir_str)
};
if !specs_dir.is_dir() {
return Err(CompileError::ManifestError(format!(
"specs folder not found: {}",
specs_dir.display()
)));
}
let mut spec_files = Vec::new();
let entries = std::fs::read_dir(&specs_dir).map_err(|e| {
CompileError::ManifestError(format!(
"failed to read specs folder {}: {}",
specs_dir.display(),
e
))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
CompileError::ManifestError(format!("failed to read directory entry: {}", e))
})?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "yaml" || ext == "yml" || ext == "json" {
spec_files.push(path);
}
}
}
}
spec_files.sort();
if spec_files.is_empty() {
return Err(CompileError::ManifestError(format!(
"no spec files (*.yaml, *.yml, *.json) found in {}",
specs_dir.display()
)));
}
Ok(spec_files)
}
pub fn has_plugin(&self, name: &str) -> bool {
self.plugins.contains_key(name)
}
pub fn plugin_names(&self) -> Vec<&str> {
self.plugins.keys().map(|s| s.as_str()).collect()
}
pub fn local_plugin_paths(&self, base_path: &Path) -> Vec<std::path::PathBuf> {
self.plugins
.values()
.filter_map(|source| match source {
PluginSource::Path(p) => Some(resolve_wasm_path(p, base_path)),
PluginSource::Url(_) => None,
})
.collect()
}
pub fn resolve_plugins(
&self,
base_path: &Path,
no_cache: bool,
) -> Result<Vec<ResolvedPlugin>, CompileError> {
self.plugins
.iter()
.map(|(name, source)| resolve_plugin(name, source, base_path, no_cache))
.collect()
}
pub fn validate_specs(&self, specs: &[ApiSpec]) -> Result<(), CompileError> {
let used = extract_plugin_names(specs);
let undeclared: Vec<_> = used
.iter()
.filter(|name| !self.plugins.contains_key(*name))
.cloned()
.collect();
if undeclared.is_empty() {
Ok(())
} else {
Err(CompileError::UndeclaredPlugin(undeclared[0].clone()))
}
}
pub fn resolve_used_plugins(
&self,
specs: &[ApiSpec],
base_path: &Path,
no_cache: bool,
) -> Result<Vec<ResolvedPlugin>, CompileError> {
self.validate_specs(specs)?;
let used = extract_plugin_names(specs);
let mut resolved = Vec::new();
for name in used {
let source = match self.plugins.get(&name) {
Some(s) => s,
None => continue, };
resolved.push(resolve_plugin(&name, source, base_path, no_cache)?);
}
Ok(resolved)
}
}
pub fn extract_plugin_names(specs: &[ApiSpec]) -> HashSet<String> {
let mut plugins = HashSet::new();
for spec in specs {
for mw in &spec.global_middlewares {
plugins.insert(normalize_plugin_name(&mw.name));
}
for op in &spec.operations {
if let Some(dispatch) = &op.dispatch {
plugins.insert(normalize_plugin_name(&dispatch.name));
}
if let Some(middlewares) = &op.middlewares {
for mw in middlewares {
plugins.insert(normalize_plugin_name(&mw.name));
}
}
}
}
plugins
}
fn normalize_plugin_name(name: &str) -> String {
match name.split_once('@') {
Some((base, _version)) => base.to_string(),
None => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn parse_empty_manifest() {
let content = "plugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
assert!(manifest.plugins.is_empty());
}
#[test]
fn parse_manifest_with_path_sources() {
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
http-upstream:
path: /absolute/path/to/http-upstream.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
assert_eq!(manifest.plugins.len(), 2);
assert!(manifest.has_plugin("mock"));
assert!(manifest.has_plugin("http-upstream"));
assert!(!manifest.has_plugin("unknown"));
}
#[test]
fn parse_manifest_with_url_sources() {
let content = r#"
plugins:
jwt-auth:
url: https://plugins.barbacane.io/jwt-auth/1.0.0/jwt-auth.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
assert!(manifest.has_plugin("jwt-auth"));
if let PluginSource::Url(u) = &manifest.plugins["jwt-auth"] {
assert!(u.url.starts_with("https://"));
assert!(u.sha256.is_none());
} else {
panic!("Expected URL source");
}
}
#[test]
fn parse_manifest_with_url_and_sha256() {
let content = r#"
plugins:
jwt-auth:
url: https://plugins.barbacane.io/jwt-auth/1.0.0/jwt-auth.wasm
sha256: abc123def456
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
if let PluginSource::Url(u) = &manifest.plugins["jwt-auth"] {
assert_eq!(u.sha256.as_deref(), Some("abc123def456"));
} else {
panic!("Expected URL source");
}
}
#[test]
fn reject_http_url_in_resolve() {
let source = PluginSource::Url(UrlSource {
url: "http://example.com/plugin.wasm".to_string(),
sha256: None,
});
let result = resolve_plugin("test", &source, Path::new("."), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("HTTPS"));
}
#[test]
fn resolve_plugins_from_path() {
let temp = TempDir::new().unwrap();
let wasm_content = vec![
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ];
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
let wasm_path = plugin_dir.join("mock.wasm");
let mut file = std::fs::File::create(&wasm_path).unwrap();
file.write_all(&wasm_content).unwrap();
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let resolved = manifest.resolve_plugins(temp.path(), false).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "mock");
assert_eq!(resolved[0].wasm_bytes, wasm_content);
}
#[test]
fn resolve_plugins_invalid_wasm() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
let wasm_path = plugin_dir.join("bad.wasm");
std::fs::write(&wasm_path, b"not a wasm file").unwrap();
let content = r#"
plugins:
bad:
path: ./plugins/bad.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.resolve_plugins(temp.path(), false);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid magic number"));
}
#[test]
fn resolve_plugins_missing_file() {
let temp = TempDir::new().unwrap();
let content = r#"
plugins:
missing:
path: ./plugins/missing.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.resolve_plugins(temp.path(), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("failed to read"));
}
#[test]
fn extract_plugin_names_from_specs() {
use crate::spec_parser::{
ApiSpec, DispatchConfig, MiddlewareConfig, Operation, SpecFormat,
};
use std::collections::BTreeMap;
let spec = ApiSpec {
filename: Some("test.yaml".to_string()),
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "Test".to_string(),
api_version: "1.0.0".to_string(),
global_middlewares: vec![MiddlewareConfig {
name: "rate-limit".to_string(),
config: serde_json::json!({}),
}],
extensions: BTreeMap::new(),
operations: vec![
Operation {
path: "/health".to_string(),
method: "GET".to_string(),
operation_id: None,
summary: None,
description: None,
parameters: vec![],
request_body: None,
dispatch: Some(DispatchConfig {
name: "mock".to_string(),
config: serde_json::json!({}),
}),
middlewares: None,
deprecated: false,
sunset: None,
extensions: BTreeMap::new(),
messages: vec![],
bindings: BTreeMap::new(),
responses: BTreeMap::new(),
},
Operation {
path: "/api".to_string(),
method: "GET".to_string(),
operation_id: None,
summary: None,
description: None,
parameters: vec![],
request_body: None,
dispatch: Some(DispatchConfig {
name: "http-upstream".to_string(),
config: serde_json::json!({}),
}),
middlewares: Some(vec![MiddlewareConfig {
name: "jwt-auth@1.0.0".to_string(),
config: serde_json::json!({}),
}]),
deprecated: false,
sunset: None,
extensions: BTreeMap::new(),
messages: vec![],
bindings: BTreeMap::new(),
responses: BTreeMap::new(),
},
],
};
let plugins = extract_plugin_names(&[spec]);
assert!(plugins.contains("mock"));
assert!(plugins.contains("http-upstream"));
assert!(plugins.contains("rate-limit"));
assert!(plugins.contains("jwt-auth"));
assert!(!plugins.contains("jwt-auth@1.0.0"));
assert_eq!(plugins.len(), 4);
}
#[test]
fn validate_specs_all_declared() {
use crate::spec_parser::{ApiSpec, DispatchConfig, Operation, SpecFormat};
use std::collections::BTreeMap;
let spec = ApiSpec {
filename: Some("test.yaml".to_string()),
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "Test".to_string(),
api_version: "1.0.0".to_string(),
global_middlewares: vec![],
extensions: BTreeMap::new(),
operations: vec![Operation {
path: "/health".to_string(),
method: "GET".to_string(),
operation_id: None,
summary: None,
description: None,
parameters: vec![],
request_body: None,
dispatch: Some(DispatchConfig {
name: "mock".to_string(),
config: serde_json::json!({}),
}),
middlewares: None,
deprecated: false,
sunset: None,
extensions: BTreeMap::new(),
messages: vec![],
bindings: BTreeMap::new(),
responses: BTreeMap::new(),
}],
};
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.validate_specs(&[spec]);
assert!(result.is_ok());
}
#[test]
fn validate_specs_undeclared_plugin() {
use crate::spec_parser::{ApiSpec, DispatchConfig, Operation, SpecFormat};
use std::collections::BTreeMap;
let spec = ApiSpec {
filename: Some("test.yaml".to_string()),
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "Test".to_string(),
api_version: "1.0.0".to_string(),
global_middlewares: vec![],
extensions: BTreeMap::new(),
operations: vec![Operation {
path: "/proxy".to_string(),
method: "GET".to_string(),
operation_id: None,
summary: None,
description: None,
parameters: vec![],
request_body: None,
dispatch: Some(DispatchConfig {
name: "http-upstream".to_string(),
config: serde_json::json!({}),
}),
middlewares: None,
deprecated: false,
sunset: None,
extensions: BTreeMap::new(),
messages: vec![],
bindings: BTreeMap::new(),
responses: BTreeMap::new(),
}],
};
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.validate_specs(&[spec]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("E1040"));
assert!(err.contains("http-upstream"));
}
fn make_spec_using_plugin(plugin_name: &str) -> ApiSpec {
use crate::spec_parser::{DispatchConfig, Operation, SpecFormat};
use std::collections::BTreeMap;
ApiSpec {
filename: Some("test.yaml".to_string()),
format: SpecFormat::OpenApi,
version: "3.1.0".to_string(),
title: "Test".to_string(),
api_version: "1.0.0".to_string(),
global_middlewares: vec![],
extensions: BTreeMap::new(),
operations: vec![Operation {
path: "/health".to_string(),
method: "GET".to_string(),
operation_id: None,
summary: None,
description: None,
parameters: vec![],
request_body: None,
dispatch: Some(DispatchConfig {
name: plugin_name.to_string(),
config: serde_json::json!({}),
}),
middlewares: None,
deprecated: false,
sunset: None,
extensions: BTreeMap::new(),
messages: vec![],
bindings: BTreeMap::new(),
responses: BTreeMap::new(),
}],
}
}
fn write_valid_wasm(dir: &Path, name: &str) -> Vec<u8> {
let wasm_content = vec![
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, ];
let path = dir.join(name);
std::fs::write(&path, &wasm_content).unwrap();
wasm_content
}
#[test]
fn resolve_used_plugins_from_path() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
let wasm_content = write_valid_wasm(&plugin_dir, "mock.wasm");
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("mock");
let resolved = manifest
.resolve_used_plugins(&[spec], temp.path(), false)
.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "mock");
assert_eq!(resolved[0].wasm_bytes, wasm_content);
}
#[test]
fn resolve_used_plugins_skips_unused() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_valid_wasm(&plugin_dir, "mock.wasm");
write_valid_wasm(&plugin_dir, "unused.wasm");
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
unused:
path: ./plugins/unused.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("mock");
let resolved = manifest
.resolve_used_plugins(&[spec], temp.path(), false)
.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "mock");
}
#[test]
fn resolve_used_plugins_invalid_wasm() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(plugin_dir.join("bad.wasm"), b"not a wasm file").unwrap();
let content = r#"
plugins:
bad:
path: ./plugins/bad.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("bad");
let result = manifest.resolve_used_plugins(&[spec], temp.path(), false);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid magic number"));
}
#[test]
fn resolve_used_plugins_missing_file() {
let temp = TempDir::new().unwrap();
let content = r#"
plugins:
missing:
path: ./plugins/missing.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("missing");
let result = manifest.resolve_used_plugins(&[spec], temp.path(), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("failed to read"));
}
#[test]
fn resolve_used_plugins_rejects_undeclared() {
let temp = TempDir::new().unwrap();
let content = "plugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("unknown");
let result = manifest.resolve_used_plugins(&[spec], temp.path(), false);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("E1040"));
}
#[test]
fn resolve_plugins_reads_metadata() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_valid_wasm(&plugin_dir, "mock.wasm");
let plugin_toml = r#"
[plugin]
name = "mock"
version = "1.2.3"
type = "dispatcher"
"#;
std::fs::write(plugin_dir.join("plugin.toml"), plugin_toml).unwrap();
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let resolved = manifest.resolve_plugins(temp.path(), false).unwrap();
assert_eq!(resolved[0].version, Some("1.2.3".to_string()));
assert_eq!(resolved[0].plugin_type, Some("dispatcher".to_string()));
}
#[test]
fn resolve_used_plugins_reads_metadata() {
let temp = TempDir::new().unwrap();
let plugin_dir = temp.path().join("plugins");
std::fs::create_dir_all(&plugin_dir).unwrap();
write_valid_wasm(&plugin_dir, "mock.wasm");
let plugin_toml = r#"
[plugin]
name = "mock"
version = "2.0.0"
type = "middleware"
"#;
std::fs::write(plugin_dir.join("plugin.toml"), plugin_toml).unwrap();
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let spec = make_spec_using_plugin("mock");
let resolved = manifest
.resolve_used_plugins(&[spec], temp.path(), false)
.unwrap();
assert_eq!(resolved[0].version, Some("2.0.0".to_string()));
assert_eq!(resolved[0].plugin_type, Some("middleware".to_string()));
}
#[test]
fn parse_manifest_without_specs() {
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
assert!(manifest.specs.is_none());
}
#[test]
fn parse_manifest_with_specs_folder() {
let content = r#"
specs: ./specs/
plugins:
mock:
path: ./plugins/mock.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
assert_eq!(manifest.specs.as_deref(), Some("./specs/"));
}
#[test]
fn discover_spec_files_from_folder() {
let temp = TempDir::new().unwrap();
let specs_dir = temp.path().join("specs");
std::fs::create_dir_all(&specs_dir).unwrap();
std::fs::write(specs_dir.join("api.yaml"), "openapi: '3.1.0'").unwrap();
std::fs::write(specs_dir.join("events.json"), "{}").unwrap();
std::fs::write(specs_dir.join("notes.txt"), "not a spec").unwrap();
let content = "specs: ./specs/\nplugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let files = manifest.discover_spec_files(temp.path()).unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("api.yaml"));
assert!(files[1].ends_with("events.json"));
}
#[test]
fn discover_spec_files_yml_extension() {
let temp = TempDir::new().unwrap();
let specs_dir = temp.path().join("specs");
std::fs::create_dir_all(&specs_dir).unwrap();
std::fs::write(specs_dir.join("api.yml"), "openapi: '3.1.0'").unwrap();
let content = "specs: ./specs/\nplugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let files = manifest.discover_spec_files(temp.path()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("api.yml"));
}
#[test]
fn discover_spec_files_no_specs_configured() {
let manifest = ProjectManifest::parse("plugins: {}", Path::new("barbacane.yaml")).unwrap();
let files = manifest.discover_spec_files(Path::new(".")).unwrap();
assert!(files.is_empty());
}
#[test]
fn discover_spec_files_missing_folder() {
let content = "specs: ./nonexistent/\nplugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.discover_spec_files(Path::new("."));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("specs folder not found"));
}
#[test]
fn discover_spec_files_empty_folder() {
let temp = TempDir::new().unwrap();
let specs_dir = temp.path().join("specs");
std::fs::create_dir_all(&specs_dir).unwrap();
let content = "specs: ./specs/\nplugins: {}";
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let result = manifest.discover_spec_files(temp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no spec files"));
}
#[test]
fn local_plugin_paths_filters_url_sources() {
let content = r#"
plugins:
mock:
path: ./plugins/mock.wasm
jwt-auth:
url: https://plugins.barbacane.io/jwt-auth.wasm
"#;
let manifest = ProjectManifest::parse(content, Path::new("barbacane.yaml")).unwrap();
let paths = manifest.local_plugin_paths(Path::new("/project"));
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("plugins/mock.wasm"));
}
}