//! Phase 5: discover a project *tree* — every `picloud.toml` under a root //! directory — and assemble the wire tree bundle the server's `/tree/{plan, //! apply}` endpoints consume. Each manifest becomes one node (app or group); //! the server resolves ancestry from its own group tree, so the on-disk //! nesting is organizational, not authoritative here. use std::collections::HashSet; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use serde_json::{json, Value}; use crate::cmds::plan::build_bundle; use crate::manifest::{Manifest, MANIFEST_FILE}; /// Find every `picloud.toml` under `root` (recursively), skipping the /// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env /// overlays (`picloud..toml`) are not matched — only the base filename. pub fn find_manifests(root: &Path) -> Result> { let mut out = Vec::new(); walk(root, &mut out)?; out.sort(); Ok(out) } fn walk(dir: &Path, out: &mut Vec) -> Result<()> { let entries = std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?; for entry in entries { let entry = entry?; let name = entry.file_name(); let ft = entry.file_type()?; if ft.is_dir() { if matches!( name.to_str(), Some(".picloud" | ".git" | "scripts" | "target") ) { continue; } walk(&entry.path(), out)?; } else if name == MANIFEST_FILE { out.push(entry.path()); } } Ok(()) } /// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`) /// from every manifest under `root`. Returns the JSON, the node count, and the /// root manifest's `[project]` environment policy (M5, empty if none). Rejects a /// tree that names the same (kind, slug) twice, or declares `[project]` anywhere /// but the root manifest. pub fn build_tree( root: &Path, env: Option<&str>, ) -> Result<(Value, usize, Vec)> { let paths = find_manifests(root)?; if paths.is_empty() { bail!( "no {MANIFEST_FILE} found under {} — nothing to apply", root.display() ); } let root_manifest = root.join(MANIFEST_FILE); let mut nodes = Vec::with_capacity(paths.len()); let mut seen: HashSet<(String, String)> = HashSet::new(); let mut project: Option = None; for path in &paths { let manifest = Manifest::load_with_env(path, env) .with_context(|| format!("loading {}", path.display()))?; // `[project]` is project-level — valid only on the tree's root manifest. if let Some(p) = &manifest.project { if path == &root_manifest { project = Some(p.clone()); } else { bail!( "{} declares [project], which is only valid on the root manifest ({})", path.display(), root_manifest.display() ); } } let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); let bundle = build_bundle(&manifest, base_dir)?; let kind = if manifest.is_group() { "group" } else { "app" }; let slug = manifest.slug().to_string(); if !seen.insert((kind.to_string(), slug.clone())) { bail!("project tree declares {kind} `{slug}` more than once"); } nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle })); } let count = nodes.len(); let envs = project .as_ref() .map(|p| p.environments.clone()) .unwrap_or_default(); let mut bundle = json!({ "nodes": nodes }); if let Some(p) = &project { bundle.as_object_mut().expect("json object").insert( "project".into(), json!({ "environments": p.environments.iter() .map(|e| json!({ "name": e.name, "confirm": e.confirm })) .collect::>(), }), ); } Ok((bundle, count, envs)) }