1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8use clap::{ArgAction, Args};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::capabilities::FilesystemMode;
13use crate::manifest::ComponentManifest;
14use crate::manifest::parse_manifest;
15use crate::test_harness::{HarnessConfig, TestHarness, WasiPreopen};
16use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
17
18#[derive(Args, Debug)]
19pub struct TestArgs {
20 #[arg(long, value_name = "PATH")]
22 pub wasm: PathBuf,
23 #[arg(long, value_name = "PATH")]
25 pub manifest: Option<PathBuf>,
26 #[arg(long, value_name = "OP", action = ArgAction::Append)]
28 pub op: Vec<String>,
29 #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
31 pub input: Vec<PathBuf>,
32 #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
34 pub input_json: Vec<String>,
35 #[arg(long, value_name = "PATH")]
37 pub output: Option<PathBuf>,
38 #[arg(long)]
40 pub pretty: bool,
41 #[arg(long)]
43 pub state_dump: bool,
44 #[arg(long = "state-set", value_name = "KEY=BASE64")]
46 pub state_set: Vec<String>,
47 #[arg(long, action = ArgAction::Count)]
49 pub step: u8,
50 #[arg(long, value_name = "PATH")]
52 pub secrets: Option<PathBuf>,
53 #[arg(long, value_name = "PATH")]
55 pub secrets_json: Option<PathBuf>,
56 #[arg(long = "secret", value_name = "KEY=VALUE")]
58 pub secret: Vec<String>,
59 #[arg(long, default_value = "dev")]
61 pub env: String,
62 #[arg(long, default_value = "default")]
64 pub tenant: String,
65 #[arg(long)]
67 pub team: Option<String>,
68 #[arg(long)]
70 pub user: Option<String>,
71 #[arg(long)]
73 pub flow: Option<String>,
74 #[arg(long)]
76 pub node: Option<String>,
77 #[arg(long)]
79 pub session: Option<String>,
80 #[arg(long)]
82 pub verbose: bool,
83}
84
85pub fn run(args: TestArgs) -> Result<()> {
86 let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
87 let manifest_raw = fs::read_to_string(&manifest_path)
88 .with_context(|| format!("read manifest {}", manifest_path.display()))?;
89 let manifest_value: Value =
90 serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
91 let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
92
93 let steps = collect_steps(&args)?;
94 for (op, _) in &steps {
95 if !manifest
96 .operations
97 .iter()
98 .any(|operation| operation.name == *op)
99 {
100 bail!("operation `{op}` not declared in manifest");
101 }
102 }
103 let wasm_bytes =
104 fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
105
106 let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(&args)?;
107 if args.verbose && generated_session {
108 eprintln!("generated session id: {session_id}");
109 }
110
111 let (allow_state_read, allow_state_write, allow_state_delete) =
112 state_permissions(&manifest_value, &manifest);
113 if !args.state_set.is_empty() && !allow_state_write {
114 bail!("manifest does not declare host.state.write; add it to use --state-set");
115 }
116 let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
117
118 let secrets = load_secrets(&args)?;
119 if !allow_secrets && !secrets.is_empty() {
120 bail!("manifest does not declare host.secrets; add host.secrets to enable secrets access");
121 }
122
123 let state_seeds = parse_state_seeds(&args)?;
124 let wasi_preopens = resolve_wasi_preopens(&manifest)?;
125 let prefix = state_prefix(args.flow.as_deref(), &session_id);
126 let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
127 let harness = TestHarness::new(HarnessConfig {
128 wasm_bytes,
129 tenant_ctx: tenant_ctx.clone(),
130 flow_id,
131 node_id: args.node.clone(),
132 state_prefix: prefix,
133 state_seeds,
134 allow_state_read,
135 allow_state_write,
136 allow_state_delete,
137 allow_secrets,
138 allowed_secrets,
139 secrets,
140 wasi_preopens,
141 })?;
142
143 if steps.len() > 1 && args.output.is_some() {
144 bail!("--output is only supported for single-step runs");
145 }
146
147 for (idx, (op, input)) in steps.iter().enumerate() {
148 let output = harness.invoke(op, input)?;
149 let output = format_output(&output, args.pretty)?;
150 if let Some(path) = &args.output {
151 fs::write(path, output.as_bytes())
152 .with_context(|| format!("write output {}", path.display()))?;
153 }
154 if steps.len() > 1 {
155 println!("step {} output:\n{output}", idx + 1);
156 } else {
157 println!("{output}");
158 }
159 }
160
161 if args.state_dump {
162 let dump = harness.state_dump();
163 let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
164 eprintln!("state dump:\n{dump_json}");
165 }
166
167 Ok(())
168}
169
170fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
171 if let Some(path) = manifest {
172 return Ok(path.to_path_buf());
173 }
174 let dir = wasm
175 .parent()
176 .ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
177 let candidate = dir.join("component.manifest.json");
178 if candidate.exists() {
179 Ok(candidate)
180 } else {
181 bail!(
182 "manifest not found; pass --manifest or place component.manifest.json next to the wasm"
183 );
184 }
185}
186
187fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
188 if args.op.is_empty() {
189 bail!("--op is required");
190 }
191 let inputs = if !args.input.is_empty() {
192 let mut values = Vec::new();
193 for path in &args.input {
194 let raw = fs::read_to_string(path)
195 .with_context(|| format!("read input {}", path.display()))?;
196 values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
197 }
198 values
199 } else if !args.input_json.is_empty() {
200 let mut values = Vec::new();
201 for raw in &args.input_json {
202 values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
203 }
204 values
205 } else {
206 bail!("--input or --input-json is required");
207 };
208
209 if args.op.len() != inputs.len() {
210 bail!("provide the same number of --op and --input/--input-json values");
211 }
212 if args.op.len() > 1 {
213 let expected_steps = args.op.len().saturating_sub(1);
214 if args.step == 0 {
215 bail!("use --step to indicate a multi-step run");
216 }
217 if args.step as usize != expected_steps {
218 bail!(
219 "expected {expected_steps} --step flags for {} operations",
220 args.op.len()
221 );
222 }
223 }
224
225 Ok(args.op.clone().into_iter().zip(inputs).collect())
226}
227
228fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
229 let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
230 let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
231 let mut ctx = TenantCtx::new(env, tenant);
232 if let Some(team) = &args.team {
233 let team: TeamId = team.clone().try_into().context("invalid --team")?;
234 ctx = ctx.with_team(Some(team));
235 }
236 if let Some(user) = &args.user {
237 let user: UserId = user.clone().try_into().context("invalid --user")?;
238 ctx = ctx.with_user(Some(user));
239 }
240
241 let (session_id, generated) = match &args.session {
242 Some(session) => (session.clone(), false),
243 None => (Uuid::new_v4().to_string(), true),
244 };
245 ctx = ctx.with_session(session_id.clone());
246
247 if let Some(flow) = &args.flow {
248 ctx = ctx.with_flow(flow.clone());
249 }
250 if let Some(node) = &args.node {
251 ctx = ctx.with_node(node.clone());
252 }
253
254 Ok((ctx, session_id, generated))
255}
256
257fn state_prefix(flow: Option<&str>, session: &str) -> String {
258 if let Some(flow) = flow {
259 format!("flow/{flow}/{session}")
260 } else {
261 format!("test/{session}")
262 }
263}
264
265fn resolve_wasi_preopens(manifest: &ComponentManifest) -> Result<Vec<WasiPreopen>> {
266 let Some(fs) = manifest.capabilities.wasi.filesystem.as_ref() else {
267 return Ok(Vec::new());
268 };
269 if fs.mode == FilesystemMode::None {
270 return Ok(Vec::new());
271 }
272 let host_root =
273 std::env::current_dir().context("resolve current working directory for mounts")?;
274 let meta = fs::metadata(&host_root)
275 .with_context(|| format!("failed to stat preopen {}", host_root.display()))?;
276 if !meta.is_dir() {
277 bail!("preopen {} must be a directory", host_root.display());
278 }
279 let read_only = matches!(fs.mode, FilesystemMode::ReadOnly);
280 let mut preopens = Vec::new();
281 for mount in &fs.mounts {
282 preopens.push(WasiPreopen::new(&host_root, mount.guest_path.clone()).read_only(read_only));
283 }
284 Ok(preopens)
285}
286
287fn state_permissions(
288 manifest_value: &Value,
289 manifest: &crate::manifest::ComponentManifest,
290) -> (bool, bool, bool) {
291 let mut allow_state_read = false;
292 let mut allow_state_write = false;
293 if let Some(state) = manifest.capabilities.host.state.as_ref() {
294 allow_state_read = state.read;
295 allow_state_write = state.write;
296 }
297 let allow_state_delete = manifest_value
298 .get("capabilities")
299 .and_then(|caps| caps.get("host"))
300 .and_then(|host| host.get("state"))
301 .and_then(|state| state.get("delete"))
302 .and_then(|value| value.as_bool())
303 .unwrap_or(false);
304 if allow_state_delete && !allow_state_write {
305 allow_state_write = true;
306 }
307 (allow_state_read, allow_state_write, allow_state_delete)
308}
309
310fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
311 let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
312 return (false, HashSet::new());
313 };
314 let allowed = secrets
315 .required
316 .iter()
317 .map(|req| req.key.as_str().to_string())
318 .collect::<HashSet<_>>();
319 (true, allowed)
320}
321
322fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
323 let mut secrets = HashMap::new();
324 if let Some(path) = &args.secrets {
325 let entries = parse_env_file(path)?;
326 secrets.extend(entries);
327 }
328 if let Some(path) = &args.secrets_json {
329 let entries = parse_json_secrets(path)?;
330 secrets.extend(entries);
331 }
332 for entry in &args.secret {
333 let (key, value) = entry
334 .split_once('=')
335 .ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
336 secrets.insert(key.to_string(), value.to_string());
337 }
338 Ok(secrets)
339}
340
341fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
342 let mut seeds = Vec::new();
343 for entry in &args.state_set {
344 let (key, value) = entry
345 .split_once('=')
346 .ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
347 let bytes = BASE64_STANDARD
348 .decode(value)
349 .with_context(|| format!("invalid base64 for state key `{key}`"))?;
350 seeds.push((key.to_string(), bytes));
351 }
352 Ok(seeds)
353}
354
355fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
356 let contents =
357 fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
358 let mut secrets = HashMap::new();
359 for (idx, line) in contents.lines().enumerate() {
360 let line = line.trim();
361 if line.is_empty() || line.starts_with('#') {
362 continue;
363 }
364 let (key, value) = line.split_once('=').ok_or_else(|| {
365 anyhow::anyhow!(
366 "invalid secrets line {} in {} (expected KEY=VALUE)",
367 idx + 1,
368 path.display()
369 )
370 })?;
371 secrets.insert(key.trim().to_string(), value.trim().to_string());
372 }
373 Ok(secrets)
374}
375
376fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
377 let contents =
378 fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
379 let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
380 let obj = value
381 .as_object()
382 .ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
383 let mut secrets = HashMap::new();
384 for (key, value) in obj {
385 let value = value
386 .as_str()
387 .ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
388 secrets.insert(key.clone(), value.to_string());
389 }
390 Ok(secrets)
391}
392
393fn format_output(raw: &str, pretty: bool) -> Result<String> {
394 if !pretty {
395 return Ok(raw.to_string());
396 }
397 let value: Value = serde_json::from_str(raw).context("output is not valid JSON")?;
398 Ok(serde_json::to_string_pretty(&value)?)
399}