Skip to main content

bijux_cli/contracts/
product_mount.rs

1//! Official product-mount reservation contracts.
2
3use std::sync::OnceLock;
4
5use schemars::JsonSchema;
6use semver::Version;
7use serde::{Deserialize, Serialize};
8
9use super::command::Namespace;
10
11const RESERVED_ROOT_NAMESPACES: &[&str] = &[
12    "apps",
13    "cli",
14    "completion",
15    "config",
16    "doctor",
17    "help",
18    "history",
19    "inspect",
20    "install",
21    "memory",
22    "plugins",
23    "repl",
24    "self",
25    "status",
26    "version",
27];
28const OFFICIAL_STATUS_VALUES: &[&str] = &["declared", "supported", "deprecated", "unsupported"];
29
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
31pub struct ProductRegistryDocument {
32    pub schema_version: String,
33    pub owner: String,
34    pub policy: String,
35    pub entries: Vec<ProductRegistryEntry>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
39pub struct ProductRegistryEntry {
40    pub namespace: String,
41    pub display_name: String,
42    #[serde(default)]
43    pub aliases: Vec<String>,
44    pub runtime_binary: String,
45    pub control_binary: String,
46    pub runtime_package: String,
47    pub control_package: String,
48    pub repository: String,
49    pub status: String,
50    pub language: String,
51    #[serde(default)]
52    pub version: Option<String>,
53    pub help_summary: String,
54    #[serde(default)]
55    pub capabilities: Vec<String>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "snake_case")]
60pub enum ProductEntrypointKind {
61    Binary,
62    PythonModule,
63    PythonConsoleScript,
64    PluginProcess,
65    EmbeddedRust,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
69pub struct ProductEntrypoint {
70    pub kind: ProductEntrypointKind,
71    pub command: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub module: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub function: Option<String>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79pub struct ProductHelpMetadata {
80    pub summary: String,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84pub struct ProductCompatibilityWindow {
85    pub min_cli_version: String,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub max_cli_version_exclusive: Option<String>,
88}
89
90impl ProductCompatibilityWindow {
91    /// Build a compatibility window validated as semver.
92    pub fn new(
93        min_cli_version: impl Into<String>,
94        max_cli_version_exclusive: Option<String>,
95    ) -> Result<Self, String> {
96        let window = Self { min_cli_version: min_cli_version.into(), max_cli_version_exclusive };
97        validate_compatibility_window(&window)?;
98        Ok(window)
99    }
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct ProductMountDescriptor {
104    pub namespace: Namespace,
105    pub display_name: String,
106    #[serde(default)]
107    pub aliases: Vec<Namespace>,
108    pub entrypoint: ProductEntrypoint,
109    pub control_entrypoint: ProductEntrypoint,
110    pub help: ProductHelpMetadata,
111    #[serde(default)]
112    pub capabilities: Vec<String>,
113    #[serde(default)]
114    pub version: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub compatibility: Option<ProductCompatibilityWindow>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ProductMountDescriptorBuilder {
121    namespace: Namespace,
122    display_name: Option<String>,
123    aliases: Vec<Namespace>,
124    entrypoint: Option<ProductEntrypoint>,
125    control_entrypoint: Option<ProductEntrypoint>,
126    help_summary: Option<String>,
127    capabilities: Vec<String>,
128    version: Option<String>,
129    compatibility: Option<ProductCompatibilityWindow>,
130}
131
132impl ProductMountDescriptor {
133    #[must_use]
134    pub fn builder(namespace: Namespace) -> ProductMountDescriptorBuilder {
135        ProductMountDescriptorBuilder {
136            namespace,
137            display_name: None,
138            aliases: Vec::new(),
139            entrypoint: None,
140            control_entrypoint: None,
141            help_summary: None,
142            capabilities: Vec::new(),
143            version: None,
144            compatibility: None,
145        }
146    }
147}
148
149impl ProductMountDescriptorBuilder {
150    #[must_use]
151    pub fn display_name(mut self, value: impl Into<String>) -> Self {
152        self.display_name = Some(value.into());
153        self
154    }
155
156    #[must_use]
157    pub fn alias(mut self, value: Namespace) -> Self {
158        self.aliases.push(value);
159        self
160    }
161
162    #[must_use]
163    pub fn aliases(mut self, values: Vec<Namespace>) -> Self {
164        self.aliases.extend(values);
165        self
166    }
167
168    #[must_use]
169    pub fn entrypoint(mut self, kind: ProductEntrypointKind, command: impl Into<String>) -> Self {
170        self.entrypoint =
171            Some(ProductEntrypoint { kind, command: command.into(), module: None, function: None });
172        self
173    }
174
175    #[must_use]
176    pub fn entrypoint_value(mut self, entrypoint: ProductEntrypoint) -> Self {
177        self.entrypoint = Some(entrypoint);
178        self
179    }
180
181    #[must_use]
182    pub fn control_entrypoint(
183        mut self,
184        kind: ProductEntrypointKind,
185        command: impl Into<String>,
186    ) -> Self {
187        self.control_entrypoint =
188            Some(ProductEntrypoint { kind, command: command.into(), module: None, function: None });
189        self
190    }
191
192    #[must_use]
193    pub fn control_entrypoint_value(mut self, entrypoint: ProductEntrypoint) -> Self {
194        self.control_entrypoint = Some(entrypoint);
195        self
196    }
197
198    #[must_use]
199    pub fn help_summary(mut self, value: impl Into<String>) -> Self {
200        self.help_summary = Some(value.into());
201        self
202    }
203
204    #[must_use]
205    pub fn capability(mut self, value: impl Into<String>) -> Self {
206        self.capabilities.push(value.into());
207        self
208    }
209
210    #[must_use]
211    pub fn capabilities(mut self, values: Vec<String>) -> Self {
212        self.capabilities.extend(values);
213        self
214    }
215
216    #[must_use]
217    pub fn version(mut self, value: impl Into<String>) -> Self {
218        self.version = Some(value.into());
219        self
220    }
221
222    #[must_use]
223    pub fn compatibility(mut self, value: ProductCompatibilityWindow) -> Self {
224        self.compatibility = Some(value);
225        self
226    }
227
228    pub fn build(self) -> Result<ProductMountDescriptor, String> {
229        let descriptor = ProductMountDescriptor {
230            namespace: self.namespace,
231            display_name: self
232                .display_name
233                .ok_or_else(|| "product mount display_name is required".to_string())?,
234            aliases: self.aliases,
235            entrypoint: self
236                .entrypoint
237                .ok_or_else(|| "product mount entrypoint is required".to_string())?,
238            control_entrypoint: self
239                .control_entrypoint
240                .ok_or_else(|| "product mount control_entrypoint is required".to_string())?,
241            help: ProductHelpMetadata {
242                summary: self
243                    .help_summary
244                    .ok_or_else(|| "product mount help summary is required".to_string())?,
245            },
246            capabilities: self.capabilities,
247            version: self.version,
248            compatibility: self.compatibility,
249        };
250        validate_product_mount_descriptor(&descriptor)?;
251        Ok(descriptor)
252    }
253}
254
255pub fn validate_product_mount_descriptor(
256    descriptor: &ProductMountDescriptor,
257) -> Result<(), String> {
258    if descriptor.display_name.trim().is_empty() {
259        return Err("product mount display_name cannot be empty".to_string());
260    }
261    if descriptor.help.summary.trim().is_empty() {
262        return Err("product mount help summary cannot be empty".to_string());
263    }
264    validate_entrypoint("entrypoint", &descriptor.entrypoint)?;
265    validate_entrypoint("control entrypoint", &descriptor.control_entrypoint)?;
266
267    let mut alias_set = std::collections::BTreeSet::new();
268    for alias in &descriptor.aliases {
269        if alias.as_str() == descriptor.namespace.as_str() {
270            return Err(format!(
271                "product mount `{}` cannot repeat itself as an alias",
272                descriptor.namespace.as_str()
273            ));
274        }
275        if RESERVED_ROOT_NAMESPACES.contains(&alias.as_str()) {
276            return Err(format!(
277                "product mount alias `{}` collides with reserved runtime root",
278                alias.as_str()
279            ));
280        }
281        if !alias_set.insert(alias.as_str().to_string()) {
282            return Err(format!(
283                "product mount `{}` declares duplicate alias `{}`",
284                descriptor.namespace.as_str(),
285                alias.as_str()
286            ));
287        }
288    }
289
290    let mut capability_set = std::collections::BTreeSet::new();
291    for capability in &descriptor.capabilities {
292        let normalized = capability.trim().to_ascii_lowercase().replace(' ', "_");
293        if normalized.is_empty() {
294            return Err(format!(
295                "product mount `{}` has an empty capability entry",
296                descriptor.namespace.as_str()
297            ));
298        }
299        if !capability_set.insert(normalized.clone()) {
300            return Err(format!(
301                "product mount `{}` declares duplicate capability `{}`",
302                descriptor.namespace.as_str(),
303                normalized
304            ));
305        }
306    }
307
308    if let Some(compatibility) = &descriptor.compatibility {
309        validate_compatibility_window(compatibility)?;
310    }
311
312    Ok(())
313}
314
315fn validate_entrypoint(label: &str, entrypoint: &ProductEntrypoint) -> Result<(), String> {
316    match entrypoint.kind {
317        ProductEntrypointKind::PythonModule => {
318            let module = entrypoint.module.as_deref().unwrap_or(entrypoint.command.as_str()).trim();
319            if module.is_empty() {
320                return Err(format!("product mount {label} python module cannot be empty"));
321            }
322            if let Some(function) = &entrypoint.function {
323                if function.trim().is_empty() {
324                    return Err(format!("product mount {label} python function cannot be empty"));
325                }
326            }
327        }
328        _ => {
329            if entrypoint.command.trim().is_empty() {
330                return Err(format!("product mount {label} command cannot be empty"));
331            }
332            if entrypoint.module.as_deref().is_some_and(|value| !value.trim().is_empty()) {
333                return Err(format!(
334                    "product mount {label} only supports `module` for python_module entrypoints"
335                ));
336            }
337            if entrypoint.function.as_deref().is_some_and(|value| !value.trim().is_empty()) {
338                return Err(format!(
339                    "product mount {label} only supports `function` for python_module entrypoints"
340                ));
341            }
342        }
343    }
344
345    Ok(())
346}
347
348fn validate_compatibility_window(window: &ProductCompatibilityWindow) -> Result<(), String> {
349    let min = Version::parse(&window.min_cli_version)
350        .map_err(|error| format!("min_cli_version is not valid semver: {error}"))?;
351    if let Some(max) = &window.max_cli_version_exclusive {
352        let max_version = Version::parse(max)
353            .map_err(|error| format!("max_cli_version_exclusive is not valid semver: {error}"))?;
354        if max_version <= min {
355            return Err(
356                "max_cli_version_exclusive must be greater than min_cli_version".to_string()
357            );
358        }
359    }
360    Ok(())
361}
362
363/// Canonical metadata for known Bijux tool projects.
364#[derive(Clone, Copy, Debug, PartialEq, Eq)]
365pub struct KnownBijuxTool {
366    /// Canonical tool namespace used in `bijux <tool> ...`.
367    pub namespace: &'static str,
368    /// Runtime executable used by `bijux <tool> ...`.
369    pub runtime_binary_name: &'static str,
370    /// Maintainer executable used by `bijux-dev-<tool> ...`.
371    pub control_binary_name: &'static str,
372    /// Canonical runtime install package.
373    pub runtime_package_name: &'static str,
374    /// Canonical control-plane install package.
375    pub control_package_name: &'static str,
376    /// Canonical repository slug.
377    pub repository_name: &'static str,
378    /// Declared lifecycle status from the registry contract.
379    pub status: &'static str,
380    /// Human-readable product display name.
381    pub display_name: &'static str,
382    /// Stable product aliases recognized by contract queries.
383    pub aliases: &'static [&'static str],
384    /// Owning implementation language.
385    pub language: &'static str,
386    /// Lightweight manifest version when declared.
387    pub version: Option<&'static str>,
388    /// Help summary for root inventory surfaces.
389    pub help_summary: &'static str,
390    /// Declared product capabilities.
391    pub capabilities: &'static [&'static str],
392}
393
394impl KnownBijuxTool {
395    /// Runtime executable used by `bijux <tool> ...`.
396    #[must_use]
397    pub fn runtime_binary(&self) -> String {
398        self.runtime_binary_name.to_string()
399    }
400
401    /// Maintainer executable used by `bijux-dev-<tool> ...`.
402    #[must_use]
403    pub fn control_binary(&self) -> String {
404        self.control_binary_name.to_string()
405    }
406
407    /// Canonical runtime package to install this product.
408    #[must_use]
409    pub fn runtime_package(&self) -> String {
410        self.runtime_package_name.to_string()
411    }
412
413    /// Canonical control-plane package to install this product.
414    #[must_use]
415    pub fn control_package(&self) -> String {
416        self.control_package_name.to_string()
417    }
418
419    /// Canonical repository slug for this product.
420    #[must_use]
421    pub fn repository(&self) -> String {
422        self.repository_name.to_string()
423    }
424
425    /// Stable runtime descriptor for root app inventory surfaces.
426    #[must_use]
427    pub fn descriptor(&self) -> ProductMountDescriptor {
428        ProductMountDescriptor {
429            namespace: Namespace(self.namespace.to_string()),
430            display_name: self.display_name.to_string(),
431            aliases: self.aliases.iter().map(|alias| Namespace((*alias).to_string())).collect(),
432            entrypoint: ProductEntrypoint {
433                kind: ProductEntrypointKind::Binary,
434                command: self.runtime_binary(),
435                module: None,
436                function: None,
437            },
438            control_entrypoint: ProductEntrypoint {
439                kind: ProductEntrypointKind::Binary,
440                command: self.control_binary(),
441                module: None,
442                function: None,
443            },
444            help: ProductHelpMetadata { summary: self.help_summary.to_string() },
445            capabilities: self.capabilities.iter().map(|value| (*value).to_string()).collect(),
446            version: self.version.map(ToOwned::to_owned),
447            compatibility: None,
448        }
449    }
450}
451
452fn leak(raw: String) -> &'static str {
453    Box::leak(raw.into_boxed_str())
454}
455
456fn leak_vec(raw: Vec<String>) -> &'static [&'static str] {
457    Box::leak(raw.into_iter().map(leak).collect::<Vec<&'static str>>().into_boxed_slice())
458}
459
460fn validate_registry_document(document: &ProductRegistryDocument) -> Result<(), String> {
461    if document.schema_version.trim() != "v1" {
462        return Err(format!(
463            "official product registry schema_version must be `v1`, got `{}`",
464            document.schema_version
465        ));
466    }
467    if document.owner.trim().is_empty() {
468        return Err("official product registry owner cannot be empty".to_string());
469    }
470    if document.policy.trim().is_empty() {
471        return Err("official product registry policy cannot be empty".to_string());
472    }
473
474    let mut seen_namespaces = std::collections::BTreeSet::new();
475    let mut seen_aliases = std::collections::BTreeSet::new();
476
477    for entry in &document.entries {
478        let namespace = Namespace::new(&entry.namespace).map_err(|error| {
479            format!("invalid official namespace `{}`: {error}", entry.namespace)
480        })?;
481        if RESERVED_ROOT_NAMESPACES.contains(&namespace.as_str()) {
482            return Err(format!(
483                "official namespace `{}` collides with reserved runtime root",
484                namespace.as_str()
485            ));
486        }
487        if !seen_namespaces.insert(namespace.as_str().to_string()) {
488            return Err(format!("duplicate official namespace `{}`", namespace.as_str()));
489        }
490        if entry.display_name.trim().is_empty() {
491            return Err(format!(
492                "official namespace `{}` is missing display_name",
493                namespace.as_str()
494            ));
495        }
496        if entry.runtime_binary.trim().is_empty()
497            || entry.control_binary.trim().is_empty()
498            || entry.runtime_package.trim().is_empty()
499            || entry.control_package.trim().is_empty()
500            || entry.repository.trim().is_empty()
501            || entry.language.trim().is_empty()
502            || entry.help_summary.trim().is_empty()
503        {
504            return Err(format!(
505                "official namespace `{}` has one or more empty required fields",
506                namespace.as_str()
507            ));
508        }
509        validate_registry_status(namespace.as_str(), &entry.status)?;
510
511        let mut capability_set = std::collections::BTreeSet::new();
512        for capability in &entry.capabilities {
513            let normalized = capability.trim().to_ascii_lowercase().replace(' ', "_");
514            if normalized.is_empty() {
515                return Err(format!(
516                    "official namespace `{}` has an empty capability entry",
517                    namespace.as_str()
518                ));
519            }
520            if !capability_set.insert(normalized.clone()) {
521                return Err(format!(
522                    "official namespace `{}` declares duplicate capability `{}`",
523                    namespace.as_str(),
524                    normalized
525                ));
526            }
527        }
528
529        let mut local_aliases = std::collections::BTreeSet::new();
530        for alias in &entry.aliases {
531            let normalized = Namespace::new(alias)
532                .map_err(|error| format!("invalid alias `{alias}`: {error}"))?;
533            if normalized.as_str() == namespace.as_str() {
534                return Err(format!(
535                    "official namespace `{}` cannot repeat itself as an alias",
536                    namespace.as_str()
537                ));
538            }
539            if RESERVED_ROOT_NAMESPACES.contains(&normalized.as_str()) {
540                return Err(format!(
541                    "official alias `{}` for `{}` collides with reserved runtime root",
542                    normalized.as_str(),
543                    namespace.as_str()
544                ));
545            }
546            if seen_namespaces.contains(normalized.as_str()) {
547                return Err(format!(
548                    "official alias `{}` for `{}` collides with another namespace",
549                    normalized.as_str(),
550                    namespace.as_str()
551                ));
552            }
553            if !local_aliases.insert(normalized.as_str().to_string()) {
554                return Err(format!(
555                    "official namespace `{}` declares duplicate alias `{}`",
556                    namespace.as_str(),
557                    normalized.as_str()
558                ));
559            }
560            if !seen_aliases.insert(normalized.as_str().to_string()) {
561                return Err(format!(
562                    "official alias `{}` is declared by multiple products",
563                    normalized.as_str()
564                ));
565            }
566        }
567    }
568
569    Ok(())
570}
571
572fn validate_registry_status(namespace: &str, status: &str) -> Result<(), String> {
573    if status.trim().is_empty() {
574        return Err(format!("official namespace `{namespace}` has empty status"));
575    }
576    let normalized = status.trim().to_ascii_lowercase();
577    if !OFFICIAL_STATUS_VALUES.contains(&normalized.as_str()) {
578        return Err(format!(
579            "official namespace `{namespace}` declares unsupported status `{status}`"
580        ));
581    }
582    Ok(())
583}
584
585fn load_known_bijux_tools() -> Vec<KnownBijuxTool> {
586    let raw = include_str!(concat!(
587        env!("CARGO_MANIFEST_DIR"),
588        "/contracts/official_product_namespace_registry.json"
589    ));
590    let document: ProductRegistryDocument =
591        serde_json::from_str(raw).expect("official product registry must stay valid JSON");
592    validate_registry_document(&document)
593        .expect("official product registry must satisfy namespace, alias, and field contracts");
594
595    document
596        .entries
597        .into_iter()
598        .map(|entry| KnownBijuxTool {
599            namespace: leak(entry.namespace),
600            runtime_binary_name: leak(entry.runtime_binary),
601            control_binary_name: leak(entry.control_binary),
602            runtime_package_name: leak(entry.runtime_package),
603            control_package_name: leak(entry.control_package),
604            repository_name: leak(entry.repository),
605            status: leak(entry.status),
606            display_name: leak(entry.display_name),
607            aliases: leak_vec(entry.aliases),
608            language: leak(entry.language),
609            version: entry.version.map(leak),
610            help_summary: leak(entry.help_summary),
611            capabilities: leak_vec(entry.capabilities),
612        })
613        .collect()
614}
615
616fn known_bijux_tools_storage() -> &'static Vec<KnownBijuxTool> {
617    static STORAGE: OnceLock<Vec<KnownBijuxTool>> = OnceLock::new();
618    STORAGE.get_or_init(load_known_bijux_tools)
619}
620
621/// Canonical known Bijux tools and their binary/package ownership contracts.
622#[must_use]
623pub fn known_bijux_tools() -> &'static [KnownBijuxTool] {
624    known_bijux_tools_storage().as_slice()
625}
626
627fn load_known_bijux_tool_namespaces() -> Vec<&'static str> {
628    known_bijux_tools().iter().map(|tool| tool.namespace).collect()
629}
630
631/// Canonical reserved namespaces for known Bijux tools.
632#[must_use]
633pub fn known_bijux_tool_namespaces() -> &'static [&'static str] {
634    static STORAGE: OnceLock<Vec<&'static str>> = OnceLock::new();
635    STORAGE.get_or_init(load_known_bijux_tool_namespaces).as_slice()
636}
637
638/// Canonical reserved namespaces for official product mounts.
639#[must_use]
640pub fn official_product_namespaces() -> &'static [&'static str] {
641    known_bijux_tool_namespaces()
642}
643
644/// Resolve known tool metadata by namespace.
645#[must_use]
646pub fn known_bijux_tool(namespace: &str) -> Option<&'static KnownBijuxTool> {
647    known_bijux_tools().iter().find(|tool| tool.namespace == namespace)
648}
649
650/// Resolve known tool metadata by namespace or declared alias.
651#[must_use]
652pub fn known_bijux_tool_by_query(query: &str) -> Option<&'static KnownBijuxTool> {
653    let normalized = Namespace::normalize(query);
654    known_bijux_tools().iter().find(|tool| {
655        tool.namespace == normalized
656            || tool.aliases.iter().any(|alias| *alias == normalized.as_str())
657    })
658}
659
660/// Resolve the canonical official app namespace for a query.
661#[must_use]
662pub fn canonical_bijux_tool_namespace(query: &str) -> Option<&'static str> {
663    known_bijux_tool_by_query(query).map(|tool| tool.namespace)
664}
665
666/// Return whether runtime delegation is allowed for an official mount status.
667#[must_use]
668pub fn official_status_allows_runtime_dispatch(status: &str) -> bool {
669    !status.trim().eq_ignore_ascii_case("unsupported")
670}
671
672/// Smallest metadata contract required for reserved product mounts.
673pub type ProductMountMetadata = ProductMountDescriptor;
674
675#[cfg(test)]
676mod tests {
677    use super::{
678        known_bijux_tool_by_query, official_product_namespaces,
679        official_status_allows_runtime_dispatch, validate_product_mount_descriptor,
680        validate_registry_document, Namespace, ProductEntrypointKind, ProductMountDescriptor,
681        ProductRegistryDocument, ProductRegistryEntry,
682    };
683
684    #[test]
685    fn descriptor_builder_builds_valid_mount() {
686        let descriptor = ProductMountDescriptor::builder(Namespace::new("workflow").expect("ns"))
687            .display_name("Workflow")
688            .entrypoint(ProductEntrypointKind::PythonModule, "workflow_app")
689            .control_entrypoint(ProductEntrypointKind::PythonModule, "workflow_app")
690            .help_summary("Workflow runtime")
691            .capability("json_output")
692            .build()
693            .expect("descriptor");
694
695        assert_eq!(descriptor.namespace.as_str(), "workflow");
696        assert_eq!(descriptor.entrypoint.command, "workflow_app");
697    }
698
699    #[test]
700    fn descriptor_validation_rejects_duplicate_aliases() {
701        let descriptor = ProductMountDescriptor {
702            namespace: Namespace::new("workflow").expect("ns"),
703            display_name: "Workflow".to_string(),
704            aliases: vec![
705                Namespace::new("wf").expect("alias"),
706                Namespace::new("wf").expect("alias"),
707            ],
708            entrypoint: super::ProductEntrypoint {
709                kind: ProductEntrypointKind::Binary,
710                command: "workflow".to_string(),
711                module: None,
712                function: None,
713            },
714            control_entrypoint: super::ProductEntrypoint {
715                kind: ProductEntrypointKind::Binary,
716                command: "workflow".to_string(),
717                module: None,
718                function: None,
719            },
720            help: super::ProductHelpMetadata { summary: "Workflow runtime".to_string() },
721            capabilities: vec!["json_output".to_string()],
722            version: None,
723            compatibility: None,
724        };
725
726        let error = validate_product_mount_descriptor(&descriptor).expect_err("must reject");
727        assert!(error.contains("duplicate alias"));
728    }
729
730    fn sample_registry_entry(namespace: &str, status: &str) -> ProductRegistryEntry {
731        ProductRegistryEntry {
732            namespace: namespace.to_string(),
733            display_name: format!("{namespace} display"),
734            aliases: Vec::new(),
735            runtime_binary: format!("bijux-{namespace}"),
736            control_binary: format!("bijux-dev-{namespace}"),
737            runtime_package: format!("bijux-{namespace}"),
738            control_package: format!("bijux-dev-{namespace}"),
739            repository: format!("bijux-{namespace}"),
740            status: status.to_string(),
741            language: "rust".to_string(),
742            version: None,
743            help_summary: format!("{namespace} summary"),
744            capabilities: vec!["json_output".to_string()],
745        }
746    }
747
748    fn sample_registry(entries: Vec<ProductRegistryEntry>) -> ProductRegistryDocument {
749        ProductRegistryDocument {
750            schema_version: "v1".to_string(),
751            owner: "bijux-cli".to_string(),
752            policy: "policy".to_string(),
753            entries,
754        }
755    }
756
757    #[test]
758    fn registry_validation_rejects_duplicate_namespaces() {
759        let document = sample_registry(vec![
760            sample_registry_entry("dag", "declared"),
761            sample_registry_entry("dag", "declared"),
762        ]);
763        let error = validate_registry_document(&document).expect_err("duplicate namespace");
764        assert!(error.contains("duplicate official namespace"));
765    }
766
767    #[test]
768    fn registry_validation_rejects_unsupported_status_values() {
769        let document = sample_registry(vec![sample_registry_entry("dag", "preview")]);
770        let error = validate_registry_document(&document).expect_err("unsupported status");
771        assert!(error.contains("unsupported status"));
772    }
773
774    #[test]
775    fn runtime_dispatch_policy_blocks_unsupported_statuses() {
776        assert!(official_status_allows_runtime_dispatch("declared"));
777        assert!(official_status_allows_runtime_dispatch("supported"));
778        assert!(official_status_allows_runtime_dispatch("deprecated"));
779        assert!(!official_status_allows_runtime_dispatch("unsupported"));
780    }
781
782    #[test]
783    fn official_namespace_registry_exposes_known_namespaces() {
784        assert!(official_product_namespaces().contains(&"dag"));
785        let dag = known_bijux_tool_by_query("dag").expect("dag tool metadata");
786        assert_eq!(dag.namespace, "dag");
787    }
788}