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