1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, Read, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::bail;
7use anyhow::{Context, Result, anyhow};
8use greentic_flow::flow_bundle::load_and_validate_bundle;
9use greentic_runner::desktop::{
10 HttpMock, HttpMockMode, MocksConfig, OtlpHook, Runner, SigningPolicy, ToolsMock,
11};
12use serde_json::{Value as JsonValue, json};
13use serde_yaml_bw as serde_yaml;
14use zip::ZipArchive;
15
16#[derive(Debug, Clone)]
17pub struct PackRunConfig<'a> {
18 pub pack_path: &'a Path,
19 pub entry: Option<String>,
20 pub input: Option<String>,
21 pub policy: RunPolicy,
22 pub otlp: Option<String>,
23 pub allow_hosts: Option<Vec<String>>,
24 pub mocks: MockSetting,
25 pub artifacts_dir: Option<&'a Path>,
26 pub json: bool,
27 pub offline: bool,
28 pub mock_exec: bool,
29 pub allow_external: bool,
30 pub mock_external: bool,
31 pub mock_external_payload: Option<JsonValue>,
32 pub secrets_seed: Option<&'a Path>,
33}
34
35#[derive(Debug, Clone, Copy)]
36pub enum RunPolicy {
37 Strict,
38 DevOk,
39}
40
41#[derive(Debug, Clone, Copy)]
42pub enum MockSetting {
43 On,
44 Off,
45}
46
47pub fn run(config: PackRunConfig<'_>) -> Result<()> {
48 if config.mock_exec {
49 let input_value = parse_input(config.input.clone())?;
50 let rendered = mock_execute_pack(
51 config.pack_path,
52 config.entry.as_deref().unwrap_or("default"),
53 &input_value,
54 config.offline,
55 config.allow_external,
56 config.mock_external,
57 config
58 .mock_external_payload
59 .clone()
60 .unwrap_or_else(|| json!({ "mocked": true })),
61 config.secrets_seed,
62 )?;
63 let mut rendered = rendered;
64 if let Some(map) = rendered.as_object_mut() {
65 map.insert("exec_mode".to_string(), json!("mock"));
66 }
67 if config.json {
68 println!(
69 "{}",
70 serde_json::to_string(&rendered).context("failed to render mock exec json")?
71 );
72 } else {
73 println!("{}", serde_json::to_string_pretty(&rendered)?);
74 }
75 let status = rendered
76 .get("status")
77 .and_then(|v| v.as_str())
78 .unwrap_or_default();
79 if status != "ok" {
80 bail!("pack run failed");
81 }
82 return Ok(());
83 }
84 let _ = tracing_subscriber::fmt::try_init();
86
87 if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
89 let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
90 let home = workspace.join(".greentic").join("wasmtime-home");
91 let cache_dir = home
92 .join("Library")
93 .join("Caches")
94 .join("BytecodeAlliance.wasmtime");
95 let config_dir = home
96 .join("Library")
97 .join("Application Support")
98 .join("wasmtime");
99 fs::create_dir_all(&cache_dir)
100 .with_context(|| format!("failed to create {}", cache_dir.display()))?;
101 fs::create_dir_all(&config_dir)
102 .with_context(|| format!("failed to create {}", config_dir.display()))?;
103 unsafe {
106 std::env::set_var("HOME", &home);
107 std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
108 }
109 }
110
111 let input_value = parse_input(config.input.clone())?;
112 let otlp_hook = if config.offline {
113 None
114 } else {
115 config.otlp.map(|endpoint| OtlpHook {
116 endpoint,
117 headers: Vec::new(),
118 sample_all: true,
119 })
120 };
121
122 unsafe {
124 std::env::set_var("NO_PROXY", "*");
125 std::env::set_var("HTTPS_PROXY", "");
126 std::env::set_var("HTTP_PROXY", "");
127 std::env::set_var("CFNETWORK_DISABLE_SYSTEM_PROXY", "1");
128 }
129
130 let allow_hosts = config.allow_hosts.unwrap_or_default();
131 let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
132
133 let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
134 if let Some(dir) = &artifacts_override {
135 fs::create_dir_all(dir)
136 .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
137 }
138
139 let runner = Runner::new();
140 let run_result = runner
141 .run_pack_with(config.pack_path, |opts| {
142 opts.entry_flow = config.entry.clone();
143 opts.input = input_value.clone();
144 opts.signing = signing_policy(config.policy);
145 if let Some(hook) = otlp_hook.clone() {
146 opts.otlp = Some(hook);
147 }
148 opts.mocks = mocks_config.clone();
149 opts.artifacts_dir = artifacts_override.clone();
150 })
151 .context("pack execution failed")?;
152
153 let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
154 let mut value = value;
155 if let Some(map) = value.as_object_mut() {
156 map.insert("exec_mode".to_string(), json!("runtime"));
157 }
158 let status = value
159 .get("status")
160 .and_then(|v| v.as_str())
161 .unwrap_or_default();
162 if config.json {
163 println!(
164 "{}",
165 serde_json::to_string(&value).context("failed to render run result JSON")?
166 );
167 } else {
168 let rendered =
169 serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
170 println!("{rendered}");
171 }
172
173 if status == "Failure" || status == "PartialFailure" {
174 let err = value
175 .get("error")
176 .and_then(|v| v.as_str())
177 .unwrap_or("pack run returned failure status");
178 bail!("pack run failed: {err}");
179 }
180
181 Ok(())
182}
183
184#[allow(clippy::too_many_arguments)]
185fn mock_execute_pack(
186 path: &Path,
187 flow_id: &str,
188 input: &JsonValue,
189 offline: bool,
190 allow_external: bool,
191 mock_external: bool,
192 mock_external_payload: JsonValue,
193 secrets_seed: Option<&Path>,
194) -> Result<JsonValue> {
195 let bytes =
196 std::fs::read(path).with_context(|| format!("failed to read pack {}", path.display()))?;
197 let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).context("open pack zip")?;
198 let mut manifest_bytes = Vec::new();
199 archive
200 .by_name("manifest.cbor")
201 .context("manifest.cbor missing")?
202 .read_to_end(&mut manifest_bytes)
203 .context("read manifest")?;
204 let manifest: greentic_types::PackManifest =
205 greentic_types::decode_pack_manifest(&manifest_bytes).context("decode manifest")?;
206 let flow = manifest
207 .flows
208 .iter()
209 .find(|f| f.id.as_str() == flow_id)
210 .ok_or_else(|| anyhow!("flow `{flow_id}` not found in pack"))?;
211 let mut exec_builder = crate::tests_exec::ExecOptions::builder();
212 if let Some(seed_path) = secrets_seed {
213 exec_builder = exec_builder
214 .load_seed_file(seed_path)
215 .context("failed to load secrets seed")?;
216 }
217 let exec_opts = exec_builder
218 .offline(offline)
219 .external_enabled(allow_external)
220 .mock_external(mock_external)
221 .mock_external_payload(mock_external_payload)
222 .build()
223 .context("build mock exec options")?;
224 let exec = crate::tests_exec::execute_with_options(&flow.flow, input, &exec_opts)?;
225 Ok(exec)
226}
227
228fn parse_input(input: Option<String>) -> Result<JsonValue> {
229 if let Some(raw) = input {
230 if raw.trim().is_empty() {
231 return Ok(json!({}));
232 }
233 serde_json::from_str(&raw).context("failed to parse --input JSON")
234 } else {
235 Ok(json!({}))
236 }
237}
238
239fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
240 let mut config = MocksConfig {
241 net_allowlist: allow_hosts
242 .into_iter()
243 .map(|host| host.trim().to_ascii_lowercase())
244 .filter(|host| !host.is_empty())
245 .collect(),
246 ..MocksConfig::default()
247 };
248
249 if matches!(setting, MockSetting::On) {
250 config.http = Some(HttpMock {
251 record_replay_dir: None,
252 mode: HttpMockMode::RecordReplay,
253 rewrites: Vec::new(),
254 });
255
256 let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
257 fs::create_dir_all(&tools_dir)
258 .with_context(|| format!("failed to create {}", tools_dir.display()))?;
259 config.mcp_tools = Some(ToolsMock {
260 directory: None,
261 script_dir: Some(tools_dir),
262 short_circuit: true,
263 });
264 }
265
266 Ok(config)
267}
268
269fn signing_policy(policy: RunPolicy) -> SigningPolicy {
270 match policy {
271 RunPolicy::Strict => SigningPolicy::Strict,
272 RunPolicy::DevOk => SigningPolicy::DevOk,
273 }
274}
275
276#[allow(dead_code)]
278pub fn run_config_flow(flow_path: &Path) -> Result<String> {
279 let source = std::fs::read_to_string(flow_path)
280 .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
281 load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
283
284 let doc: serde_yaml::Value = serde_yaml::from_str(&source)
285 .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
286 let nodes = doc
287 .get("nodes")
288 .and_then(|v| v.as_mapping())
289 .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
290
291 let mut current = nodes
292 .iter()
293 .next()
294 .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
295 .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
296 let mut state: BTreeMap<String, String> = BTreeMap::new();
297 let mut visited: BTreeSet<String> = BTreeSet::new();
298 let is_tty = io::stdin().is_terminal();
299
300 loop {
301 if !visited.insert(current.clone()) {
302 bail!("config flow routing loop detected at {}", current);
303 }
304
305 let node_val = nodes
306 .get(serde_yaml::Value::String(current.clone(), None))
307 .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
308 let mapping = node_val
309 .as_mapping()
310 .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
311
312 if let Some(fields) = mapping
314 .get(serde_yaml::Value::String("questions".to_string(), None))
315 .and_then(|q| {
316 q.as_mapping()
317 .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
318 })
319 .and_then(|v| v.as_sequence())
320 {
321 for field in fields {
322 let Some(field_map) = field.as_mapping() else {
323 continue;
324 };
325 let id = field_map
326 .get(serde_yaml::Value::String("id".to_string(), None))
327 .and_then(|v| v.as_str())
328 .unwrap_or("")
329 .to_string();
330 if id.is_empty() {
331 continue;
332 }
333 let prompt = field_map
334 .get(serde_yaml::Value::String("prompt".to_string(), None))
335 .and_then(|v| v.as_str())
336 .unwrap_or(&id);
337 let default = field_map
338 .get(serde_yaml::Value::String("default".to_string(), None))
339 .and_then(|v| v.as_str())
340 .unwrap_or("");
341 let value = if is_tty {
342 print!("{prompt} [{default}]: ");
343 let _ = io::stdout().flush();
344 let mut buf = String::new();
345 io::stdin().read_line(&mut buf).ok();
346 let trimmed = buf.trim();
347 if trimmed.is_empty() {
348 default.to_string()
349 } else {
350 trimmed.to_string()
351 }
352 } else {
353 default.to_string()
354 };
355 state.insert(id, value);
356 }
357 }
358
359 if let Some(template) = mapping
361 .get(serde_yaml::Value::String("template".to_string(), None))
362 .and_then(|v| v.as_str())
363 {
364 let mut rendered = template.to_string();
365 for (k, v) in &state {
366 let needle = format!("{{{{state.{k}}}}}");
367 rendered = rendered.replace(&needle, v);
368 }
369 return Ok(rendered);
370 }
371
372 if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
374 let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
375 payload.clone(),
376 )?)
377 .context("failed to render config flow payload")?;
378 return Ok(json_str);
379 }
380
381 if let Some(next) = mapping
383 .get(serde_yaml::Value::String("routing".to_string(), None))
384 .and_then(|r| r.as_sequence())
385 .and_then(|seq| seq.first())
386 .and_then(|entry| {
387 entry
388 .as_mapping()
389 .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
390 .and_then(|v| v.as_str())
391 })
392 {
393 current = next.to_string();
394 continue;
395 }
396
397 bail!("config flow ended without producing template or payload");
398 }
399}