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 write_default_pack_if_missing(root);
68
69 Ok(())
70}
71
72const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
77
78fn write_default_pack_if_missing(bundle_root: &Path) {
80 let target = bundle_root.join("packs").join("default.gtpack");
81 if target.exists() {
82 return;
83 }
84 if let Err(err) = std::fs::write(&target, EMBEDDED_WELCOME_PACK) {
85 eprintln!(
86 " [scaffold] WARNING: failed to write default.gtpack: {}",
87 err,
88 );
89 } else {
90 println!(" [scaffold] created default.gtpack (welcome flow)");
91 }
92}
93
94pub fn write_if_missing(path: &Path, contents: &str) -> anyhow::Result<()> {
96 if path.exists() {
97 return Ok(());
98 }
99 if let Some(parent) = path.parent() {
100 std::fs::create_dir_all(parent)?;
101 }
102 std::fs::write(path, contents)?;
103 Ok(())
104}
105
106pub fn validate_bundle_exists(bundle: &Path) -> anyhow::Result<()> {
108 if !bundle.exists() {
109 return Err(anyhow!("bundle path {} does not exist", bundle.display()));
110 }
111 if !is_bundle_root(bundle) {
112 return Err(anyhow!(
113 "bundle {} missing {} or {}",
114 bundle.display(),
115 LEGACY_BUNDLE_MARKER,
116 BUNDLE_WORKSPACE_MARKER,
117 ));
118 }
119 Ok(())
120}
121
122pub fn is_bundle_root(bundle: &Path) -> bool {
123 bundle.join(LEGACY_BUNDLE_MARKER).exists() || bundle.join(BUNDLE_WORKSPACE_MARKER).exists()
124}
125
126pub fn gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
128 let mut path = bundle.join("tenants").join(tenant);
129 if let Some(team) = team {
130 path = path.join("teams").join(team).join("team.gmap");
131 } else {
132 path = path.join("tenant.gmap");
133 }
134 path
135}
136
137pub fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
139 match team {
140 Some(team) => format!("{tenant}.{team}.yaml"),
141 None => format!("{tenant}.yaml"),
142 }
143}
144
145pub fn find_provider_pack_path(bundle: &Path, provider_id: &str) -> Option<PathBuf> {
147 for subdir in &["providers/messaging", "providers/events", "packs"] {
148 let candidate = bundle.join(subdir).join(format!("{provider_id}.gtpack"));
149 if candidate.exists() {
150 return Some(candidate);
151 }
152 }
153 None
154}
155
156pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
164 if let Some(domain_name) = domain {
166 let domain_dir = bundle.join(domain_name).join("tenants");
167 if let Some(tenants) = read_tenants_from_dir(&domain_dir)? {
168 return Ok(tenants);
169 }
170 }
171
172 let general_dir = bundle.join("tenants");
174 if let Some(tenants) = read_tenants_from_dir(&general_dir)? {
175 return Ok(tenants);
176 }
177
178 Ok(Vec::new())
179}
180
181fn read_tenants_from_dir(dir: &Path) -> anyhow::Result<Option<Vec<String>>> {
183 use std::collections::BTreeSet;
184
185 if !dir.exists() {
186 return Ok(None);
187 }
188
189 let mut tenants = BTreeSet::new();
190 for entry in std::fs::read_dir(dir)? {
191 let entry = entry?;
192 let path = entry.path();
193
194 if path.is_dir() {
195 if let Some(name) = path.file_name().and_then(|v| v.to_str()) {
196 tenants.insert(name.to_string());
197 }
198 continue;
199 }
200
201 if path.is_file()
202 && let Some(stem) = path.file_stem().and_then(|v| v.to_str())
203 {
204 tenants.insert(stem.to_string());
205 }
206 }
207
208 Ok(Some(tenants.into_iter().collect()))
209}
210
211pub fn load_provider_registry(bundle: &Path) -> anyhow::Result<serde_json::Value> {
213 let path = bundle.join("providers").join("providers.json");
214 if path.exists() {
215 let raw = std::fs::read_to_string(&path)
216 .with_context(|| format!("read provider registry {}", path.display()))?;
217 serde_json::from_str(&raw)
218 .with_context(|| format!("parse provider registry {}", path.display()))
219 } else {
220 Ok(serde_json::json!({ "providers": [] }))
221 }
222}
223
224pub fn write_provider_registry(bundle: &Path, root: &serde_json::Value) -> anyhow::Result<()> {
226 let path = bundle.join("providers").join("providers.json");
227 if let Some(parent) = path.parent() {
228 std::fs::create_dir_all(parent)?;
229 }
230 let payload = serde_json::to_string_pretty(root)
231 .with_context(|| format!("serialize provider registry {}", path.display()))?;
232 std::fs::write(&path, payload).with_context(|| format!("write {}", path.display()))
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn create_bundle_structure() {
241 let temp = tempfile::tempdir().unwrap();
242 let root = temp.path().join("demo-bundle");
243 create_demo_bundle_structure(&root, Some("test")).unwrap();
244 assert!(root.join(LEGACY_BUNDLE_MARKER).exists());
245 assert!(root.join("providers/messaging").exists());
246 assert!(root.join("tenants/demo/teams/default/team.gmap").exists());
247 }
248
249 #[test]
250 fn embedded_welcome_pack_written_when_no_sibling() {
251 let temp = tempfile::tempdir().unwrap();
252 let root = temp.path().join("new-bundle");
253 create_demo_bundle_structure(&root, Some("test")).unwrap();
254 let pack = root.join("packs").join("default.gtpack");
255 assert!(pack.exists(), "embedded welcome pack should be written");
256 assert!(
257 pack.metadata().unwrap().len() > 1000,
258 "pack should not be empty"
259 );
260 }
261
262 #[test]
263 fn embedded_welcome_pack_not_overwritten() {
264 let temp = tempfile::tempdir().unwrap();
265 let root = temp.path().join("existing-bundle");
266 std::fs::create_dir_all(root.join("packs")).unwrap();
267 std::fs::write(root.join("packs").join("default.gtpack"), b"custom").unwrap();
268 create_demo_bundle_structure(&root, Some("test")).unwrap();
269 let contents = std::fs::read(root.join("packs").join("default.gtpack")).unwrap();
270 assert_eq!(
271 contents, b"custom",
272 "existing pack should not be overwritten"
273 );
274 }
275
276 #[test]
277 fn validate_bundle_exists_fails_for_missing() {
278 let result = validate_bundle_exists(Path::new("/nonexistent"));
279 assert!(result.is_err());
280 }
281
282 #[test]
283 fn validate_bundle_exists_accepts_bundle_yaml_workspace() {
284 let temp = tempfile::tempdir().unwrap();
285 let root = temp.path().join("bundle-workspace");
286 std::fs::create_dir_all(&root).unwrap();
287 std::fs::write(root.join(BUNDLE_WORKSPACE_MARKER), "schema_version: 1\n").unwrap();
288
289 validate_bundle_exists(&root).unwrap();
290 assert!(is_bundle_root(&root));
291 }
292
293 #[test]
294 fn gmap_paths() {
295 let p = gmap_path(Path::new("/b"), "demo", None);
296 assert_eq!(p, PathBuf::from("/b/tenants/demo/tenant.gmap"));
297
298 let p = gmap_path(Path::new("/b"), "demo", Some("ops"));
299 assert_eq!(p, PathBuf::from("/b/tenants/demo/teams/ops/team.gmap"));
300 }
301
302 #[test]
303 fn resolved_manifest_filenames() {
304 assert_eq!(resolved_manifest_filename("demo", None), "demo.yaml");
305 assert_eq!(
306 resolved_manifest_filename("demo", Some("ops")),
307 "demo.ops.yaml"
308 );
309 }
310
311 #[test]
312 fn discover_tenants_reads_dirs_and_files() {
313 let bundle = tempfile::tempdir().unwrap();
314 let tenants_dir = bundle.path().join("tenants");
315 std::fs::create_dir_all(tenants_dir.join("alpha")).unwrap();
316 std::fs::write(tenants_dir.join("beta.json"), "{}").unwrap();
317
318 let tenants = discover_tenants(bundle.path(), None).unwrap();
319 assert!(tenants.contains(&"alpha".to_string()));
320 assert!(tenants.contains(&"beta".to_string()));
321 }
322
323 #[test]
324 fn discover_tenants_domain_specific() {
325 let bundle = tempfile::tempdir().unwrap();
326 let domain_dir = bundle.path().join("messaging").join("tenants");
327 std::fs::create_dir_all(domain_dir.join("gamma")).unwrap();
328
329 let tenants = discover_tenants(bundle.path(), Some("messaging")).unwrap();
330 assert_eq!(tenants, vec!["gamma".to_string()]);
331 }
332
333 #[test]
334 fn discover_tenants_falls_back_to_general() {
335 let bundle = tempfile::tempdir().unwrap();
336 let tenants_dir = bundle.path().join("tenants");
337 std::fs::create_dir_all(tenants_dir.join("delta")).unwrap();
338
339 let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
341 assert_eq!(tenants, vec!["delta".to_string()]);
342 }
343}