//! `.picloud/` link state — gitignored, per-project metadata the project tool //! carries between CLI invocations. Today it holds just the bound-plan token: //! `pic plan` records the fingerprint of the live state it diffed against, and //! `pic apply` replays it so the server can refuse if the app moved underneath. //! //! All paths are relative to the manifest's directory (the project root). use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; const DIR: &str = ".picloud"; const PLAN_FILE: &str = "plan.json"; const PROJECT_FILE: &str = "project.json"; /// The repo's stable project identity (§7, M3): a random, opaque key minted on /// first need and persisted (gitignored) in `.picloud/`. It is the server-side /// ownership handle — the same repo keeps the same project across clones/CI /// because the key travels in the working tree, never in a committed file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectLink { pub key: String, } fn project_path(base: &Path) -> PathBuf { base.join(DIR).join(PROJECT_FILE) } /// Read the project key, if one has been minted under `base/.picloud/`. #[must_use] pub fn read_project_key(base: &Path) -> Option { let body = fs::read(project_path(base)).ok()?; serde_json::from_slice::(&body) .ok() .map(|l| l.key) } /// Read the project key, minting and persisting a fresh one if absent. Used by /// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets /// a stable ownership identity on first use. The new key is a random v4 UUID. pub fn ensure_project_key(base: &Path) -> Result { if let Some(k) = read_project_key(base) { return Ok(k); } let key = uuid::Uuid::new_v4().to_string(); write_project_key(base, &key)?; Ok(key) } /// Persist `key` as the project identity, creating `.picloud/` (self-ignored) /// if needed. Idempotent overwrite — callers usually go through /// [`ensure_project_key`]. pub fn write_project_key(base: &Path, key: &str) -> Result<()> { let dir = base.join(DIR); fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; let ignore = dir.join(".gitignore"); if !ignore.exists() { fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?; } let body = serde_json::to_vec_pretty(&ProjectLink { key: key.to_string(), }) .context("encoding .picloud/project.json")?; fs::write(project_path(base), body).context("writing .picloud/project.json")?; Ok(()) } /// The recorded result of the last `pic plan`, scoped to the app it was for. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanLink { /// App slug the token belongs to — guards against replaying a token from a /// different app if the manifest's `slug` changed. pub app: String, pub state_token: String, } fn plan_path(base: &Path) -> PathBuf { base.join(DIR).join(PLAN_FILE) } /// Record the bound-plan token for `app` under `base/.picloud/`. pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> { let dir = base.join(DIR); fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; // Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole // dir out of git regardless of the project root's `.gitignore` — so this // is safe even when reached via `pic plan`/`pull` (which, unlike `init`, // don't touch the root `.gitignore`). let ignore = dir.join(".gitignore"); if !ignore.exists() { fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?; } let link = PlanLink { app: app.to_string(), state_token: state_token.to_string(), }; let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?; fs::write(plan_path(base), body).context("writing .picloud/plan.json")?; Ok(()) } /// Read the recorded plan token, if any. Returns `None` when absent or /// unreadable (treated as "no prior plan" — never an error). #[must_use] pub fn read_plan(base: &Path) -> Option { let body = fs::read(plan_path(base)).ok()?; serde_json::from_slice(&body).ok() } /// Remove the recorded plan token (best-effort) **iff it belongs to `app`**. /// Called after a successful apply consumes it, so the next apply requires a /// fresh plan — without clobbering a token recorded for a different app that /// happens to share the directory. pub fn clear_plan(base: &Path, app: &str) { if read_plan(base).is_some_and(|l| l.app == app) { let _ = fs::remove_file(plan_path(base)); } }