//! ยง6 M2 โ€” structural-divergence detection + declarative reparent. //! //! Once the manifest declares a group's parent (by directory nesting), a //! `pic apply --dir` compares it to the server's actual parent. A divergence is //! REFUSED by default (Terraform detect-and-refuse); `--force-local-structure` //! reparents the group to match the manifest, `--adopt-server-structure` keeps //! the server's placement and reconciles content in place. `pic plan` previews //! the divergence first. use std::fs; use std::path::Path; use tempfile::TempDir; use crate::common; use crate::common::cleanup::GroupGuard; /// A repo that declares `mover` NESTED under `alt` (so its manifest parent is /// `alt`), with `alt` at the attach point `base`. fn diverging_repo(dir: &Path, project: &str, base: &str, alt: &str, mover: &str) { fs::write( dir.join("picloud.toml"), format!( "[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\ [group]\nslug = \"{alt}\"\nname = \"Alt\"\n" ), ) .unwrap(); let sub = dir.join("mover"); fs::create_dir_all(&sub).unwrap(); fs::write( sub.join("picloud.toml"), format!("[group]\nslug = \"{mover}\"\nname = \"Mover\"\n"), ) .unwrap(); } fn server_parent(env: &common::TestEnv, group: &str) -> String { let ls = common::pic_as(env) .args(["groups", "ls"]) .output() .expect("groups ls"); let table = String::from_utf8(ls.stdout).unwrap(); table .lines() .map(common::cells) .find(|c| c.first() == Some(&group)) .and_then(|c| c.get(2).map(|s| (*s).to_string())) .unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}")) } /// Pre-create base โ†’ {alt, mover} (both directly under base), so a repo that /// nests `mover` under `alt` diverges. fn seed(env: &common::TestEnv, base: &str, alt: &str, mover: &str) { common::pic_as(env) .args(["groups", "create", base]) .assert() .success(); common::pic_as(env) .args(["groups", "create", alt, "--parent", base]) .assert() .success(); common::pic_as(env) .args(["groups", "create", mover, "--parent", base]) .assert() .success(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn divergence_refused_by_default_then_force_local_reparents() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let base = common::unique_slug("sd-base"); let alt = common::unique_slug("sd-alt"); let mover = common::unique_slug("sd-mover"); let project = common::unique_slug("sd-proj"); let _b = GroupGuard::new(&env.url, &env.token, &base); let _a = GroupGuard::new(&env.url, &env.token, &alt); let _m = GroupGuard::new(&env.url, &env.token, &mover); seed(&env, &base, &alt, &mover); let dir = TempDir::new().unwrap(); diverging_repo(dir.path(), &project, &base, &alt, &mover); // Plan previews the divergence (server parent `base`, manifest parent `alt`). let plan = String::from_utf8( common::pic_as(&env) .args(["plan", "--dir"]) .arg(dir.path()) .output() .unwrap() .stdout, ) .unwrap(); assert!( plan.contains("diverged") && plan.contains(&base) && plan.contains(&alt), "plan should show `mover` diverged (server={base}, manifest={alt}):\n{plan}" ); // A bare apply is REFUSED on the divergence (422). let out = common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .output() .expect("apply"); assert!(!out.status.success(), "a divergence must refuse by default"); let err = String::from_utf8_lossy(&out.stderr).to_lowercase(); assert!( err.contains("structural") || err.contains("force-local") || err.contains("different parent"), "the refusal must name the resolution flags:\n{err}" ); assert_eq!( server_parent(&env, &mover), base, "a refused apply must not move the group" ); // `--force-local-structure` reparents `mover` to `alt` (the manifest shape). common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .arg("--force-local-structure") .assert() .success(); assert_eq!( server_parent(&env, &mover), alt, "--force-local-structure must reparent to the manifest's parent" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn adopt_server_structure_keeps_the_server_placement() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let base = common::unique_slug("sda-base"); let alt = common::unique_slug("sda-alt"); let mover = common::unique_slug("sda-mover"); let project = common::unique_slug("sda-proj"); let _b = GroupGuard::new(&env.url, &env.token, &base); let _a = GroupGuard::new(&env.url, &env.token, &alt); let _m = GroupGuard::new(&env.url, &env.token, &mover); seed(&env, &base, &alt, &mover); let dir = TempDir::new().unwrap(); diverging_repo(dir.path(), &project, &base, &alt, &mover); // `--adopt-server-structure` proceeds without reparenting โ€” `mover` stays // under `base` (its server placement), and content reconciles in place. common::pic_as(&env) .args(["apply", "--dir"]) .arg(dir.path()) .arg("--adopt-server-structure") .assert() .success(); assert_eq!( server_parent(&env, &mover), base, "--adopt-server-structure must keep the server's parent" ); }