Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.
* New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
`target`), one node per manifest, app/group inferred from the manifest;
rejects a duplicate (kind, slug). On-disk nesting is organizational — the
server resolves ancestry from its own group tree.
* `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
--dir` renders a per-node diff table and records ONE tree bound-token under
a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
prune apply tree-wide). `--dir` conflicts with `--file`.
Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76 lines
2.8 KiB
Rust
76 lines
2.8 KiB
Rust
//! 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.<env>.toml`) are not matched — only the base filename.
|
|
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
|
|
let mut out = Vec::new();
|
|
walk(root, &mut out)?;
|
|
out.sort();
|
|
Ok(out)
|
|
}
|
|
|
|
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> 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 plus the node count. Rejects a tree
|
|
/// that names the same (kind, slug) twice.
|
|
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
|
let paths = find_manifests(root)?;
|
|
if paths.is_empty() {
|
|
bail!(
|
|
"no {MANIFEST_FILE} found under {} — nothing to apply",
|
|
root.display()
|
|
);
|
|
}
|
|
let mut nodes = Vec::with_capacity(paths.len());
|
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
|
for path in &paths {
|
|
let manifest = Manifest::load_with_env(path, env)
|
|
.with_context(|| format!("loading {}", path.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();
|
|
Ok((json!({ "nodes": nodes }), count))
|
|
}
|