//! 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). //! //! §6: a GROUP node's PARENT is inferred from directory nesting — its parent is //! the nearest ancestor directory that also holds a `[group]` manifest; the //! topmost group binds to the repo's `[project] parent_group` (attach point) or //! the instance root. The server uses this declared parent to create a missing //! group and to detect structural divergence (M2). An app node declares no //! parent (an app pre-exists with a fixed group). use std::collections::{HashMap, 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, ManifestProject, 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}] }`) from every /// manifest under `root`. Returns the JSON, the node count, and the owning /// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one /// project owns the whole tree; a `[project]` in any other node is ignored with /// a note. Rejects a tree that names the same (kind, slug) twice. pub fn build_tree( root: &Path, env: Option<&str>, ) -> Result<(Value, usize, Option)> { 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); // Load every manifest once, remembering its directory so we can infer group // parentage from the nesting. let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len()); let mut project: Option = None; for path in &paths { let manifest = Manifest::load_with_env(path, env) .with_context(|| format!("loading {}", path.display()))?; if let Some(p) = &manifest.project { if path.as_path() == root_manifest { project = Some(p.clone()); } else { eprintln!( "note: [project] in {} is ignored — declare it in the tree root {}", path.display(), root_manifest.display() ); } } loaded.push((path.clone(), manifest)); } // Map each GROUP manifest's DIRECTORY → its slug, so a group node can find // its nearest ancestor-directory group (its declared parent). let group_dir_to_slug: HashMap = loaded .iter() .filter(|(_, m)| m.is_group()) .map(|(path, m)| { let dir = path .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); (dir, m.slug().to_string()) }) .collect(); let attach_point = project.as_ref().and_then(|p| p.parent_group.clone()); // Build (sort_key, node) pairs. Emit groups before apps, and shallower // groups before deeper ones (a parent's directory has fewer components than // its child's), so the server creates a parent group before the child that // nests under it. let mut keyed: Vec<((bool, usize), Value)> = Vec::with_capacity(loaded.len()); let mut seen: HashSet<(String, String)> = HashSet::new(); for (path, manifest) in &loaded { let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); let bundle = build_bundle(manifest, base_dir)?; let is_group = manifest.is_group(); let kind = if 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"); } let depth = base_dir.components().count(); let node = if is_group { let parent = nearest_group_ancestor(base_dir, &group_dir_to_slug) .or_else(|| attach_point.clone()); let g = manifest.group.as_ref(); let name = g.map(|g| g.name.clone()); let description = g.and_then(|g| g.description.clone()); json!({ "kind": kind, "slug": slug, "parent": parent, "name": name, "description": description, "bundle": bundle, }) } else { json!({ "kind": kind, "slug": slug, "bundle": bundle }) }; keyed.push(((!is_group, depth), node)); } keyed.sort_by(|a, b| a.0.cmp(&b.0)); let nodes: Vec = keyed.into_iter().map(|(_, n)| n).collect(); let count = nodes.len(); Ok((json!({ "nodes": nodes }), count, project)) } /// The slug of the nearest STRICT ancestor directory of `dir` that holds a /// `[group]` manifest, or `None` if there is no group above it in the tree. fn nearest_group_ancestor(dir: &Path, group_dirs: &HashMap) -> Option { dir.ancestors() .skip(1) // skip `dir` itself — we want a strict ancestor .find_map(|anc| group_dirs.get(anc).cloned()) }