1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::io::{self, IsTerminal, 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;
14
15#[derive(Debug, Clone)]
16pub struct PackRunConfig<'a> {
17 pub pack_path: &'a Path,
18 pub entry: Option<String>,
19 pub input: Option<String>,
20 pub policy: RunPolicy,
21 pub otlp: Option<String>,
22 pub allow_hosts: Option<Vec<String>>,
23 pub mocks: MockSetting,
24 pub artifacts_dir: Option<&'a Path>,
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum RunPolicy {
29 Strict,
30 DevOk,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub enum MockSetting {
35 On,
36 Off,
37}
38
39pub fn run(config: PackRunConfig<'_>) -> Result<()> {
40 let _ = tracing_subscriber::fmt::try_init();
42
43 if std::env::var_os("HOME").is_none() || std::env::var_os("WASMTIME_CACHE_DIR").is_none() {
45 let workspace = std::env::current_dir().context("failed to resolve workspace root")?;
46 let home = workspace.join(".greentic").join("wasmtime-home");
47 let cache_dir = home
48 .join("Library")
49 .join("Caches")
50 .join("BytecodeAlliance.wasmtime");
51 let config_dir = home
52 .join("Library")
53 .join("Application Support")
54 .join("wasmtime");
55 fs::create_dir_all(&cache_dir)
56 .with_context(|| format!("failed to create {}", cache_dir.display()))?;
57 fs::create_dir_all(&config_dir)
58 .with_context(|| format!("failed to create {}", config_dir.display()))?;
59 unsafe {
62 std::env::set_var("HOME", &home);
63 std::env::set_var("WASMTIME_CACHE_DIR", &cache_dir);
64 }
65 }
66
67 let input_value = parse_input(config.input)?;
68 let otlp_hook = config.otlp.map(|endpoint| OtlpHook {
69 endpoint,
70 headers: Vec::new(),
71 sample_all: true,
72 });
73 let allow_hosts = config.allow_hosts.unwrap_or_default();
74 let mocks_config = build_mocks_config(config.mocks, allow_hosts)?;
75
76 let artifacts_override = config.artifacts_dir.map(|dir| dir.to_path_buf());
77 if let Some(dir) = &artifacts_override {
78 fs::create_dir_all(dir)
79 .with_context(|| format!("failed to create artifacts directory {}", dir.display()))?;
80 }
81
82 let runner = Runner::new();
83 let run_result = runner
84 .run_pack_with(config.pack_path, |opts| {
85 opts.entry_flow = config.entry.clone();
86 opts.input = input_value.clone();
87 opts.signing = signing_policy(config.policy);
88 if let Some(hook) = otlp_hook.clone() {
89 opts.otlp = Some(hook);
90 }
91 opts.mocks = mocks_config.clone();
92 opts.artifacts_dir = artifacts_override.clone();
93 })
94 .context("pack execution failed")?;
95
96 let value = serde_json::to_value(&run_result).context("failed to render run result JSON")?;
97 let status = value
98 .get("status")
99 .and_then(|v| v.as_str())
100 .unwrap_or_default();
101 let rendered =
102 serde_json::to_string_pretty(&value).context("failed to render run result JSON")?;
103 println!("{rendered}");
104
105 if status == "Failure" || status == "PartialFailure" {
106 let err = value
107 .get("error")
108 .and_then(|v| v.as_str())
109 .unwrap_or("pack run returned failure status");
110 bail!("pack run failed: {err}");
111 }
112
113 Ok(())
114}
115
116fn parse_input(input: Option<String>) -> Result<JsonValue> {
117 if let Some(raw) = input {
118 if raw.trim().is_empty() {
119 return Ok(json!({}));
120 }
121 serde_json::from_str(&raw).context("failed to parse --input JSON")
122 } else {
123 Ok(json!({}))
124 }
125}
126
127fn build_mocks_config(setting: MockSetting, allow_hosts: Vec<String>) -> Result<MocksConfig> {
128 let mut config = MocksConfig {
129 net_allowlist: allow_hosts
130 .into_iter()
131 .map(|host| host.trim().to_ascii_lowercase())
132 .filter(|host| !host.is_empty())
133 .collect(),
134 ..MocksConfig::default()
135 };
136
137 if matches!(setting, MockSetting::On) {
138 config.http = Some(HttpMock {
139 record_replay_dir: None,
140 mode: HttpMockMode::RecordReplay,
141 rewrites: Vec::new(),
142 });
143
144 let tools_dir = PathBuf::from(".greentic").join("mocks").join("tools");
145 fs::create_dir_all(&tools_dir)
146 .with_context(|| format!("failed to create {}", tools_dir.display()))?;
147 config.mcp_tools = Some(ToolsMock {
148 directory: None,
149 script_dir: Some(tools_dir),
150 short_circuit: true,
151 });
152 }
153
154 Ok(config)
155}
156
157fn signing_policy(policy: RunPolicy) -> SigningPolicy {
158 match policy {
159 RunPolicy::Strict => SigningPolicy::Strict,
160 RunPolicy::DevOk => SigningPolicy::DevOk,
161 }
162}
163
164#[allow(dead_code)]
166pub fn run_config_flow(flow_path: &Path) -> Result<String> {
167 let source = std::fs::read_to_string(flow_path)
168 .with_context(|| format!("failed to read config flow {}", flow_path.display()))?;
169 load_and_validate_bundle(&source, Some(flow_path)).context("config flow validation failed")?;
171
172 let doc: serde_yaml::Value = serde_yaml::from_str(&source)
173 .with_context(|| format!("invalid YAML in {}", flow_path.display()))?;
174 let nodes = doc
175 .get("nodes")
176 .and_then(|v| v.as_mapping())
177 .ok_or_else(|| anyhow!("config flow missing nodes map"))?;
178
179 let mut current = nodes
180 .iter()
181 .next()
182 .map(|(k, _)| k.as_str().unwrap_or_default().to_string())
183 .ok_or_else(|| anyhow!("config flow has no nodes to execute"))?;
184 let mut state: BTreeMap<String, String> = BTreeMap::new();
185 let mut visited: BTreeSet<String> = BTreeSet::new();
186 let is_tty = io::stdin().is_terminal();
187
188 loop {
189 if !visited.insert(current.clone()) {
190 bail!("config flow routing loop detected at {}", current);
191 }
192
193 let node_val = nodes
194 .get(serde_yaml::Value::String(current.clone(), None))
195 .ok_or_else(|| anyhow!("node `{current}` not found in config flow"))?;
196 let mapping = node_val
197 .as_mapping()
198 .ok_or_else(|| anyhow!("node `{current}` is not a mapping"))?;
199
200 if let Some(fields) = mapping
202 .get(serde_yaml::Value::String("questions".to_string(), None))
203 .and_then(|q| {
204 q.as_mapping()
205 .and_then(|m| m.get(serde_yaml::Value::String("fields".to_string(), None)))
206 })
207 .and_then(|v| v.as_sequence())
208 {
209 for field in fields {
210 let Some(field_map) = field.as_mapping() else {
211 continue;
212 };
213 let id = field_map
214 .get(serde_yaml::Value::String("id".to_string(), None))
215 .and_then(|v| v.as_str())
216 .unwrap_or("")
217 .to_string();
218 if id.is_empty() {
219 continue;
220 }
221 let prompt = field_map
222 .get(serde_yaml::Value::String("prompt".to_string(), None))
223 .and_then(|v| v.as_str())
224 .unwrap_or(&id);
225 let default = field_map
226 .get(serde_yaml::Value::String("default".to_string(), None))
227 .and_then(|v| v.as_str())
228 .unwrap_or("");
229 let value = if is_tty {
230 print!("{prompt} [{default}]: ");
231 let _ = io::stdout().flush();
232 let mut buf = String::new();
233 io::stdin().read_line(&mut buf).ok();
234 let trimmed = buf.trim();
235 if trimmed.is_empty() {
236 default.to_string()
237 } else {
238 trimmed.to_string()
239 }
240 } else {
241 default.to_string()
242 };
243 state.insert(id, value);
244 }
245 }
246
247 if let Some(template) = mapping
249 .get(serde_yaml::Value::String("template".to_string(), None))
250 .and_then(|v| v.as_str())
251 {
252 let mut rendered = template.to_string();
253 for (k, v) in &state {
254 let needle = format!("{{{{state.{k}}}}}");
255 rendered = rendered.replace(&needle, v);
256 }
257 return Ok(rendered);
258 }
259
260 if let Some(payload) = mapping.get(serde_yaml::Value::String("payload".to_string(), None)) {
262 let json_str = serde_json::to_string(&serde_yaml::from_value::<serde_json::Value>(
263 payload.clone(),
264 )?)
265 .context("failed to render config flow payload")?;
266 return Ok(json_str);
267 }
268
269 if let Some(next) = mapping
271 .get(serde_yaml::Value::String("routing".to_string(), None))
272 .and_then(|r| r.as_sequence())
273 .and_then(|seq| seq.first())
274 .and_then(|entry| {
275 entry
276 .as_mapping()
277 .and_then(|m| m.get(serde_yaml::Value::String("to".to_string(), None)))
278 .and_then(|v| v.as_str())
279 })
280 {
281 current = next.to_string();
282 continue;
283 }
284
285 bail!("config flow ended without producing template or payload");
286 }
287}