Skip to main content

pgevolve_core/plan/
plan.rs

1// The module is named `plan` inside the `plan/` parent — the inception is
2// intentional: this is *the* canonical `Plan` definition for the planner.
3#![allow(clippy::module_inception)]
4
5//! [`Plan`] — the canonical in-memory artifact produced by the planner.
6//!
7//! Spec §6.6. A `Plan` is a set of [`TransactionGroup`]s plus the auxiliary
8//! data needed to round-trip to/from the on-disk three-file layout
9//! (`plan.sql` + `intent.toml` + `manifest.toml`, spec §7).
10//!
11//! [`PlanId`] is a 32-byte BLAKE3 hash over a deterministic serialization of
12//! (source catalog, target catalog, pgevolve version, planner ruleset version).
13//! Identical inputs always produce the same id across runs and machines.
14
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17
18use crate::ir::catalog::Catalog;
19use crate::plan::error::PlanError;
20use crate::plan::grouping::TransactionGroup;
21
22/// A `LintAtPlan` finding captured at plan time for apply-time replay.
23///
24/// Persisted in `manifest.toml` under `lint_at_plan_findings`. At apply time,
25/// preflight checks that each recorded finding still has a matching
26/// `[[lint_waiver]]` row, catching the case where a waiver is removed from
27/// `intent.toml` between plan and apply.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct RecordedFinding {
30    /// The lint rule ID that fired, e.g. `"column-position-drift"`.
31    pub rule: String,
32    /// The qualified target the finding pointed at, e.g. `"app.users"`.
33    /// Extracted from the leading `"<qname>: …"` of the finding message.
34    pub target: String,
35    /// Full finding message, used for substring matching against waiver targets.
36    pub message: String,
37}
38
39/// 32-byte plan identity. See module docs and [`PlanId::compute`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct PlanId(pub [u8; 32]);
42
43impl PlanId {
44    /// Deterministic identity hash over the planner's logical inputs.
45    ///
46    /// The hash payload is: a domain-separator string, the pgevolve version,
47    /// the planner ruleset version, and JSON-serialized source and target
48    /// catalogs. `serde_json` field ordering is determined by struct
49    /// declaration order; all map-like containers in `Catalog` use `Vec` or
50    /// `BTreeMap`, so serialization is deterministic — same value, same bytes —
51    /// which is the property `PlanId` requires.
52    ///
53    /// # Errors
54    ///
55    /// Returns `PlanError::Internal` if either catalog cannot be serialized to
56    /// JSON (this would indicate a bug in the `Catalog` type, not a user error).
57    pub fn compute(
58        source: &Catalog,
59        target: &Catalog,
60        pgevolve_version: &str,
61        planner_ruleset_version: u32,
62    ) -> Result<Self, PlanError> {
63        let mut h = blake3::Hasher::new();
64        h.update(b"pgevolve-plan-id-v1\n");
65        h.update(pgevolve_version.as_bytes());
66        h.update(&[0]);
67        h.update(&planner_ruleset_version.to_be_bytes());
68        h.update(&[0]);
69        let source_bytes = serde_json::to_vec(source)
70            .map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
71        let target_bytes = serde_json::to_vec(target)
72            .map_err(|e| PlanError::Internal(format!("Catalog serialization failed: {e}")))?;
73        h.update(&source_bytes);
74        h.update(&[0]);
75        h.update(&target_bytes);
76        Ok(Self(*h.finalize().as_bytes()))
77    }
78
79    /// First 8 bytes hex-encoded (16 chars) — used in human-facing places like
80    /// directive headers and intent/manifest cross-references.
81    pub fn short(&self) -> String {
82        hex::encode(&self.0[..8])
83    }
84
85    /// Full 64-char lowercase hex string.
86    pub fn to_hex(&self) -> String {
87        hex::encode(self.0)
88    }
89
90    /// Parse a full 64-char lowercase hex string.
91    pub fn from_full_hex(s: &str) -> Result<Self, InvalidPlanHash> {
92        let bytes = hex::decode(s).map_err(|_| InvalidPlanHash(s.to_string()))?;
93        let arr: [u8; 32] = bytes
94            .try_into()
95            .map_err(|_| InvalidPlanHash(s.to_string()))?;
96        Ok(Self(arr))
97    }
98}
99
100impl std::fmt::Display for PlanId {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(&self.to_hex())
103    }
104}
105
106/// Error returned by [`PlanId::from_full_hex`] when the input is not a valid
107/// 64-character lowercase hex string.
108#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
109#[error("invalid plan hash: {0}")]
110pub struct InvalidPlanHash(pub String);
111
112/// One `[[step_override]]` row in `intent.toml`.
113///
114/// Non-destructive per-step modifier — the user can suppress an
115/// auto-emitted step (e.g., the REFRESH MATERIALIZED VIEW that follows
116/// every CREATE MATERIALIZED VIEW).
117#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118pub struct StepOverride {
119    /// `StepKind` wire-form tag (`snake_case`): `"refresh_materialized_view"`,
120    /// `"create_view"`, etc.
121    pub kind: String,
122    /// Target qname (matches the step's primary target).
123    pub target: String,
124    /// When true, the executor skips the step entirely.
125    #[serde(default)]
126    pub suppress: bool,
127}
128
129/// One `[[lint_waiver]]` row in `intent.toml`. Acknowledges a `LintAtPlan`
130/// finding so that `pgevolve plan` can proceed despite the detected drift.
131///
132/// Waivers are matched against findings by (`rule`, `target`). The `target`
133/// must appear as a substring of the finding's message (findings always lead
134/// with the qualified table name, e.g. `"app.users: column position drift…"`).
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct LintWaiver {
137    /// The lint rule ID being waived, e.g. `"column-position-drift"`.
138    pub rule: String,
139    /// The qualified target the finding pointed at, e.g. `"app.users"`.
140    pub target: String,
141    /// Free-text reason; surfaces in audit logs.
142    pub reason: String,
143}
144
145/// One destructive intent — a step whose execution requires the user to flip
146/// the `approved` flag in `intent.toml` before the executor will run it.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct DestructiveIntent {
149    /// 1-indexed intent id, unique within a plan.
150    pub id: u32,
151    /// Step number (1-indexed across the whole plan) that this intent gates.
152    pub step: u32,
153    /// Human kind name (e.g., `drop_column`). Same vocabulary as
154    /// [`StepKind`](crate::plan::raw_step::StepKind) serialized.
155    pub kind: String,
156    /// Rendered target (e.g., `app.users.legacy_email`).
157    pub target: String,
158    /// Human-readable reason copied from the diff `Destructiveness`.
159    pub reason: String,
160    /// Whether the user has set `approved = true` in `intent.toml`.
161    ///
162    /// Populated by `read_plan_dir` / `read_intent_toml`. Defaults to `false`
163    /// (every newly written `intent.toml` starts with `approved = false`).
164    #[serde(default)]
165    pub approved: bool,
166}
167
168/// Metadata produced alongside a `Plan` and embedded into `manifest.toml`.
169#[derive(Debug, Clone, PartialEq)]
170pub struct PlanMetadata {
171    /// pgevolve crate version string at plan time.
172    pub pgevolve_version: String,
173    /// Planner ruleset version (from `PlannerPolicy`) at plan time.
174    pub planner_ruleset_version: u32,
175    /// Optional source-tree revision identifier (e.g., `git:abc1234`).
176    pub source_rev: Option<String>,
177    /// Stable identifier for the target database
178    /// (hash of `host/port/dbname/system_identifier`, computed by the apply path).
179    pub target_identity: String,
180    /// Catalog snapshot used as the diff pre-image; the executor uses it for
181    /// drift detection at apply time.
182    pub target_snapshot: Catalog,
183    /// UTC timestamp when the plan was constructed.
184    pub created_at: OffsetDateTime,
185    /// `LintAtPlan` findings present at plan time. Populated by `pgevolve plan`
186    /// whenever drift lints fire. Empty when no `LintAtPlan` findings exist.
187    /// Used by apply-time preflight to detect waiver removal between plan and apply.
188    pub lint_at_plan_findings: Vec<RecordedFinding>,
189}
190
191/// The canonical in-memory representation of a plan.
192#[derive(Debug, Clone, PartialEq)]
193pub struct Plan {
194    /// Deterministic identity hash; see [`PlanId::compute`].
195    pub id: PlanId,
196    /// Steps partitioned into transaction groups; each step's `step_no` and
197    /// `intent_id` are filled in by [`Plan::from_grouped`].
198    pub groups: Vec<TransactionGroup>,
199    /// Destructive intents, one per destructive step, in step order.
200    pub intents: Vec<DestructiveIntent>,
201    /// Lint waivers loaded from `[[lint_waiver]]` rows in `intent.toml`.
202    ///
203    /// When `pgevolve plan` detects unwaived `LintAtPlan` findings, it prints
204    /// an example `[[lint_waiver]]` row to stderr for the user to copy into
205    /// `intent.toml`; the field is omitted from serialized output when empty
206    /// (`skip_serializing_if = "Vec::is_empty"`). The field is populated when
207    /// reading back a plan directory whose `intent.toml` already contains
208    /// `[[lint_waiver]]` rows.
209    pub lint_waivers: Vec<LintWaiver>,
210    /// Step overrides loaded from `[[step_override]]` rows in `intent.toml`.
211    ///
212    /// Each row can suppress a specific auto-emitted step at apply time.
213    /// The executor checks this list before running each step and skips
214    /// (recording as `skipped` in the audit log) any step whose `kind`
215    /// and primary `target` match an override with `suppress = true`.
216    pub step_overrides: Vec<StepOverride>,
217    /// Plan metadata.
218    pub metadata: PlanMetadata,
219    /// Advisory (Warning-severity) lint findings produced at plan time.
220    ///
221    /// These are informational — they never block plan construction — and are
222    /// **not** persisted to disk. Callers (CLI, conformance tests) can inspect
223    /// this field and print or assert on them as needed.
224    ///
225    /// Currently populated by the `pgevolve::api::build_plan` shim from
226    /// [`crate::lint::check_changeset`] (changeset-level rules such as
227    /// `storage-downgrade-not-retroactive` and
228    /// `compression-change-not-retroactive`).
229    pub advisory_findings: Vec<RecordedFinding>,
230}
231
232impl Plan {
233    /// Assemble a `Plan` from a step-grouped output of the rewrite pass.
234    ///
235    /// Walks `groups` in order to:
236    /// 1. Assign 1-indexed `step_no` to every step (continuous across groups).
237    /// 2. Allocate a `DestructiveIntent` (and `intent_id`) for every
238    ///    destructive step, in step order.
239    /// 3. Compute the deterministic `PlanId` over `(source, target, version,
240    ///    ruleset_version)`.
241    ///
242    /// `target_identity` is opaque to the planner — the executor binary
243    /// computes it from `(host, port, dbname, system_identifier)` at apply time.
244    ///
245    /// # Errors
246    ///
247    /// Returns `PlanError::Internal` if the source or target catalog cannot be
248    /// serialized to JSON for hashing (see [`PlanId::compute`]).
249    #[allow(clippy::too_many_arguments)]
250    pub fn from_grouped(
251        mut groups: Vec<TransactionGroup>,
252        source: &Catalog,
253        target: &Catalog,
254        target_identity: String,
255        source_rev: Option<String>,
256        pgevolve_version: &str,
257        planner_ruleset_version: u32,
258    ) -> Result<Self, PlanError> {
259        let mut step_no: u32 = 0;
260        let mut intent_no: u32 = 0;
261        let mut intents: Vec<DestructiveIntent> = Vec::new();
262        for group in &mut groups {
263            for step in &mut group.steps {
264                step_no += 1;
265                step.step_no = step_no;
266                if step.destructive {
267                    intent_no += 1;
268                    step.intent_id = Some(intent_no);
269                    intents.push(DestructiveIntent {
270                        id: intent_no,
271                        step: step_no,
272                        kind: kind_name(step.kind).to_string(),
273                        target: render_targets(&step.targets),
274                        reason: step
275                            .destructive_reason
276                            .clone()
277                            .unwrap_or_else(|| "destructive".to_string()),
278                        approved: false,
279                    });
280                }
281            }
282        }
283        let id = PlanId::compute(source, target, pgevolve_version, planner_ruleset_version)?;
284        let metadata = PlanMetadata {
285            pgevolve_version: pgevolve_version.to_string(),
286            planner_ruleset_version,
287            source_rev,
288            target_identity,
289            target_snapshot: target.clone(),
290            created_at: OffsetDateTime::now_utc(),
291            lint_at_plan_findings: Vec::new(),
292        };
293        Ok(Self {
294            id,
295            groups,
296            intents,
297            lint_waivers: Vec::new(),
298            step_overrides: Vec::new(),
299            metadata,
300            advisory_findings: Vec::new(),
301        })
302    }
303
304    /// Mark every destructive intent as `approved = true`.
305    ///
306    /// Intended for test harnesses that build plans programmatically and
307    /// want to bypass the `intent.toml`-based approval workflow. Production
308    /// apply must continue to require explicit approval in `intent.toml`.
309    pub fn approve_all_intents(&mut self) {
310        for intent in &mut self.intents {
311            intent.approved = true;
312        }
313    }
314}
315
316/// Human-readable kind name used in directive comments and intent rows.
317///
318/// The vocabulary matches [`crate::plan::StepKind`]'s `snake_case` serde encoding; this
319/// `const fn` exists so callers do not pay for a serde round-trip.
320#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
321pub const fn kind_name(k: crate::plan::raw_step::StepKind) -> &'static str {
322    use crate::plan::raw_step::StepKind as K;
323    match k {
324        K::CreateSchema => "create_schema",
325        K::DropSchema => "drop_schema",
326        K::AlterSchemaComment => "alter_schema_comment",
327        K::CreateTable => "create_table",
328        K::DropTable => "drop_table",
329        K::AlterTableSetComment => "alter_table_set_comment",
330        K::AddColumn => "add_column",
331        K::DropColumn => "drop_column",
332        K::AlterColumnType => "alter_column_type",
333        K::SetColumnNullable => "set_column_nullable",
334        K::SetColumnDefault => "set_column_default",
335        K::SetColumnComment => "set_column_comment",
336        K::SetColumnIdentity => "set_column_identity",
337        K::SetColumnGenerated => "set_column_generated",
338        K::SetColumnStorage => "set_column_storage",
339        K::SetColumnCompression => "set_column_compression",
340        K::AddConstraint => "add_constraint",
341        K::AddConstraintNotValid => "add_constraint_not_valid",
342        K::ValidateConstraint => "validate_constraint",
343        K::DropConstraint => "drop_constraint",
344        K::SetConstraintComment => "set_constraint_comment",
345        K::CreateIndex => "create_index",
346        K::CreateIndexConcurrent => "create_index_concurrent",
347        K::DropIndex => "drop_index",
348        K::DropIndexConcurrent => "drop_index_concurrent",
349        K::CreateSequence => "create_sequence",
350        K::DropSequence => "drop_sequence",
351        K::AlterSequence => "alter_sequence",
352        K::AddCheckForNotNull => "add_check_for_not_null",
353        K::CreateView => "create_view",
354        K::DropView => "drop_view",
355        K::CreateMaterializedView => "create_materialized_view",
356        K::DropMaterializedView => "drop_materialized_view",
357        K::RefreshMaterializedView => "refresh_materialized_view",
358        K::AlterViewSetReloption => "alter_view_set_reloption",
359        K::CommentOnView => "comment_on_view",
360        K::CreateType => "create_type",
361        K::DropType => "drop_type",
362        K::AlterTypeAddValue => "alter_type_add_value",
363        K::AlterTypeRenameValue => "alter_type_rename_value",
364        K::AlterDomainAddConstraint => "alter_domain_add_constraint",
365        K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
366        K::AlterDomainSetDefault => "alter_domain_set_default",
367        K::AlterDomainSetNotNull => "alter_domain_set_not_null",
368        K::AlterTypeAddAttribute => "alter_type_add_attribute",
369        K::AlterTypeDropAttribute => "alter_type_drop_attribute",
370        K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
371        K::CommentOnType => "comment_on_type",
372        K::CreateOrReplaceFunction => "create_or_replace_function",
373        K::DropFunction => "drop_function",
374        K::CommentOnFunction => "comment_on_function",
375        K::CreateOrReplaceProcedure => "create_or_replace_procedure",
376        K::DropProcedure => "drop_procedure",
377        K::CommentOnProcedure => "comment_on_procedure",
378        K::CreateExtension => "create_extension",
379        K::DropExtension => "drop_extension",
380        K::AlterExtensionUpdate => "alter_extension_update",
381        K::CommentOnExtension => "comment_on_extension",
382        K::CreateTrigger => "create_trigger",
383        K::DropTrigger => "drop_trigger",
384        K::CommentOnTrigger => "comment_on_trigger",
385        K::AttachPartition => "attach_partition",
386        K::DetachPartition => "detach_partition",
387        K::CreateRole => "create_role",
388        K::DropRole => "drop_role",
389        K::AlterRole => "alter_role",
390        K::GrantRoleMembership => "grant_role_membership",
391        K::RevokeRoleMembership => "revoke_role_membership",
392        K::CommentOnRole => "comment_on_role",
393        K::AlterObjectOwner => "alter_object_owner",
394        K::GrantObjectPrivilege => "grant_object_privilege",
395        K::RevokeObjectPrivilege => "revoke_object_privilege",
396        K::GrantColumnPrivilege => "grant_column_privilege",
397        K::RevokeColumnPrivilege => "revoke_column_privilege",
398        K::AlterDefaultPrivileges => "alter_default_privileges",
399        K::CreatePolicy => "create_policy",
400        K::DropPolicy => "drop_policy",
401        K::AlterPolicy => "alter_policy",
402        K::SetTableRowSecurity => "set_table_row_security",
403        K::SetTableForceRowSecurity => "set_table_force_row_security",
404        K::SetTableStorage => "set_table_storage",
405        K::SetIndexStorage => "set_index_storage",
406        K::SetMaterializedViewStorage => "set_materialized_view_storage",
407        K::CreatePublication => "create_publication",
408        K::DropPublication => "drop_publication",
409        K::ReplacePublication => "replace_publication",
410        K::AlterPublicationAddTable => "alter_publication_add_table",
411        K::AlterPublicationDropTable => "alter_publication_drop_table",
412        K::AlterPublicationSetTable => "alter_publication_set_table",
413        K::AlterPublicationAddSchema => "alter_publication_add_schema",
414        K::AlterPublicationDropSchema => "alter_publication_drop_schema",
415        K::AlterPublicationSetPublish => "alter_publication_set_publish",
416        K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
417        K::CommentOnPublication => "comment_on_publication",
418        K::CreateSubscription => "create_subscription",
419        K::DropSubscription => "drop_subscription",
420        K::AlterSubscriptionConnection => "alter_subscription_connection",
421        K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
422        K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
423        K::AlterSubscriptionSetPublication => "alter_subscription_set_publication",
424        K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
425        K::CommentOnSubscription => "comment_on_subscription",
426    }
427}
428
429/// Parse [`kind_name`]'s output back into [`crate::plan::StepKind`].
430#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
431pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
432    use crate::plan::raw_step::StepKind as K;
433    Some(match s {
434        "create_schema" => K::CreateSchema,
435        "drop_schema" => K::DropSchema,
436        "alter_schema_comment" => K::AlterSchemaComment,
437        "create_table" => K::CreateTable,
438        "drop_table" => K::DropTable,
439        "alter_table_set_comment" => K::AlterTableSetComment,
440        "add_column" => K::AddColumn,
441        "drop_column" => K::DropColumn,
442        "alter_column_type" => K::AlterColumnType,
443        "set_column_nullable" => K::SetColumnNullable,
444        "set_column_default" => K::SetColumnDefault,
445        "set_column_comment" => K::SetColumnComment,
446        "set_column_identity" => K::SetColumnIdentity,
447        "set_column_generated" => K::SetColumnGenerated,
448        "set_column_storage" => K::SetColumnStorage,
449        "set_column_compression" => K::SetColumnCompression,
450        "add_constraint" => K::AddConstraint,
451        "add_constraint_not_valid" => K::AddConstraintNotValid,
452        "validate_constraint" => K::ValidateConstraint,
453        "drop_constraint" => K::DropConstraint,
454        "set_constraint_comment" => K::SetConstraintComment,
455        "create_index" => K::CreateIndex,
456        "create_index_concurrent" => K::CreateIndexConcurrent,
457        "drop_index" => K::DropIndex,
458        "drop_index_concurrent" => K::DropIndexConcurrent,
459        "create_sequence" => K::CreateSequence,
460        "drop_sequence" => K::DropSequence,
461        "alter_sequence" => K::AlterSequence,
462        "add_check_for_not_null" => K::AddCheckForNotNull,
463        "create_view" => K::CreateView,
464        "drop_view" => K::DropView,
465        "create_materialized_view" => K::CreateMaterializedView,
466        "drop_materialized_view" => K::DropMaterializedView,
467        "refresh_materialized_view" => K::RefreshMaterializedView,
468        "alter_view_set_reloption" => K::AlterViewSetReloption,
469        "comment_on_view" => K::CommentOnView,
470        "create_type" => K::CreateType,
471        "drop_type" => K::DropType,
472        "alter_type_add_value" => K::AlterTypeAddValue,
473        "alter_type_rename_value" => K::AlterTypeRenameValue,
474        "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
475        "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
476        "alter_domain_set_default" => K::AlterDomainSetDefault,
477        "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
478        "alter_type_add_attribute" => K::AlterTypeAddAttribute,
479        "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
480        "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
481        "comment_on_type" => K::CommentOnType,
482        "create_or_replace_function" => K::CreateOrReplaceFunction,
483        "drop_function" => K::DropFunction,
484        "comment_on_function" => K::CommentOnFunction,
485        "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
486        "drop_procedure" => K::DropProcedure,
487        "comment_on_procedure" => K::CommentOnProcedure,
488        "create_extension" => K::CreateExtension,
489        "drop_extension" => K::DropExtension,
490        "alter_extension_update" => K::AlterExtensionUpdate,
491        "comment_on_extension" => K::CommentOnExtension,
492        "create_trigger" => K::CreateTrigger,
493        "drop_trigger" => K::DropTrigger,
494        "comment_on_trigger" => K::CommentOnTrigger,
495        "attach_partition" => K::AttachPartition,
496        "detach_partition" => K::DetachPartition,
497        "create_role" => K::CreateRole,
498        "drop_role" => K::DropRole,
499        "alter_role" => K::AlterRole,
500        "grant_role_membership" => K::GrantRoleMembership,
501        "revoke_role_membership" => K::RevokeRoleMembership,
502        "comment_on_role" => K::CommentOnRole,
503        "alter_object_owner" => K::AlterObjectOwner,
504        "grant_object_privilege" => K::GrantObjectPrivilege,
505        "revoke_object_privilege" => K::RevokeObjectPrivilege,
506        "grant_column_privilege" => K::GrantColumnPrivilege,
507        "revoke_column_privilege" => K::RevokeColumnPrivilege,
508        "alter_default_privileges" => K::AlterDefaultPrivileges,
509        "create_policy" => K::CreatePolicy,
510        "drop_policy" => K::DropPolicy,
511        "alter_policy" => K::AlterPolicy,
512        "set_table_row_security" => K::SetTableRowSecurity,
513        "set_table_force_row_security" => K::SetTableForceRowSecurity,
514        "set_table_storage" => K::SetTableStorage,
515        "set_index_storage" => K::SetIndexStorage,
516        "set_materialized_view_storage" => K::SetMaterializedViewStorage,
517        "create_publication" => K::CreatePublication,
518        "drop_publication" => K::DropPublication,
519        "replace_publication" => K::ReplacePublication,
520        "alter_publication_add_table" => K::AlterPublicationAddTable,
521        "alter_publication_drop_table" => K::AlterPublicationDropTable,
522        "alter_publication_set_table" => K::AlterPublicationSetTable,
523        "alter_publication_add_schema" => K::AlterPublicationAddSchema,
524        "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
525        "alter_publication_set_publish" => K::AlterPublicationSetPublish,
526        "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
527        "comment_on_publication" => K::CommentOnPublication,
528        "create_subscription" => K::CreateSubscription,
529        "drop_subscription" => K::DropSubscription,
530        "alter_subscription_connection" => K::AlterSubscriptionConnection,
531        "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
532        "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
533        "alter_subscription_set_publication" => K::AlterSubscriptionSetPublication,
534        "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
535        "comment_on_subscription" => K::CommentOnSubscription,
536        _ => return None,
537    })
538}
539
540/// Render a step's `targets` list as a comma-separated string of qnames.
541fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
542    let mut s = String::new();
543    for (i, t) in targets.iter().enumerate() {
544        if i > 0 {
545            s.push(',');
546        }
547        s.push_str(&t.render_sql());
548    }
549    s
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::identifier::Identifier;
556    use crate::ir::schema::Schema;
557
558    fn id_id(s: &str) -> Identifier {
559        Identifier::from_unquoted(s).unwrap()
560    }
561
562    fn cat_with_schema(name: &str) -> Catalog {
563        let mut c = Catalog::empty();
564        c.schemas.push(Schema::new(id_id(name)));
565        c
566    }
567
568    #[test]
569    fn plan_id_is_deterministic_across_calls() {
570        let s = cat_with_schema("app");
571        let t = Catalog::empty();
572        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
573        let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
574        assert_eq!(a, b);
575    }
576
577    #[test]
578    fn plan_id_differs_when_target_differs() {
579        let s = cat_with_schema("app");
580        let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
581        let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
582        assert_ne!(a, b);
583    }
584
585    #[test]
586    fn plan_id_differs_when_version_differs() {
587        let s = cat_with_schema("app");
588        let t = Catalog::empty();
589        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
590        let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
591        assert_ne!(a, b);
592    }
593
594    #[test]
595    fn plan_id_differs_when_ruleset_differs() {
596        let s = cat_with_schema("app");
597        let t = Catalog::empty();
598        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
599        let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
600        assert_ne!(a, b);
601    }
602
603    #[test]
604    fn plan_id_short_is_sixteen_hex_chars() {
605        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
606        let short = id.short();
607        assert_eq!(short.len(), 16);
608        assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
609    }
610
611    #[test]
612    fn plan_id_full_hex_round_trips() {
613        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
614        let hex = id.to_hex();
615        assert_eq!(hex.len(), 64);
616        let back = PlanId::from_full_hex(&hex).unwrap();
617        assert_eq!(id, back);
618    }
619
620    // ---- Plan::from_grouped (Task 7.2) ----
621
622    use crate::identifier::QualifiedName;
623    use crate::plan::grouping::TransactionGroup;
624    use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
625
626    fn qn(schema: &str, name: &str) -> QualifiedName {
627        QualifiedName::new(id_id(schema), id_id(name))
628    }
629
630    fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
631        RawStep {
632            step_no: 0,
633            kind,
634            destructive,
635            destructive_reason: destructive.then(|| "test".to_string()),
636            intent_id: None,
637            targets,
638            sql: String::new(),
639            transactional: TransactionConstraint::InTransaction,
640        }
641    }
642
643    fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
644        TransactionGroup {
645            id,
646            transactional: true,
647            steps,
648        }
649    }
650
651    #[test]
652    fn from_grouped_assigns_step_numbers_contiguously() {
653        let groups = vec![
654            group(
655                1,
656                vec![
657                    step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
658                    step(StepKind::CreateTable, false, vec![qn("app", "users")]),
659                ],
660            ),
661            group(
662                2,
663                vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
664            ),
665        ];
666        let plan = Plan::from_grouped(
667            groups,
668            &Catalog::empty(),
669            &Catalog::empty(),
670            "tid".into(),
671            None,
672            "0.1.0",
673            1,
674        )
675        .unwrap();
676        let nos: Vec<u32> = plan
677            .groups
678            .iter()
679            .flat_map(|g| g.steps.iter().map(|s| s.step_no))
680            .collect();
681        assert_eq!(nos, vec![1, 2, 3]);
682    }
683
684    #[test]
685    fn from_grouped_allocates_one_intent_per_destructive_step() {
686        let groups = vec![group(
687            1,
688            vec![
689                step(StepKind::CreateTable, false, vec![qn("app", "x")]),
690                step(StepKind::DropColumn, true, vec![qn("app", "x")]),
691                step(StepKind::DropTable, true, vec![qn("app", "y")]),
692            ],
693        )];
694        let plan = Plan::from_grouped(
695            groups,
696            &Catalog::empty(),
697            &Catalog::empty(),
698            "tid".into(),
699            None,
700            "0.1.0",
701            1,
702        )
703        .unwrap();
704        assert_eq!(plan.intents.len(), 2);
705        assert_eq!(plan.intents[0].id, 1);
706        assert_eq!(plan.intents[0].step, 2);
707        assert_eq!(plan.intents[0].kind, "drop_column");
708        assert_eq!(plan.intents[1].id, 2);
709        assert_eq!(plan.intents[1].step, 3);
710        assert_eq!(plan.intents[1].kind, "drop_table");
711        // The destructive steps carry back their intent ids.
712        let intent_ids: Vec<Option<u32>> = plan
713            .groups
714            .iter()
715            .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
716            .collect();
717        assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
718    }
719
720    #[test]
721    fn from_grouped_metadata_captures_target_snapshot() {
722        let target = cat_with_schema("legacy");
723        let plan = Plan::from_grouped(
724            Vec::new(),
725            &Catalog::empty(),
726            &target,
727            "tid".into(),
728            Some("git:abc".into()),
729            "0.1.0",
730            1,
731        )
732        .unwrap();
733        assert_eq!(plan.metadata.target_snapshot, target);
734        assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
735        assert_eq!(plan.metadata.target_identity, "tid");
736    }
737
738    #[test]
739    fn kind_name_round_trips_via_parse() {
740        for k in [
741            StepKind::CreateSchema,
742            StepKind::DropColumn,
743            StepKind::CreateIndexConcurrent,
744            StepKind::AddCheckForNotNull,
745        ] {
746            assert_eq!(parse_kind_name(kind_name(k)), Some(k));
747        }
748    }
749
750    #[test]
751    fn user_type_step_kinds_round_trip_through_kind_name() {
752        for k in [
753            StepKind::CreateType,
754            StepKind::DropType,
755            StepKind::AlterTypeAddValue,
756            StepKind::AlterTypeRenameValue,
757            StepKind::AlterDomainAddConstraint,
758            StepKind::AlterDomainDropConstraint,
759            StepKind::AlterDomainSetDefault,
760            StepKind::AlterDomainSetNotNull,
761            StepKind::AlterTypeAddAttribute,
762            StepKind::AlterTypeDropAttribute,
763            StepKind::AlterTypeAlterAttributeType,
764            StepKind::CommentOnType,
765        ] {
766            let n = kind_name(k);
767            let parsed = parse_kind_name(n).unwrap();
768            assert_eq!(parsed, k, "round-trip failed for {n}");
769        }
770    }
771
772    #[test]
773    fn routine_step_kinds_round_trip_through_kind_name() {
774        for k in [
775            StepKind::CreateOrReplaceFunction,
776            StepKind::DropFunction,
777            StepKind::CommentOnFunction,
778            StepKind::CreateOrReplaceProcedure,
779            StepKind::DropProcedure,
780            StepKind::CommentOnProcedure,
781        ] {
782            let n = kind_name(k);
783            let parsed = parse_kind_name(n).unwrap();
784            assert_eq!(parsed, k, "round-trip failed for {n}");
785        }
786    }
787
788    #[test]
789    fn plan_id_from_invalid_hex_errors() {
790        assert!(PlanId::from_full_hex("not-hex").is_err());
791        assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); // wrong length
792    }
793
794    // ---- StepOverride round-trip (Task 9) ----
795
796    #[test]
797    fn step_override_round_trips() {
798        let override_ = StepOverride {
799            kind: "refresh_materialized_view".to_string(),
800            target: "app.daily_revenue".to_string(),
801            suppress: true,
802        };
803        // Serialize a single StepOverride as TOML and confirm it parses back equal.
804        let toml_text = toml::to_string(&override_).unwrap();
805        let back: StepOverride = toml::from_str(&toml_text).unwrap();
806        assert_eq!(back, override_);
807    }
808
809    #[test]
810    fn step_override_suppress_defaults_to_false() {
811        let toml_text = r#"kind = "refresh_materialized_view"
812target = "app.daily_revenue"
813"#;
814        let back: StepOverride = toml::from_str(toml_text).unwrap();
815        assert!(!back.suppress);
816    }
817
818    #[test]
819    fn step_override_round_trips_inside_intent_doc() {
820        #[derive(serde::Deserialize)]
821        #[allow(dead_code)]
822        struct Doc {
823            plan_id: String,
824            #[serde(default, rename = "step_override")]
825            step_overrides: Vec<StepOverride>,
826        }
827
828        let toml_text = r#"
829plan_id = "abc1234567890abc"
830
831[[step_override]]
832kind = "refresh_materialized_view"
833target = "app.daily_revenue"
834suppress = true
835"#;
836        let doc: Doc = toml::from_str(toml_text).unwrap();
837        assert_eq!(doc.step_overrides.len(), 1);
838        assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
839        assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
840        assert!(doc.step_overrides[0].suppress);
841    }
842
843    // ---- LintWaiver round-trip (Task 8) ----
844
845    #[test]
846    fn lint_waiver_round_trips() {
847        let waiver = LintWaiver {
848            rule: "column-position-drift".to_string(),
849            target: "app.users".to_string(),
850            reason: "applied via rewrite-table; see PR #234".to_string(),
851        };
852
853        // Serialize a single waiver as TOML and confirm it parses back equal.
854        let toml_text = toml::to_string(&waiver).unwrap();
855        let back: LintWaiver = toml::from_str(&toml_text).unwrap();
856        assert_eq!(back, waiver);
857    }
858
859    #[test]
860    fn lint_waiver_round_trips_inside_intent_doc() {
861        // The deserializer must accept the full intent.toml shape (including
862        // [[intent]] rows) alongside [[lint_waiver]] rows. We use local structs
863        // that mirror the real IntentDocDe shape. Declared before any `let`
864        // statements to satisfy the `items_after_statements` lint.
865        #[derive(serde::Deserialize)]
866        #[allow(dead_code)]
867        struct IntentRow {
868            id: u32,
869            step: u32,
870            kind: String,
871            target: String,
872            reason: String,
873            #[serde(default)]
874            approved: bool,
875        }
876        #[derive(serde::Deserialize)]
877        #[allow(dead_code)]
878        struct Doc {
879            plan_id: String,
880            #[serde(default, rename = "intent")]
881            intents: Vec<IntentRow>,
882            #[serde(default, rename = "lint_waiver")]
883            lint_waivers: Vec<LintWaiver>,
884        }
885
886        // Simulate the shape that intent.toml produces: a document with a
887        // `plan_id` key and one or more `[[lint_waiver]]` array rows.
888        let toml_text = r#"
889plan_id = "abc1234567890abc"
890
891[[intent]]
892id = 1
893step = 2
894kind = "drop_table"
895target = "app.legacy"
896reason = "drop old table"
897approved = false
898
899[[lint_waiver]]
900rule = "column-position-drift"
901target = "app.users"
902reason = "rewrite-table applied; PR #234"
903"#;
904        let doc: Doc = toml::from_str(toml_text).unwrap();
905        assert_eq!(doc.lint_waivers.len(), 1);
906        assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
907        assert_eq!(doc.lint_waivers[0].target, "app.users");
908    }
909
910    #[test]
911    fn approve_all_intents_flips_every_intent_to_approved() {
912        let mut plan = sample_plan_with_two_unapproved_intents();
913        assert!(!plan.intents[0].approved);
914        assert!(!plan.intents[1].approved);
915        plan.approve_all_intents();
916        assert!(plan.intents[0].approved);
917        assert!(plan.intents[1].approved);
918    }
919
920    fn sample_plan_with_two_unapproved_intents() -> Plan {
921        Plan {
922            id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
923            groups: Vec::new(),
924            intents: vec![
925                DestructiveIntent {
926                    id: 1,
927                    step: 1,
928                    kind: "drop_column".into(),
929                    target: "app.users.legacy_email".into(),
930                    reason: "test".into(),
931                    approved: false,
932                },
933                DestructiveIntent {
934                    id: 2,
935                    step: 2,
936                    kind: "drop_table".into(),
937                    target: "app.old_users".into(),
938                    reason: "test".into(),
939                    approved: false,
940                },
941            ],
942            lint_waivers: Vec::new(),
943            step_overrides: Vec::new(),
944            metadata: PlanMetadata {
945                pgevolve_version: crate::VERSION.to_string(),
946                planner_ruleset_version: 1,
947                source_rev: None,
948                target_identity: "test-identity".into(),
949                target_snapshot: Catalog::empty(),
950                created_at: OffsetDateTime::UNIX_EPOCH,
951                lint_at_plan_findings: Vec::new(),
952            },
953            advisory_findings: Vec::new(),
954        }
955    }
956}