Skip to main content

bijux_cli/sdk/
mod.rs

1#![forbid(unsafe_code)]
2//! Rust SDK surfaces for mounted Bijux apps.
3
4mod harness;
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use schemars::JsonSchema;
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13
14use crate::contracts::diagnostics::DiagnosticRecord;
15use crate::contracts::{
16    ColorMode, CommandPath, ErrorDetailsV1, ErrorEnvelopeV1, ErrorPayloadV1, ExitCode, LogLevel,
17    Namespace, OutputEnvelopeMetaV1, OutputEnvelopeV1, OutputFormat, PrettyMode,
18    ProductCompatibilityWindow, ProductEntrypoint, ProductEntrypointKind, ProductMountDescriptor,
19};
20use crate::shared::output::{emit_error, emit_success, EmitterConfig};
21use crate::shared::version::runtime_semver;
22
23pub use harness::{BijuxCliHarness, HarnessRun, SnapshotHelper};
24
25/// Standard feature-capability declarations for mounted apps.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub struct FeatureCapabilityDeclaration {
28    pub uses_config: bool,
29    pub uses_history: bool,
30    pub uses_memory: bool,
31    pub uses_plugins: bool,
32    pub supports_completion: bool,
33    pub supports_repl: bool,
34}
35
36impl FeatureCapabilityDeclaration {
37    /// Convert boolean declarations into stable capability strings.
38    #[must_use]
39    pub fn capability_labels(&self) -> Vec<String> {
40        let mut labels = Vec::new();
41        if self.uses_config {
42            labels.push("uses_config".to_string());
43        }
44        if self.uses_history {
45            labels.push("uses_history".to_string());
46        }
47        if self.uses_memory {
48            labels.push("uses_memory".to_string());
49        }
50        if self.uses_plugins {
51            labels.push("uses_plugins".to_string());
52        }
53        if self.supports_completion {
54            labels.push("supports_completion".to_string());
55        }
56        if self.supports_repl {
57            labels.push("supports_repl".to_string());
58        }
59        labels
60    }
61}
62
63/// Runtime compatibility declaration for mounted apps.
64pub type SdkCompatibilityWindow = ProductCompatibilityWindow;
65
66/// Compatibility-check report for mounted apps.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
68pub struct SdkCompatibilityReport {
69    pub compatible: bool,
70    pub host_cli_version: String,
71    pub min_cli_version: String,
72    pub max_cli_version_exclusive: Option<String>,
73    pub reasons: Vec<String>,
74}
75
76/// Mounted-app metadata materialized from the high-level SDK builder.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78pub struct BijuxAppMetadata {
79    pub namespace: String,
80    pub display_name: String,
81    pub aliases: Vec<String>,
82    pub summary: String,
83    pub version: Option<String>,
84    pub entrypoint_kind: ProductEntrypointKind,
85    pub entrypoint: String,
86    pub control_entrypoint_kind: ProductEntrypointKind,
87    pub control_entrypoint: String,
88    pub capabilities: Vec<String>,
89    pub feature_capabilities: FeatureCapabilityDeclaration,
90    pub compatibility: Option<SdkCompatibilityWindow>,
91}
92
93/// High-level SDK builder for mounted apps.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ProductMount {
96    namespace: Namespace,
97    display_name: Option<String>,
98    aliases: Vec<String>,
99    runtime_entrypoint: Option<ProductEntrypoint>,
100    control_entrypoint: Option<ProductEntrypoint>,
101    summary: Option<String>,
102    version: Option<String>,
103    capabilities: Vec<String>,
104    feature_capabilities: FeatureCapabilityDeclaration,
105    compatibility: Option<SdkCompatibilityWindow>,
106}
107
108impl ProductMount {
109    /// Start a new mounted-app contract builder.
110    pub fn new(raw_namespace: &str) -> Result<Self, String> {
111        Ok(Self {
112            namespace: Namespace::new(raw_namespace)?,
113            display_name: None,
114            aliases: Vec::new(),
115            runtime_entrypoint: None,
116            control_entrypoint: None,
117            summary: None,
118            version: None,
119            capabilities: Vec::new(),
120            feature_capabilities: FeatureCapabilityDeclaration::default(),
121            compatibility: None,
122        })
123    }
124
125    #[must_use]
126    pub fn display_name(mut self, value: impl Into<String>) -> Self {
127        self.display_name = Some(value.into());
128        self
129    }
130
131    #[must_use]
132    pub fn alias(mut self, value: impl Into<String>) -> Self {
133        self.aliases.push(value.into());
134        self
135    }
136
137    #[must_use]
138    pub fn summary(mut self, value: impl Into<String>) -> Self {
139        self.summary = Some(value.into());
140        self
141    }
142
143    #[must_use]
144    pub fn version(mut self, value: impl Into<String>) -> Self {
145        self.version = Some(value.into());
146        self
147    }
148
149    #[must_use]
150    pub fn capability(mut self, value: impl Into<String>) -> Self {
151        self.capabilities.push(value.into());
152        self
153    }
154
155    #[must_use]
156    pub fn feature_capabilities(mut self, value: FeatureCapabilityDeclaration) -> Self {
157        self.feature_capabilities = value;
158        self
159    }
160
161    #[must_use]
162    pub fn compatibility(mut self, value: SdkCompatibilityWindow) -> Self {
163        self.compatibility = Some(value);
164        self
165    }
166
167    #[must_use]
168    pub fn binary(self, command: impl Into<String>) -> Self {
169        self.runtime_entrypoint(ProductEntrypointKind::Binary, command)
170    }
171
172    #[must_use]
173    pub fn python_module(self, module: impl Into<String>) -> Self {
174        self.runtime_entrypoint(ProductEntrypointKind::PythonModule, module)
175    }
176
177    #[must_use]
178    pub fn python_callable(
179        mut self,
180        module: impl Into<String>,
181        function: impl Into<String>,
182    ) -> Self {
183        let module = module.into();
184        self.runtime_entrypoint = Some(ProductEntrypoint {
185            kind: ProductEntrypointKind::PythonModule,
186            command: module.clone(),
187            module: Some(module),
188            function: Some(function.into()),
189        });
190        self
191    }
192
193    #[must_use]
194    pub fn python_console_script(self, command: impl Into<String>) -> Self {
195        self.runtime_entrypoint(ProductEntrypointKind::PythonConsoleScript, command)
196    }
197
198    #[must_use]
199    pub fn plugin_process(self, command: impl Into<String>) -> Self {
200        self.runtime_entrypoint(ProductEntrypointKind::PluginProcess, command)
201    }
202
203    #[must_use]
204    pub fn embedded_rust(self, symbol: impl Into<String>) -> Self {
205        self.runtime_entrypoint(ProductEntrypointKind::EmbeddedRust, symbol)
206    }
207
208    #[must_use]
209    pub fn control_binary(self, command: impl Into<String>) -> Self {
210        self.control_entrypoint(ProductEntrypointKind::Binary, command)
211    }
212
213    #[must_use]
214    pub fn control_python_module(self, module: impl Into<String>) -> Self {
215        self.control_entrypoint(ProductEntrypointKind::PythonModule, module)
216    }
217
218    #[must_use]
219    pub fn control_python_callable(
220        mut self,
221        module: impl Into<String>,
222        function: impl Into<String>,
223    ) -> Self {
224        let module = module.into();
225        self.control_entrypoint = Some(ProductEntrypoint {
226            kind: ProductEntrypointKind::PythonModule,
227            command: module.clone(),
228            module: Some(module),
229            function: Some(function.into()),
230        });
231        self
232    }
233
234    #[must_use]
235    pub fn control_python_console_script(self, command: impl Into<String>) -> Self {
236        self.control_entrypoint(ProductEntrypointKind::PythonConsoleScript, command)
237    }
238
239    #[must_use]
240    pub fn control_plugin_process(self, command: impl Into<String>) -> Self {
241        self.control_entrypoint(ProductEntrypointKind::PluginProcess, command)
242    }
243
244    #[must_use]
245    pub fn control_embedded_rust(self, symbol: impl Into<String>) -> Self {
246        self.control_entrypoint(ProductEntrypointKind::EmbeddedRust, symbol)
247    }
248
249    #[must_use]
250    fn runtime_entrypoint(
251        mut self,
252        kind: ProductEntrypointKind,
253        command: impl Into<String>,
254    ) -> Self {
255        self.runtime_entrypoint =
256            Some(ProductEntrypoint { kind, command: command.into(), module: None, function: None });
257        self
258    }
259
260    #[must_use]
261    fn control_entrypoint(
262        mut self,
263        kind: ProductEntrypointKind,
264        command: impl Into<String>,
265    ) -> Self {
266        self.control_entrypoint =
267            Some(ProductEntrypoint { kind, command: command.into(), module: None, function: None });
268        self
269    }
270
271    #[must_use]
272    pub fn namespace(&self) -> &Namespace {
273        &self.namespace
274    }
275
276    #[must_use]
277    pub fn matches_query(&self, query: &str) -> bool {
278        let normalized = Namespace::normalize(query);
279        self.namespace.as_str() == normalized
280            || self.aliases.iter().any(|alias| Namespace::normalize(alias) == normalized)
281    }
282
283    /// Build the validated product-mount descriptor consumed by the root runtime.
284    pub fn build_descriptor(&self) -> Result<ProductMountDescriptor, String> {
285        let runtime_entrypoint = self
286            .runtime_entrypoint
287            .clone()
288            .ok_or_else(|| "product mount runtime entrypoint is required".to_string())?;
289        let control_entrypoint =
290            self.control_entrypoint.clone().unwrap_or_else(|| runtime_entrypoint.clone());
291        let display_name = self
292            .display_name
293            .clone()
294            .unwrap_or_else(|| default_display_name(self.namespace.as_str()));
295        let summary =
296            self.summary.clone().ok_or_else(|| "product mount summary is required".to_string())?;
297        let aliases = self
298            .aliases
299            .iter()
300            .map(|alias| Namespace::new(alias))
301            .collect::<Result<Vec<_>, _>>()?;
302
303        let mut builder = ProductMountDescriptor::builder(self.namespace.clone())
304            .display_name(display_name)
305            .entrypoint_value(runtime_entrypoint)
306            .control_entrypoint_value(control_entrypoint)
307            .help_summary(summary);
308
309        for alias in aliases {
310            builder = builder.alias(alias);
311        }
312        for capability in merged_capabilities(&self.capabilities, &self.feature_capabilities) {
313            builder = builder.capability(capability);
314        }
315        if let Some(version) = &self.version {
316            builder = builder.version(version.clone());
317        }
318        if let Some(compatibility) = &self.compatibility {
319            builder = builder.compatibility(compatibility.clone());
320        }
321        builder.build()
322    }
323
324    /// Render the validated product-mount descriptor as canonical JSON.
325    pub fn manifest_json(&self) -> Result<String, String> {
326        let descriptor = self.build_descriptor()?;
327        serde_json::to_string_pretty(&descriptor)
328            .map_err(|error| format!("failed to render product mount manifest JSON: {error}"))
329    }
330
331    /// Materialize metadata suitable for app-author tooling and docs.
332    pub fn metadata(&self) -> Result<BijuxAppMetadata, String> {
333        let descriptor = self.build_descriptor()?;
334        Ok(BijuxAppMetadata {
335            namespace: descriptor.namespace.as_str().to_string(),
336            display_name: descriptor.display_name,
337            aliases: descriptor.aliases.iter().map(|alias| alias.as_str().to_string()).collect(),
338            summary: descriptor.help.summary,
339            version: descriptor.version,
340            entrypoint_kind: descriptor.entrypoint.kind.clone(),
341            entrypoint: descriptor.entrypoint.command.clone(),
342            control_entrypoint_kind: descriptor.control_entrypoint.kind.clone(),
343            control_entrypoint: descriptor.control_entrypoint.command.clone(),
344            capabilities: descriptor.capabilities,
345            feature_capabilities: self.feature_capabilities.clone(),
346            compatibility: self.compatibility.clone(),
347        })
348    }
349
350    /// Check whether this app is compatible with the current host runtime.
351    pub fn compatibility_report(&self) -> Result<Option<SdkCompatibilityReport>, String> {
352        let Some(window) = &self.compatibility else {
353            return Ok(None);
354        };
355
356        let host_cli_version = runtime_semver().to_string();
357        let host = Version::parse(&host_cli_version)
358            .map_err(|error| format!("host semver is invalid: {error}"))?;
359        let min = Version::parse(&window.min_cli_version)
360            .map_err(|error| format!("min_cli_version is invalid: {error}"))?;
361        let max = window
362            .max_cli_version_exclusive
363            .as_ref()
364            .map(|value| Version::parse(value))
365            .transpose()
366            .map_err(|error| format!("max_cli_version_exclusive is invalid: {error}"))?;
367
368        let mut reasons = Vec::new();
369        if host < min {
370            reasons.push(format!(
371                "host version `{host_cli_version}` is below required minimum `{}`",
372                window.min_cli_version
373            ));
374        }
375        if let Some(max_version) = &max {
376            if host >= *max_version {
377                reasons.push(format!(
378                    "host version `{host_cli_version}` is not below exclusive maximum `{}`",
379                    max_version
380                ));
381            }
382        }
383
384        Ok(Some(SdkCompatibilityReport {
385            compatible: reasons.is_empty(),
386            host_cli_version,
387            min_cli_version: window.min_cli_version.clone(),
388            max_cli_version_exclusive: window.max_cli_version_exclusive.clone(),
389            reasons,
390        }))
391    }
392}
393
394/// Execution context passed to mounted app handlers.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
396pub struct CommandContext {
397    pub cwd: PathBuf,
398    pub project_root: Option<PathBuf>,
399    pub config_dirs: Vec<PathBuf>,
400    pub output_format: OutputFormat,
401    pub pretty_mode: PrettyMode,
402    pub color_mode: ColorMode,
403    pub verbosity: LogLevel,
404    pub quiet: bool,
405    pub invocation_id: String,
406    pub parent_command: CommandPath,
407}
408
409impl CommandContext {
410    /// Start a builder from the required parent command path.
411    #[must_use]
412    pub fn builder(parent_command: CommandPath) -> CommandContextBuilder {
413        CommandContextBuilder::new(parent_command)
414    }
415
416    /// Build a child command path below the mounted app command root.
417    pub fn command_path(&self, tail_segments: &[&str]) -> Result<CommandPath, String> {
418        let mut segments = self
419            .parent_command
420            .segments
421            .iter()
422            .map(|segment| segment.as_str().to_string())
423            .collect::<Vec<_>>();
424        segments.extend(tail_segments.iter().map(|segment| segment.to_string()));
425        let refs = segments.iter().map(String::as_str).collect::<Vec<_>>();
426        CommandPath::new(&refs)
427    }
428}
429
430/// Builder for mounted-app execution context.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct CommandContextBuilder {
433    cwd: PathBuf,
434    project_root: Option<PathBuf>,
435    config_dirs: Vec<PathBuf>,
436    output_format: OutputFormat,
437    pretty_mode: PrettyMode,
438    color_mode: ColorMode,
439    verbosity: LogLevel,
440    quiet: bool,
441    invocation_id: String,
442    parent_command: CommandPath,
443}
444
445impl CommandContextBuilder {
446    fn new(parent_command: CommandPath) -> Self {
447        Self {
448            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
449            project_root: None,
450            config_dirs: Vec::new(),
451            output_format: OutputFormat::Json,
452            pretty_mode: PrettyMode::Pretty,
453            color_mode: ColorMode::Auto,
454            verbosity: LogLevel::Info,
455            quiet: false,
456            invocation_id: "bijux-sdk-invocation".to_string(),
457            parent_command,
458        }
459    }
460
461    #[must_use]
462    pub fn cwd(mut self, value: impl Into<PathBuf>) -> Self {
463        self.cwd = value.into();
464        self
465    }
466
467    #[must_use]
468    pub fn project_root(mut self, value: impl Into<PathBuf>) -> Self {
469        self.project_root = Some(value.into());
470        self
471    }
472
473    #[must_use]
474    pub fn config_dir(mut self, value: impl Into<PathBuf>) -> Self {
475        self.config_dirs.push(value.into());
476        self
477    }
478
479    #[must_use]
480    pub fn output_format(mut self, value: OutputFormat) -> Self {
481        self.output_format = value;
482        self
483    }
484
485    #[must_use]
486    pub fn pretty_mode(mut self, value: PrettyMode) -> Self {
487        self.pretty_mode = value;
488        self
489    }
490
491    #[must_use]
492    pub fn color_mode(mut self, value: ColorMode) -> Self {
493        self.color_mode = value;
494        self
495    }
496
497    #[must_use]
498    pub fn verbosity(mut self, value: LogLevel) -> Self {
499        self.verbosity = value;
500        self
501    }
502
503    #[must_use]
504    pub fn quiet(mut self, value: bool) -> Self {
505        self.quiet = value;
506        self
507    }
508
509    #[must_use]
510    pub fn invocation_id(mut self, value: impl Into<String>) -> Self {
511        self.invocation_id = value.into();
512        self
513    }
514
515    #[must_use]
516    pub fn build(self) -> CommandContext {
517        CommandContext {
518            cwd: self.cwd,
519            project_root: self.project_root,
520            config_dirs: self.config_dirs,
521            output_format: self.output_format,
522            pretty_mode: self.pretty_mode,
523            color_mode: self.color_mode,
524            verbosity: self.verbosity,
525            quiet: self.quiet,
526            invocation_id: self.invocation_id,
527            parent_command: self.parent_command,
528        }
529    }
530}
531
532/// Public render configuration for mounted-app command results.
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
534pub struct SdkRenderConfig {
535    pub format: OutputFormat,
536    pub pretty_mode: PrettyMode,
537    pub color_mode: ColorMode,
538    pub verbosity: LogLevel,
539    pub quiet: bool,
540    pub no_color: bool,
541}
542
543impl Default for SdkRenderConfig {
544    fn default() -> Self {
545        Self {
546            format: OutputFormat::Json,
547            pretty_mode: PrettyMode::Pretty,
548            color_mode: ColorMode::Never,
549            verbosity: LogLevel::Info,
550            quiet: false,
551            no_color: true,
552        }
553    }
554}
555
556impl From<SdkRenderConfig> for EmitterConfig {
557    fn from(value: SdkRenderConfig) -> Self {
558        Self {
559            format: value.format,
560            pretty: matches!(value.pretty_mode, PrettyMode::Pretty),
561            color: value.color_mode,
562            log_level: value.verbosity,
563            quiet: value.quiet,
564            no_color: value.no_color,
565        }
566    }
567}
568
569/// Stream-routing policy for app-command results.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
571#[serde(rename_all = "snake_case")]
572pub enum StreamPolicy {
573    Auto,
574    Always,
575    Never,
576}
577
578/// Mounted-app result envelope.
579#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
580#[serde(rename_all = "snake_case", tag = "kind", content = "envelope")]
581pub enum CommandEnvelope {
582    Success(OutputEnvelopeV1),
583    Error(ErrorEnvelopeV1),
584}
585
586/// Standard mounted-app command result.
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
588pub struct CommandResult {
589    pub exit_code: ExitCode,
590    pub envelope: CommandEnvelope,
591    pub stdout_policy: StreamPolicy,
592    pub stderr_policy: StreamPolicy,
593}
594
595impl CommandResult {
596    /// Construct a successful command result.
597    #[must_use]
598    pub fn success(envelope: OutputEnvelopeV1) -> Self {
599        Self {
600            exit_code: ExitCode::Success,
601            envelope: CommandEnvelope::Success(envelope),
602            stdout_policy: StreamPolicy::Auto,
603            stderr_policy: StreamPolicy::Never,
604        }
605    }
606
607    /// Construct a failed command result.
608    #[must_use]
609    pub fn failure(exit_code: ExitCode, envelope: ErrorEnvelopeV1) -> Self {
610        Self {
611            exit_code,
612            envelope: CommandEnvelope::Error(envelope),
613            stdout_policy: StreamPolicy::Never,
614            stderr_policy: StreamPolicy::Auto,
615        }
616    }
617
618    #[must_use]
619    pub fn stdout_policy(mut self, value: StreamPolicy) -> Self {
620        self.stdout_policy = value;
621        self
622    }
623
624    #[must_use]
625    pub fn stderr_policy(mut self, value: StreamPolicy) -> Self {
626        self.stderr_policy = value;
627        self
628    }
629
630    /// Render the command result according to the requested output configuration.
631    pub fn render(&self, cfg: SdkRenderConfig) -> Result<RenderedCommandResult, String> {
632        let emitter = EmitterConfig::from(cfg);
633        match &self.envelope {
634            CommandEnvelope::Success(envelope) => {
635                let mut stdout = String::new();
636                if !matches!(self.stdout_policy, StreamPolicy::Never) {
637                    let effective = if matches!(self.stdout_policy, StreamPolicy::Always) {
638                        EmitterConfig { quiet: false, ..emitter }
639                    } else {
640                        emitter
641                    };
642                    if let Some(rendered) =
643                        emit_success(envelope, effective).map_err(|error| error.to_string())?
644                    {
645                        stdout = rendered.content;
646                    }
647                }
648                Ok(RenderedCommandResult {
649                    exit_code: self.exit_code,
650                    stdout,
651                    stderr: String::new(),
652                })
653            }
654            CommandEnvelope::Error(envelope) => {
655                let stderr = if matches!(self.stderr_policy, StreamPolicy::Never) {
656                    String::new()
657                } else {
658                    emit_error(envelope, emitter).map_err(|error| error.to_string())?.content
659                };
660                Ok(RenderedCommandResult {
661                    exit_code: self.exit_code,
662                    stdout: String::new(),
663                    stderr,
664                })
665            }
666        }
667    }
668}
669
670/// Rendered command result returned by the SDK harness.
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
672pub struct RenderedCommandResult {
673    pub exit_code: ExitCode,
674    pub stdout: String,
675    pub stderr: String,
676}
677
678/// Builder for root-compatible diagnostic records.
679#[derive(Debug, Clone, PartialEq)]
680pub struct DiagnosticRecordBuilder {
681    id: String,
682    severity: String,
683    message: Option<String>,
684    fields: BTreeMap<String, Value>,
685}
686
687impl DiagnosticRecordBuilder {
688    #[must_use]
689    pub fn new(id: impl Into<String>) -> Self {
690        Self {
691            id: id.into(),
692            severity: "error".to_string(),
693            message: None,
694            fields: BTreeMap::new(),
695        }
696    }
697
698    #[must_use]
699    pub fn severity(mut self, value: impl Into<String>) -> Self {
700        self.severity = value.into();
701        self
702    }
703
704    #[must_use]
705    pub fn message(mut self, value: impl Into<String>) -> Self {
706        self.message = Some(value.into());
707        self
708    }
709
710    #[must_use]
711    pub fn field(mut self, key: impl Into<String>, value: Value) -> Self {
712        self.fields.insert(key.into(), value);
713        self
714    }
715
716    pub fn build(self) -> Result<DiagnosticRecord, String> {
717        if self.id.trim().is_empty() {
718            return Err("diagnostic id cannot be empty".to_string());
719        }
720        if self.severity.trim().is_empty() {
721            return Err("diagnostic severity cannot be empty".to_string());
722        }
723        let message = self.message.ok_or_else(|| "diagnostic message is required".to_string())?;
724        if message.trim().is_empty() {
725            return Err("diagnostic message cannot be empty".to_string());
726        }
727        Ok(DiagnosticRecord { id: self.id, severity: self.severity, message, fields: self.fields })
728    }
729}
730
731/// Builder for root-compatible structured failures.
732#[derive(Debug, Clone, PartialEq)]
733pub struct CommandFailureBuilder {
734    code: String,
735    category: String,
736    message: Option<String>,
737    failure: Option<String>,
738    context: BTreeMap<String, Value>,
739}
740
741impl CommandFailureBuilder {
742    #[must_use]
743    pub fn new(code: impl Into<String>, category: impl Into<String>) -> Self {
744        Self {
745            code: code.into(),
746            category: category.into(),
747            message: None,
748            failure: None,
749            context: BTreeMap::new(),
750        }
751    }
752
753    #[must_use]
754    pub fn message(mut self, value: impl Into<String>) -> Self {
755        self.message = Some(value.into());
756        self
757    }
758
759    #[must_use]
760    pub fn failure(mut self, value: impl Into<String>) -> Self {
761        self.failure = Some(value.into());
762        self
763    }
764
765    #[must_use]
766    pub fn context(mut self, key: impl Into<String>, value: Value) -> Self {
767        self.context.insert(key.into(), value);
768        self
769    }
770
771    pub fn build(self) -> Result<ErrorPayloadV1, String> {
772        let message = self.message.ok_or_else(|| "error message is required".to_string())?;
773        let mut payload = ErrorPayloadV1::new(&self.code, &message, &self.category)?;
774        if self.failure.is_some() || !self.context.is_empty() {
775            payload.details = Some(ErrorDetailsV1 { failure: self.failure, context: self.context });
776        }
777        Ok(payload)
778    }
779}
780
781/// Stable helpers for mounted-app envelopes and payload shapes.
782pub struct OutputEnvelopeHelper;
783
784impl OutputEnvelopeHelper {
785    pub fn success(
786        command: CommandPath,
787        data: Value,
788        timestamp: &str,
789    ) -> Result<OutputEnvelopeV1, String> {
790        let meta = OutputEnvelopeMetaV1::new("v1", command, timestamp)?;
791        Ok(OutputEnvelopeV1::success(data, meta))
792    }
793
794    pub fn failure(
795        command: CommandPath,
796        error: ErrorPayloadV1,
797        timestamp: &str,
798    ) -> Result<ErrorEnvelopeV1, String> {
799        let meta = OutputEnvelopeMetaV1::new("v1", command, timestamp)?;
800        Ok(ErrorEnvelopeV1::failure(error, meta))
801    }
802
803    #[must_use]
804    pub fn json(value: Value) -> Value {
805        value
806    }
807
808    #[must_use]
809    pub fn text(message: impl Into<String>) -> Value {
810        json!({ "message": message.into() })
811    }
812
813    pub fn table(columns: &[&str], rows: &[Vec<Value>]) -> Result<Value, String> {
814        if columns.is_empty() {
815            return Err("table columns cannot be empty".to_string());
816        }
817        for row in rows {
818            if row.len() != columns.len() {
819                return Err("table rows must match the column count".to_string());
820            }
821        }
822        Ok(json!({
823            "kind": "table",
824            "columns": columns,
825            "rows": rows,
826        }))
827    }
828
829    #[must_use]
830    pub fn quiet() -> Value {
831        json!({})
832    }
833}
834
835/// Trait implemented by mounted Rust apps.
836pub trait BijuxApp {
837    fn mount(&self) -> ProductMount;
838    fn route(&self, argv: &[String], ctx: &CommandContext) -> CommandResult;
839
840    fn namespace(&self) -> String {
841        self.mount().namespace().as_str().to_string()
842    }
843
844    fn metadata(&self) -> Result<BijuxAppMetadata, String> {
845        self.mount().metadata()
846    }
847
848    fn manifest_descriptor(&self) -> Result<ProductMountDescriptor, String> {
849        self.mount().build_descriptor()
850    }
851}
852
853fn default_display_name(namespace: &str) -> String {
854    namespace
855        .split('-')
856        .filter(|segment| !segment.is_empty())
857        .map(|segment| {
858            let mut chars = segment.chars();
859            match chars.next() {
860                Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
861                None => String::new(),
862            }
863        })
864        .collect::<Vec<_>>()
865        .join(" ")
866}
867
868fn merged_capabilities(
869    capabilities: &[String],
870    feature_capabilities: &FeatureCapabilityDeclaration,
871) -> Vec<String> {
872    let mut merged = capabilities.to_vec();
873    merged.extend(feature_capabilities.capability_labels());
874    merged.sort();
875    merged.dedup();
876    merged
877}