//! `.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"; /// 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)); } }