1use anyhow::{Context, Result, bail};
2use serde::{Deserialize, Serialize};
3use serde_yaml_bw::{self as serde_yaml, Value};
4use std::{
5 collections::HashSet,
6 fs,
7 path::{Path, PathBuf},
8};
9use url::Url;
10
11use self::component::ComponentFeatures;
12use greentic_types::cbor::decode_pack_manifest;
13use runner_core::normalize_under_root;
14
15pub mod component;
16pub mod input;
17
18fn yaml_string(value: impl Into<String>) -> Value {
19 Value::String(value.into(), None)
20}
21
22#[derive(Debug)]
23pub struct PackMetadata {
24 pub tenant: String,
25 pub pack_id: String,
26 pub pack_ref: String,
27 pub flows: Vec<FlowMetadata>,
28 pub hints: BindingsHints,
29}
30
31#[derive(Debug)]
32pub struct FlowMetadata {
33 pub name: String,
34 pub document: Value,
35}
36
37#[derive(Debug, Clone, Deserialize, Default)]
38pub struct BindingsHints {
39 #[serde(default)]
40 pub network: NetworkHints,
41 #[serde(default)]
42 pub secrets: SecretsHints,
43 #[serde(default)]
44 pub env: EnvHints,
45 #[serde(default)]
46 pub mcp: McpHints,
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
50pub struct NetworkHints {
51 #[serde(default)]
52 pub allow: Vec<String>,
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56pub struct SecretsHints {
57 #[serde(default)]
58 pub required: Vec<String>,
59}
60
61#[derive(Debug, Clone, Deserialize, Default)]
62pub struct EnvHints {
63 #[serde(default)]
64 pub passthrough: Vec<String>,
65}
66
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct McpHints {
69 #[serde(default)]
70 pub servers: Vec<McpServer>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct McpServer {
75 pub name: String,
76 pub transport: String,
77 pub endpoint: String,
78 #[serde(default)]
79 pub caps: Vec<String>,
80}
81
82pub fn load_pack(pack_dir: &Path) -> Result<PackMetadata> {
83 let (root, candidate) = if pack_dir.is_absolute() {
85 let parent = pack_dir.parent().ok_or_else(|| {
86 anyhow::anyhow!("pack directory has no parent: {}", pack_dir.display())
87 })?;
88 let root = parent
89 .canonicalize()
90 .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
91 let name = pack_dir
92 .file_name()
93 .ok_or_else(|| anyhow::anyhow!("pack directory has no name: {}", pack_dir.display()))?;
94 (root, PathBuf::from(name))
95 } else {
96 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
97 (cwd, pack_dir.to_path_buf())
98 };
99 let pack_dir = normalize_under_root(&root, &candidate)?;
100 if !pack_dir.is_dir() {
101 anyhow::bail!("pack directory {} does not exist", pack_dir.display());
102 }
103
104 let manifest = pack_dir.join("pack.yaml");
105 let content = fs::read_to_string(&manifest)
106 .with_context(|| format!("failed to read {}", manifest.display()))?;
107 let pack_manifest: PackManifest =
108 serde_yaml::from_str(&content).with_context(|| "failed to parse pack manifest")?;
109
110 let hints_path = pack_dir.join("bindings.hints.yaml");
111 let hints = if hints_path.exists() {
112 serde_yaml::from_reader(fs::File::open(&hints_path)?)
113 .with_context(|| format!("failed to read hints {}", hints_path.display()))?
114 } else {
115 BindingsHints::default()
116 };
117
118 let flows_dir = pack_dir.join("flows");
119 let mut flows = Vec::new();
120 if flows_dir.is_dir() {
121 for entry in fs::read_dir(&flows_dir)? {
122 let entry = entry?;
123 let path = entry.path();
124 if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
125 let flow = load_flow(&path)?;
126 flows.push(flow);
127 }
128 }
129 }
130
131 let pack_id = pack_manifest
132 .pack_id
133 .filter(|id| !id.trim().is_empty())
134 .ok_or_else(|| anyhow::anyhow!("pack.yaml missing pack_id"))?;
135 let version = pack_manifest
136 .version
137 .filter(|value| !value.trim().is_empty())
138 .ok_or_else(|| anyhow::anyhow!("pack.yaml missing version"))?;
139 let pack_ref = format!("{}@{}", pack_id, version);
140
141 let tenant = pack_manifest
142 .name
143 .or_else(|| {
144 pack_dir
145 .file_name()
146 .and_then(|n| n.to_str())
147 .map(|s| s.to_string())
148 })
149 .unwrap_or_else(|| pack_id.clone());
150
151 Ok(PackMetadata {
152 tenant,
153 pack_id,
154 pack_ref,
155 flows,
156 hints,
157 })
158}
159
160pub fn load_pack_root(pack_root: &Path) -> Result<PackMetadata> {
161 let manifest = pack_root.join("pack.yaml");
162 if manifest.is_file() {
163 return load_pack(pack_root);
164 }
165 let cbor_path = pack_root.join("manifest.cbor");
166 if cbor_path.is_file() {
167 return load_pack_manifest_cbor(pack_root, &cbor_path);
168 }
169 bail!(
170 "pack directory {} does not include pack.yaml or manifest.cbor",
171 pack_root.display()
172 );
173}
174
175fn load_flow(path: &Path) -> Result<FlowMetadata> {
176 let content = fs::read_to_string(path)?;
177 let fallback = path
178 .file_stem()
179 .and_then(|s| s.to_str())
180 .unwrap_or("<unknown>");
181 load_flow_content(&content, fallback, &path.display().to_string())
182}
183
184fn load_flow_content(content: &str, fallback_name: &str, label: &str) -> Result<FlowMetadata> {
185 let parsed: Value =
186 serde_yaml::from_str(content).with_context(|| format!("failed to parse flow {}", label))?;
187 let name = parsed
188 .get("name")
189 .and_then(Value::as_str)
190 .map(|s| s.to_string())
191 .unwrap_or_else(|| fallback_name.to_string());
192 Ok(FlowMetadata {
193 name,
194 document: parsed,
195 })
196}
197
198fn load_pack_manifest_cbor(pack_root: &Path, cbor_path: &Path) -> Result<PackMetadata> {
199 let bytes =
200 fs::read(cbor_path).with_context(|| format!("failed to read {}", cbor_path.display()))?;
201 let manifest = decode_pack_manifest(&bytes)
202 .with_context(|| format!("failed to decode {}", cbor_path.display()))?;
203 let flows = manifest
204 .flows
205 .iter()
206 .map(|entry| {
207 let serialized = serde_yaml::to_string(&entry.flow)
208 .context("failed to serialize flow from manifest")?;
209 let parsed: Value =
210 serde_yaml::from_str(&serialized).context("failed to parse flow from manifest")?;
211 Ok(FlowMetadata {
212 name: entry.id.to_string(),
213 document: parsed,
214 })
215 })
216 .collect::<Result<Vec<_>>>()?;
217
218 let hints_path = pack_root.join("bindings.hints.yaml");
219 let hints = if hints_path.exists() {
220 serde_yaml::from_reader(fs::File::open(&hints_path)?)
221 .with_context(|| format!("failed to read hints {}", hints_path.display()))?
222 } else {
223 BindingsHints::default()
224 };
225
226 let pack_id = manifest.pack_id.to_string();
227 let pack_ref = format!("{}@{}", pack_id, manifest.version);
228 let tenant = pack_id.clone();
229
230 Ok(PackMetadata {
231 tenant,
232 pack_id,
233 pack_ref,
234 flows,
235 hints,
236 })
237}
238
239#[derive(Debug, Deserialize)]
240struct PackManifest {
241 name: Option<String>,
242 pack_id: Option<String>,
243 version: Option<String>,
244}
245
246#[derive(Clone, Default)]
247pub struct GeneratorOptions {
248 pub strict: bool,
249 pub complete: bool,
250 pub component: Option<ComponentFeatures>,
251 pub pack_locator: Option<String>,
252}
253
254#[derive(Debug, Serialize)]
255pub struct GeneratedBindings {
256 pub tenant: String,
257 pub pack_id: String,
258 pub pack_ref: String,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 pub pack_locator: Option<String>,
261 #[serde(skip_serializing_if = "Vec::is_empty")]
262 pub env_passthrough: Vec<String>,
263 #[serde(skip_serializing_if = "Vec::is_empty")]
264 pub network_allow: Vec<String>,
265 #[serde(skip_serializing_if = "Vec::is_empty")]
266 pub secrets_required: Vec<String>,
267 #[serde(skip_serializing_if = "Vec::is_empty")]
268 pub flows: Vec<FlowHint>,
269 #[serde(skip_serializing_if = "Vec::is_empty")]
270 pub mcp_servers: Vec<McpServer>,
271}
272
273#[derive(Debug, Serialize)]
274pub struct FlowHint {
275 pub id: String,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub name: Option<String>,
278 #[serde(skip_serializing_if = "Vec::is_empty")]
279 pub urls: Vec<String>,
280 #[serde(skip_serializing_if = "Vec::is_empty")]
281 pub secrets: Vec<String>,
282 #[serde(skip_serializing_if = "Vec::is_empty")]
283 pub env: Vec<String>,
284 #[serde(skip_serializing_if = "Vec::is_empty")]
285 pub mcp_components: Vec<String>,
286}
287
288impl FlowHint {
289 fn from_flow(flow: &FlowMetadata) -> Self {
290 let mut bindings = collect_flow_bindings(&flow.document);
291 let meta = find_meta_bindings(&flow.document);
292 bindings.urls.extend(meta.urls);
293 bindings.secrets.extend(meta.secrets);
294 bindings.env.extend(meta.env);
295 bindings.mcp_components.extend(meta.mcp_components);
296 FlowHint {
297 id: flow.name.clone(),
298 name: Some(flow.name.clone()),
299 urls: bindings.urls.clone(),
300 secrets: bindings.secrets.clone(),
301 env: bindings.env.clone(),
302 mcp_components: bindings.mcp_components.clone(),
303 }
304 }
305}
306
307#[derive(Default)]
308struct FlowBindings {
309 urls: Vec<String>,
310 secrets: Vec<String>,
311 env: Vec<String>,
312 mcp_components: Vec<String>,
313}
314
315fn collect_flow_bindings(doc: &Value) -> FlowBindings {
316 let mut bindings = FlowBindings::default();
317 scan_value_for_placeholders(doc, &mut bindings);
318 collect_mcp_components(doc, &mut bindings);
319 bindings
320}
321
322fn scan_value_for_placeholders(value: &Value, bindings: &mut FlowBindings) {
323 match value {
324 Value::String(s, _) => {
325 bindings.secrets.extend(extract_placeholders(s, "secrets."));
326 bindings.env.extend(extract_placeholders(s, "env."));
327 if let Some(origin) = extract_origin(s) {
328 bindings.urls.push(origin);
329 }
330 }
331 Value::Sequence(seq) => {
332 for item in seq {
333 scan_value_for_placeholders(item, bindings);
334 }
335 }
336 Value::Mapping(map) => {
337 for (_, item) in map {
338 scan_value_for_placeholders(item, bindings);
339 }
340 }
341 _ => {}
342 }
343}
344
345fn collect_mcp_components(value: &Value, bindings: &mut FlowBindings) {
346 match value {
347 Value::Mapping(map) => {
348 let exec_key = yaml_string("mcp.exec");
349 let component_key = yaml_string("component");
350 if let Some(Value::Mapping(exec_map)) = map.get(&exec_key)
351 && let Some(Value::String(component, _)) = exec_map.get(&component_key)
352 {
353 bindings.mcp_components.push(component.clone());
354 }
355 for (_, v) in map {
356 collect_mcp_components(v, bindings);
357 }
358 }
359 Value::Sequence(seq) => {
360 for item in seq {
361 collect_mcp_components(item, bindings);
362 }
363 }
364 _ => {}
365 }
366}
367
368fn extract_placeholders(value: &str, prefix: &str) -> Vec<String> {
369 let mut results = Vec::new();
370 let mut start = 0;
371 while let Some(idx) = value[start..].find(prefix) {
372 let idx = start + idx + prefix.len();
373 let rest = &value[idx..];
374 let end = rest
375 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
376 .unwrap_or(rest.len());
377 if end > 0 {
378 results.push(rest[..end].to_string());
379 }
380 start = idx + end;
381 }
382 results
383}
384
385fn extract_origin(text: &str) -> Option<String> {
386 if let Ok(url) = Url::parse(text)
387 && url.scheme().starts_with("http")
388 && let Some(host) = url.host_str()
389 {
390 let port = match url.port() {
391 Some(p) => format!(":{}", p),
392 None => "".to_string(),
393 };
394 return Some(format!("{}://{}{}", url.scheme(), host, port));
395 }
396 None
397}
398
399pub fn generate_bindings(
400 metadata: &PackMetadata,
401 opts: GeneratorOptions,
402) -> Result<GeneratedBindings> {
403 let mut env = base_env_passthrough();
404 env.extend(metadata.hints.env.passthrough.iter().cloned());
405 let flows: Vec<FlowHint> = metadata.flows.iter().map(FlowHint::from_flow).collect();
406 for flow in &flows {
407 env.extend(flow.env.clone());
408 }
409
410 let mut env = unique_sorted(env);
411
412 let mut secrets = metadata.hints.secrets.required.clone();
413 for flow in &flows {
414 secrets.extend(flow.secrets.clone());
415 }
416 let secrets = unique_sorted(secrets);
417
418 let mut network = metadata.hints.network.allow.clone();
419 for flow in &flows {
420 network.extend(flow.urls.clone());
421 }
422 let mut network = unique_sorted(network);
423
424 if opts.complete && network.is_empty() {
425 network.push("https://*".to_string());
426 }
427
428 if opts.complete {
429 for value in base_env_passthrough() {
430 if !env.contains(&value) {
431 env.push(value);
432 }
433 }
434 }
435
436 let mut mcp_servers = metadata.hints.mcp.servers.clone();
437 let mut referenced_components = Vec::new();
438 for flow in &flows {
439 referenced_components.extend(flow.mcp_components.clone());
440 }
441 let referenced_components = unique_sorted(referenced_components);
442 for component in referenced_components {
443 if mcp_servers.iter().any(|server| server.name == component) {
444 continue;
445 }
446 if opts.strict {
447 bail!(
448 "MCP component '{}' referenced but no server hint; add hints or rerun with --complete",
449 component
450 );
451 }
452 mcp_servers.push(McpServer {
453 name: component.clone(),
454 transport: "websocket".into(),
455 endpoint: "ws://localhost:9000".into(),
456 caps: Vec::new(),
457 });
458 }
459
460 if opts.strict {
461 if opts.component.as_ref().map(|c| c.http).unwrap_or(false) && network.is_empty() {
462 bail!(
463 "HTTP capability detected but no network allow rules; add hints or use --complete"
464 );
465 }
466 if opts.component.as_ref().map(|c| c.secrets).unwrap_or(false) && secrets.is_empty() {
467 bail!(
468 "Secrets capability detected but no secrets.required hints; add hints or use --complete"
469 );
470 }
471 }
472
473 Ok(GeneratedBindings {
474 tenant: metadata.tenant.clone(),
475 pack_id: metadata.pack_id.clone(),
476 pack_ref: metadata.pack_ref.clone(),
477 pack_locator: opts.pack_locator.clone(),
478 env_passthrough: env,
479 network_allow: network,
480 secrets_required: secrets,
481 flows,
482 mcp_servers,
483 })
484}
485
486fn base_env_passthrough() -> Vec<String> {
487 vec![
488 "RUST_LOG".into(),
489 "OTEL_EXPORTER_OTLP_ENDPOINT".into(),
490 "OTEL_RESOURCE_ATTRIBUTES".into(),
491 ]
492}
493
494fn unique_sorted(values: Vec<String>) -> Vec<String> {
495 let mut set = HashSet::new();
496 let mut result = Vec::new();
497 for v in values {
498 if set.insert(v.clone()) {
499 result.push(v);
500 }
501 }
502 result.sort_unstable();
503 result
504}
505
506fn find_meta_bindings(meta: &Value) -> FlowBindings {
507 let mut hints = FlowBindings::default();
508 if let Some(bindings) = find_bindings_value(meta) {
509 hints.urls.extend(extract_string_list(bindings, "urls"));
510 hints
511 .secrets
512 .extend(extract_string_list(bindings, "secrets"));
513 hints.env.extend(extract_string_list(bindings, "env"));
514 hints
515 .mcp_components
516 .extend(extract_string_list(bindings, "mcp_components"));
517 }
518 hints
519}
520
521fn find_bindings_value(meta: &Value) -> Option<&Value> {
522 if let Value::Mapping(map) = meta {
523 let bindings_key = yaml_string("bindings");
524 if let Some(bindings) = map.get(&bindings_key) {
525 return Some(bindings);
526 }
527 let meta_key = yaml_string("meta");
528 if let Some(Value::Mapping(inner_map)) = map.get(&meta_key) {
529 let inner_bindings_key = yaml_string("bindings");
530 return inner_map.get(&inner_bindings_key);
531 }
532 }
533 None
534}
535
536fn extract_string_list(bindings: &Value, key: &str) -> Vec<String> {
537 if let Value::Mapping(map) = bindings {
538 let key_value = yaml_string(key);
539 if let Some(value) = map.get(&key_value) {
540 return value_to_list(value);
541 }
542 }
543 Vec::new()
544}
545
546fn value_to_list(value: &Value) -> Vec<String> {
547 match value {
548 Value::Sequence(seq) => seq
549 .iter()
550 .filter_map(|v| v.as_str().map(|s| s.to_string()))
551 .collect(),
552 Value::String(s, _) => vec![s.clone()],
553 _ => Vec::new(),
554 }
555}