Skip to main content

greentic_setup/
bundle.rs

1//! Bundle directory structure creation and management.
2//!
3//! Handles creating the demo bundle scaffold, writing configuration files,
4//! and managing tenant/team directories.
5
6use 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
13/// Create the standard demo bundle directory structure.
14pub 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 embedded welcome default.gtpack only when the bundle does not already
67    // declare its own app packs (e.g. via wizard --answers).  When app_packs is
68    // present in bundle.yaml the user has an explicit pack reference and the
69    // generic welcome pack would just shadow it.
70    if !bundle_has_app_packs(root) {
71        write_default_pack_if_missing(root);
72    }
73
74    Ok(())
75}
76
77/// Return `true` when `bundle.yaml` already declares at least one app pack.
78fn 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    // Simple check: look for a non-empty `app_packs:` list.
84    // A full YAML parse is avoided here to keep the dependency footprint minimal.
85    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
94/// Embedded quickstart pack bytes (built from `assets/default-welcome.gtpack`).
95///
96/// This pack contains an Adaptive Card menu flow (quickstart demo) using the
97/// adaptive-card component with text + button routing, i18n support, and
98/// Handlebars template rendering for dynamic card content.
99const EMBEDDED_WELCOME_PACK: &[u8] = include_bytes!("../assets/default-welcome.gtpack");
100
101/// Write the embedded welcome pack as `packs/default.gtpack` if not already present.
102fn 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
117/// Write a file only if it doesn't already exist.
118pub 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
129/// Validate that a bundle directory exists and has the expected marker file.
130pub 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
149/// Compute the gmap file path for a tenant/team in a bundle.
150pub 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
160/// Compute the resolved manifest filename for a tenant/team.
161pub 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
168/// Locate a provider's `.gtpack` file in the bundle by provider_id stem.
169pub 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
179/// Discover tenants inside the bundle.
180///
181/// Scans `{bundle}/tenants/` for subdirectories and files, returning
182/// tenant names (directory names or file stems without extension).
183///
184/// If `domain` is provided, first checks `{bundle}/{domain}/tenants/`
185/// and falls back to the general `{bundle}/tenants/` directory.
186pub fn discover_tenants(bundle: &Path, domain: Option<&str>) -> anyhow::Result<Vec<String>> {
187    // Try domain-specific tenants directory first
188    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    // Fall back to general tenants directory
196    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
204/// Read tenant names from a directory.
205fn 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
234/// Read and parse the provider registry JSON from a bundle.
235pub 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
247/// Write the provider registry JSON to a bundle.
248pub 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        // Write a bundle.yaml that declares an app pack
305        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        // No domain-specific directory, should fall back
381        let tenants = discover_tenants(bundle.path(), Some("events")).unwrap();
382        assert_eq!(tenants, vec!["delta".to_string()]);
383    }
384}