1#![forbid(unsafe_code)]
2use 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#[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
28pub 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
51pub 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}