//! 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::collections::BTreeMap; 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)] #[serde(deny_unknown_fields)] pub struct Manifest { /// An app node declares `[app]`; a group node declares `[group]` (Phase 5). /// Exactly one is present (enforced by [`Manifest::parse`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub app: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub group: Option, /// `[project]` (§7 multi-repo ownership) — the repo-root that OWNS the nodes /// it applies. Independent of the app/group node kind; declared in the /// repo's root manifest and threaded onto every apply from it. The first /// apply with a new slug claims each group node it touches. #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, #[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, /// `[vars]` — app config key → value, reconciled at app scope `*`. Values /// are non-secret and live inline (unlike `[secrets]`, which is name-only). /// The overlay merges per-env, last-write-wins by key. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub vars: BTreeMap, /// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates. /// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`]. #[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")] pub suppress: ManifestSuppress, /// `[[workflows]]` (v1.2 Workflows) — declarative DAG orchestrations. /// App-owned; a `[group]` carrying them is rejected server-side. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub workflows: Vec, } impl Manifest { /// Parse a manifest from TOML text. Enforces the app-XOR-group invariant. pub fn parse(text: &str) -> Result { let m: Self = toml::from_str(text).context("parsing manifest TOML")?; match (&m.app, &m.group) { (Some(_), None) | (None, Some(_)) => {} (Some(_), Some(_)) => { anyhow::bail!( "manifest declares both [app] and [group]; a node is one or the other" ) } (None, None) => { anyhow::bail!("manifest declares neither [app] nor [group]") } } // A group node owns scripts + vars (+ secret names) and, as of §11 tail, // ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that // fan out live to descendant apps. As of §11 tail M1 a group may also // declare `[suppress]` to decline a template it inherits from a higher // ancestor, for its whole subtree. // §4.5 M5: a group may declare STATEFUL trigger templates too — cron, // queue, and email materialize a per-descendant-app row. An email // template resolves its `inbound_secret_ref` against the GROUP's own // secret store once at apply (shared-group-secret model); the server // rejects it there if the secret is unset. Ok(m) } /// This node's slug (app or group). #[must_use] pub fn slug(&self) -> &str { match (&self.app, &self.group) { (Some(a), _) => &a.slug, (_, Some(g)) => &g.slug, _ => "", } } /// True iff this manifest declares a `[group]` node (Phase 5). #[must_use] pub fn is_group(&self) -> bool { self.group.is_some() } /// This node's declared extension-point names (§5.5), from whichever of /// `[app]` / `[group]` is present. #[must_use] pub fn extension_points(&self) -> &[String] { match (&self.app, &self.group) { (Some(a), _) => &a.extension_points, (_, Some(g)) => &g.extension_points, _ => &[], } } /// This node's declared shared group collections (§11.6), normalized to /// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]` /// manifest carrying `collections` is a hard parse error (`ManifestApp` has /// no such field + `deny_unknown_fields`). #[must_use] pub fn collections(&self) -> Vec<(String, String)> { match &self.group { Some(g) => g .collections .iter() .map(CollectionDecl::normalized) .collect(), None => Vec::new(), } } /// 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, and vars (overlay vars win per key); scripts/routes/triggers /// stay in the shared base. 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(app) = &mut self.app { if let Some(slug) = overlay.app.slug { app.slug = slug; } if let Some(name) = overlay.app.name { app.name = name; } } for n in overlay.secrets.names { if !self.secrets.names.contains(&n) { self.secrets.names.push(n); } } // Overlay vars override base vars per key (the env-specific value of // an env-agnostic default); keys only in the base are kept. for (k, v) in overlay.vars { self.vars.insert(k, v); } } } /// `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]`, `[secrets]`, /// and `[vars]`. 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, #[serde(default)] pub vars: BTreeMap, } #[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)] #[serde(deny_unknown_fields)] pub struct ManifestApp { pub slug: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// `extension_points = ["theme", …]` (§5.5) — module names this node marks /// as provided/overridable by descendants. Name-only, like `[secrets]`; the /// optional default body is a co-located `[[scripts]]` module of the same /// name. A key of the `[app]` table so TOML can't mis-nest it. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extension_points: Vec, } /// A `[group]` node (Phase 5): a group's own declarative content — its scripts /// and `[vars]`. The group must already exist on the server (created with /// `pic groups create`); the manifest reconciles its content, not the tree /// shape. In a nested project the parent is inferred from the directory tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestGroup { pub slug: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// See [`ManifestApp::extension_points`]. A key of the `[group]` table. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extension_points: Vec, /// `collections = [...]` (§11.6) — shared collections this group offers as /// cross-app-shared (read by any app in the subtree, written by an /// authenticated editor+). Each entry is either a bare string (a `kv` /// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`). /// Group-only: there is no `collections` key on `[app]`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub collections: Vec, } /// A `[project]` block (§7 multi-repo ownership): the identity of the repo-root /// managing these nodes. The `slug` is committed (stable across clones); the /// server assigns the UUID and the first apply registers it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestProject { pub slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// `parent_group` (§7/§6) — the pre-existing group this repo binds UNDER /// (its attach point). Applies are refused for any node not strictly within /// that subtree. Absent = instance root = no ceiling (the default). #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_group: Option, /// §3 M3 per-env approval policy: `[project.environments]` maps an env name /// to its policy. A `confirm = true` env requires an explicit `--approve /// ` on `pic apply --env ` (a blanket `--yes` does NOT cover it — /// §4.2 "CI must opt in per environment"). The CLI gates client-side (fast /// fail) AND sends the policy to the server, which re-derives the gate from /// the request and enforces it (admin-gated on every declared node + audited). /// This closes the "patched/older CLI that still declares the project but /// skips the local check" bypass; it is NOT a hermetic boundary against a /// request that omits the policy entirely (the policy lives in the applier's /// manifest, not persisted server state — full closure would persist it). /// Sent only when non-empty (pre-M3 wire otherwise). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub environments: std::collections::BTreeMap, } /// §3 M3: the apply-gating policy for one environment. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EnvPolicy { /// Require an explicit `--approve ` to apply to this env. NOT /// `#[serde(default)]`: an env listed with an empty policy (`prod = {}`) /// must fail to load (`missing field 'confirm'`) rather than silently /// parse as un-gated — listing an env to protect it, then having the gate /// quietly absent, is the failure mode to avoid. pub confirm: bool, } /// The store kind of a shared collection (§11.6). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CollectionKind { Kv, Docs, Files, /// §11.6 D2: a storeless group-scoped publish namespace, watched by /// `shared = true` group pubsub triggers. Topic, /// §11.6 D3: a group-keyed durable queue with competing per-descendant /// consumers. Queue, } impl CollectionKind { #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Kv => "kv", Self::Docs => "docs", Self::Files => "files", Self::Topic => "topic", Self::Queue => "queue", } } } /// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }` /// table. Untagged so the shipped `collections = ["catalog"]` form keeps working /// alongside `{ name = "articles", kind = "docs" }`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum CollectionDecl { Name(String), Full { name: String, #[serde(default = "kv_kind")] kind: CollectionKind, }, } fn kv_kind() -> CollectionKind { CollectionKind::Kv } impl CollectionDecl { /// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`. #[must_use] pub fn normalized(&self) -> (String, String) { match self { Self::Name(n) => (n.clone(), "kv".to_string()), Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()), } } } #[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, /// §11 tail: `sealed = true` on a `[group]` route template makes it /// non-suppressible — a descendant's `[suppress]` cannot decline it. /// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed. #[serde(default, skip_serializing_if = "is_false")] pub sealed: 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, /// §11 tail: `sealed = true` on a `[group]` template makes it /// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed. #[serde(default, skip_serializing_if = "is_false")] pub sealed: bool, /// §11.6: `shared = true` on a `[group]` template makes it watch the group's /// SHARED collection (not per-app ones); group-only. Omitted ⇒ per-app. #[serde(default, skip_serializing_if = "is_false")] pub shared: bool, } #[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, /// See [`KvTriggerSpec::sealed`]. #[serde(default, skip_serializing_if = "is_false")] pub sealed: bool, /// See [`KvTriggerSpec::shared`]. #[serde(default, skip_serializing_if = "is_false")] pub shared: bool, } #[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, /// See [`KvTriggerSpec::sealed`]. #[serde(default, skip_serializing_if = "is_false")] pub sealed: bool, /// See [`KvTriggerSpec::shared`]. #[serde(default, skip_serializing_if = "is_false")] pub shared: bool, } #[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, /// See [`KvTriggerSpec::sealed`]. #[serde(default, skip_serializing_if = "is_false")] pub sealed: bool, /// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a subtree /// app publishes to the group's declared shared topic, not on per-app /// publishes. Group-only; requires a declared `kind = "topic"` collection. #[serde(default, skip_serializing_if = "is_false")] pub shared: bool, } #[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, /// §11.6 D3: `true` for a shared-QUEUE group consumer over a declared /// `kind = "queue"` collection — competing per-descendant consumers drain /// one group-owned store. Group-only. #[serde(default, skip_serializing_if = "is_false")] pub shared: bool, } /// `[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() } } /// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates. /// `triggers = [...]` names handler SCRIPTS whose inherited triggers this app /// declines; `routes = [...]` names PATHS whose inherited route this app /// declines (404s instead of serving). App-only — a `[group]` carrying /// `[suppress]` is a hard parse error. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestSuppress { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub triggers: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub routes: Vec, } impl ManifestSuppress { #[must_use] pub fn is_empty(&self) -> bool { self.triggers.is_empty() && self.routes.is_empty() } } /// `[[workflows]]` (v1.2 Workflows) — a named DAG. Steps are authored as a /// `[[workflows.steps]]` array of tables. The server nests `steps` under the /// stored `definition` JSONB and validates the graph (acyclic, deps exist). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestWorkflow { pub name: String, /// Three-state lifecycle (§4.3); omitted ⇒ active. #[serde( default = "picloud_shared::default_true", skip_serializing_if = "is_true" )] pub enabled: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub steps: Vec, } /// One step of a `[[workflows]]` DAG. Serializes to the server's /// `WorkflowStepDef` shape (the CLI nests these under `definition.steps`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestWorkflowStep { pub name: String, /// A function step invokes a script by name; exactly one of /// `function`/`workflow` is set (validated server-side). #[serde(default, skip_serializing_if = "Option::is_none")] pub function: Option, /// A workflow step starts a nested sub-workflow by name. #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow: Option, /// Input mapping — an arbitrary TOML table whose string leaves may carry /// `{{ … }}` references, round-tripped to JSON for the wire. #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub depends_on: Vec, /// A `when` JSON-predicate condition (false ⇒ the step is skipped). #[serde(default, skip_serializing_if = "Option::is_none")] pub when: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry: Option, /// `fail` (default) or `continue`. #[serde(default, skip_serializing_if = "Option::is_none")] pub on_error: Option, } /// Per-step retry policy in the manifest (mirrors the server `WorkflowRetry`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManifestWorkflowRetry { pub max_attempts: u32, /// `constant` | `linear` | `exponential` (default). #[serde(default, skip_serializing_if = "Option::is_none")] pub backoff: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_ms: Option, } // ---- 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 } /// Skip-serialize helper: `sealed` defaults false, so only emit it when true. fn is_false(b: &bool) -> bool { !*b } fn default_timezone() -> String { "UTC".to_string() } #[cfg(test)] mod tests { use super::*; fn sample() -> Manifest { Manifest { app: Some(ManifestApp { slug: "blog".into(), name: "My Blog".into(), description: Some("demo".into()), extension_points: vec!["theme".into()], }), group: None, project: None, 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, sealed: false, }], 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), sealed: false, shared: false, }], ..ManifestTriggers::default() }, secrets: ManifestSecrets { names: vec!["STRIPE_KEY".into()], }, vars: BTreeMap::from([ ("region".to_string(), toml::Value::String("eu".into())), ("max-retries".to_string(), toml::Value::Integer(3)), ]), suppress: ManifestSuppress::default(), workflows: Vec::new(), } } #[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); let app = m.app.as_ref().unwrap(); assert_eq!(app.slug, "blog-staging", "overlay slug wins"); assert_eq!(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: Some(ManifestApp { slug: "x".into(), name: "X".into(), description: None, extension_points: vec![], }), group: None, project: None, scripts: vec![], routes: vec![], triggers: ManifestTriggers::default(), secrets: ManifestSecrets::default(), vars: BTreeMap::new(), suppress: ManifestSuppress::default(), workflows: Vec::new(), }; 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}"); assert!(!text.contains("vars"), "got:\n{text}"); assert!(!text.contains("extension_points"), "got:\n{text}"); // Still round-trips. assert_eq!(m, Manifest::parse(&text).unwrap()); } #[test] fn rejects_misplaced_or_unknown_keys() { // §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node // key. Placed at top level (before any table header) TOML reads it as a // root key — `deny_unknown_fields` must reject it loudly rather than // silently drop it (the silent-drop bug that bit in C5). let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n"; assert!( Manifest::parse(misplaced).is_err(), "a top-level extension_points must be rejected, not silently ignored" ); // A typo'd key inside [app] is likewise a hard error, not a drop. let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n"; assert!( Manifest::parse(typo).is_err(), "an unknown key in [app] must be rejected" ); // The correct node-key placement still parses. let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n"; let m = Manifest::parse(ok).expect("node-key extension_points must parse"); assert_eq!(m.extension_points(), ["theme"]); } #[test] fn collections_string_or_table_normalizes_kind() { // §11.6: a bare string is a `kv` collection (the shipped form); a // `{ name, kind }` table sets an explicit kind. Both coexist. let m = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \ { name = \"assets\", kind = \"files\" }]\n", ) .expect("string-or-table collections must parse"); assert_eq!( m.collections(), vec![ ("catalog".to_string(), "kv".to_string()), ("articles".to_string(), "docs".to_string()), ("assets".to_string(), "files".to_string()), ] ); // An app manifest carrying `collections` is rejected (no such field + // deny_unknown_fields). let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n"; assert!( Manifest::parse(app).is_err(), "collections on [app] must be rejected" ); } #[test] fn app_and_group_suppress_parse() { // §11 tail: an [app] declares [suppress] to opt out of inherited // templates — script names (triggers) + paths (routes). let m = Manifest::parse( "[app]\nslug = \"blog\"\nname = \"Blog\"\n\n\ [suppress]\ntriggers = [\"audit\"]\nroutes = [\"/hello\"]\n", ) .expect("app [suppress] must parse"); assert_eq!(m.suppress.triggers, ["audit"]); assert_eq!(m.suppress.routes, ["/hello"]); // §11 tail M1: a [group] MAY suppress — it declines a template it // inherits from a higher ancestor, for its whole subtree. let g = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [suppress]\ntriggers = [\"audit\"]\n", ) .expect("group [suppress] must parse"); assert_eq!(g.suppress.triggers, ["audit"]); // An unknown key inside [suppress] is a hard error (deny_unknown_fields). let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n"; assert!( Manifest::parse(typo).is_err(), "an unknown key in [suppress] must be rejected" ); } #[test] fn sealed_parses_on_group_route_and_trigger_templates() { // §11 tail: a [group] can mark a route/trigger template `sealed = true` // (non-suppressible). The default is unsealed. let m = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\ host_kind = \"any\"\nsealed = true\n\n\ [[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\ [[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n", ) .expect("sealed templates parse"); assert!(m.routes[0].sealed, "route sealed must parse"); assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse"); assert!( !m.triggers.docs[0].sealed, "an omitted sealed defaults to false" ); } #[test] fn group_manifest_parses_and_rejects_app_only_blocks() { // A [group] node: scripts + vars, no [app]. let m = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\ [vars]\nregion = \"eu\"\n", ) .expect("group manifest parses"); assert!(m.is_group()); assert_eq!(m.slug(), "acme"); assert_eq!(m.scripts.len(), 1); // §11 tail: a group MAY carry ROUTE templates (inherited by descendants). let routed = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n", ) .expect("group with a route template parses"); assert!(routed.is_group()); assert_eq!(routed.routes.len(), 1); // §4.5 M5: a group cron template is now ALLOWED (materialized per app). let cron = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n", ) .expect("group cron template parses"); assert_eq!(cron.triggers.cron.len(), 1); // §4.5 M5.5: a group email template is now ALLOWED too — it materializes // per descendant app and resolves its `inbound_secret_ref` against the // GROUP's own secret store at apply (the server rejects it there if the // secret is unset). Parse no longer refuses it. let email = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n", ) .expect("group email template parses"); assert_eq!(email.triggers.email.len(), 1); // Neither / both is rejected. Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]"); Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n") .expect_err("both [app] and [group]"); } #[test] fn project_block_parses_independently_of_node_kind() { // §7: [project] is orthogonal to the app/group XOR — either node kind // may declare the owning project. let a = Manifest::parse( "[project]\nslug = \"platform\"\nname = \"Platform\"\n\n\ [app]\nslug = \"web\"\nname = \"Web\"\n", ) .expect("[app] + [project] parses"); assert_eq!(a.project.as_ref().unwrap().slug, "platform"); assert_eq!( a.project.as_ref().unwrap().name.as_deref(), Some("Platform") ); let g = Manifest::parse( "[project]\nslug = \"platform\"\n\n[group]\nslug = \"acme\"\nname = \"ACME\"\n", ) .expect("[group] + [project] parses"); assert_eq!(g.project.as_ref().unwrap().slug, "platform"); assert!( g.project.as_ref().unwrap().name.is_none(), "name is optional" ); // Absent [project] → None (backward-compatible). assert!(Manifest::parse("[app]\nslug=\"w\"\nname=\"W\"\n") .unwrap() .project .is_none()); // Unknown key inside [project] is rejected (deny_unknown_fields). Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n") .expect_err("unknown [project] key rejected"); // §6/§7 M2: the optional attach-point `parent_group`. let m = Manifest::parse( "[project]\nslug = \"p\"\nparent_group = \"acme\"\n\n\ [group]\nslug = \"team\"\nname = \"T\"\n", ) .expect("parent_group parses"); assert_eq!(m.project.unwrap().parent_group.as_deref(), Some("acme")); } #[test] fn overlay_vars_override_base_per_key() { let mut base = sample(); base.vars .insert("region".into(), toml::Value::String("eu".into())); base.vars .insert("tier".into(), toml::Value::String("base".into())); let overlay: ManifestOverlay = toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap(); base.apply_overlay(overlay); // overlay wins for `region`, base-only `tier` survives, overlay adds `extra`. assert_eq!(base.vars["region"], toml::Value::String("us".into())); assert_eq!(base.vars["tier"], toml::Value::String("base".into())); assert_eq!(base.vars["extra"], toml::Value::Boolean(true)); } }