//! `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, PlanDto}; use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; 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 plan = client.plan(&manifest.app.slug, &bundle).await?; // Record the bound-plan token so a subsequent `pic apply` can detect the // app 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.app.slug, &plan.state_token) { eprintln!("warning: could not record plan state for `pic apply`: {e}"); } } render(&plan, mode); Ok(()) } /// 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, })) } /// 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"]); let groups: [(&str, &Vec); 5] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), ("secret", &plan.secrets), ("var", &plan.vars), ]; 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); }