Skip to main content

bijux_cli/sdk/
harness.rs

1#![forbid(unsafe_code)]
2//! Deterministic harness helpers for mounted Rust apps.
3
4use std::path::PathBuf;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::contracts::{CommandPath, ExitCode, OutputFormat, PrettyMode};
11
12use super::{
13    BijuxApp, CommandContext, CommandEnvelope, CommandFailureBuilder, CommandResult,
14    OutputEnvelopeHelper, ProductMount, SdkRenderConfig,
15};
16
17/// Result of running a mounted app through the SDK harness.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
19pub struct HarnessRun {
20    pub query: String,
21    pub matched_namespace: Option<String>,
22    pub exit_code: ExitCode,
23    pub stdout: String,
24    pub stderr: String,
25    pub envelope: CommandEnvelope,
26}
27
28/// Stable snapshot rendering helpers for harness output.
29pub struct SnapshotHelper;
30
31impl SnapshotHelper {
32    #[must_use]
33    pub fn normalize_text(value: &str) -> String {
34        value.replace("\r\n", "\n")
35    }
36
37    #[must_use]
38    pub fn render_run(run: &HarnessRun) -> String {
39        serde_json::to_string_pretty(&serde_json::json!({
40            "query": run.query,
41            "matched_namespace": run.matched_namespace,
42            "exit_code": run.exit_code,
43            "stdout": Self::normalize_text(&run.stdout),
44            "stderr": Self::normalize_text(&run.stderr),
45            "envelope": run.envelope,
46        }))
47        .expect("harness snapshot should serialize")
48    }
49}
50
51/// In-process harness for mounted Rust apps.
52pub struct BijuxCliHarness {
53    apps: Vec<Box<dyn BijuxApp>>,
54    render: SdkRenderConfig,
55    cwd: PathBuf,
56    project_root: Option<PathBuf>,
57    config_dirs: Vec<PathBuf>,
58    invocation_id: String,
59    timestamp: String,
60}
61
62impl BijuxCliHarness {
63    #[must_use]
64    pub fn new() -> Self {
65        Self {
66            apps: Vec::new(),
67            render: SdkRenderConfig::default(),
68            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
69            project_root: None,
70            config_dirs: Vec::new(),
71            invocation_id: "bijux-sdk-harness".to_string(),
72            timestamp: "1970-01-01T00:00:00Z".to_string(),
73        }
74    }
75
76    #[must_use]
77    pub fn mount<T>(mut self, app: T) -> Self
78    where
79        T: BijuxApp + 'static,
80    {
81        self.apps.push(Box::new(app));
82        self
83    }
84
85    #[must_use]
86    pub fn with_output_format(mut self, format: OutputFormat) -> Self {
87        self.render.format = format;
88        self
89    }
90
91    #[must_use]
92    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
93        self.cwd = cwd.into();
94        self
95    }
96
97    #[must_use]
98    pub fn with_pretty(mut self, pretty: bool) -> Self {
99        self.render.pretty_mode = if pretty { PrettyMode::Pretty } else { PrettyMode::Compact };
100        self
101    }
102
103    #[must_use]
104    pub fn with_quiet(mut self, quiet: bool) -> Self {
105        self.render.quiet = quiet;
106        self
107    }
108
109    #[must_use]
110    pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
111        self.project_root = Some(project_root.into());
112        self
113    }
114
115    #[must_use]
116    pub fn with_config_dir(mut self, config_dir: impl Into<PathBuf>) -> Self {
117        self.config_dirs.push(config_dir.into());
118        self
119    }
120
121    #[must_use]
122    pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
123        self.invocation_id = invocation_id.into();
124        self
125    }
126
127    #[must_use]
128    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
129        self.timestamp = timestamp.into();
130        self
131    }
132
133    pub fn run(&self, argv: &[&str]) -> Result<HarnessRun, String> {
134        let Some(query) = argv.first() else {
135            return self.render_harness_error(
136                "",
137                None,
138                CommandFailureBuilder::new("sdk.usage.missing_namespace", "usage")
139                    .message("mounted app harness requires a namespace query")
140                    .build()?,
141                ExitCode::Usage,
142            );
143        };
144
145        let Some((app, mount)) = self.resolve_app(query) else {
146            return self.render_harness_error(
147                query,
148                None,
149                CommandFailureBuilder::new("sdk.route.unknown_namespace", "usage")
150                    .message(format!("unknown mounted app namespace `{query}`"))
151                    .context("query", Value::String((*query).to_string()))
152                    .build()?,
153                ExitCode::Usage,
154            );
155        };
156
157        if let Some(report) = mount.compatibility_report()? {
158            if !report.compatible {
159                return self.render_harness_error(
160                    query,
161                    Some(mount.namespace().as_str().to_string()),
162                    CommandFailureBuilder::new("sdk.compatibility.unsupported_host", "validation")
163                        .message("mounted app is not compatible with this bijux host")
164                        .context(
165                            "compatibility",
166                            serde_json::to_value(report).map_err(|error| {
167                                format!("failed to serialize compatibility report: {error}")
168                            })?,
169                        )
170                        .build()?,
171                    ExitCode::Usage,
172                );
173            }
174        }
175
176        let parent_command = CommandPath::new(&[mount.namespace().as_str()])?;
177        let mut builder = CommandContext::builder(parent_command)
178            .cwd(self.cwd.clone())
179            .output_format(self.render.format)
180            .pretty_mode(self.render.pretty_mode)
181            .color_mode(self.render.color_mode)
182            .verbosity(self.render.verbosity)
183            .quiet(self.render.quiet)
184            .invocation_id(self.invocation_id.clone());
185        if let Some(project_root) = &self.project_root {
186            builder = builder.project_root(project_root.clone());
187        }
188        for config_dir in &self.config_dirs {
189            builder = builder.config_dir(config_dir.clone());
190        }
191        let ctx = builder.build();
192        let route_args = argv.iter().skip(1).map(|value| (*value).to_string()).collect::<Vec<_>>();
193        let command_result = app.route(&route_args, &ctx);
194        let rendered = command_result.render(self.render)?;
195        Ok(HarnessRun {
196            query: (*query).to_string(),
197            matched_namespace: Some(mount.namespace().as_str().to_string()),
198            exit_code: rendered.exit_code,
199            stdout: rendered.stdout,
200            stderr: rendered.stderr,
201            envelope: command_result.envelope,
202        })
203    }
204
205    fn resolve_app(&self, query: &str) -> Option<(&dyn BijuxApp, ProductMount)> {
206        self.apps.iter().find_map(|app| {
207            let mount = app.mount();
208            mount.matches_query(query).then_some((app.as_ref(), mount))
209        })
210    }
211
212    fn render_harness_error(
213        &self,
214        query: &str,
215        matched_namespace: Option<String>,
216        error: crate::contracts::ErrorPayloadV1,
217        exit_code: ExitCode,
218    ) -> Result<HarnessRun, String> {
219        let command = if let Some(namespace) = &matched_namespace {
220            CommandPath::new(&[namespace.as_str()])?
221        } else if query.is_empty() {
222            CommandPath::new(&["apps"])?
223        } else {
224            CommandPath::new(&[query])?
225        };
226        let envelope = OutputEnvelopeHelper::failure(command, error, &self.timestamp)?;
227        let result = CommandResult::failure(exit_code, envelope.clone());
228        let rendered = result.render(self.render)?;
229        Ok(HarnessRun {
230            query: query.to_string(),
231            matched_namespace,
232            exit_code: rendered.exit_code,
233            stdout: rendered.stdout,
234            stderr: rendered.stderr,
235            envelope: CommandEnvelope::Error(envelope),
236        })
237    }
238}