feat(suppress): author + persist per-app template suppressions (§11 tail S2)

The declarative half — a `[suppress]` block persists as markers; no runtime
effect yet (S3 consumes them).

- manifest: `[suppress]` table on an app with `triggers = [...]` (handler
  script names) + `routes = [...]` (paths), `deny_unknown_fields`; rejected
  on a `[group]` (a group just wouldn't declare the template).
- suppression_repo.rs: app-keyed `list_for_app` / `insert` / `delete_tx`
  over `(app_id, target_kind, reference)`, mirroring extension_point_repo
  minus the owner polymorphism.
- apply_service: the extension-point marker-reconcile pattern —
  `Bundle.suppress_triggers/_routes`, `Plan.suppressions`,
  `CurrentState.suppressions`, `load_current(App)` load, `diff_suppressions`
  (key `"{kind}:{reference}"`, split on the first `:` so a route param path
  survives), create + prune reconcile blocks, `validate_bundle_for` group
  reject, `ApplyReport` counters.
- CLI: `build_bundle` carries the two vecs; `pic plan` + apply report gain a
  suppressions row (DTOs + display).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 07:32:26 +02:00
parent 18ac9f5afa
commit 32cb6c1f1f
9 changed files with 302 additions and 2 deletions

View File

@@ -94,6 +94,14 @@ pub struct Bundle {
/// `[group]` nodes only (the CLI rejects it on `[app]`). /// `[group]` nodes only (the CLI rejects it on `[app]`).
#[serde(default)] #[serde(default)]
pub collections: Vec<CollectionSpec>, pub collections: Vec<CollectionSpec>,
/// §11 tail per-app opt-out: handler SCRIPT names whose inherited group
/// triggers this app declines. App-only (rejected on a group node).
#[serde(default)]
pub suppress_triggers: Vec<String>,
/// §11 tail per-app opt-out: PATHS whose inherited group route this app
/// declines (the binding 404s instead of serving). App-only.
#[serde(default)]
pub suppress_routes: Vec<String>,
} }
/// One declared shared-collection marker on the wire: a name + its store kind. /// One declared shared-collection marker on the wire: a name + its store kind.
@@ -346,6 +354,9 @@ pub struct Plan {
pub extension_points: Vec<ResourceChange>, pub extension_points: Vec<ResourceChange>,
#[serde(default)] #[serde(default)]
pub collections: Vec<ResourceChange>, pub collections: Vec<ResourceChange>,
/// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`.
#[serde(default)]
pub suppressions: Vec<ResourceChange>,
} }
impl Plan { impl Plan {
@@ -441,6 +452,9 @@ pub struct CurrentState {
/// Shared group collections declared directly at this node (§11.6), as /// Shared group collections declared directly at this node (§11.6), as
/// `(name, kind)` pairs. /// `(name, kind)` pairs.
pub collections: Vec<(String, String)>, pub collections: Vec<(String, String)>,
/// §11 tail per-app suppression markers at this node (app-only), as
/// `(target_kind, reference)` pairs.
pub suppressions: Vec<(String, String)>,
} }
/// One row of the read-only extension-point report (§5.5). /// One row of the read-only extension-point report (§5.5).
@@ -681,6 +695,20 @@ impl ApplyService {
.into(), .into(),
)); ));
} }
// §11 tail: only an APP opts out of inherited templates. A group that
// doesn't want a template simply doesn't declare it (the CLI already
// rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire
// Bundle — the reconcile's `owner.app_id().expect(...)` would otherwise
// panic on a group).
if matches!(owner, ApplyOwner::Group(_))
&& (!bundle.suppress_triggers.is_empty() || !bundle.suppress_routes.is_empty())
{
return Err(ApplyError::Invalid(
"a group cannot declare suppressions — only an app opts out of \
inherited templates"
.into(),
));
}
self.validate_bundle(bundle, inherited_endpoints) self.validate_bundle(bundle, inherited_endpoints)
} }
@@ -961,6 +989,22 @@ impl ApplyService {
} }
} }
// 3e. Per-app suppression markers (§11 tail) — insert each Create
// (idempotent). App-only; deletes happen in prune → the template
// re-inherits. Key is `"{target_kind}:{reference}"`; split on the FIRST
// `:` so a route reference containing `:` (a param path `/users/:id`)
// survives.
for ch in &plan.suppressions {
if ch.op == Op::Create {
let app_id = owner.app_id().expect("suppressions are app-only");
let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::insert_suppression_tx(&mut *tx, app_id, kind, reference)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.suppressions_created += 1;
}
}
// 4. Prune (only with --prune): delete stale triggers, then scripts // 4. Prune (only with --prune): delete stale triggers, then scripts
// (route removals already happened in the route delete-pass above). // (route removals already happened in the route delete-pass above).
// Secret pruning is deliberately deferred (destructive + irreversible): // Secret pruning is deliberately deferred (destructive + irreversible):
@@ -1068,6 +1112,22 @@ impl ApplyService {
report.collections_deleted += 1; report.collections_deleted += 1;
} }
} }
// Per-app suppression markers are prunable config too (§11 tail).
// Removing a marker re-inherits the template.
for ch in &plan.suppressions {
if ch.op == Op::Delete {
let app_id = owner.app_id().expect("suppressions are app-only");
let (kind, reference) =
ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::delete_suppression_tx(
&mut *tx, app_id, kind, reference,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.suppressions_deleted += 1;
}
}
} }
Ok(name_to_id) Ok(name_to_id)
} }
@@ -2290,6 +2350,14 @@ impl ApplyService {
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner()) crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
// §11 tail per-app suppression markers — app-only (a group never
// suppresses; it just wouldn't declare the template).
let suppressions = match owner {
ApplyOwner::App(app_id) => crate::suppression_repo::list_for_app(&self.pool, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
ApplyOwner::Group(_) => Vec::new(),
};
Ok(CurrentState { Ok(CurrentState {
scripts, scripts,
routes, routes,
@@ -2298,6 +2366,7 @@ impl ApplyService {
vars, vars,
extension_point_names, extension_point_names,
collections, collections,
suppressions,
}) })
} }
@@ -2363,6 +2432,7 @@ fn compute_diff_with_names(
vars: diff_vars(current, bundle), vars: diff_vars(current, bundle),
extension_points: diff_extension_points(current, bundle), extension_points: diff_extension_points(current, bundle),
collections: diff_collections(current, bundle), collections: diff_collections(current, bundle),
suppressions: diff_suppressions(current, bundle),
} }
} }
@@ -2920,6 +2990,55 @@ fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChan
out out
} }
/// §11 tail per-app suppression diff. Reconciles the app's declared
/// `suppress_triggers` (handler script names) + `suppress_routes` (paths) into
/// `template_suppressions` markers. Keyed `"{target_kind}:{reference}"` so a
/// trigger ref and a route ref of the same string are distinct markers; the
/// reconcile splits the key back on the first `:`.
fn diff_suppressions(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
// Desired (kind, reference) pairs from the two manifest lists.
let desired: Vec<(&str, &str)> = bundle
.suppress_triggers
.iter()
.map(|r| (crate::suppression_repo::TARGET_TRIGGER, r.as_str()))
.chain(
bundle
.suppress_routes
.iter()
.map(|r| (crate::suppression_repo::TARGET_ROUTE, r.as_str())),
)
.collect();
let live: HashSet<(&str, &str)> = current
.suppressions
.iter()
.map(|(k, r)| (k.as_str(), r.as_str()))
.collect();
let declared: HashSet<(&str, &str)> = desired.iter().copied().collect();
let mut out = Vec::new();
for (kind, reference) in &desired {
out.push(ResourceChange {
op: if live.contains(&(*kind, *reference)) {
Op::NoOp
} else {
Op::Create
},
key: format!("{kind}:{reference}"),
detail: Some((*kind).to_string()),
});
}
for (kind, reference) in &current.suppressions {
if !declared.contains(&(kind.as_str(), reference.as_str())) {
out.push(ResourceChange {
op: Op::Delete,
key: format!("{kind}:{reference}"),
detail: Some(kind.clone()),
});
}
}
out
}
/// Reject any email trigger whose referenced secret isn't set, so `plan` and /// Reject any email trigger whose referenced secret isn't set, so `plan` and
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but /// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest). /// only after taking the lock — surfacing it here keeps plan honest).
@@ -3214,6 +3333,10 @@ pub struct ApplyReport {
pub collections_created: u32, pub collections_created: u32,
#[serde(default)] #[serde(default)]
pub collections_deleted: u32, pub collections_deleted: u32,
#[serde(default)]
pub suppressions_created: u32,
#[serde(default)]
pub suppressions_deleted: u32,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -3531,6 +3654,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1); assert_eq!(plan.scripts.len(), 1);
@@ -3555,6 +3680,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -3574,6 +3701,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update); assert_eq!(plan.scripts[0].op, Op::Update);
@@ -3594,6 +3723,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete); assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -3643,6 +3774,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update); assert_eq!(plan.routes[0].op, Op::Update);
@@ -3657,6 +3790,8 @@ mod tests {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(), extension_points: Vec::new(),
collections: Vec::new(), collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
} }
} }

View File

@@ -88,6 +88,7 @@ pub mod secrets_api;
pub mod secrets_repo; pub mod secrets_repo;
pub mod secrets_service; pub mod secrets_service;
pub mod ssrf; pub mod ssrf;
pub mod suppression_repo;
pub mod topic_repo; pub mod topic_repo;
pub mod topics_api; pub mod topics_api;
pub mod trigger_config; pub mod trigger_config;

View File

@@ -0,0 +1,75 @@
//! Template-suppression markers (§11 tail) — the `template_suppressions` table
//! (0058). A marker `(app_id, target_kind, reference)` records that an app opts
//! OUT of an inherited group template: a handler script name it declines
//! (`target_kind='trigger'`) or a path it declines (`target_kind='route'`).
//!
//! App-only (a group would just not declare the template), so — unlike the
//! owner-polymorphic `extension_points` — these free functions take a plain
//! `AppId`. Same tx-function style: read over `&PgPool`, write over a
//! `&mut Transaction`.
use picloud_shared::AppId;
use sqlx::{PgPool, Postgres, Transaction};
/// The two suppressible template kinds, as stored in `target_kind`.
pub const TARGET_TRIGGER: &str = "trigger";
pub const TARGET_ROUTE: &str = "route";
/// List the suppression markers declared at `app_id`, as `(target_kind,
/// reference)` pairs, stably ordered. Backs `load_current` (the apply diff) and
/// the read-only `suppress ls`.
pub async fn list_for_app(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT target_kind, reference FROM template_suppressions \
WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)",
)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Insert a suppression marker in the apply transaction. Idempotent: re-apply
/// of an already-declared `(kind, reference)` is a no-op (`ON CONFLICT DO
/// NOTHING`), so the marker survives without a spurious version bump.
pub async fn insert_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
app_id: AppId,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO template_suppressions (app_id, target_kind, reference) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, target_kind, reference) DO NOTHING",
)
.bind(app_id.into_inner())
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a suppression marker in the apply transaction. Used by `--prune` when
/// the manifest stops declaring the reference → the template re-inherits.
pub async fn delete_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
app_id: AppId,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"DELETE FROM template_suppressions \
WHERE app_id = $1 AND target_kind = $2 AND reference = $3",
)
.bind(app_id.into_inner())
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -1429,6 +1429,8 @@ pub struct PlanDto {
pub extension_points: Vec<ChangeDto>, pub extension_points: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub collections: Vec<ChangeDto>, pub collections: Vec<ChangeDto>,
#[serde(default)]
pub suppressions: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried /// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check. /// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)] #[serde(default)]
@@ -1471,6 +1473,8 @@ pub struct NodePlanDto {
pub extension_points: Vec<ChangeDto>, pub extension_points: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub collections: Vec<ChangeDto>, pub collections: Vec<ChangeDto>,
#[serde(default)]
pub suppressions: Vec<ChangeDto>,
} }
/// Response of `POST .../apply`: counts of what changed. /// Response of `POST .../apply`: counts of what changed.
@@ -1507,6 +1511,10 @@ pub struct ApplyReportDto {
#[serde(default)] #[serde(default)]
pub collections_deleted: u32, pub collections_deleted: u32,
#[serde(default)] #[serde(default)]
pub suppressions_created: u32,
#[serde(default)]
pub suppressions_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }

View File

@@ -98,6 +98,13 @@ pub async fn run(
"+{} -{}", "+{} -{}",
report.collections_created, report.collections_deleted report.collections_created, report.collections_deleted
), ),
)
.field(
"suppressions",
format!(
"+{} -{}",
report.suppressions_created, report.suppressions_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());
@@ -179,6 +186,13 @@ pub async fn run_tree(
"+{} -{}", "+{} -{}",
report.collections_created, report.collections_deleted report.collections_created, report.collections_deleted
), ),
)
.field(
"suppressions",
format!(
"+{} -{}",
report.suppressions_created, report.suppressions_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());

View File

@@ -140,6 +140,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
triggers: crate::manifest::ManifestTriggers::default(), triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(), secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
suppress: crate::manifest::ManifestSuppress::default(),
} }
} }

View File

@@ -64,7 +64,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes { for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug); let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 7] = [ let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &n.scripts), ("script", &n.scripts),
("route", &n.routes), ("route", &n.routes),
("trigger", &n.triggers), ("trigger", &n.triggers),
@@ -72,6 +72,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
("var", &n.vars), ("var", &n.vars),
("extension_point", &n.extension_points), ("extension_point", &n.extension_points),
("collection", &n.collections), ("collection", &n.collections),
("suppression", &n.suppressions),
]; ];
for (rk, changes) in groups { for (rk, changes) in groups {
for c in changes { for c in changes {
@@ -168,6 +169,8 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
.into_iter() .into_iter()
.map(|(name, kind)| json!({ "name": name, "kind": kind })) .map(|(name, kind)| json!({ "name": name, "kind": kind }))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
"suppress_triggers": manifest.suppress.triggers,
"suppress_routes": manifest.suppress.routes,
})) }))
} }
@@ -182,7 +185,7 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) { fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]); let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 7] = [ let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &plan.scripts), ("script", &plan.scripts),
("route", &plan.routes), ("route", &plan.routes),
("trigger", &plan.triggers), ("trigger", &plan.triggers),
@@ -190,6 +193,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
("var", &plan.vars), ("var", &plan.vars),
("extension_point", &plan.extension_points), ("extension_point", &plan.extension_points),
("collection", &plan.collections), ("collection", &plan.collections),
("suppression", &plan.suppressions),
]; ];
for (kind, changes) in groups { for (kind, changes) in groups {
for c in changes { for c in changes {

View File

@@ -234,6 +234,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
names: secrets.iter().map(|s| s.name.clone()).collect(), names: secrets.iter().map(|s| s.name.clone()).collect(),
}, },
vars: manifest_vars, vars: manifest_vars,
suppress: crate::manifest::ManifestSuppress::default(),
}; };
std::fs::write(&manifest_path, manifest.to_toml()?) std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -50,6 +50,10 @@ pub struct Manifest {
/// The overlay merges per-env, last-write-wins by key. /// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>, pub vars: BTreeMap<String, toml::Value>,
/// `[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,
} }
impl Manifest { impl Manifest {
@@ -73,6 +77,12 @@ impl Manifest {
// (cron/queue/email) stay app-only — they need per-app state/secrets → // (cron/queue/email) stay app-only — they need per-app state/secrets →
// materialization. // materialization.
if m.group.is_some() { if m.group.is_some() {
if !m.suppress.is_empty() {
anyhow::bail!(
"a [group] cannot declare [suppress] — only an app opts out of \
inherited templates; a group simply doesn't declare the template"
);
}
let t = &m.triggers; let t = &m.triggers;
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() { if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
anyhow::bail!( anyhow::bail!(
@@ -492,6 +502,27 @@ impl ManifestSecrets {
} }
} }
/// `[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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<String>,
}
impl ManifestSuppress {
#[must_use]
pub fn is_empty(&self) -> bool {
self.triggers.is_empty() && self.routes.is_empty()
}
}
// ---- serde skip/default helpers ---- // ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool { fn is_endpoint(kind: &ScriptKind) -> bool {
@@ -584,6 +615,7 @@ mod tests {
("region".to_string(), toml::Value::String("eu".into())), ("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)), ("max-retries".to_string(), toml::Value::Integer(3)),
]), ]),
suppress: ManifestSuppress::default(),
} }
} }
@@ -688,6 +720,7 @@ mod tests {
triggers: ManifestTriggers::default(), triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(), secrets: ManifestSecrets::default(),
vars: BTreeMap::new(), vars: BTreeMap::new(),
suppress: ManifestSuppress::default(),
}; };
let text = m.to_toml().unwrap(); let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("[[scripts]]"), "got:\n{text}");
@@ -749,6 +782,34 @@ mod tests {
); );
} }
#[test]
fn app_suppress_parses_group_suppress_rejected() {
// §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"]);
// A [group] cannot suppress — it simply wouldn't declare the template.
let err = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[suppress]\ntriggers = [\"audit\"]\n",
)
.expect_err("group [suppress] is rejected");
assert!(err.to_string().contains("suppress"), "got: {err}");
// 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] #[test]
fn group_manifest_parses_and_rejects_app_only_blocks() { fn group_manifest_parses_and_rejects_app_only_blocks() {
// A [group] node: scripts + vars, no [app]. // A [group] node: scripts + vars, no [app].