Skip to main content

barbacane_compiler/
artifact.rs

1use std::collections::{HashMap, HashSet};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::Path;
5
6use flate2::write::GzEncoder;
7use flate2::Compression;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use tar::Builder;
11
12use std::collections::BTreeMap;
13
14use crate::spec_parser::{
15    parse_spec_file, ApiSpec, DispatchConfig, Message, MiddlewareConfig, Parameter, RequestBody,
16    ResponseContent, SpecFormat,
17};
18
19use crate::error::{CompileError, CompileWarning};
20use crate::manifest::ProjectManifest;
21
22/// Current artifact format version.
23pub const ARTIFACT_VERSION: u32 = 3;
24
25/// Options for compilation.
26#[derive(Debug, Clone)]
27pub struct CompileOptions {
28    /// Allow plaintext HTTP upstream URLs (development only).
29    /// If false, compilation fails with E1031 for http:// URLs.
30    pub allow_plaintext: bool,
31    /// Maximum JSON Schema nesting depth (default: 32).
32    pub max_schema_depth: usize,
33    /// Maximum total properties in a schema (default: 256).
34    pub max_schema_properties: usize,
35    /// Git commit SHA for build provenance tracking.
36    pub provenance_commit: Option<String>,
37    /// Source identifier for build provenance (e.g., "ci/github-actions").
38    pub provenance_source: Option<String>,
39    /// Bypass the plugin download cache entirely (no read, no write).
40    pub no_cache: bool,
41}
42
43impl Default for CompileOptions {
44    fn default() -> Self {
45        Self {
46            allow_plaintext: false,
47            max_schema_depth: 32,
48            max_schema_properties: 256,
49            provenance_commit: None,
50            provenance_source: None,
51            no_cache: false,
52        }
53    }
54}
55
56/// Compiler version (from Cargo.toml).
57pub const COMPILER_VERSION: &str = env!("CARGO_PKG_VERSION");
58
59/// Known x-barbacane-* extensions (structural spec extensions).
60/// Extensions not in this list will trigger E1015 warning.
61///
62/// Note: Middleware functionality (rate-limit, cache, auth, etc.) is configured
63/// via `x-barbacane-middlewares` with the plugin name, not as separate extensions.
64/// Backend connections are configured in the `http-upstream` dispatcher config.
65const KNOWN_EXTENSIONS: &[&str] = &[
66    "x-barbacane-dispatch",    // Operation level - dispatcher config (required)
67    "x-barbacane-middlewares", // Root or operation level - middleware chain
68    "x-barbacane-mcp",         // Root or operation level - MCP server config
69];
70
71/// Result of compilation including the manifest and any warnings.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CompileResult {
74    /// The compiled manifest.
75    pub manifest: Manifest,
76    /// Warnings produced during compilation (non-fatal issues).
77    pub warnings: Vec<CompileWarning>,
78}
79
80/// The manifest.json embedded in a .bca artifact.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Manifest {
83    pub barbacane_artifact_version: u32,
84    pub compiled_at: String,
85    pub compiler_version: String,
86    pub source_specs: Vec<SourceSpec>,
87    pub routes_count: usize,
88    /// Checksums use BTreeMap for deterministic JSON serialization order.
89    pub checksums: BTreeMap<String, String>,
90    /// Bundled plugins (empty if no plugins bundled).
91    pub plugins: Vec<BundledPlugin>,
92    /// Combined SHA-256 fingerprint of all artifact inputs (specs + routes + plugins).
93    pub artifact_hash: String,
94    /// Build provenance metadata (git commit, CI source, etc.).
95    pub provenance: Provenance,
96    /// MCP server configuration (from root-level x-barbacane-mcp).
97    #[serde(default)]
98    pub mcp: McpConfig,
99}
100
101/// MCP server configuration extracted from `x-barbacane-mcp`.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct McpConfig {
104    /// Whether MCP is enabled globally.
105    pub enabled: bool,
106    /// MCP server name (defaults to info.title).
107    #[serde(default)]
108    pub server_name: Option<String>,
109    /// MCP server version (defaults to info.version).
110    #[serde(default)]
111    pub server_version: Option<String>,
112}
113
114/// Build provenance metadata embedded in the manifest.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct Provenance {
117    /// Git commit SHA at build time.
118    pub commit: Option<String>,
119    /// Build source identifier (e.g., "ci/github-actions").
120    pub source: Option<String>,
121}
122
123/// Metadata about a bundled plugin.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct BundledPlugin {
126    /// Plugin name.
127    pub name: String,
128    /// Plugin version.
129    pub version: String,
130    /// Plugin type (middleware or dispatcher).
131    pub plugin_type: String,
132    /// Path within the artifact (e.g., "plugins/rate-limit.wasm").
133    pub wasm_path: String,
134    /// SHA-256 hash of the WASM file.
135    pub sha256: String,
136    /// Plugin capabilities declared in plugin.toml.
137    pub capabilities: PluginCapabilities,
138}
139
140/// Plugin capabilities stored in the artifact manifest.
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct PluginCapabilities {
143    /// Whether the middleware receives the request body in `on_request`.
144    #[serde(default)]
145    pub body_access: bool,
146}
147
148/// A plugin loaded from a .bca artifact, ready for compilation.
149#[derive(Debug, Clone)]
150pub struct LoadedPlugin {
151    /// Plugin version.
152    pub version: String,
153    /// WASM binary content.
154    pub wasm_bytes: Vec<u8>,
155    /// Whether this plugin needs the request body.
156    pub body_access: bool,
157}
158
159/// Metadata about a source spec included in the artifact.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SourceSpec {
162    pub file: String,
163    pub sha256: String,
164    #[serde(rename = "type")]
165    pub spec_type: String,
166    pub version: String,
167}
168
169/// Compiled route data stored in routes.json.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CompiledRoutes {
172    pub operations: Vec<CompiledOperation>,
173}
174
175/// A compiled operation ready for the data plane.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct CompiledOperation {
178    pub index: usize,
179    /// Path template (OpenAPI: "/users/{id}", AsyncAPI: channel address).
180    pub path: String,
181    /// HTTP method (OpenAPI: "GET", AsyncAPI: "SEND"/"RECEIVE").
182    pub method: String,
183    pub operation_id: Option<String>,
184    /// Operation summary (short description).
185    #[serde(default)]
186    pub summary: Option<String>,
187    /// Operation description (detailed).
188    #[serde(default)]
189    pub description: Option<String>,
190    /// Parameters for validation (path, query, header).
191    pub parameters: Vec<Parameter>,
192    /// Request body schema for validation.
193    pub request_body: Option<RequestBody>,
194    pub dispatch: DispatchConfig,
195    /// Resolved middleware chain for this operation.
196    /// If the operation has its own middlewares, uses those.
197    /// Otherwise, uses the global middlewares from the spec.
198    #[serde(default)]
199    pub middlewares: Vec<MiddlewareConfig>,
200    /// Whether this operation is deprecated.
201    #[serde(default)]
202    pub deprecated: bool,
203    /// Sunset date for deprecated operations (HTTP-date format per RFC 9110).
204    #[serde(default)]
205    pub sunset: Option<String>,
206    /// AsyncAPI messages (for async operations only).
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub messages: Vec<Message>,
209    /// Protocol bindings (AsyncAPI: kafka, nats, mqtt, amqp, ws).
210    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211    pub bindings: BTreeMap<String, serde_json::Value>,
212    /// Response definitions keyed by status code.
213    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214    pub responses: BTreeMap<String, ResponseContent>,
215    /// Whether this operation is exposed as an MCP tool.
216    #[serde(default)]
217    pub mcp_enabled: Option<bool>,
218    /// MCP-specific tool description override.
219    #[serde(default)]
220    pub mcp_description: Option<String>,
221}
222
223/// Compile one or more spec files into a .bca artifact.
224///
225/// Bundles the provided plugins into the artifact. Pass `&[]` if the specs
226/// don't reference any plugins.
227///
228/// This function does NOT validate that spec-referenced plugins are present
229/// in `plugins` — the caller is responsible for validation (see
230/// [`extract_plugin_names`] and [`ProjectManifest::validate_specs`]).
231/// For manifest-based compilation with built-in validation, use
232/// [`compile_with_manifest`].
233pub fn compile(
234    spec_paths: &[&Path],
235    plugins: &[PluginBundle],
236    output: &Path,
237    options: &CompileOptions,
238) -> Result<CompileResult, CompileError> {
239    let specs = parse_specs(spec_paths)?;
240    compile_inner(&specs, plugins, output, options)
241}
242
243/// Compile specs with a project manifest into a .bca artifact.
244///
245/// This is the primary compilation entry point for manifest-based projects.
246/// It validates that all plugins used in specs are declared in the manifest,
247/// resolves them, and bundles them into the artifact.
248///
249/// # Arguments
250/// * `spec_paths` - Paths to OpenAPI/AsyncAPI spec files
251/// * `project_manifest` - The project manifest declaring available plugins
252/// * `manifest_base_path` - Base path for resolving relative plugin paths
253/// * `output` - Output path for the .bca artifact
254/// * `options` - Compilation options
255pub fn compile_with_manifest(
256    spec_paths: &[&Path],
257    project_manifest: &ProjectManifest,
258    manifest_base_path: &Path,
259    output: &Path,
260    options: &CompileOptions,
261) -> Result<CompileResult, CompileError> {
262    let specs = parse_specs(spec_paths)?;
263
264    // Extract just the ApiSpec for validation
265    let api_specs: Vec<ApiSpec> = specs.iter().map(|(spec, _, _)| spec.clone()).collect();
266
267    // Validate all plugins are declared (E1040)
268    project_manifest.validate_specs(&api_specs)?;
269
270    // Resolve used plugins (loads WASM bytes)
271    let resolved_plugins =
272        project_manifest.resolve_used_plugins(&api_specs, manifest_base_path, options.no_cache)?;
273
274    // Convert to PluginBundle
275    let plugin_bundles: Vec<PluginBundle> = resolved_plugins
276        .into_iter()
277        .map(|p| PluginBundle {
278            name: p.name,
279            version: p.version.unwrap_or_else(|| "0.1.0".to_string()),
280            plugin_type: p.plugin_type.unwrap_or_else(|| "plugin".to_string()),
281            wasm_bytes: p.wasm_bytes,
282            body_access: p.body_access,
283        })
284        .collect();
285
286    compile_inner(&specs, &plugin_bundles, output, options)
287}
288
289/// Load a manifest from a .bca artifact.
290pub fn load_manifest(artifact_path: &Path) -> Result<Manifest, CompileError> {
291    let file = File::open(artifact_path)?;
292    let decoder = flate2::read::GzDecoder::new(file);
293    let mut archive = tar::Archive::new(decoder);
294
295    for entry in archive.entries()? {
296        let mut entry = entry?;
297        let path = entry.path()?;
298
299        if path.to_str() == Some("manifest.json") {
300            let mut content = String::new();
301            entry.read_to_string(&mut content)?;
302            let manifest: Manifest = serde_json::from_str(&content)?;
303            return Ok(manifest);
304        }
305    }
306
307    Err(CompileError::Io(std::io::Error::new(
308        std::io::ErrorKind::NotFound,
309        "manifest.json not found in artifact",
310    )))
311}
312
313/// Load compiled routes from a .bca artifact.
314pub fn load_routes(artifact_path: &Path) -> Result<CompiledRoutes, CompileError> {
315    let file = File::open(artifact_path)?;
316    let decoder = flate2::read::GzDecoder::new(file);
317    let mut archive = tar::Archive::new(decoder);
318
319    for entry in archive.entries()? {
320        let mut entry = entry?;
321        let path = entry.path()?;
322
323        if path.to_str() == Some("routes.json") {
324            let mut content = String::new();
325            entry.read_to_string(&mut content)?;
326            let routes: CompiledRoutes = serde_json::from_str(&content)?;
327            return Ok(routes);
328        }
329    }
330
331    Err(CompileError::Io(std::io::Error::new(
332        std::io::ErrorKind::NotFound,
333        "routes.json not found in artifact",
334    )))
335}
336
337/// Load all source specs from a .bca artifact.
338/// Returns a map of filename -> content.
339pub fn load_specs(artifact_path: &Path) -> Result<HashMap<String, String>, CompileError> {
340    let file = File::open(artifact_path)?;
341    let decoder = flate2::read::GzDecoder::new(file);
342    let mut archive = tar::Archive::new(decoder);
343
344    let mut specs = HashMap::new();
345
346    for entry in archive.entries()? {
347        let mut entry = entry?;
348        let path_str = entry.path()?.to_string_lossy().into_owned();
349
350        if let Some(filename) = path_str.strip_prefix("specs/") {
351            if !filename.is_empty() {
352                let mut content = String::new();
353                entry.read_to_string(&mut content)?;
354                specs.insert(filename.to_string(), content);
355            }
356        }
357    }
358
359    Ok(specs)
360}
361
362/// Load all bundled plugins from a .bca artifact.
363/// Returns a map of plugin name -> LoadedPlugin.
364pub fn load_plugins(artifact_path: &Path) -> Result<HashMap<String, LoadedPlugin>, CompileError> {
365    // First load manifest to get plugin metadata
366    let manifest = load_manifest(artifact_path)?;
367
368    let file = File::open(artifact_path)?;
369    let decoder = flate2::read::GzDecoder::new(file);
370    let mut archive = tar::Archive::new(decoder);
371
372    let mut plugins = HashMap::new();
373
374    // Build a map of wasm_path -> plugin info from manifest
375    let plugin_info: HashMap<String, (&BundledPlugin,)> = manifest
376        .plugins
377        .iter()
378        .map(|p| (p.wasm_path.clone(), (p,)))
379        .collect();
380
381    for entry in archive.entries()? {
382        let mut entry = entry?;
383        let path_str = entry.path()?.to_string_lossy().into_owned();
384
385        if let Some((bundled,)) = plugin_info.get(&path_str) {
386            let mut wasm_bytes = Vec::new();
387            entry.read_to_end(&mut wasm_bytes)?;
388            plugins.insert(
389                bundled.name.clone(),
390                LoadedPlugin {
391                    version: bundled.version.clone(),
392                    wasm_bytes,
393                    body_access: bundled.capabilities.body_access,
394                },
395            );
396        }
397    }
398
399    Ok(plugins)
400}
401
402/// A plugin to be bundled into an artifact.
403pub struct PluginBundle {
404    /// Plugin name.
405    pub name: String,
406    /// Plugin version.
407    pub version: String,
408    /// Plugin type ("middleware" or "dispatcher").
409    pub plugin_type: String,
410    /// WASM binary content.
411    pub wasm_bytes: Vec<u8>,
412    /// Whether this plugin needs the request body.
413    pub body_access: bool,
414}
415
416/// Parse spec files into (ApiSpec, content, sha256) tuples.
417fn parse_specs(spec_paths: &[&Path]) -> Result<Vec<(ApiSpec, String, String)>, CompileError> {
418    let mut specs = Vec::new();
419    for path in spec_paths {
420        let content = std::fs::read_to_string(path)?;
421        let sha256 = compute_sha256(content.as_bytes());
422        let spec = parse_spec_file(path)?;
423        specs.push((spec, content, sha256));
424    }
425    Ok(specs)
426}
427
428/// Resolve middleware chain for an operation:
429/// - None: use global middlewares only
430/// - Some([]): explicit opt-out, no middlewares at all
431/// - Some([items]): global middlewares (excluding any overridden by name) + operation-level
432fn resolve_middlewares(
433    global: &[MiddlewareConfig],
434    operation: &Option<Vec<MiddlewareConfig>>,
435) -> Vec<MiddlewareConfig> {
436    match operation {
437        None => global.to_vec(),
438        Some(op_mw) if op_mw.is_empty() => Vec::new(),
439        Some(op_mw) => {
440            let op_names: HashSet<_> = op_mw.iter().map(|m| m.name.as_str()).collect();
441            let mut merged: Vec<_> = global
442                .iter()
443                .filter(|m| !op_names.contains(m.name.as_str()))
444                .cloned()
445                .collect();
446            merged.extend(op_mw.clone());
447            merged
448        }
449    }
450}
451
452/// Shared compilation core: validates specs, builds operations, and writes the .bca archive.
453fn compile_inner(
454    specs: &[(ApiSpec, String, String)],
455    plugins: &[PluginBundle],
456    output: &Path,
457    options: &CompileOptions,
458) -> Result<CompileResult, CompileError> {
459    let mut warnings: Vec<CompileWarning> = Vec::new();
460    let mut operations: Vec<CompiledOperation> = Vec::new();
461    let mut seen_routes: HashMap<(String, String), String> = HashMap::new();
462    let mut seen_structural: HashMap<(String, String), (String, String)> = HashMap::new();
463    let mut seen_operation_ids: HashMap<String, String> = HashMap::new();
464
465    // Extract root-level MCP config from first spec that has it
466    let root_mcp_config = extract_root_mcp_config(specs);
467
468    for (spec, _, _) in specs {
469        let spec_file = spec.filename.as_deref().unwrap_or("unknown");
470
471        // Validate global middlewares (E1011)
472        for (idx, mw) in spec.global_middlewares.iter().enumerate() {
473            if mw.name.is_empty() {
474                return Err(CompileError::MissingMiddlewareName(format!(
475                    "global middleware #{} in '{}'",
476                    idx + 1,
477                    spec_file
478                )));
479            }
480        }
481
482        // Check for unknown extensions at spec level (E1015 - warning)
483        for key in spec.extensions.keys() {
484            if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
485                warnings.push(CompileWarning {
486                    code: "E1015".to_string(),
487                    message: format!("unknown extension: {}", key),
488                    location: Some(spec_file.to_string()),
489                });
490            }
491        }
492
493        for op in &spec.operations {
494            let location = format!("{} {} in '{}'", op.method, op.path, spec_file);
495
496            // Check for unknown extensions at operation level (E1015 - warning)
497            for key in op.extensions.keys() {
498                if key.starts_with("x-barbacane-") && !KNOWN_EXTENSIONS.contains(&key.as_str()) {
499                    warnings.push(CompileWarning {
500                        code: "E1015".to_string(),
501                        message: format!("unknown extension: {}", key),
502                        location: Some(location.clone()),
503                    });
504                }
505            }
506
507            // Validate path template syntax (E1054)
508            validate_path_template(&op.path, &location)?;
509
510            // Check for duplicate operationId (E1055)
511            if let Some(ref op_id) = op.operation_id {
512                if let Some(first_location) = seen_operation_ids.get(op_id) {
513                    return Err(CompileError::DuplicateOperationId(
514                        op_id.clone(),
515                        format!("first at {}, duplicate at {}", first_location, location),
516                    ));
517                }
518                seen_operation_ids.insert(op_id.clone(), location.clone());
519            }
520
521            // Check for routing conflicts (E1010)
522            let key = (op.path.clone(), op.method.clone());
523            if let Some(other_spec) = seen_routes.get(&key) {
524                return Err(CompileError::RoutingConflict(format!(
525                    "{} {} declared in both '{}' and '{}'",
526                    op.method, op.path, other_spec, spec_file
527                )));
528            }
529            seen_routes.insert(key, spec_file.to_string());
530
531            // Check for ambiguous routes (E1050) - same structure, different param names
532            let normalized = normalize_path_template(&op.path);
533            let structural_key = (normalized, op.method.clone());
534            if let Some((other_path, other_spec)) = seen_structural.get(&structural_key) {
535                if other_path != &op.path {
536                    return Err(CompileError::AmbiguousRoute(format!(
537                        "'{}' and '{}' have same structure but different param names ({} in '{}' vs '{}')",
538                        op.path, other_path, op.method, spec_file, other_spec
539                    )));
540                }
541            }
542            seen_structural.insert(structural_key, (op.path.clone(), spec_file.to_string()));
543
544            // Check for missing dispatcher (E1020)
545            let dispatch = op.dispatch.clone().ok_or_else(|| {
546                CompileError::MissingDispatch(format!(
547                    "{} {} in '{}'",
548                    op.method, op.path, spec_file
549                ))
550            })?;
551
552            // Check for plaintext HTTP upstream URLs (E1031)
553            if !options.allow_plaintext {
554                if let Some(url) = extract_upstream_url(&dispatch.config) {
555                    if url.starts_with("http://") {
556                        return Err(CompileError::PlaintextUpstream(format!(
557                            "{} {} in '{}' - upstream URL: {}",
558                            op.method, op.path, spec_file, url
559                        )));
560                    }
561                }
562            }
563
564            let middlewares = resolve_middlewares(&spec.global_middlewares, &op.middlewares);
565
566            // Validate middleware names (E1011)
567            for (idx, mw) in middlewares.iter().enumerate() {
568                if mw.name.is_empty() {
569                    return Err(CompileError::MissingMiddlewareName(format!(
570                        "middleware #{} in {}",
571                        idx + 1,
572                        location
573                    )));
574                }
575            }
576
577            // Validate schema complexity for parameters (E1051, E1052)
578            // Note: circular $ref detection (E1053) is now performed at parse time.
579            for param in &op.parameters {
580                if let Some(schema) = &param.schema {
581                    let param_location = format!("{} parameter '{}'", location, param.name);
582                    validate_schema_complexity(
583                        schema,
584                        options.max_schema_depth,
585                        options.max_schema_properties,
586                        &param_location,
587                    )?;
588                }
589            }
590
591            // Validate request body schema complexity (E1051, E1052)
592            if let Some(ref body) = op.request_body {
593                for (content_type, content) in &body.content {
594                    if let Some(schema) = &content.schema {
595                        let body_location = format!("{} request body ({})", location, content_type);
596                        validate_schema_complexity(
597                            schema,
598                            options.max_schema_depth,
599                            options.max_schema_properties,
600                            &body_location,
601                        )?;
602                    }
603                }
604            }
605
606            // Resolve MCP enabled state for this operation
607            let (mcp_enabled, mcp_description) =
608                resolve_mcp_config(&root_mcp_config, op.extensions.get("x-barbacane-mcp"));
609
610            // MCP warnings
611            if mcp_enabled == Some(true) {
612                if op.operation_id.is_none() {
613                    warnings.push(CompileWarning {
614                        code: "E1060".to_string(),
615                        message: "operation without operationId cannot be exposed as MCP tool"
616                            .to_string(),
617                        location: Some(location.clone()),
618                    });
619                }
620                if op.summary.is_none() && op.description.is_none() {
621                    warnings.push(CompileWarning {
622                        code: "E1061".to_string(),
623                        message:
624                            "MCP-enabled operation has no summary or description for tool metadata"
625                                .to_string(),
626                        location: Some(location.clone()),
627                    });
628                }
629            }
630
631            operations.push(CompiledOperation {
632                index: operations.len(),
633                path: op.path.clone(),
634                method: op.method.clone(),
635                operation_id: op.operation_id.clone(),
636                summary: op.summary.clone(),
637                description: op.description.clone(),
638                parameters: op.parameters.clone(),
639                request_body: op.request_body.clone(),
640                dispatch,
641                middlewares,
642                deprecated: op.deprecated,
643                sunset: op.sunset.clone(),
644                messages: op.messages.clone(),
645                bindings: op.bindings.clone(),
646                responses: op.responses.clone(),
647                mcp_enabled,
648                mcp_description,
649            });
650        }
651    }
652
653    // Sort operations by (path, method) for deterministic output, then reassign indices
654    operations.sort_by(|a, b| (&a.path, &a.method).cmp(&(&b.path, &b.method)));
655    for (i, op) in operations.iter_mut().enumerate() {
656        op.index = i;
657    }
658
659    // Build routes.json
660    let routes = CompiledRoutes { operations };
661    let routes_json = serde_json::to_string_pretty(&routes)?;
662    let routes_sha256 = compute_sha256(routes_json.as_bytes());
663
664    // Build plugin metadata
665    let mut bundled_plugins = Vec::new();
666    let mut checksums = BTreeMap::new();
667    checksums.insert(
668        "routes.json".to_string(),
669        format!("sha256:{}", routes_sha256),
670    );
671
672    for plugin in plugins {
673        let wasm_path = format!("plugins/{}.wasm", plugin.name);
674        let sha256 = compute_sha256(&plugin.wasm_bytes);
675
676        checksums.insert(wasm_path.clone(), format!("sha256:{}", sha256));
677
678        bundled_plugins.push(BundledPlugin {
679            name: plugin.name.clone(),
680            version: plugin.version.clone(),
681            plugin_type: plugin.plugin_type.clone(),
682            wasm_path,
683            sha256,
684            capabilities: PluginCapabilities {
685                body_access: plugin.body_access,
686            },
687        });
688    }
689
690    // Sort bundled_plugins by name for deterministic output
691    bundled_plugins.sort_by(|a, b| a.name.cmp(&b.name));
692
693    // Build manifest
694    let mut source_specs: Vec<SourceSpec> = specs
695        .iter()
696        .map(|(spec, _, sha256)| SourceSpec {
697            file: spec
698                .filename
699                .clone()
700                .unwrap_or_else(|| "unknown".to_string()),
701            sha256: sha256.clone(),
702            spec_type: match spec.format {
703                SpecFormat::OpenApi => "openapi".to_string(),
704                SpecFormat::AsyncApi => "asyncapi".to_string(),
705            },
706            version: spec.version.clone(),
707        })
708        .collect();
709
710    // Sort source_specs by filename for deterministic output
711    source_specs.sort_by(|a, b| a.file.cmp(&b.file));
712
713    let artifact_hash = compute_artifact_hash(&source_specs, &checksums);
714
715    let provenance = Provenance {
716        commit: options.provenance_commit.clone(),
717        source: options.provenance_source.clone(),
718    };
719
720    // Build MCP config for manifest, defaulting server_name/server_version from spec info
721    let mcp = {
722        let mut cfg = root_mcp_config.clone();
723        if cfg.enabled {
724            if cfg.server_name.is_none() {
725                cfg.server_name = specs.first().map(|(s, _, _)| s.title.clone());
726            }
727            if cfg.server_version.is_none() {
728                cfg.server_version = specs.first().map(|(s, _, _)| s.api_version.clone());
729            }
730        }
731        cfg
732    };
733
734    let manifest = Manifest {
735        barbacane_artifact_version: ARTIFACT_VERSION,
736        compiled_at: now_utc_iso8601(),
737        compiler_version: COMPILER_VERSION.to_string(),
738        source_specs,
739        routes_count: routes.operations.len(),
740        checksums,
741        plugins: bundled_plugins,
742        artifact_hash,
743        provenance,
744        mcp,
745    };
746
747    let manifest_json = serde_json::to_string_pretty(&manifest)?;
748
749    // Create the .bca archive (tar.gz)
750    let file = File::create(output)?;
751    let encoder = GzEncoder::new(file, Compression::default());
752    let mut archive = Builder::new(encoder);
753
754    // Add manifest.json and routes.json
755    add_file_to_tar(&mut archive, "manifest.json", manifest_json.as_bytes())?;
756    add_file_to_tar(&mut archive, "routes.json", routes_json.as_bytes())?;
757
758    // Add source specs under specs/ directory
759    for (spec, content, _) in specs {
760        let filename = spec
761            .filename
762            .as_deref()
763            .and_then(|p| Path::new(p).file_name())
764            .and_then(|n| n.to_str())
765            .unwrap_or("spec.yaml");
766        let archive_path = format!("specs/{}", filename);
767        add_file_to_tar(&mut archive, &archive_path, content.as_bytes())?;
768    }
769
770    // Add plugins
771    for plugin in plugins {
772        let wasm_path = format!("plugins/{}.wasm", plugin.name);
773        add_file_to_tar(&mut archive, &wasm_path, &plugin.wasm_bytes)?;
774    }
775
776    // Finish the archive
777    let encoder = archive.into_inner()?;
778    encoder.finish()?;
779
780    // Sort warnings for deterministic output
781    warnings.sort_by(|a, b| {
782        (&a.location, &a.code, &a.message).cmp(&(&b.location, &b.code, &b.message))
783    });
784
785    Ok(CompileResult { manifest, warnings })
786}
787
788/// Compute SHA-256 hash of bytes.
789fn compute_sha256(content: &[u8]) -> String {
790    hex::encode(Sha256::new().chain_update(content).finalize())
791}
792
793/// Compute a combined artifact hash from all individual input checksums.
794///
795/// Produces a single SHA-256 that represents the entire artifact content by
796/// hashing all source spec hashes and all checksums (routes + plugins) in
797/// deterministic sorted order.
798fn compute_artifact_hash(
799    source_specs: &[SourceSpec],
800    checksums: &BTreeMap<String, String>,
801) -> String {
802    let mut hasher = Sha256::new();
803    // Source spec hashes (already sorted by filename before this call)
804    for spec in source_specs {
805        hasher.update(format!("source_spec:{}={}\n", spec.file, spec.sha256).as_bytes());
806    }
807    // Routes + plugin checksums (BTreeMap is sorted by key)
808    for (key, value) in checksums {
809        hasher.update(format!("{}={}\n", key, value).as_bytes());
810    }
811    format!("sha256:{}", hex::encode(hasher.finalize()))
812}
813
814/// Add a file to a tar archive from bytes.
815fn add_file_to_tar<W: Write>(
816    archive: &mut Builder<W>,
817    name: &str,
818    content: &[u8],
819) -> std::io::Result<()> {
820    let mut header = tar::Header::new_gnu();
821    header.set_size(content.len() as u64);
822    header.set_mode(0o644);
823    header.set_mtime(0); // Reproducible builds
824    header.set_cksum();
825    archive.append_data(&mut header, name, content)
826}
827
828/// Get current UTC timestamp in ISO 8601 format.
829fn now_utc_iso8601() -> String {
830    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
831}
832
833/// Extract upstream URL from dispatch config, if present.
834///
835/// Looks for common URL fields in the dispatch config:
836/// - `url` (e.g., for http-upstream dispatcher)
837/// - `upstream` (alternative field name)
838fn extract_upstream_url(config: &serde_json::Value) -> Option<String> {
839    // Check for "url" field
840    if let Some(url) = config.get("url").and_then(|v| v.as_str()) {
841        return Some(url.to_string());
842    }
843
844    // Check for "upstream" field (which could be a URL or a name)
845    if let Some(upstream) = config.get("upstream").and_then(|v| v.as_str()) {
846        // Only return if it looks like a URL
847        if upstream.starts_with("http://") || upstream.starts_with("https://") {
848            return Some(upstream.to_string());
849        }
850    }
851
852    None
853}
854
855/// Validate path template syntax (E1054).
856///
857/// Checks for:
858/// - Balanced braces
859/// - Non-empty parameter names
860/// - Valid characters in parameter names (alphanumeric + underscore)
861/// - Wildcard suffix `+` allowed only as the last character before `}`, and only on the final segment
862/// - At most one wildcard parameter per path
863/// - No duplicate parameter names in the same path
864fn validate_path_template(path: &str, location: &str) -> Result<(), CompileError> {
865    let mut seen_params: HashSet<String> = HashSet::new();
866    let mut brace_depth = 0;
867    let mut current_param = String::new();
868    let mut in_param = false;
869    let mut has_wildcard = false;
870
871    // Split into segments to enforce "wildcard must be the last segment" rule.
872    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
873    let last_segment = segments.last().copied().unwrap_or("");
874
875    for ch in path.chars() {
876        match ch {
877            '{' => {
878                if in_param {
879                    return Err(CompileError::InvalidPathTemplate(format!(
880                        "{} - nested braces not allowed",
881                        location
882                    )));
883                }
884                brace_depth += 1;
885                in_param = true;
886            }
887            '}' => {
888                if !in_param {
889                    return Err(CompileError::InvalidPathTemplate(format!(
890                        "{} - unmatched closing brace",
891                        location
892                    )));
893                }
894                brace_depth -= 1;
895                in_param = false;
896
897                let is_wildcard_param = current_param.ends_with('+');
898                let base_name = if is_wildcard_param {
899                    &current_param[..current_param.len() - 1]
900                } else {
901                    &current_param
902                };
903
904                if base_name.is_empty() {
905                    return Err(CompileError::InvalidPathTemplate(format!(
906                        "{} - empty parameter name",
907                        location
908                    )));
909                }
910
911                if is_wildcard_param {
912                    if has_wildcard {
913                        return Err(CompileError::InvalidPathTemplate(format!(
914                            "{} - at most one wildcard parameter ({{name+}}) allowed per path",
915                            location
916                        )));
917                    }
918                    // Wildcard must be the last segment
919                    let param_segment = format!("{{{}}}", current_param);
920                    if last_segment != param_segment {
921                        return Err(CompileError::InvalidPathTemplate(format!(
922                            "{} - wildcard parameter '{{{}}}' must be the last path segment",
923                            location, current_param
924                        )));
925                    }
926                    has_wildcard = true;
927                }
928
929                if !seen_params.insert(base_name.to_string()) {
930                    return Err(CompileError::InvalidPathTemplate(format!(
931                        "{} - duplicate parameter '{}'",
932                        location, base_name
933                    )));
934                }
935                current_param.clear();
936            }
937            _ if in_param => {
938                // Allow `+` only as the final character before `}` (wildcard suffix).
939                // We check this lazily: accept `+` here but verify at `}` that it's last.
940                if ch == '+' {
941                    // Peek-ahead isn't available in a char iterator; we'll detect misplacement
942                    // at `}` time by checking that `+` is the last char of current_param.
943                    current_param.push(ch);
944                } else if !ch.is_alphanumeric() && ch != '_' {
945                    return Err(CompileError::InvalidPathTemplate(format!(
946                        "{} - invalid character '{}' in parameter name",
947                        location, ch
948                    )));
949                } else if current_param.ends_with('+') {
950                    // A non-`}` character after `+` means `+` was mid-name, not a suffix.
951                    return Err(CompileError::InvalidPathTemplate(format!(
952                        "{} - '+' is only allowed as the last character of a wildcard parameter name (e.g. {{key+}})",
953                        location
954                    )));
955                } else {
956                    current_param.push(ch);
957                }
958            }
959            _ => {}
960        }
961    }
962
963    if brace_depth != 0 {
964        return Err(CompileError::InvalidPathTemplate(format!(
965            "{} - unclosed brace",
966            location
967        )));
968    }
969
970    Ok(())
971}
972
973/// Normalize a path template for structural comparison (E1050).
974///
975/// Replaces parameter names with a placeholder while preserving the wildcard `+` modifier:
976/// - `/users/{id}` -> `/users/{_}`
977/// - `/files/{bucket}/{key+}` -> `/files/{_}/{_+}`
978fn normalize_path_template(path: &str) -> String {
979    let mut result = String::with_capacity(path.len());
980    let mut in_param = false;
981    let mut is_wildcard = false;
982
983    for ch in path.chars() {
984        match ch {
985            '{' => {
986                result.push('{');
987                result.push('_');
988                in_param = true;
989                is_wildcard = false;
990            }
991            '}' => {
992                if is_wildcard {
993                    result.push('+');
994                }
995                result.push('}');
996                in_param = false;
997                is_wildcard = false;
998            }
999            '+' if in_param => {
1000                // Mark as wildcard; the `+` is emitted at `}` time.
1001                is_wildcard = true;
1002            }
1003            _ if in_param => {
1004                // Skip parameter name characters
1005            }
1006            _ => {
1007                result.push(ch);
1008            }
1009        }
1010    }
1011
1012    result
1013}
1014
1015/// Validate schema complexity (E1051, E1052).
1016fn validate_schema_complexity(
1017    schema: &serde_json::Value,
1018    max_depth: usize,
1019    max_properties: usize,
1020    location: &str,
1021) -> Result<(), CompileError> {
1022    let (depth, props) = measure_schema_complexity(schema, 0);
1023
1024    if depth > max_depth {
1025        return Err(CompileError::SchemaTooDeep(format!(
1026            "{} - depth {} exceeds limit {}",
1027            location, depth, max_depth
1028        )));
1029    }
1030    if props > max_properties {
1031        return Err(CompileError::SchemaTooComplex(format!(
1032            "{} - {} properties exceed limit {}",
1033            location, props, max_properties
1034        )));
1035    }
1036    Ok(())
1037}
1038
1039/// Measure schema complexity: returns (max_depth, total_property_count).
1040fn measure_schema_complexity(value: &serde_json::Value, current_depth: usize) -> (usize, usize) {
1041    match value {
1042        serde_json::Value::Object(obj) => {
1043            let mut max_depth = current_depth;
1044            let mut total_props = 0;
1045
1046            // Count properties in "properties" field
1047            if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1048                total_props += props.len();
1049                for prop_value in props.values() {
1050                    let (d, p) = measure_schema_complexity(prop_value, current_depth + 1);
1051                    max_depth = max_depth.max(d);
1052                    total_props += p;
1053                }
1054            }
1055
1056            // Handle items (for arrays)
1057            if let Some(items) = obj.get("items") {
1058                let (d, p) = measure_schema_complexity(items, current_depth + 1);
1059                max_depth = max_depth.max(d);
1060                total_props += p;
1061            }
1062
1063            // Handle allOf, oneOf, anyOf
1064            for key in ["allOf", "oneOf", "anyOf"] {
1065                if let Some(serde_json::Value::Array(schemas)) = obj.get(key) {
1066                    for schema in schemas {
1067                        let (d, p) = measure_schema_complexity(schema, current_depth + 1);
1068                        max_depth = max_depth.max(d);
1069                        total_props += p;
1070                    }
1071                }
1072            }
1073
1074            // Handle additionalProperties if it's a schema
1075            if let Some(additional) = obj.get("additionalProperties") {
1076                if additional.is_object() {
1077                    let (d, p) = measure_schema_complexity(additional, current_depth + 1);
1078                    max_depth = max_depth.max(d);
1079                    total_props += p;
1080                }
1081            }
1082
1083            (max_depth, total_props)
1084        }
1085        serde_json::Value::Array(arr) => {
1086            let mut max_depth = current_depth;
1087            let mut total_props = 0;
1088            for item in arr {
1089                let (d, p) = measure_schema_complexity(item, current_depth + 1);
1090                max_depth = max_depth.max(d);
1091                total_props += p;
1092            }
1093            (max_depth, total_props)
1094        }
1095        _ => (current_depth, 0),
1096    }
1097}
1098
1099// Need to add hex encoding manually since we don't have the hex crate
1100mod hex {
1101    const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
1102
1103    pub fn encode(bytes: impl AsRef<[u8]>) -> String {
1104        let bytes = bytes.as_ref();
1105        let mut result = String::with_capacity(bytes.len() * 2);
1106        for &byte in bytes {
1107            result.push(HEX_CHARS[(byte >> 4) as usize] as char);
1108            result.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
1109        }
1110        result
1111    }
1112}
1113
1114/// Extract root-level `x-barbacane-mcp` config from the first spec that defines it.
1115fn extract_root_mcp_config(specs: &[(ApiSpec, String, String)]) -> McpConfig {
1116    for (spec, _, _) in specs {
1117        if let Some(mcp_value) = spec.extensions.get("x-barbacane-mcp") {
1118            let enabled = mcp_value
1119                .get("enabled")
1120                .and_then(|v| v.as_bool())
1121                .unwrap_or(false);
1122            let server_name = mcp_value
1123                .get("server_name")
1124                .and_then(|v| v.as_str())
1125                .map(|s| s.to_string());
1126            let server_version = mcp_value
1127                .get("server_version")
1128                .and_then(|v| v.as_str())
1129                .map(|s| s.to_string());
1130            return McpConfig {
1131                enabled,
1132                server_name,
1133                server_version,
1134            };
1135        }
1136    }
1137    McpConfig::default()
1138}
1139
1140/// Resolve MCP enabled/description for a single operation from root + operation-level config.
1141fn resolve_mcp_config(
1142    root: &McpConfig,
1143    op_extension: Option<&serde_json::Value>,
1144) -> (Option<bool>, Option<String>) {
1145    if let Some(ext) = op_extension {
1146        let enabled = ext.get("enabled").and_then(|v| v.as_bool());
1147        let description = ext
1148            .get("description")
1149            .and_then(|v| v.as_str())
1150            .map(|s| s.to_string());
1151        // Operation-level enabled wins; if not set, inherit from root
1152        let resolved_enabled = enabled.or(if root.enabled { Some(true) } else { None });
1153        (resolved_enabled, description)
1154    } else if root.enabled {
1155        (Some(true), None)
1156    } else {
1157        (None, None)
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use std::io::Write;
1165    use tempfile::TempDir;
1166
1167    fn create_test_spec(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
1168        let path = dir.join(name);
1169        let mut file = File::create(&path).unwrap();
1170        file.write_all(content.as_bytes()).unwrap();
1171        path
1172    }
1173
1174    #[test]
1175    fn compile_minimal_spec() {
1176        let temp = TempDir::new().unwrap();
1177
1178        let spec_content = r#"
1179openapi: "3.1.0"
1180info:
1181  title: Test API
1182  version: "1.0.0"
1183paths:
1184  /health:
1185    get:
1186      x-barbacane-dispatch:
1187        name: mock
1188        config:
1189          status: 200
1190"#;
1191        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1192        let output_path = temp.path().join("artifact.bca");
1193
1194        let result = compile(
1195            &[spec_path.as_path()],
1196            &[],
1197            &output_path,
1198            &CompileOptions::default(),
1199        )
1200        .unwrap();
1201
1202        assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
1203        assert_eq!(result.manifest.routes_count, 1);
1204        assert_eq!(result.manifest.source_specs.len(), 1);
1205        assert_eq!(result.manifest.source_specs[0].spec_type, "openapi");
1206
1207        // Verify the artifact file was created
1208        assert!(output_path.exists());
1209    }
1210
1211    #[test]
1212    fn compile_detects_missing_dispatch() {
1213        let temp = TempDir::new().unwrap();
1214
1215        let spec_content = r#"
1216openapi: "3.1.0"
1217info:
1218  title: Test API
1219  version: "1.0.0"
1220paths:
1221  /health:
1222    get:
1223      operationId: getHealth
1224"#;
1225        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1226        let output_path = temp.path().join("artifact.bca");
1227
1228        let result = compile(
1229            &[spec_path.as_path()],
1230            &[],
1231            &output_path,
1232            &CompileOptions::default(),
1233        );
1234
1235        assert!(matches!(result, Err(CompileError::MissingDispatch(_))));
1236    }
1237
1238    #[test]
1239    fn compile_detects_routing_conflict() {
1240        let temp = TempDir::new().unwrap();
1241
1242        let spec1 = r#"
1243openapi: "3.1.0"
1244info:
1245  title: API 1
1246  version: "1.0.0"
1247paths:
1248  /users:
1249    get:
1250      x-barbacane-dispatch:
1251        name: mock
1252"#;
1253        let spec2 = r#"
1254openapi: "3.1.0"
1255info:
1256  title: API 2
1257  version: "1.0.0"
1258paths:
1259  /users:
1260    get:
1261      x-barbacane-dispatch:
1262        name: mock
1263"#;
1264        let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
1265        let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
1266        let output_path = temp.path().join("artifact.bca");
1267
1268        let result = compile(
1269            &[path1.as_path(), path2.as_path()],
1270            &[],
1271            &output_path,
1272            &CompileOptions::default(),
1273        );
1274
1275        assert!(matches!(result, Err(CompileError::RoutingConflict(_))));
1276    }
1277
1278    #[test]
1279    fn load_artifact_manifest() {
1280        let temp = TempDir::new().unwrap();
1281
1282        let spec_content = r#"
1283openapi: "3.1.0"
1284info:
1285  title: Test API
1286  version: "1.0.0"
1287paths:
1288  /health:
1289    get:
1290      x-barbacane-dispatch:
1291        name: mock
1292"#;
1293        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1294        let output_path = temp.path().join("artifact.bca");
1295
1296        compile(
1297            &[spec_path.as_path()],
1298            &[],
1299            &output_path,
1300            &CompileOptions::default(),
1301        )
1302        .unwrap();
1303
1304        let loaded = load_manifest(&output_path).unwrap();
1305        assert_eq!(loaded.barbacane_artifact_version, ARTIFACT_VERSION);
1306        assert_eq!(loaded.routes_count, 1);
1307    }
1308
1309    #[test]
1310    fn load_artifact_routes() {
1311        let temp = TempDir::new().unwrap();
1312
1313        let spec_content = r#"
1314openapi: "3.1.0"
1315info:
1316  title: Test API
1317  version: "1.0.0"
1318paths:
1319  /health:
1320    get:
1321      x-barbacane-dispatch:
1322        name: mock
1323        config:
1324          status: 200
1325  /users/{id}:
1326    get:
1327      x-barbacane-dispatch:
1328        name: mock
1329"#;
1330        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1331        let output_path = temp.path().join("artifact.bca");
1332
1333        compile(
1334            &[spec_path.as_path()],
1335            &[],
1336            &output_path,
1337            &CompileOptions::default(),
1338        )
1339        .unwrap();
1340
1341        let routes = load_routes(&output_path).unwrap();
1342        assert_eq!(routes.operations.len(), 2);
1343    }
1344
1345    #[test]
1346    fn compile_rejects_plaintext_http_url() {
1347        let temp = TempDir::new().unwrap();
1348
1349        let spec_content = r#"
1350openapi: "3.1.0"
1351info:
1352  title: Test API
1353  version: "1.0.0"
1354paths:
1355  /proxy:
1356    get:
1357      x-barbacane-dispatch:
1358        name: http-upstream
1359        config:
1360          url: "http://backend.internal:8080/api"
1361"#;
1362        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1363        let output_path = temp.path().join("artifact.bca");
1364
1365        // Default options reject plaintext HTTP
1366        let result = compile(
1367            &[spec_path.as_path()],
1368            &[],
1369            &output_path,
1370            &CompileOptions::default(),
1371        );
1372        assert!(matches!(result, Err(CompileError::PlaintextUpstream(_))));
1373
1374        // With allow_plaintext, it should succeed
1375        let result = compile(
1376            &[spec_path.as_path()],
1377            &[],
1378            &output_path,
1379            &CompileOptions {
1380                allow_plaintext: true,
1381                ..Default::default()
1382            },
1383        );
1384        assert!(result.is_ok());
1385    }
1386
1387    #[test]
1388    fn compile_allows_https_url() {
1389        let temp = TempDir::new().unwrap();
1390
1391        let spec_content = r#"
1392openapi: "3.1.0"
1393info:
1394  title: Test API
1395  version: "1.0.0"
1396paths:
1397  /proxy:
1398    get:
1399      x-barbacane-dispatch:
1400        name: http-upstream
1401        config:
1402          url: "https://backend.internal:8080/api"
1403"#;
1404        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1405        let output_path = temp.path().join("artifact.bca");
1406
1407        // HTTPS should be allowed by default
1408        let result = compile(
1409            &[spec_path.as_path()],
1410            &[],
1411            &output_path,
1412            &CompileOptions::default(),
1413        );
1414        assert!(result.is_ok());
1415    }
1416
1417    #[test]
1418    fn compile_with_bundled_plugins() {
1419        let temp = TempDir::new().unwrap();
1420
1421        let spec_content = r#"
1422openapi: "3.1.0"
1423info:
1424  title: Test API
1425  version: "1.0.0"
1426paths:
1427  /health:
1428    get:
1429      x-barbacane-dispatch:
1430        name: mock
1431"#;
1432        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1433        let output_path = temp.path().join("artifact.bca");
1434
1435        // Create a fake plugin (minimal valid WASM)
1436        let fake_wasm = vec![
1437            0x00, 0x61, 0x73, 0x6d, // magic
1438            0x01, 0x00, 0x00, 0x00, // version
1439        ];
1440
1441        let plugins = vec![PluginBundle {
1442            name: "test-plugin".to_string(),
1443            version: "1.0.0".to_string(),
1444            plugin_type: "middleware".to_string(),
1445            wasm_bytes: fake_wasm.clone(),
1446            body_access: false,
1447        }];
1448
1449        let result = compile(
1450            &[spec_path.as_path()],
1451            &plugins,
1452            &output_path,
1453            &CompileOptions::default(),
1454        )
1455        .unwrap();
1456
1457        assert_eq!(result.manifest.plugins.len(), 1);
1458        assert_eq!(result.manifest.plugins[0].name, "test-plugin");
1459        assert_eq!(result.manifest.plugins[0].version, "1.0.0");
1460        assert_eq!(result.manifest.plugins[0].plugin_type, "middleware");
1461        assert_eq!(
1462            result.manifest.plugins[0].wasm_path,
1463            "plugins/test-plugin.wasm"
1464        );
1465
1466        // Load plugins back
1467        let loaded = load_plugins(&output_path).unwrap();
1468        assert_eq!(loaded.len(), 1);
1469        let plugin = loaded.get("test-plugin").unwrap();
1470        assert_eq!(plugin.version, "1.0.0");
1471        assert_eq!(plugin.wasm_bytes, fake_wasm);
1472    }
1473
1474    #[test]
1475    fn compile_asyncapi_spec() {
1476        let temp = TempDir::new().unwrap();
1477
1478        let spec_content = r#"
1479asyncapi: "3.0.0"
1480info:
1481  title: User Events API
1482  version: "1.0.0"
1483channels:
1484  userSignedUp:
1485    address: user/signedup
1486    messages:
1487      UserSignedUpMessage:
1488        contentType: application/json
1489        payload:
1490          type: object
1491          properties:
1492            userId:
1493              type: string
1494    bindings:
1495      kafka:
1496        topic: user-events
1497        partitions: 10
1498operations:
1499  processUserSignup:
1500    action: receive
1501    channel:
1502      $ref: '#/channels/userSignedUp'
1503    x-barbacane-dispatch:
1504      name: kafka
1505      config:
1506        topic: user-events
1507    bindings:
1508      kafka:
1509        groupId: user-processor
1510"#;
1511        let spec_path = create_test_spec(temp.path(), "events.yaml", spec_content);
1512        let output_path = temp.path().join("artifact.bca");
1513
1514        let result = compile(
1515            &[spec_path.as_path()],
1516            &[],
1517            &output_path,
1518            &CompileOptions::default(),
1519        )
1520        .unwrap();
1521
1522        assert_eq!(result.manifest.barbacane_artifact_version, ARTIFACT_VERSION);
1523        assert_eq!(result.manifest.routes_count, 1);
1524        assert_eq!(result.manifest.source_specs.len(), 1);
1525        assert_eq!(result.manifest.source_specs[0].spec_type, "asyncapi");
1526
1527        // Load routes and verify AsyncAPI fields
1528        let routes = load_routes(&output_path).unwrap();
1529        assert_eq!(routes.operations.len(), 1);
1530
1531        let op = &routes.operations[0];
1532        assert_eq!(op.path, "user/signedup");
1533        assert_eq!(op.method, "RECEIVE");
1534        assert_eq!(op.operation_id, Some("processUserSignup".to_string()));
1535
1536        // Verify messages are preserved
1537        assert_eq!(op.messages.len(), 1);
1538        assert_eq!(op.messages[0].name, "UserSignedUpMessage");
1539        assert_eq!(
1540            op.messages[0].content_type,
1541            Some("application/json".to_string())
1542        );
1543
1544        // Verify bindings are preserved (operation binding overrides channel)
1545        assert!(op.bindings.contains_key("kafka"));
1546        let kafka_binding = op.bindings.get("kafka").unwrap();
1547        assert_eq!(
1548            kafka_binding.get("groupId").and_then(|v| v.as_str()),
1549            Some("user-processor")
1550        );
1551    }
1552
1553    #[test]
1554    fn compile_asyncapi_send_operation() {
1555        let temp = TempDir::new().unwrap();
1556
1557        let spec_content = r#"
1558asyncapi: "3.0.0"
1559info:
1560  title: Notification Service
1561  version: "1.0.0"
1562channels:
1563  notifications:
1564    address: notifications/{userId}
1565    parameters:
1566      userId:
1567        schema:
1568          type: string
1569    messages:
1570      NotificationMessage:
1571        contentType: application/json
1572        payload:
1573          type: object
1574          required:
1575            - title
1576          properties:
1577            title:
1578              type: string
1579operations:
1580  sendNotification:
1581    action: send
1582    channel:
1583      $ref: '#/channels/notifications'
1584    x-barbacane-dispatch:
1585      name: nats
1586      config:
1587        subject: notifications
1588"#;
1589        let spec_path = create_test_spec(temp.path(), "notifications.yaml", spec_content);
1590        let output_path = temp.path().join("artifact.bca");
1591
1592        let result = compile(
1593            &[spec_path.as_path()],
1594            &[],
1595            &output_path,
1596            &CompileOptions::default(),
1597        )
1598        .unwrap();
1599
1600        assert_eq!(result.manifest.routes_count, 1);
1601
1602        let routes = load_routes(&output_path).unwrap();
1603        let op = &routes.operations[0];
1604
1605        assert_eq!(op.path, "notifications/{userId}");
1606        assert_eq!(op.method, "SEND");
1607
1608        // SEND operations should have channel parameters
1609        assert_eq!(op.parameters.len(), 1);
1610        assert_eq!(op.parameters[0].name, "userId");
1611
1612        // SEND operations should have request_body from message payload
1613        assert!(op.request_body.is_some());
1614    }
1615
1616    #[test]
1617    fn compile_detects_invalid_path_template_unclosed_brace() {
1618        let temp = TempDir::new().unwrap();
1619
1620        let spec_content = r#"
1621openapi: "3.1.0"
1622info:
1623  title: Test API
1624  version: "1.0.0"
1625paths:
1626  /users/{id:
1627    get:
1628      x-barbacane-dispatch:
1629        name: mock
1630"#;
1631        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1632        let output_path = temp.path().join("artifact.bca");
1633
1634        let result = compile(
1635            &[spec_path.as_path()],
1636            &[],
1637            &output_path,
1638            &CompileOptions::default(),
1639        );
1640
1641        assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1642    }
1643
1644    #[test]
1645    fn compile_detects_invalid_path_template_empty_param() {
1646        let temp = TempDir::new().unwrap();
1647
1648        let spec_content = r#"
1649openapi: "3.1.0"
1650info:
1651  title: Test API
1652  version: "1.0.0"
1653paths:
1654  /users/{}:
1655    get:
1656      x-barbacane-dispatch:
1657        name: mock
1658"#;
1659        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1660        let output_path = temp.path().join("artifact.bca");
1661
1662        let result = compile(
1663            &[spec_path.as_path()],
1664            &[],
1665            &output_path,
1666            &CompileOptions::default(),
1667        );
1668
1669        assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1670    }
1671
1672    #[test]
1673    fn compile_detects_invalid_path_template_duplicate_param() {
1674        let temp = TempDir::new().unwrap();
1675
1676        let spec_content = r#"
1677openapi: "3.1.0"
1678info:
1679  title: Test API
1680  version: "1.0.0"
1681paths:
1682  /users/{id}/posts/{id}:
1683    get:
1684      x-barbacane-dispatch:
1685        name: mock
1686"#;
1687        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1688        let output_path = temp.path().join("artifact.bca");
1689
1690        let result = compile(
1691            &[spec_path.as_path()],
1692            &[],
1693            &output_path,
1694            &CompileOptions::default(),
1695        );
1696
1697        assert!(matches!(result, Err(CompileError::InvalidPathTemplate(_))));
1698    }
1699
1700    #[test]
1701    fn compile_detects_duplicate_operation_ids() {
1702        let temp = TempDir::new().unwrap();
1703
1704        let spec_content = r#"
1705openapi: "3.1.0"
1706info:
1707  title: Test API
1708  version: "1.0.0"
1709paths:
1710  /users:
1711    get:
1712      operationId: getUsers
1713      x-barbacane-dispatch:
1714        name: mock
1715  /customers:
1716    get:
1717      operationId: getUsers
1718      x-barbacane-dispatch:
1719        name: mock
1720"#;
1721        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1722        let output_path = temp.path().join("artifact.bca");
1723
1724        let result = compile(
1725            &[spec_path.as_path()],
1726            &[],
1727            &output_path,
1728            &CompileOptions::default(),
1729        );
1730
1731        assert!(matches!(
1732            result,
1733            Err(CompileError::DuplicateOperationId(_, _))
1734        ));
1735    }
1736
1737    #[test]
1738    fn compile_detects_missing_middleware_name() {
1739        let temp = TempDir::new().unwrap();
1740
1741        let spec_content = r#"
1742openapi: "3.1.0"
1743info:
1744  title: Test API
1745  version: "1.0.0"
1746paths:
1747  /users:
1748    get:
1749      x-barbacane-dispatch:
1750        name: mock
1751      x-barbacane-middlewares:
1752        - name: ""
1753          config: {}
1754"#;
1755        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1756        let output_path = temp.path().join("artifact.bca");
1757
1758        let result = compile(
1759            &[spec_path.as_path()],
1760            &[],
1761            &output_path,
1762            &CompileOptions::default(),
1763        );
1764
1765        assert!(matches!(
1766            result,
1767            Err(CompileError::MissingMiddlewareName(_))
1768        ));
1769    }
1770
1771    #[test]
1772    fn compile_detects_missing_global_middleware_name() {
1773        let temp = TempDir::new().unwrap();
1774
1775        let spec_content = r#"
1776openapi: "3.1.0"
1777info:
1778  title: Test API
1779  version: "1.0.0"
1780x-barbacane-middlewares:
1781  - name: ""
1782    config: {}
1783paths:
1784  /users:
1785    get:
1786      x-barbacane-dispatch:
1787        name: mock
1788"#;
1789        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1790        let output_path = temp.path().join("artifact.bca");
1791
1792        let result = compile(
1793            &[spec_path.as_path()],
1794            &[],
1795            &output_path,
1796            &CompileOptions::default(),
1797        );
1798
1799        assert!(matches!(
1800            result,
1801            Err(CompileError::MissingMiddlewareName(_))
1802        ));
1803    }
1804
1805    #[test]
1806    fn compile_detects_ambiguous_routes() {
1807        let temp = TempDir::new().unwrap();
1808
1809        let spec1 = r#"
1810openapi: "3.1.0"
1811info:
1812  title: API 1
1813  version: "1.0.0"
1814paths:
1815  /users/{id}:
1816    get:
1817      x-barbacane-dispatch:
1818        name: mock
1819"#;
1820        let spec2 = r#"
1821openapi: "3.1.0"
1822info:
1823  title: API 2
1824  version: "1.0.0"
1825paths:
1826  /users/{userId}:
1827    get:
1828      x-barbacane-dispatch:
1829        name: mock
1830"#;
1831        let path1 = create_test_spec(temp.path(), "api1.yaml", spec1);
1832        let path2 = create_test_spec(temp.path(), "api2.yaml", spec2);
1833        let output_path = temp.path().join("artifact.bca");
1834
1835        let result = compile(
1836            &[path1.as_path(), path2.as_path()],
1837            &[],
1838            &output_path,
1839            &CompileOptions::default(),
1840        );
1841
1842        assert!(matches!(result, Err(CompileError::AmbiguousRoute(_))));
1843    }
1844
1845    #[test]
1846    fn compile_allows_same_structure_same_params() {
1847        // Same path in different specs with same param names should work
1848        // (it's a routing conflict, not ambiguous)
1849        let temp = TempDir::new().unwrap();
1850
1851        let spec_content = r#"
1852openapi: "3.1.0"
1853info:
1854  title: Test API
1855  version: "1.0.0"
1856paths:
1857  /users/{id}:
1858    get:
1859      x-barbacane-dispatch:
1860        name: mock
1861  /posts/{id}:
1862    get:
1863      x-barbacane-dispatch:
1864        name: mock
1865"#;
1866        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
1867        let output_path = temp.path().join("artifact.bca");
1868
1869        // This should succeed - different paths, same param name is fine
1870        let result = compile(
1871            &[spec_path.as_path()],
1872            &[],
1873            &output_path,
1874            &CompileOptions::default(),
1875        );
1876        assert!(result.is_ok());
1877    }
1878
1879    #[test]
1880    fn compile_detects_schema_too_deep() {
1881        let temp = TempDir::new().unwrap();
1882
1883        // Create a deeply nested schema (40 levels, default limit is 32)
1884        let mut nested = r#"{"type": "string"}"#.to_string();
1885        for _ in 0..40 {
1886            nested = format!(
1887                r#"{{"type": "object", "properties": {{"nested": {}}}}}"#,
1888                nested
1889            );
1890        }
1891
1892        let spec_content = format!(
1893            r#"
1894openapi: "3.1.0"
1895info:
1896  title: Test API
1897  version: "1.0.0"
1898paths:
1899  /test:
1900    post:
1901      x-barbacane-dispatch:
1902        name: mock
1903      requestBody:
1904        content:
1905          application/json:
1906            schema: {}
1907"#,
1908            nested
1909        );
1910
1911        let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
1912        let output_path = temp.path().join("artifact.bca");
1913
1914        let result = compile(
1915            &[spec_path.as_path()],
1916            &[],
1917            &output_path,
1918            &CompileOptions::default(),
1919        );
1920
1921        assert!(matches!(result, Err(CompileError::SchemaTooDeep(_))));
1922    }
1923
1924    #[test]
1925    fn compile_detects_schema_too_complex() {
1926        let temp = TempDir::new().unwrap();
1927
1928        // Create a schema with 300 properties (default limit is 256)
1929        let mut properties = String::new();
1930        for i in 0..300 {
1931            if i > 0 {
1932                properties.push_str(", ");
1933            }
1934            properties.push_str(&format!(r#""prop{}": {{"type": "string"}}"#, i));
1935        }
1936
1937        let spec_content = format!(
1938            r#"
1939openapi: "3.1.0"
1940info:
1941  title: Test API
1942  version: "1.0.0"
1943paths:
1944  /test:
1945    post:
1946      x-barbacane-dispatch:
1947        name: mock
1948      requestBody:
1949        content:
1950          application/json:
1951            schema:
1952              type: object
1953              properties:
1954                {{{}}}
1955"#,
1956            properties
1957        );
1958
1959        let spec_path = create_test_spec(temp.path(), "test.yaml", &spec_content);
1960        let output_path = temp.path().join("artifact.bca");
1961
1962        let result = compile(
1963            &[spec_path.as_path()],
1964            &[],
1965            &output_path,
1966            &CompileOptions::default(),
1967        );
1968
1969        assert!(matches!(result, Err(CompileError::SchemaTooComplex(_))));
1970    }
1971
1972    #[test]
1973    fn compile_allows_schema_within_limits() {
1974        let temp = TempDir::new().unwrap();
1975
1976        let spec_content = r#"
1977openapi: "3.1.0"
1978info:
1979  title: Test API
1980  version: "1.0.0"
1981paths:
1982  /test:
1983    post:
1984      x-barbacane-dispatch:
1985        name: mock
1986      requestBody:
1987        content:
1988          application/json:
1989            schema:
1990              type: object
1991              properties:
1992                name:
1993                  type: string
1994                age:
1995                  type: integer
1996                address:
1997                  type: object
1998                  properties:
1999                    street:
2000                      type: string
2001                    city:
2002                      type: string
2003"#;
2004        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2005        let output_path = temp.path().join("artifact.bca");
2006
2007        let result = compile(
2008            &[spec_path.as_path()],
2009            &[],
2010            &output_path,
2011            &CompileOptions::default(),
2012        );
2013        assert!(result.is_ok());
2014    }
2015
2016    #[test]
2017    fn normalize_path_template_works() {
2018        assert_eq!(normalize_path_template("/users/{id}"), "/users/{_}");
2019        assert_eq!(normalize_path_template("/users/{userId}"), "/users/{_}");
2020        assert_eq!(
2021            normalize_path_template("/users/{id}/posts/{postId}"),
2022            "/users/{_}/posts/{_}"
2023        );
2024        assert_eq!(normalize_path_template("/static/path"), "/static/path");
2025        // Wildcard params preserve the `+` suffix in normalized form
2026        assert_eq!(normalize_path_template("/files/{path+}"), "/files/{_+}");
2027        assert_eq!(
2028            normalize_path_template("/files/{bucket}/{key+}"),
2029            "/files/{_}/{_+}"
2030        );
2031    }
2032
2033    #[test]
2034    fn validate_path_template_valid_cases() {
2035        assert!(validate_path_template("/users", "test").is_ok());
2036        assert!(validate_path_template("/users/{id}", "test").is_ok());
2037        assert!(validate_path_template("/users/{user_id}", "test").is_ok());
2038        assert!(validate_path_template("/users/{id}/posts/{postId}", "test").is_ok());
2039        // Wildcard as sole param
2040        assert!(validate_path_template("/files/{path+}", "test").is_ok());
2041        // Wildcard after a regular param
2042        assert!(validate_path_template("/files/{bucket}/{key+}", "test").is_ok());
2043        // Wildcard after two regular params
2044        assert!(validate_path_template("/api/{version}/files/{rest+}", "test").is_ok());
2045    }
2046
2047    #[test]
2048    fn validate_path_template_invalid_cases() {
2049        // Unclosed brace
2050        assert!(validate_path_template("/users/{id", "test").is_err());
2051        // Empty param
2052        assert!(validate_path_template("/users/{}", "test").is_err());
2053        // Duplicate param
2054        assert!(validate_path_template("/users/{id}/posts/{id}", "test").is_err());
2055        // Nested braces
2056        assert!(validate_path_template("/users/{{id}}", "test").is_err());
2057        // Invalid character in param name
2058        assert!(validate_path_template("/users/{id-name}", "test").is_err());
2059        // Wildcard not at end
2060        assert!(validate_path_template("/users/{id+}/orders", "test").is_err());
2061        // Multiple wildcards
2062        assert!(validate_path_template("/a/{x+}/{y+}", "test").is_err());
2063        // `+` in the middle of the name (not a suffix)
2064        assert!(validate_path_template("/users/{na+me}", "test").is_err());
2065        // Empty base name with wildcard only
2066        assert!(validate_path_template("/users/{+}", "test").is_err());
2067    }
2068
2069    #[test]
2070    fn compile_inherits_global_middlewares_when_none() {
2071        let temp = TempDir::new().unwrap();
2072
2073        let spec_content = r#"
2074openapi: "3.1.0"
2075info:
2076  title: Test API
2077  version: "1.0.0"
2078x-barbacane-middlewares:
2079  - name: rate-limit
2080    config:
2081      quota: 60
2082  - name: cors
2083    config:
2084      allow_origin: "*"
2085paths:
2086  /users:
2087    get:
2088      x-barbacane-dispatch:
2089        name: mock
2090"#;
2091        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2092        let output_path = temp.path().join("artifact.bca");
2093
2094        compile(
2095            &[spec_path.as_path()],
2096            &[],
2097            &output_path,
2098            &CompileOptions::default(),
2099        )
2100        .unwrap();
2101
2102        let routes = load_routes(&output_path).unwrap();
2103        assert_eq!(routes.operations.len(), 1);
2104        let op = &routes.operations[0];
2105        assert_eq!(op.middlewares.len(), 2);
2106        assert_eq!(op.middlewares[0].name, "rate-limit");
2107        assert_eq!(op.middlewares[1].name, "cors");
2108    }
2109
2110    #[test]
2111    fn compile_empty_middlewares_opts_out_of_globals() {
2112        let temp = TempDir::new().unwrap();
2113
2114        let spec_content = r#"
2115openapi: "3.1.0"
2116info:
2117  title: Test API
2118  version: "1.0.0"
2119x-barbacane-middlewares:
2120  - name: rate-limit
2121    config:
2122      quota: 60
2123paths:
2124  /users:
2125    get:
2126      x-barbacane-dispatch:
2127        name: mock
2128      x-barbacane-middlewares: []
2129"#;
2130        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2131        let output_path = temp.path().join("artifact.bca");
2132
2133        compile(
2134            &[spec_path.as_path()],
2135            &[],
2136            &output_path,
2137            &CompileOptions::default(),
2138        )
2139        .unwrap();
2140
2141        let routes = load_routes(&output_path).unwrap();
2142        let op = &routes.operations[0];
2143        assert_eq!(op.middlewares.len(), 0);
2144    }
2145
2146    #[test]
2147    fn compile_merges_global_and_operation_middlewares() {
2148        let temp = TempDir::new().unwrap();
2149
2150        let spec_content = r#"
2151openapi: "3.1.0"
2152info:
2153  title: Test API
2154  version: "1.0.0"
2155x-barbacane-middlewares:
2156  - name: rate-limit
2157    config:
2158      quota: 60
2159  - name: cors
2160    config:
2161      allow_origin: "*"
2162paths:
2163  /users:
2164    get:
2165      x-barbacane-dispatch:
2166        name: mock
2167      x-barbacane-middlewares:
2168        - name: auth
2169          config:
2170            type: bearer
2171"#;
2172        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2173        let output_path = temp.path().join("artifact.bca");
2174
2175        compile(
2176            &[spec_path.as_path()],
2177            &[],
2178            &output_path,
2179            &CompileOptions::default(),
2180        )
2181        .unwrap();
2182
2183        let routes = load_routes(&output_path).unwrap();
2184        let op = &routes.operations[0];
2185        // Global middlewares first, then operation-level
2186        assert_eq!(op.middlewares.len(), 3);
2187        assert_eq!(op.middlewares[0].name, "rate-limit");
2188        assert_eq!(op.middlewares[1].name, "cors");
2189        assert_eq!(op.middlewares[2].name, "auth");
2190    }
2191
2192    #[test]
2193    fn compile_operation_middleware_overrides_global_by_name() {
2194        let temp = TempDir::new().unwrap();
2195
2196        let spec_content = r#"
2197openapi: "3.1.0"
2198info:
2199  title: Test API
2200  version: "1.0.0"
2201x-barbacane-middlewares:
2202  - name: rate-limit
2203    config:
2204      quota: 60
2205  - name: cors
2206    config:
2207      allow_origin: "*"
2208paths:
2209  /users:
2210    get:
2211      x-barbacane-dispatch:
2212        name: mock
2213      x-barbacane-middlewares:
2214        - name: rate-limit
2215          config:
2216            quota: 1000
2217"#;
2218        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2219        let output_path = temp.path().join("artifact.bca");
2220
2221        compile(
2222            &[spec_path.as_path()],
2223            &[],
2224            &output_path,
2225            &CompileOptions::default(),
2226        )
2227        .unwrap();
2228
2229        let routes = load_routes(&output_path).unwrap();
2230        let op = &routes.operations[0];
2231        // cors from global + rate-limit from operation (overrides global rate-limit)
2232        assert_eq!(op.middlewares.len(), 2);
2233        assert_eq!(op.middlewares[0].name, "cors");
2234        assert_eq!(op.middlewares[1].name, "rate-limit");
2235        // The operation-level config should be used
2236        assert_eq!(op.middlewares[1].config.get("quota").unwrap(), 1000);
2237    }
2238
2239    #[test]
2240    fn compile_inherits_global_middlewares() {
2241        let temp = TempDir::new().unwrap();
2242
2243        let spec_content = r#"
2244openapi: "3.1.0"
2245info:
2246  title: Test API
2247  version: "1.0.0"
2248x-barbacane-middlewares:
2249  - name: rate-limit
2250    config:
2251      quota: 60
2252paths:
2253  /health:
2254    get:
2255      x-barbacane-dispatch:
2256        name: mock
2257"#;
2258        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2259        let output_path = temp.path().join("artifact.bca");
2260
2261        let plugins = vec![];
2262        let result = compile(
2263            &[spec_path.as_path()],
2264            &plugins,
2265            &output_path,
2266            &CompileOptions::default(),
2267        )
2268        .unwrap();
2269
2270        let routes = load_routes(&output_path).unwrap();
2271        let op = &routes.operations[0];
2272        assert_eq!(op.middlewares.len(), 1);
2273        assert_eq!(op.middlewares[0].name, "rate-limit");
2274        assert_eq!(result.manifest.plugins.len(), 0);
2275    }
2276
2277    #[test]
2278    fn artifact_hash_is_deterministic() {
2279        let source_specs = vec![
2280            SourceSpec {
2281                file: "api.yaml".to_string(),
2282                sha256: "aaa".to_string(),
2283                spec_type: "openapi".to_string(),
2284                version: "3.1.0".to_string(),
2285            },
2286            SourceSpec {
2287                file: "events.yaml".to_string(),
2288                sha256: "bbb".to_string(),
2289                spec_type: "asyncapi".to_string(),
2290                version: "3.0.0".to_string(),
2291            },
2292        ];
2293        let mut checksums = BTreeMap::new();
2294        checksums.insert("routes.json".to_string(), "ccc".to_string());
2295        checksums.insert("plugins/mock.wasm".to_string(), "ddd".to_string());
2296
2297        let hash1 = compute_artifact_hash(&source_specs, &checksums);
2298        let hash2 = compute_artifact_hash(&source_specs, &checksums);
2299
2300        assert_eq!(hash1, hash2, "Same inputs must produce same hash");
2301        assert!(
2302            hash1.starts_with("sha256:"),
2303            "Hash must have sha256: prefix"
2304        );
2305    }
2306
2307    #[test]
2308    fn artifact_hash_differs_with_different_specs() {
2309        let specs_a = vec![SourceSpec {
2310            file: "api.yaml".to_string(),
2311            sha256: "aaa".to_string(),
2312            spec_type: "openapi".to_string(),
2313            version: "3.1.0".to_string(),
2314        }];
2315        let specs_b = vec![SourceSpec {
2316            file: "api.yaml".to_string(),
2317            sha256: "bbb".to_string(),
2318            spec_type: "openapi".to_string(),
2319            version: "3.1.0".to_string(),
2320        }];
2321        let checksums = BTreeMap::new();
2322
2323        let hash_a = compute_artifact_hash(&specs_a, &checksums);
2324        let hash_b = compute_artifact_hash(&specs_b, &checksums);
2325
2326        assert_ne!(
2327            hash_a, hash_b,
2328            "Different spec hashes must produce different artifact hashes"
2329        );
2330    }
2331
2332    #[test]
2333    fn artifact_hash_differs_with_different_checksums() {
2334        let specs = vec![SourceSpec {
2335            file: "api.yaml".to_string(),
2336            sha256: "aaa".to_string(),
2337            spec_type: "openapi".to_string(),
2338            version: "3.1.0".to_string(),
2339        }];
2340        let mut checksums_a = BTreeMap::new();
2341        checksums_a.insert("routes.json".to_string(), "v1".to_string());
2342        let mut checksums_b = BTreeMap::new();
2343        checksums_b.insert("routes.json".to_string(), "v2".to_string());
2344
2345        let hash_a = compute_artifact_hash(&specs, &checksums_a);
2346        let hash_b = compute_artifact_hash(&specs, &checksums_b);
2347
2348        assert_ne!(
2349            hash_a, hash_b,
2350            "Different route checksums must produce different artifact hashes"
2351        );
2352    }
2353
2354    #[test]
2355    fn provenance_serialization_round_trip() {
2356        let prov = Provenance {
2357            commit: Some("abc123".to_string()),
2358            source: Some("ci/github-actions".to_string()),
2359        };
2360
2361        let json = serde_json::to_string(&prov).unwrap();
2362        let deserialized: Provenance = serde_json::from_str(&json).unwrap();
2363
2364        assert_eq!(deserialized.commit, Some("abc123".to_string()));
2365        assert_eq!(deserialized.source, Some("ci/github-actions".to_string()));
2366    }
2367
2368    #[test]
2369    fn provenance_defaults_to_none() {
2370        let prov = Provenance::default();
2371
2372        assert!(prov.commit.is_none());
2373        assert!(prov.source.is_none());
2374
2375        // Round-trip with nulls
2376        let json = serde_json::to_string(&prov).unwrap();
2377        let deserialized: Provenance = serde_json::from_str(&json).unwrap();
2378        assert!(deserialized.commit.is_none());
2379        assert!(deserialized.source.is_none());
2380    }
2381
2382    #[test]
2383    fn compile_produces_artifact_hash_and_provenance() {
2384        let temp = TempDir::new().unwrap();
2385
2386        let spec_content = r#"
2387openapi: "3.1.0"
2388info:
2389  title: Test API
2390  version: "1.0.0"
2391paths:
2392  /health:
2393    get:
2394      x-barbacane-dispatch:
2395        name: mock
2396"#;
2397        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2398        let output_path = temp.path().join("artifact.bca");
2399
2400        let result = compile(
2401            &[spec_path.as_path()],
2402            &[],
2403            &output_path,
2404            &CompileOptions {
2405                provenance_commit: Some("deadbeef".to_string()),
2406                provenance_source: Some("test".to_string()),
2407                ..Default::default()
2408            },
2409        )
2410        .unwrap();
2411
2412        // Artifact hash is present and well-formed
2413        assert!(result.manifest.artifact_hash.starts_with("sha256:"));
2414        assert!(result.manifest.artifact_hash.len() > 10);
2415
2416        // Provenance is present
2417        assert_eq!(
2418            result.manifest.provenance.commit,
2419            Some("deadbeef".to_string())
2420        );
2421        assert_eq!(result.manifest.provenance.source, Some("test".to_string()));
2422
2423        // Round-trip: load manifest and verify
2424        let loaded = load_manifest(&output_path).unwrap();
2425        assert_eq!(loaded.artifact_hash, result.manifest.artifact_hash);
2426        assert_eq!(loaded.provenance.commit, Some("deadbeef".to_string()));
2427        assert_eq!(loaded.provenance.source, Some("test".to_string()));
2428    }
2429
2430    #[test]
2431    fn compile_without_provenance_has_none_fields() {
2432        let temp = TempDir::new().unwrap();
2433
2434        let spec_content = r#"
2435openapi: "3.1.0"
2436info:
2437  title: Test API
2438  version: "1.0.0"
2439paths:
2440  /health:
2441    get:
2442      x-barbacane-dispatch:
2443        name: mock
2444"#;
2445        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2446        let output_path = temp.path().join("artifact.bca");
2447
2448        let result = compile(
2449            &[spec_path.as_path()],
2450            &[],
2451            &output_path,
2452            &CompileOptions::default(),
2453        )
2454        .unwrap();
2455
2456        // Hash is always present
2457        assert!(result.manifest.artifact_hash.starts_with("sha256:"));
2458
2459        // Provenance fields are None when not provided
2460        assert!(result.manifest.provenance.commit.is_none());
2461        assert!(result.manifest.provenance.source.is_none());
2462    }
2463
2464    #[test]
2465    fn same_spec_produces_same_artifact_hash() {
2466        let temp = TempDir::new().unwrap();
2467
2468        let spec_content = r#"
2469openapi: "3.1.0"
2470info:
2471  title: Test API
2472  version: "1.0.0"
2473paths:
2474  /health:
2475    get:
2476      x-barbacane-dispatch:
2477        name: mock
2478        config:
2479          status: 200
2480"#;
2481        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2482
2483        let out1 = temp.path().join("artifact1.bca");
2484        let out2 = temp.path().join("artifact2.bca");
2485
2486        let r1 = compile(
2487            &[spec_path.as_path()],
2488            &[],
2489            &out1,
2490            &CompileOptions::default(),
2491        )
2492        .unwrap();
2493        let r2 = compile(
2494            &[spec_path.as_path()],
2495            &[],
2496            &out2,
2497            &CompileOptions::default(),
2498        )
2499        .unwrap();
2500
2501        assert_eq!(
2502            r1.manifest.artifact_hash, r2.manifest.artifact_hash,
2503            "Compiling the same spec twice must produce the same artifact hash"
2504        );
2505    }
2506
2507    #[test]
2508    fn different_specs_produce_different_artifact_hashes() {
2509        let temp = TempDir::new().unwrap();
2510
2511        let spec_a = r#"
2512openapi: "3.1.0"
2513info:
2514  title: API A
2515  version: "1.0.0"
2516paths:
2517  /health:
2518    get:
2519      x-barbacane-dispatch:
2520        name: mock
2521        config:
2522          status: 200
2523"#;
2524        let spec_b = r#"
2525openapi: "3.1.0"
2526info:
2527  title: API B
2528  version: "1.0.0"
2529paths:
2530  /health:
2531    get:
2532      x-barbacane-dispatch:
2533        name: mock
2534        config:
2535          status: 200
2536  /users:
2537    get:
2538      x-barbacane-dispatch:
2539        name: mock
2540        config:
2541          status: 200
2542"#;
2543        let path_a = create_test_spec(temp.path(), "a.yaml", spec_a);
2544        let path_b = create_test_spec(temp.path(), "b.yaml", spec_b);
2545        let out_a = temp.path().join("a.bca");
2546        let out_b = temp.path().join("b.bca");
2547
2548        let ra = compile(&[path_a.as_path()], &[], &out_a, &CompileOptions::default()).unwrap();
2549        let rb = compile(&[path_b.as_path()], &[], &out_b, &CompileOptions::default()).unwrap();
2550
2551        assert_ne!(
2552            ra.manifest.artifact_hash, rb.manifest.artifact_hash,
2553            "Different specs must produce different artifact hashes"
2554        );
2555    }
2556
2557    #[test]
2558    fn provenance_does_not_affect_artifact_hash() {
2559        let temp = TempDir::new().unwrap();
2560
2561        let spec_content = r#"
2562openapi: "3.1.0"
2563info:
2564  title: Test API
2565  version: "1.0.0"
2566paths:
2567  /health:
2568    get:
2569      x-barbacane-dispatch:
2570        name: mock
2571"#;
2572        let spec_path = create_test_spec(temp.path(), "test.yaml", spec_content);
2573        let out1 = temp.path().join("a.bca");
2574        let out2 = temp.path().join("b.bca");
2575
2576        let r1 = compile(
2577            &[spec_path.as_path()],
2578            &[],
2579            &out1,
2580            &CompileOptions {
2581                provenance_commit: Some("commit-a".to_string()),
2582                provenance_source: Some("source-a".to_string()),
2583                ..Default::default()
2584            },
2585        )
2586        .unwrap();
2587        let r2 = compile(
2588            &[spec_path.as_path()],
2589            &[],
2590            &out2,
2591            &CompileOptions {
2592                provenance_commit: Some("commit-b".to_string()),
2593                provenance_source: Some("source-b".to_string()),
2594                ..Default::default()
2595            },
2596        )
2597        .unwrap();
2598
2599        assert_eq!(
2600            r1.manifest.artifact_hash, r2.manifest.artifact_hash,
2601            "Provenance metadata must not affect artifact hash"
2602        );
2603    }
2604
2605    // --- MCP config tests ---
2606
2607    #[test]
2608    fn extract_root_mcp_config_enabled() {
2609        let spec = ApiSpec {
2610            filename: None,
2611            format: SpecFormat::OpenApi,
2612            version: "3.1.0".to_string(),
2613            title: "My API".to_string(),
2614            api_version: "2.0.0".to_string(),
2615            operations: vec![],
2616            global_middlewares: vec![],
2617            extensions: BTreeMap::from([(
2618                "x-barbacane-mcp".to_string(),
2619                serde_json::json!({
2620                    "enabled": true,
2621                    "server_name": "Custom Name"
2622                }),
2623            )]),
2624        };
2625        let specs = vec![(spec, String::new(), String::new())];
2626        let cfg = extract_root_mcp_config(&specs);
2627        assert!(cfg.enabled);
2628        assert_eq!(cfg.server_name.as_deref(), Some("Custom Name"));
2629        assert!(cfg.server_version.is_none());
2630    }
2631
2632    #[test]
2633    fn extract_root_mcp_config_disabled_by_default() {
2634        let spec = ApiSpec {
2635            filename: None,
2636            format: SpecFormat::OpenApi,
2637            version: "3.1.0".to_string(),
2638            title: "Test".to_string(),
2639            api_version: "1.0.0".to_string(),
2640            operations: vec![],
2641            global_middlewares: vec![],
2642            extensions: BTreeMap::new(),
2643        };
2644        let specs = vec![(spec, String::new(), String::new())];
2645        let cfg = extract_root_mcp_config(&specs);
2646        assert!(!cfg.enabled);
2647    }
2648
2649    #[test]
2650    fn resolve_mcp_config_inherits_from_root() {
2651        let root = McpConfig {
2652            enabled: true,
2653            server_name: None,
2654            server_version: None,
2655        };
2656        // No operation-level extension → inherits root
2657        let (enabled, desc) = resolve_mcp_config(&root, None);
2658        assert_eq!(enabled, Some(true));
2659        assert!(desc.is_none());
2660    }
2661
2662    #[test]
2663    fn resolve_mcp_config_operation_overrides_root() {
2664        let root = McpConfig {
2665            enabled: true,
2666            server_name: None,
2667            server_version: None,
2668        };
2669        // Operation opts out
2670        let ext = serde_json::json!({"enabled": false});
2671        let (enabled, _) = resolve_mcp_config(&root, Some(&ext));
2672        assert_eq!(enabled, Some(false));
2673    }
2674
2675    #[test]
2676    fn resolve_mcp_config_operation_description_override() {
2677        let root = McpConfig {
2678            enabled: true,
2679            server_name: None,
2680            server_version: None,
2681        };
2682        let ext = serde_json::json!({"description": "Custom tool description"});
2683        let (enabled, desc) = resolve_mcp_config(&root, Some(&ext));
2684        // enabled not set at operation level → inherits root true
2685        assert_eq!(enabled, Some(true));
2686        assert_eq!(desc.as_deref(), Some("Custom tool description"));
2687    }
2688
2689    #[test]
2690    fn resolve_mcp_config_root_disabled_no_inheritance() {
2691        let root = McpConfig {
2692            enabled: false,
2693            server_name: None,
2694            server_version: None,
2695        };
2696        let (enabled, _) = resolve_mcp_config(&root, None);
2697        assert!(enabled.is_none());
2698    }
2699}