//! `pic plan [--file picloud.toml]` — diff the manifest's desired state //! against the live app and print the per-resource changes. Read-only: //! builds a bundle (manifest + script sources) and POSTs it to the //! server's plan endpoint, which computes the diff. use std::path::Path; use anyhow::{Context, Result}; use serde::Serialize; use serde_json::{json, Map, Value}; use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto}; use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; /// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single /// slug, so its token is stored under this reserved marker. pub const TREE_TOKEN_KEY: &str = ""; pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let manifest = Manifest::load_with_env(manifest_path, env)?; let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); let bundle = build_bundle(&manifest, base_dir)?; let kind = if manifest.is_group() { NodeKind::Group } else { NodeKind::App }; let plan = client .plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref()) .await?; // Record the bound-plan token so a subsequent `pic apply` can detect the // node changing underneath the reviewed plan (best-effort — a read-only // plan still succeeds if the project dir isn't writable). if !plan.state_token.is_empty() { if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) { eprintln!("warning: could not record plan state for `pic apply`: {e}"); } } render(&plan, mode); Ok(()) } /// `pic plan --dir ` (Phase 5): diff a whole project tree — every /// `picloud.toml` under `root` — against the live group/app subtree. pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let (bundle, _count, project) = crate::discover::build_tree(dir, env)?; let plan = client.plan_tree(&bundle, project.as_ref()).await?; if !plan.state_token.is_empty() { if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) { eprintln!("warning: could not record plan state for `pic apply`: {e}"); } } render_tree(&plan, mode); Ok(()) } fn render_tree(plan: &TreePlanDto, mode: OutputMode) { let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); for n in &plan.nodes { let node = format!("{}:{}", n.kind, n.slug); if let Some(o) = &n.ownership { table.row([ node.clone(), "ownership".to_string(), o.action.clone(), o.owner.clone().unwrap_or_default(), String::new(), ]); for b in &o.blast_radius { table.row([ node.clone(), "blast_radius".to_string(), b.project.clone(), format!("{} app(s)", b.apps), String::new(), ]); } } let groups: [(&str, &Vec); 8] = [ ("script", &n.scripts), ("route", &n.routes), ("trigger", &n.triggers), ("secret", &n.secrets), ("var", &n.vars), ("extension_point", &n.extension_points), ("collection", &n.collections), ("suppression", &n.suppressions), ]; for (rk, changes) in groups { for c in changes { table.row([ node.clone(), rk.to_string(), c.op.clone(), c.key.clone(), c.detail.clone().unwrap_or_default(), ]); } } } table.print(mode); } /// Assemble the wire bundle: scripts carry inlined source (read from /// their `file`), routes pass through, triggers flatten into a tagged /// array, secrets are names only. pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { let mut scripts = Vec::with_capacity(manifest.scripts.len()); for s in &manifest.scripts { let source = std::fs::read_to_string(base_dir.join(&s.file)) .with_context(|| format!("reading script source {}", s.file))?; let mut obj = Map::new(); obj.insert("name".into(), json!(s.name)); obj.insert("source".into(), json!(source)); obj.insert("kind".into(), serde_json::to_value(s.kind)?); if let Some(d) = &s.description { obj.insert("description".into(), json!(d)); } if let Some(t) = s.timeout_seconds { obj.insert("timeout_seconds".into(), json!(t)); } if let Some(m) = s.memory_limit_mb { obj.insert("memory_limit_mb".into(), json!(m)); } if let Some(sb) = &s.sandbox { obj.insert("sandbox".into(), serde_json::to_value(sb)?); } obj.insert("enabled".into(), json!(s.enabled)); scripts.push(Value::Object(obj)); } let routes = manifest .routes .iter() .map(serde_json::to_value) .collect::, _>>()?; let t = &manifest.triggers; let mut triggers = Vec::new(); for s in &t.kv { triggers.push(tagged("kv", s)?); } for s in &t.docs { triggers.push(tagged("docs", s)?); } for s in &t.files { triggers.push(tagged("files", s)?); } for s in &t.cron { triggers.push(tagged("cron", s)?); } for s in &t.pubsub { triggers.push(tagged("pubsub", s)?); } for s in &t.email { triggers.push(tagged("email", s)?); } for s in &t.queue { triggers.push(tagged("queue", s)?); } // Vars: key → JSON value. TOML values round-trip to JSON via serde so the // wire shape matches the server's `serde_json::Value`. let mut vars = Map::new(); for (k, v) in &manifest.vars { vars.insert( k.clone(), serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?, ); } Ok(json!({ "scripts": scripts, "routes": routes, "triggers": triggers, "secrets": manifest.secrets.names, "vars": vars, "extension_points": manifest.extension_points(), "collections": manifest .collections() .into_iter() .map(|(name, kind)| json!({ "name": name, "kind": kind })) .collect::>(), "suppress_triggers": manifest.suppress.triggers, "suppress_routes": manifest.suppress.routes, })) } /// Serialize a trigger spec and stamp its `kind` discriminator. fn tagged(kind: &str, spec: impl Serialize) -> Result { let mut v = serde_json::to_value(spec)?; if let Value::Object(map) = &mut v { map.insert("kind".into(), Value::String(kind.to_string())); } Ok(v) } fn render(plan: &PlanDto, mode: OutputMode) { let mut table = Table::new(["kind", "op", "resource", "detail"]); if let Some(o) = &plan.ownership { table.row([ "ownership".to_string(), o.action.clone(), o.owner.clone().unwrap_or_default(), String::new(), ]); for b in &o.blast_radius { table.row([ "blast_radius".to_string(), b.project.clone(), format!("{} app(s)", b.apps), String::new(), ]); } } let groups: [(&str, &Vec); 8] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), ("secret", &plan.secrets), ("var", &plan.vars), ("extension_point", &plan.extension_points), ("collection", &plan.collections), ("suppression", &plan.suppressions), ]; for (kind, changes) in groups { for c in changes { table.row([ kind.to_string(), c.op.clone(), c.key.clone(), c.detail.clone().unwrap_or_default(), ]); } } table.print(mode); }