1use std::path::{Path, PathBuf};
7
8use anyhow::{Context, anyhow};
9
10pub const LEGACY_BUNDLE_MARKER: &str = "greentic.demo.yaml";
11pub const BUNDLE_WORKSPACE_MARKER: &str = "bundle.yaml";
12
13pub fn create_demo_bundle_structure(root: &Path, bundle_name: Option<&str>) -> anyhow::Result<()> {
15 let directories = [
16 "",
17 "providers",
18 "providers/messaging",
19 "providers/events",
20 "providers/secrets",
21 "providers/oauth",
22 "packs",
23 "resolved",
24 "state",
25 "state/resolved",
26 "state/runs",
27 "state/pids",
28 "state/logs",
29 "state/runtime",
30 "state/doctor",
31 "tenants",
32 "tenants/default",
33 "tenants/default/teams",
34 "tenants/demo",
35 "tenants/demo/teams",
36 "tenants/demo/teams/default",
37 "logs",
38 ];
39 for directory in directories {
40 std::fs::create_dir_all(root.join(directory))?;
41 }
42
43 let mut demo_yaml = "version: \"1\"\nproject_root: \"./\"\n".to_string();
44 if let Some(name) = bundle_name.filter(|v| !v.trim().is_empty()) {
45 demo_yaml.push_str(&format!("bundle_name: \"{}\"\n", name.replace('"', "")));
46 }
47 write_if_missing(&root.join(LEGACY_BUNDLE_MARKER), &demo_yaml)?;
48 write_if_missing(
49 &root.join("tenants").join("default").join("tenant.gmap"),
50 "_ = forbidden\n",
51 )?;
52 write_if_missing(
53 &root.join("tenants").join("demo").join("tenant.gmap"),
54 "_ = forbidden\n",
55 )?;
56 write_if_missing(
57 &root
58 .join("tenants")
59 .join("demo")
60 .join("teams")
61 .join("default")
62 .join("team.gmap"),
63 "_ = forbidden\n",
64 )?;
65
66 if !bundle_has_app_packs(root) {
71 write_default_pack_if_missing(root);
72 }
73
74 Ok(())
75}
76
77fn bundle_has_app_packs(bundle_root: &Path) -> bool {
79 let workspace = bundle_root.join(BUNDLE_WORKSPACE_MARKER);
80 let Ok(contents) = std::fs::read_to_string(&workspace) else {
81 return false;
82 };
83 for line in contents.lines() {
86 let trimmed = line.trim();
87 if trimmed.starts_with("- packs/") || trimmed.starts_with("- ./packs/") {
88 return true;
89 }
90 }
91 false
92}
93
94const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
100
101fn write_default_pack_if_missing(bundle_root: &Path) {
103 let target = bundle_root.join("packs").join("default.gtpack");
104 if target.exists() {
105 return;
106 }
107 if let Err(err) = std::fs::write(&target, EMBEDDED_WELCOME_PACK) {
108 eprintln!(
109 " [scaffold] WARNING: failed to write default.gtpack: {}",
110 err,
111 );
112 } else {
113 println!(" [scaffold] created default.gtpack (welcome flow)");
114 }
115}
116
117pub fn write_if_missing(path: &Path, contents: &str) -> anyhow::Result<()> {
119 if path.exists() {
120 return Ok(());
121 }
122 if let Some(parent) = path.parent() {
123 std::fs::create_dir_all(parent)?;
124 }
125 std::fs::write(path, contents)?;
126 Ok(())
127}
128
129pub fn validate_bundle_exists(bundle: &Path) -> anyhow::Result<()> {
131 if !bundle.exists() {
132 return Err(anyhow!("bundle path {} does not exist", bundle.display()));
133 }
134 if !is_bundle_root(bundle) {
135 return Err(anyhow!(
136 "bundle {} missing {} or {}",
137 bundle.display(),
138 LEGACY_BUNDLE_MARKER,
139 BUNDLE_WORKSPACE_MARKER,
140 ));
141 }
142 Ok(())
143}
144
145pub fn is_bundle_root(bundle: &Path) -> bool {
146 bundle.join(LEGACY_BUNDLE_MARKER).exists() || bundle.join(BUNDLE_WORKSPACE_MARKER).exists()
147}
148
149pub fn gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
151 let mut path = bundle.join("tenants").join(tenant);
152 if let Some(team) = team {
153 path = path.join("teams").join(team).join("team.gmap");
154 } else {
155 path = path.join("tenant.gmap");
156 }
157 path
158}
159
160pub fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
162 match team {
163 Some(team) => format!("{tenant}.{team}.yaml"),
164 None => format!("{tenant}.yaml"),
165 }
166}
167
168pub fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<PathBuf> {
170 for subdir in &["providers/messaging", "providers/events", "packs"] {
171 let candidate = bundle.join(subdir).join(format!("{provider_id}.gtpack"));
172 if candidate.exists() {
173 return Some(candidate);
174 }
175 }
176 None
177}
178
179pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
187 if let Some(domain_name) = domain {
189 let domain_dir = bundle.join(domain_name).join("tenants");
190 if let Some(tenants) = read_tenants_from_dir(&domain_dir)? {
191 return Ok(tenants);
192 }
193 }
194
195 let general_dir = bundle.join("tenants");
197 if let Some(tenants) = read_tenants_from_dir(&general_dir)? {
198 return Ok(tenants);
199 }
200
201 Ok(Vec::new())
202}
203
204fn read_tenants_from_dir(dir: &Path) -> anyhow::Result<Option<Vec<String>>> {
206 use std::collections::BTreeSet;
207
208 if !dir.exists() {
209 return Ok(None);
210 }
211
212 let mut tenants = BTreeSet::new();
213 for entry in std::fs::read_dir(dir)? {
214 let entry = entry?;
215 let path = entry.path();
216
217 if path.is_dir() {
218 if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
219 tenants.insert(name.to_string());
220 }
221 continue;
222 }
223
224 if path.is_file()
225 && let Some(stem) = path.file_stem().and_then(|v| v.to_str())
226 {
227 tenants.insert(stem.to_string());
228 }
229 }
230
231 Ok(Some(tenants.into_iter().collect()))
232}
233
234pub fn load_provider_registry(bundle: &Path) -> anyhow::Result<serde_json::Value> {
236 let path = bundle.join("providers").join("providers.json");
237 if path.exists() {
238 let raw = std::fs::read_to_string(&path)
239 .with_context(|| format!("read provider registry {}", path.display()))?;
240 serde_json::from_str(&raw)
241 .with_context(|| format!("parse provider registry {}", path.display()))
242 } else {
243 Ok(serde_json::json!({ "providers": [] }))
244 }
245}
246
247pub fn write_provider_registry(bundle: &Path, root: &serde_json::Value) -> anyhow::Result<()> {
249 let path = bundle.join("providers").join("providers.json");
250 if let Some(parent) = path.parent() {
251 std::fs::create_dir_all(parent)?;
252 }
253 let payload = serde_json::to_string_pretty(root)
254 .with_context(|| format!("serialize provider registry {}", path.display()))?;
255 std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn create_bundle_structure() {
264 let temp = tempfile::tempdir().unwrap();
265 let root = temp.path().join("demo-bundle");
266 create_demo_bundle_structure(&root, Some("test")).unwrap();
267 assert!(root.join(LEGACY_BUNDLE_MARKER).exists());
268 assert!(root.join("providers/messaging").exists());
269 assert!(root.join("tenants/demo/teams/default/team.gmap").exists());
270 }
271
272 #[test]
273 fn embedded_welcome_pack_written_when_no_sibling() {
274 let temp = tempfile::tempdir().unwrap();
275 let root = temp.path().join("new-bundle");
276 create_demo_bundle_structure(&root, Some("test")).unwrap();
277 let pack = root.join("packs").join("default.gtpack");
278 assert!(pack.exists(), "embedded welcome pack should be written");
279 assert!(
280 pack.metadata().unwrap().len() > 1000,
281 "pack should not be empty"
282 );
283 }
284
285 #[test]
286 fn embedded_welcome_pack_not_overwritten() {
287 let temp = tempfile::tempdir().unwrap();
288 let root = temp.path().join("existing-bundle");
289 std::fs::create_dir_all(root.join("packs")).unwrap();
290 std::fs::write(root.join("packs").join("default.gtpack"), b"custom").unwrap();
291 create_demo_bundle_structure(&root, Some("test")).unwrap();
292 let contents = std::fs::read(root.join("packs").join("default.gtpack")).unwrap();
293 assert_eq!(
294 contents, b"custom",
295 "existing pack should not be overwritten"
296 );
297 }
298
299 #[test]
300 fn default_pack_skipped_when_bundle_has_app_packs() {
301 let temp = tempfile::tempdir().unwrap();
302 let root = temp.path().join("custom-bundle");
303 std::fs::create_dir_all(root.join("packs")).unwrap();
304 std::fs::write(
306 root.join(BUNDLE_WORKSPACE_MARKER),
307 "schema_version: 1\napp_packs:\n - packs/my-flow.pack\n",
308 )
309 .unwrap();
310 create_demo_bundle_structure(&root, Some("test")).unwrap();
311 assert!(
312 !root.join("packs").join("default.gtpack").exists(),
313 "default.gtpack should NOT be created when app_packs are declared"
314 );
315 }
316
317 #[test]
318 fn validate_bundle_exists_fails_for_missing() {
319 let result = validate_bundle_exists(Path::new("/nonexistent"));
320 assert!(result.is_err());
321 }
322
323 #[test]
324 fn validate_bundle_exists_accepts_bundle_yaml_workspace() {
325 let temp = tempfile::tempdir().unwrap();
326 let root = temp.path().join("bundle-workspace");
327 std::fs::create_dir_all(&root).unwrap();
328 std::fs::write(root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n").unwrap();
329
330 validate_bundle_exists(&root).unwrap();
331 assert!(is_bundle_root(&root));
332 }
333
334 #[test]
335 fn gmap_paths() {
336 let p = gmap_path(Path::new("/b"), "demo", None);
337 assert_eq!(p, PathBuf::from("/b/tenants/demo/tenant.gmap"));
338
339 let p = gmap_path(Path::new("/b"), "demo", Some("ops"));
340 assert_eq!(p, PathBuf::from("/b/tenants/demo/teams/ops/team.gmap"));
341 }
342
343 #[test]
344 fn resolved_manifest_filenames() {
345 assert_eq!(resolved_manifest_filename("demo", None), "demo.yaml");
346 assert_eq!(
347 resolved_manifest_filename("demo", Some("ops")),
348 "demo.ops.yaml"
349 );
350 }
351
352 #[test]
353 fn discover_tenants_reads_dirs_and_files() {
354 let bundle = tempfile::tempdir().unwrap();
355 let tenants_dir = bundle.path().join("tenants");
356 std::fs::create_dir_all(tenants_dir.join("alpha")).unwrap();
357 std::fs::write(tenants_dir.join("beta.json"), "{}").unwrap();
358
359 let tenants = discover_tenants(bundle.path(), None).unwrap();
360 assert!(tenants.contains(&"alpha".to_string()));
361 assert!(tenants.contains(&"beta".to_string()));
362 }
363
364 #[test]
365 fn discover_tenants_domain_specific() {
366 let bundle = tempfile::tempdir().unwrap();
367 let domain_dir = bundle.path().join("messaging").join("tenants");
368 std::fs::create_dir_all(domain_dir.join("gamma")).unwrap();
369
370 let tenants = discover_tenants(bundle.path(), Some("messaging")).unwrap();
371 assert_eq!(tenants, vec!["gamma".to_string()]);
372 }
373
374 #[test]
375 fn discover_tenants_falls_back_to_general() {
376 let bundle = tempfile::tempdir().unwrap();
377 let tenants_dir = bundle.path().join("tenants");
378 std::fs::create_dir_all(tenants_dir.join("delta")).unwrap();
379
380 let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
382 assert_eq!(tenants, vec!["delta".to_string()]);
383 }
384}