//! Declarative project manifest (`picloud.toml`). //! //! One manifest describes the desired state of a **single app** — its //! scripts, routes, triggers, and the *names* of the secrets it expects //! (values are pushed out-of-band via `pic secret set`, never committed). //! //! This is the foundation of the declarative project tool (`pic pull` / //! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared` //! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`, //! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays //! identical to the admin API — the CLI never depends on `manager-core`. //! //! All eight trigger kinds are representable except `dead_letter` (not //! exposed declaratively). `email` triggers carry an `inbound_secret_ref` //! (a secret name) resolved server-side at apply. use std::fs; use std::path::Path; use anyhow::{Context, Result}; use picloud_shared::{ DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind, ScriptSandbox, }; use serde::{Deserialize, Serialize}; /// Conventional manifest filename at a project root. pub const MANIFEST_FILE: &str = "picloud.toml"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Manifest { pub app: ManifestApp, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scripts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub routes: Vec, #[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")] pub triggers: ManifestTriggers, #[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")] pub secrets: ManifestSecrets, } impl Manifest { /// Parse a manifest from TOML text. pub fn parse(text: &str) -> Result { toml::from_str(text).context("parsing manifest TOML") } /// Load and parse the manifest at `path`. pub fn load(path: &Path) -> Result { let body = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; Self::parse(&body) } /// Render to TOML text. Tables are emitted after scalars (the struct /// field order already satisfies TOML's "values before tables" rule). pub fn to_toml(&self) -> Result { toml::to_string_pretty(self).context("serializing manifest TOML") } /// Load the base manifest, then (if `env` is set) merge the sparse /// `picloud..toml` overlay on top — the §4.1 base+overlay model /// where "an environment is an app". The overlay carries per-env slug + /// secrets; scripts/routes/triggers stay in the shared base. (Rich /// per-key `vars` resolution is Phase 3.) pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result { let mut base = Self::load(base_path)?; if let Some(env) = env { let path = overlay_path(base_path, env); let body = fs::read_to_string(&path).with_context(|| { format!( "reading overlay {} for env `{env}` (expected next to the base manifest)", path.display() ) })?; let overlay: ManifestOverlay = toml::from_str(&body) .with_context(|| format!("parsing overlay {}", path.display()))?; base.apply_overlay(overlay); } Ok(base) } /// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name` /// replace the base's; overlay secret names union into the base set. fn apply_overlay(&mut self, overlay: ManifestOverlay) { if let Some(slug) = overlay.app.slug { self.app.slug = slug; } if let Some(name) = overlay.app.name { self.app.name = name; } for n in overlay.secrets.names { if !self.secrets.names.contains(&n) { self.secrets.names.push(n); } } } } /// `picloud.toml` → `picloud..toml`, alongside the base (works for a /// custom `--file` too: `custom.toml` → `custom..toml`). fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf { let parent = base_path.parent().unwrap_or_else(|| Path::new(".")); let name = base_path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| MANIFEST_FILE.to_string()); let stem = name.strip_suffix(".toml").unwrap_or(&name); parent.join(format!("{stem}.{env}.toml")) } /// A sparse per-environment overlay (`picloud..toml`). Only the fields /// that vary per environment today — slug/name and secret names. /// /// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`. /// Scripts/routes/triggers belong in the shared base manifest, so a /// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an /// overlay is a mistake — error loudly rather than silently dropping it. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestOverlay { #[serde(default)] pub app: OverlayApp, #[serde(default)] pub secrets: ManifestSecrets, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct OverlayApp { #[serde(default)] pub slug: Option, #[serde(default)] pub name: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ManifestApp { pub slug: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ManifestScript { pub name: String, /// Path to the `.rhai` source, relative to the manifest's directory. pub file: String, #[serde(default, skip_serializing_if = "is_endpoint")] pub kind: ScriptKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_seconds: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_limit_mb: Option, /// Per-script sandbox overrides; omitted entirely when no knob is set. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, /// Three-state lifecycle (§4.3): `false` deploys the script inert (not /// invocable). Omitted ⇒ active; only serialized when disabled. #[serde( default = "picloud_shared::default_true", skip_serializing_if = "is_true" )] pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ManifestRoute { /// Name of the script this route binds to. pub script: String, /// HTTP method; omit for ANY. #[serde(default, skip_serializing_if = "Option::is_none")] pub method: Option, pub host_kind: HostKind, #[serde(default, skip_serializing_if = "String::is_empty")] pub host: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub host_param_name: Option, pub path_kind: PathKind, pub path: String, #[serde(default, skip_serializing_if = "is_sync")] pub dispatch_mode: DispatchMode, /// Three-state lifecycle (§4.3): `false` deploys the route inert (404). /// Omitted ⇒ active; only serialized when disabled. #[serde( default = "picloud_shared::default_true", skip_serializing_if = "is_true" )] pub enabled: bool, } /// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ManifestTriggers { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub kv: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub docs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub files: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub cron: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pubsub: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub email: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub queue: Vec, } impl ManifestTriggers { #[must_use] pub fn is_empty(&self) -> bool { self.kv.is_empty() && self.docs.is_empty() && self.files.is_empty() && self.cron.is_empty() && self.pubsub.is_empty() && self.email.is_empty() && self.queue.is_empty() } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct KvTriggerSpec { pub script: String, pub collection_glob: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ops: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DocsTriggerSpec { pub script: String, pub collection_glob: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ops: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FilesTriggerSpec { pub script: String, pub collection_glob: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ops: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CronTriggerSpec { pub script: String, /// 6-field cron expression (with seconds). pub schedule: String, #[serde(default = "default_timezone")] pub timezone: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PubsubTriggerSpec { pub script: String, pub topic_pattern: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EmailTriggerSpec { pub script: String, /// Name of the secret (set via `pic secret set`) holding the inbound /// HMAC value — resolved + sealed server-side at apply. Never the value. pub inbound_secret_ref: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct QueueTriggerSpec { pub script: String, pub queue_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility_timeout_secs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, } /// `[secrets] names = [...]` — declares which secrets the app expects. /// Values are never in the manifest; `pic secret set` pushes them. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ManifestSecrets { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub names: Vec, } impl ManifestSecrets { #[must_use] pub fn is_empty(&self) -> bool { self.names.is_empty() } } // ---- serde skip/default helpers ---- fn is_endpoint(kind: &ScriptKind) -> bool { *kind == ScriptKind::Endpoint } fn is_sync(mode: &DispatchMode) -> bool { *mode == DispatchMode::Sync } /// Skip-serialize helper: `enabled` defaults true, so only emit it when false. fn is_true(b: &bool) -> bool { *b } fn default_timezone() -> String { "UTC".to_string() } #[cfg(test)] mod tests { use super::*; fn sample() -> Manifest { Manifest { app: ManifestApp { slug: "blog".into(), name: "My Blog".into(), description: Some("demo".into()), }, scripts: vec![ ManifestScript { name: "create-post".into(), file: "scripts/create-post.rhai".into(), kind: ScriptKind::Endpoint, description: None, timeout_seconds: Some(10), memory_limit_mb: Some(256), sandbox: None, enabled: true, }, ManifestScript { name: "lib".into(), file: "scripts/lib.rhai".into(), kind: ScriptKind::Module, description: None, timeout_seconds: None, memory_limit_mb: None, sandbox: Some(ScriptSandbox { max_operations: Some(5_000_000), ..ScriptSandbox::empty() }), enabled: false, }, ], routes: vec![ManifestRoute { script: "create-post".into(), method: Some("POST".into()), host_kind: HostKind::Any, host: String::new(), host_param_name: None, path_kind: PathKind::Exact, path: "/posts".into(), dispatch_mode: DispatchMode::Sync, enabled: true, }], triggers: ManifestTriggers { cron: vec![CronTriggerSpec { script: "create-post".into(), schedule: "0 6 * * * *".into(), timezone: "UTC".into(), dispatch_mode: None, retry_max_attempts: None, }], kv: vec![KvTriggerSpec { script: "create-post".into(), collection_glob: "users".into(), ops: vec![KvEventOp::Insert, KvEventOp::Update], dispatch_mode: Some(DispatchMode::Async), retry_max_attempts: Some(5), }], ..ManifestTriggers::default() }, secrets: ManifestSecrets { names: vec!["STRIPE_KEY".into()], }, } } #[test] fn round_trips_through_toml() { let m = sample(); let text = m.to_toml().expect("serialize"); let back = Manifest::parse(&text).expect("parse"); assert_eq!(m, back, "manifest must survive a TOML round-trip"); } #[test] fn omits_defaulted_fields() { let text = sample().to_toml().unwrap(); // Endpoint kind + sync dispatch are defaults → not emitted. assert!( !text.contains("kind = \"endpoint\""), "default kind should be omitted:\n{text}" ); assert!( !text.contains("dispatch_mode = \"sync\""), "default route dispatch should be omitted:\n{text}" ); // Module kind IS non-default → emitted. assert!(text.contains("kind = \"module\""), "got:\n{text}"); // `enabled` defaults true → omitted for the active script/route, but // the disabled `lib` script emits `enabled = false`. assert!( text.contains("enabled = false"), "a disabled entity must emit enabled:\n{text}" ); assert!( !text.contains("enabled = true"), "active entities must omit the default:\n{text}" ); } #[test] fn overlay_merges_slug_and_unions_secrets() { let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"] let overlay: ManifestOverlay = toml::from_str( "[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n", ) .unwrap(); m.apply_overlay(overlay); assert_eq!(m.app.slug, "blog-staging", "overlay slug wins"); assert_eq!( m.app.name, "My Blog", "base name kept when overlay omits it" ); assert_eq!( m.secrets.names, vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()], "secrets union, no dupes" ); // Scripts/routes come from the base, untouched by the overlay. assert_eq!(m.scripts.len(), 2); } #[test] fn overlay_rejects_non_overlay_tables() { // An overlay carries only [app]/[secrets]. Scripts/routes/triggers // belong in the shared base, so a `[[scripts]]` table (or a typo'd // key) in an overlay must error loudly, not be silently dropped. let err = toml::from_str::( "[app]\nslug = \"blog-staging\"\n\n\ [[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n", ) .expect_err("overlay with [[scripts]] must be rejected"); assert!( err.to_string().contains("scripts") || err.to_string().contains("unknown"), "error should point at the offending table: {err}" ); // Typo'd key inside [app] is likewise rejected. toml::from_str::("[app]\nslag = \"oops\"\n") .expect_err("overlay with a typo'd [app] key must be rejected"); } #[test] fn overlay_path_derivation() { assert_eq!( overlay_path(Path::new("proj/picloud.toml"), "staging"), Path::new("proj/picloud.staging.toml") ); assert_eq!( overlay_path(Path::new("custom.toml"), "prod"), Path::new("custom.prod.toml") ); } #[test] fn empty_optional_sections_omitted() { let m = Manifest { app: ManifestApp { slug: "x".into(), name: "X".into(), description: None, }, scripts: vec![], routes: vec![], triggers: ManifestTriggers::default(), secrets: ManifestSecrets::default(), }; let text = m.to_toml().unwrap(); assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("triggers"), "got:\n{text}"); assert!(!text.contains("secrets"), "got:\n{text}"); // Still round-trips. assert_eq!(m, Manifest::parse(&text).unwrap()); } }