//! `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 }; // §3 M3: preview the gate against the GOVERNING project (own block, else the // nearest ancestor's), symmetric with `apply::run` — so `pic plan --file` on // a leaf still surfaces `approvals_required` for the gate the apply enforces. let governing = manifest .project .clone() .or_else(|| crate::cmds::apply::find_governing_project(manifest_path)); let plan = client .plan_node(kind, manifest.slug(), &bundle, governing.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(s) = &n.structure { table.row([ node.clone(), "structure".to_string(), s.status.clone(), format!( "server={}", s.server_parent.clone().unwrap_or_else(|| "".into()) ), format!( "manifest={}", s.manifest_parent.clone().unwrap_or_else(|| "".into()) ), ]); } 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); 10] = [ ("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), ("workflow", &n.workflows), ("interceptor", &n.interceptors), ]; 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(), ]); } } for w in &n.warnings { table.row([ node.clone(), "warning".to_string(), String::new(), w.clone(), String::new(), ]); } } table.print(mode); render_approvals(&plan.approvals_required, mode); } /// §3 M3: surface the confirm-required environments so CI sees the gate at plan /// time, before an `apply --approve ` is needed. fn render_approvals(approvals_required: &[String], mode: OutputMode) { if mode != OutputMode::Json && !approvals_required.is_empty() { eprintln!("\nenvironments requiring explicit approval (apply with --approve ):"); for e in approvals_required { eprintln!(" - {e}"); } } } /// Surface the desired-state warnings `apply` would emit, so `pic plan` is a /// faithful preview for CI (disabled binding, unreachable endpoint, dangling /// suppress). JSON callers read them off the `warnings` field. fn render_warnings(warnings: &[String], mode: OutputMode) { if mode != OutputMode::Json && !warnings.is_empty() { eprintln!("\nwarnings:"); for w in warnings { eprintln!(" - {w}"); } } } /// 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, "workflows": manifest .workflows .iter() .map(workflow_to_wire) .collect::>>()?, // §9.4: expand each `[[interceptors]]` entry's `ops` into one wire // marker per (service, op). "interceptors": manifest .interceptors .iter() .flat_map(|i| { i.ops.iter().map(move |op| { json!({ "service": i.service, "op": op, "script": i.script }) }) }) .collect::>(), })) } /// Convert a `[[workflows]]` manifest block to the server `BundleWorkflow` /// shape `{ name, enabled, definition: { steps } }`. The manifest authors steps /// at the workflow's top level; the wire nests them under `definition`. fn workflow_to_wire(w: &crate::manifest::ManifestWorkflow) -> Result { let steps = serde_json::to_value(&w.steps) .with_context(|| format!("encoding workflow `{}`", w.name))?; Ok(json!({ "name": w.name, "enabled": w.enabled, "definition": { "steps": steps }, })) } /// 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); 10] = [ ("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), ("workflow", &plan.workflows), ("interceptor", &plan.interceptors), ]; 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); render_approvals(&plan.approvals_required, mode); render_warnings(&plan.warnings, mode); }