1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7
8#[derive(Debug, Clone)]
9pub struct PackBinding {
10 pub pack_id: String,
11 pub pack_ref: String,
12 pub pack_locator: Option<String>,
13 pub flows: Vec<String>,
14}
15
16#[derive(Debug, Clone)]
17pub struct TenantBindings {
18 pub tenant: String,
19 pub packs: Vec<PackBinding>,
20 pub env_passthrough: Vec<String>,
21}
22
23#[derive(Debug, Deserialize)]
24struct GtBindFile {
25 tenant: String,
26 pack_id: String,
27 pack_ref: String,
28 #[serde(default)]
29 pack_locator: Option<String>,
30 #[serde(default)]
31 flows: Vec<GtBindFlow>,
32 #[serde(default)]
33 env_passthrough: Vec<String>,
34}
35
36#[derive(Debug, Deserialize)]
37struct GtBindFlow {
38 id: String,
39}
40
41pub fn collect_gtbind_paths(paths: &[PathBuf], dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
42 let mut resolved = Vec::new();
43 for path in paths {
44 if path.is_dir() {
45 resolved.extend(scan_dir(path)?);
46 } else if path.is_file() {
47 resolved.push(path.to_path_buf());
48 } else {
49 bail!("bindings path does not exist: {}", path.display());
50 }
51 }
52 for dir in dirs {
53 if !dir.is_dir() {
54 bail!("bindings dir does not exist: {}", dir.display());
55 }
56 resolved.extend(scan_dir(dir)?);
57 }
58 resolved.sort();
59 resolved.dedup();
60 Ok(resolved)
61}
62
63pub fn load_gtbinds(paths: &[PathBuf]) -> Result<HashMap<String, TenantBindings>> {
64 let mut tenants: HashMap<String, TenantBindings> = HashMap::new();
65 for path in paths {
66 let content = fs::read_to_string(path)
67 .with_context(|| format!("failed to read gtbind {}", path.display()))?;
68 let raw: GtBindFile = serde_yaml_bw::from_str(&content)
69 .with_context(|| format!("failed to parse gtbind {}", path.display()))?;
70 if raw.pack_id.trim().is_empty() {
71 bail!("gtbind {} missing pack_id", path.display());
72 }
73 if raw.pack_ref.trim().is_empty() {
74 bail!("gtbind {} missing pack_ref", path.display());
75 }
76 if raw.tenant.trim().is_empty() {
77 bail!("gtbind {} missing tenant", path.display());
78 }
79 let flows = raw
80 .flows
81 .into_iter()
82 .map(|flow| flow.id)
83 .filter(|id| !id.trim().is_empty())
84 .collect::<Vec<_>>();
85 let pack = PackBinding {
86 pack_id: raw.pack_id,
87 pack_ref: raw.pack_ref,
88 pack_locator: raw.pack_locator,
89 flows,
90 };
91 let entry = tenants
92 .entry(raw.tenant.clone())
93 .or_insert_with(|| TenantBindings {
94 tenant: raw.tenant.clone(),
95 packs: Vec::new(),
96 env_passthrough: Vec::new(),
97 });
98 merge_pack(entry, pack)?;
99 merge_env(entry, raw.env_passthrough);
100 }
101 Ok(tenants)
102}
103
104fn scan_dir(dir: &Path) -> Result<Vec<PathBuf>> {
105 let mut entries = Vec::new();
106 for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
107 let entry = entry?;
108 let path = entry.path();
109 if path.extension().and_then(|ext| ext.to_str()) == Some("gtbind") {
110 entries.push(path);
111 }
112 }
113 Ok(entries)
114}
115
116fn merge_pack(tenant: &mut TenantBindings, pack: PackBinding) -> Result<()> {
117 if let Some(existing) = tenant
118 .packs
119 .iter_mut()
120 .find(|entry| entry.pack_id == pack.pack_id)
121 {
122 if existing.pack_ref != pack.pack_ref {
123 bail!(
124 "pack_ref mismatch for tenant {} pack {}",
125 tenant.tenant,
126 pack.pack_id
127 );
128 }
129 match (&existing.pack_locator, &pack.pack_locator) {
130 (Some(existing), Some(incoming)) if existing != incoming => {
131 bail!(
132 "pack_locator mismatch for tenant {} pack {}",
133 tenant.tenant,
134 pack.pack_id
135 );
136 }
137 (None, Some(incoming)) => {
138 existing.pack_locator = Some(incoming.clone());
139 }
140 _ => {}
141 }
142 let mut flows = HashSet::new();
143 flows.extend(existing.flows.iter().cloned());
144 flows.extend(pack.flows);
145 existing.flows = flows.into_iter().collect();
146 existing.flows.sort();
147 return Ok(());
148 }
149 tenant.packs.push(pack);
150 tenant.packs.sort_by(|a, b| a.pack_id.cmp(&b.pack_id));
151 Ok(())
152}
153
154fn merge_env(tenant: &mut TenantBindings, envs: Vec<String>) {
155 let mut merged = HashSet::new();
156 merged.extend(tenant.env_passthrough.iter().cloned());
157 merged.extend(envs);
158 tenant.env_passthrough = merged.into_iter().collect();
159 tenant.env_passthrough.sort();
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use tempfile::TempDir;
166
167 fn write_file(path: &Path, body: &str) {
168 fs::write(path, body).expect("write file");
169 }
170
171 #[test]
172 fn collect_gtbind_paths_scans_dirs_and_dedups() {
173 let temp = TempDir::new().expect("tempdir");
174 let a = temp.path().join("a.gtbind");
175 let b = temp.path().join("b.gtbind");
176 let ignored = temp.path().join("ignored.txt");
177 write_file(&a, "tenant: demo\npack_id: pack\npack_ref: pack@1\n");
178 write_file(&b, "tenant: demo\npack_id: pack2\npack_ref: pack2@1\n");
179 write_file(&ignored, "ignore");
180
181 let paths = collect_gtbind_paths(std::slice::from_ref(&a), &[temp.path().to_path_buf()])
182 .expect("collect paths");
183
184 assert_eq!(paths, vec![a, b]);
185 }
186
187 #[test]
188 fn load_gtbinds_merges_flows_locators_and_env() {
189 let temp = TempDir::new().expect("tempdir");
190 let one = temp.path().join("one.gtbind");
191 let two = temp.path().join("two.gtbind");
192 write_file(
193 &one,
194 r#"
195tenant: demo
196pack_id: pack.main
197pack_ref: pack.main@1.0.0
198pack_locator: fs:///packs/main.gtpack
199flows:
200 - id: flow-a
201 - id: ""
202env_passthrough: [TOKEN, API_KEY]
203"#,
204 );
205 write_file(
206 &two,
207 r#"
208tenant: demo
209pack_id: pack.main
210pack_ref: pack.main@1.0.0
211flows:
212 - id: flow-b
213env_passthrough: [API_KEY, SECRET]
214"#,
215 );
216
217 let tenants = load_gtbinds(&[one, two]).expect("load gtbinds");
218 let tenant = tenants.get("demo").expect("tenant");
219 assert_eq!(tenant.packs.len(), 1);
220 assert_eq!(tenant.packs[0].flows, vec!["flow-a", "flow-b"]);
221 assert_eq!(
222 tenant.packs[0].pack_locator.as_deref(),
223 Some("fs:///packs/main.gtpack")
224 );
225 assert_eq!(tenant.env_passthrough, vec!["API_KEY", "SECRET", "TOKEN"]);
226 }
227
228 #[test]
229 fn load_gtbinds_rejects_missing_required_fields() {
230 let temp = TempDir::new().expect("tempdir");
231 let file = temp.path().join("broken.gtbind");
232 write_file(&file, "tenant: demo\npack_id: ''\npack_ref: pack@1\n");
233
234 assert!(load_gtbinds(&[file]).is_err());
235 }
236
237 #[test]
238 fn merge_pack_rejects_conflicting_refs_and_locators() {
239 let mut tenant = TenantBindings {
240 tenant: "demo".into(),
241 packs: vec![PackBinding {
242 pack_id: "pack.main".into(),
243 pack_ref: "pack.main@1.0.0".into(),
244 pack_locator: Some("fs:///packs/a.gtpack".into()),
245 flows: vec!["flow-a".into()],
246 }],
247 env_passthrough: Vec::new(),
248 };
249
250 assert!(
251 merge_pack(
252 &mut tenant,
253 PackBinding {
254 pack_id: "pack.main".into(),
255 pack_ref: "pack.main@2.0.0".into(),
256 pack_locator: Some("fs:///packs/a.gtpack".into()),
257 flows: vec![],
258 }
259 )
260 .is_err()
261 );
262
263 assert!(
264 merge_pack(
265 &mut tenant,
266 PackBinding {
267 pack_id: "pack.main".into(),
268 pack_ref: "pack.main@1.0.0".into(),
269 pack_locator: Some("fs:///packs/b.gtpack".into()),
270 flows: vec![],
271 }
272 )
273 .is_err()
274 );
275 }
276}