Files
PiCloud/crates/picloud-cli/tests/tree_shape.rs
MechaCat02 c18ce7c2c4 feat(hierarchies): declarative group create/reparent — tree shape (M2)
M2 of the remaining-hierarchies work. `pic apply --dir` now owns the org-tree
SHAPE, not just node content: a `[group]` manifest for a group that doesn't
exist is CREATED under its declared parent, and an existing group whose declared
parent changed is REPARENTED — all inside the single tree-apply transaction
(create + reparent; structural prune is deferred to M3 with the ownership layer).

group_repo: extract transaction-aware structural mutations — `create_group_tx`,
`reparent_group_tx` (the ancestor-walk cycle guard, now reading through the tx so
it sees in-tx writes), `delete_group_tx`, and `acquire_structural_lock_tx`. The
trait `create`/`reparent`/`delete` delegate to them (one SQL definition,
behavior-preserving: the coarse structural advisory lock + structure_version
bump + delete=RESTRICT all preserved).

apply_service: `TreeNode` gains `parent_slug` + `name`. `prepare_tree` classifies
group nodes into existing (resolved id, maybe reparent) vs to-create (absent →
deferred), returning a `PreparedTree { prepared, creates, reparents, token }`.
`apply_tree` adds Phase 0 (`reconcile_tree_structure_tx`): create absent groups
parent-first (topo loop with cycle/unresolved-parent detection) and reparent
existing ones, then Phase A/B reconcile content as before. A to-create group
reconciles against an empty CurrentState (all-Create) — so it needs no DB read
and sidesteps the pool-vs-tx coupling. The bound-plan token folds a
declared-absent marker, so a group created out-of-band between plan and apply
trips StateMoved.

authz (apply_api `authz_tree`): a to-create group mirrors the interactive create
gate — root-level needs `InstanceCreateGroup`, a subgroup under an existing
parent needs only `GroupAdmin(parent)`; a reparent needs `GroupAdmin` at the
group, the SOURCE parent, and the DESTINATION parent (§5.6, parity with
`reparent_group`). No 404 on a to-create node.

CLI: `[group] parent` manifest key (else the parent is inferred from the nearest
ancestor directory's group); `build_tree` emits `parent_slug` + `name` per group
node; the apply report shows a `groups +N reparented M` line.

Reviewed by subagent (core mechanism verified sound: topo termination, empty-
CurrentState reconcile, in-tx cycle guard, atomicity, StateMoved, idempotency);
fixed the two authz-parity gaps it found (missing source-parent check on
reparent; over-gated subgroup create). Tests: a new `tree_shape` journey
(create + reparent + no-op re-plan); 393 manager-core lib + the Phase-5 tree/
group/ext journeys all green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:18:27 +02:00

114 lines
4.1 KiB
Rust

//! M2 declarative tree SHAPE via `pic apply --dir`:
//! * a group manifest for a group that does NOT exist yet is CREATED under
//! its declared parent, with its content, in one atomic apply,
//! * re-applying the same tree is a no-op (the group now exists),
//! * changing the declared parent REPARENTS the group on the next apply.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{GroupGuard, ScriptGuard};
/// Write a single-group project dir declaring group `slug` under `parent` with
/// one endpoint script.
fn group_dir(slug: &str, parent: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_creates_then_reparents_a_group() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("ts-par");
let child = common::unique_slug("ts-child");
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
// --- Create: the child group does NOT exist; apply --dir creates it. ---
let dir = group_dir(&child, &parent);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
out.status.success(),
"tree apply (create) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// The group now exists with its script.
let groups = ls(&env, &["groups", "ls"]);
assert!(groups.contains(&child), "created group missing:\n{groups}");
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
let script_id = scripts
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&"hello"))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
// Re-applying the same tree is a no-op (group + content unchanged).
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
);
// --- Reparent: move the child under the instance root. ---
let dir2 = group_dir(&child, "root");
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir2.path())
.output()
.expect("apply --dir reparent");
assert!(
out.status.success(),
"tree apply (reparent) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Re-plan is a no-op now that the parent matches.
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir2.path())
.output()
.expect("plan --dir after reparent");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan after reparent must be a no-op:\n{plan_txt}"
);
}
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
}