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_env_prefix: Option<String>,
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_env_prefix.as_deref(),
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_env_prefix: Option<&str>,
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 exec_opts = crate::tests_exec::ExecOptions::builder()
212 .offline(offline)
213 .external_enabled(allow_external)
214 .mock_external(mock_external)
215 .mock_external_payload(mock_external_payload)
216 .secrets_env_prefix(secrets_env_prefix.unwrap_or_default())
217 .build();
218 let exec = crate::tests_exec::execute_with_options(&flow.flow, input, &exec_opts)?;
219 Ok(exec)
220}
221
222fn parse_input(input: Option<String>) -> Result<JsonValue> {
223 if let Some(raw) = input {
224 if raw.trim().is_empty() {
225 return Ok(json!({}));
226 }
227 serde_json::from_str(&raw).context("failed to parse --input JSON")
228 } else {
229 Ok(json!({}))
230 }
231}
232
233fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
234 let mut config = MocksConfig {
235 net_allowlist: allow_hosts
236 .into_iter()
237 .map(|host| host.trim().to_ascii_lowercase())
238 .filter(|host| !host.is_empty())
239 .collect(),
240 ..MocksConfig::default()
241 };
242
243 if matches!(setting, MockSetting::On) {
244 config.http = Some(HttpMock {
245 record_replay_dir: None,
246 mode: HttpMockMode::RecordReplay,
247 rewrites: Vec::new(),
248 });
249
250 let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
251 fs::create_dir_all(&tools_dir)
252 .with_context(|| format!("failed to create {}", tools_dir.display()))?;
253 config.mcp_tools = Some(ToolsMock {
254 directory: None,
255 script_dir: Some(tools_dir),
256 short_circuit: true,
257 });
258 }
259
260 Ok(config)
261}
262
263fn signing_policy(policy: RunPolicy) -> SigningPolicy {
264 match policy {
265 RunPolicy::Strict => SigningPolicy::Strict,
266 RunPolicy::DevOk => SigningPolicy::DevOk,
267 }
268}
269
270#[allow(dead_code)]
272pub fn run_config_flow(flow_path: &Path) -> Result<String> {
273 let source = std::fs::read_to_string(flow_path)
274 .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
275 load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
277
278 let doc: serde_yaml::Value = serde_yaml::from_str(&source)
279 .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
280 let nodes = doc
281 .get("nodes")
282 .and_then(|v| v.as_mapping())
283 .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
284
285 let mut current = nodes
286 .iter()
287 .next()
288 .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
289 .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
290 let mut state: BTreeMap<String, String> = BTreeMap::new();
291 let mut visited: BTreeSet<String> = BTreeSet::new();
292 let is_tty = io::stdin().is_terminal();
293
294 loop {
295 if !visited.insert(current.clone()) {
296 bail!("config flow routing loop detected at {}", current);
297 }
298
299 let node_val = nodes
300 .get(serde_yaml::Value::String(current.clone(), None))
301 .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
302 let mapping = node_val
303 .as_mapping()
304 .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
305
306 if let Some(fields) = mapping
308 .get(serde_yaml::Value::String("questions".to_string(), None))
309 .and_then(|q| {
310 q.as_mapping()
311 .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
312 })
313 .and_then(|v| v.as_sequence())
314 {
315 for field in fields {
316 let Some(field_map) = field.as_mapping() else {
317 continue;
318 };
319 let id = field_map
320 .get(serde_yaml::Value::String("id".to_string(), None))
321 .and_then(|v| v.as_str())
322 .unwrap_or("")
323 .to_string();
324 if id.is_empty() {
325 continue;
326 }
327 let prompt = field_map
328 .get(serde_yaml::Value::String("prompt".to_string(), None))
329 .and_then(|v| v.as_str())
330 .unwrap_or(&id);
331 let default = field_map
332 .get(serde_yaml::Value::String("default".to_string(), None))
333 .and_then(|v| v.as_str())
334 .unwrap_or("");
335 let value = if is_tty {
336 print!("{prompt} [{default}]: ");
337 let _ = io::stdout().flush();
338 let mut buf = String::new();
339 io::stdin().read_line(&mut buf).ok();
340 let trimmed = buf.trim();
341 if trimmed.is_empty() {
342 default.to_string()
343 } else {
344 trimmed.to_string()
345 }
346 } else {
347 default.to_string()
348 };
349 state.insert(id, value);
350 }
351 }
352
353 if let Some(template) = mapping
355 .get(serde_yaml::Value::String("template".to_string(), None))
356 .and_then(|v| v.as_str())
357 {
358 let mut rendered = template.to_string();
359 for (k, v) in &state {
360 let needle = format!("{{{{state.{k}}}}}");
361 rendered = rendered.replace(&needle, v);
362 }
363 return Ok(rendered);
364 }
365
366 if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
368 let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
369 payload.clone(),
370 )?)
371 .context("failed to render config flow payload")?;
372 return Ok(json_str);
373 }
374
375 if let Some(next) = mapping
377 .get(serde_yaml::Value::String("routing".to_string(), None))
378 .and_then(|r| r.as_sequence())
379 .and_then(|seq| seq.first())
380 .and_then(|entry| {
381 entry
382 .as_mapping()
383 .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
384 .and_then(|v| v.as_str())
385 })
386 {
387 current = next.to_string();
388 continue;
389 }
390
391 bail!("config flow ended without producing template or payload");
392 }
393}