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::AlterViewSetCheckOption => "alter_view_set_check_option",
356        K::CreateMaterializedView => "create_materialized_view",
357        K::DropMaterializedView => "drop_materialized_view",
358        K::RefreshMaterializedView => "refresh_materialized_view",
359        K::AlterViewSetReloption => "alter_view_set_reloption",
360        K::CommentOnView => "comment_on_view",
361        K::CreateType => "create_type",
362        K::DropType => "drop_type",
363        K::AlterTypeAddValue => "alter_type_add_value",
364        K::AlterTypeRenameValue => "alter_type_rename_value",
365        K::AlterDomainAddConstraint => "alter_domain_add_constraint",
366        K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
367        K::AlterDomainSetDefault => "alter_domain_set_default",
368        K::AlterDomainSetNotNull => "alter_domain_set_not_null",
369        K::AlterTypeAddAttribute => "alter_type_add_attribute",
370        K::AlterTypeDropAttribute => "alter_type_drop_attribute",
371        K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
372        K::CommentOnType => "comment_on_type",
373        K::CreateOrReplaceFunction => "create_or_replace_function",
374        K::DropFunction => "drop_function",
375        K::CommentOnFunction => "comment_on_function",
376        K::CreateOrReplaceProcedure => "create_or_replace_procedure",
377        K::DropProcedure => "drop_procedure",
378        K::CommentOnProcedure => "comment_on_procedure",
379        K::CreateExtension => "create_extension",
380        K::DropExtension => "drop_extension",
381        K::AlterExtensionUpdate => "alter_extension_update",
382        K::CommentOnExtension => "comment_on_extension",
383        K::CreateTrigger => "create_trigger",
384        K::DropTrigger => "drop_trigger",
385        K::CommentOnTrigger => "comment_on_trigger",
386        K::AttachPartition => "attach_partition",
387        K::DetachPartition => "detach_partition",
388        K::CreateRole => "create_role",
389        K::DropRole => "drop_role",
390        K::AlterRole => "alter_role",
391        K::GrantRoleMembership => "grant_role_membership",
392        K::RevokeRoleMembership => "revoke_role_membership",
393        K::CommentOnRole => "comment_on_role",
394        K::AlterObjectOwner => "alter_object_owner",
395        K::GrantObjectPrivilege => "grant_object_privilege",
396        K::RevokeObjectPrivilege => "revoke_object_privilege",
397        K::GrantColumnPrivilege => "grant_column_privilege",
398        K::RevokeColumnPrivilege => "revoke_column_privilege",
399        K::AlterDefaultPrivileges => "alter_default_privileges",
400        K::CreatePolicy => "create_policy",
401        K::DropPolicy => "drop_policy",
402        K::AlterPolicy => "alter_policy",
403        K::SetTableRowSecurity => "set_table_row_security",
404        K::SetTableForceRowSecurity => "set_table_force_row_security",
405        K::SetTableStorage => "set_table_storage",
406        K::SetIndexStorage => "set_index_storage",
407        K::SetMaterializedViewStorage => "set_materialized_view_storage",
408        K::CreatePublication => "create_publication",
409        K::DropPublication => "drop_publication",
410        K::ReplacePublication => "replace_publication",
411        K::AlterPublicationAddTable => "alter_publication_add_table",
412        K::AlterPublicationDropTable => "alter_publication_drop_table",
413        K::AlterPublicationSetTable => "alter_publication_set_table",
414        K::AlterPublicationAddSchema => "alter_publication_add_schema",
415        K::AlterPublicationDropSchema => "alter_publication_drop_schema",
416        K::AlterPublicationSetPublish => "alter_publication_set_publish",
417        K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
418        K::CommentOnPublication => "comment_on_publication",
419        K::CreateSubscription => "create_subscription",
420        K::DropSubscription => "drop_subscription",
421        K::AlterSubscriptionConnection => "alter_subscription_connection",
422        K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
423        K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
424        K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
425        K::CommentOnSubscription => "comment_on_subscription",
426        K::CreateStatistic => "create_statistic",
427        K::DropStatistic => "drop_statistic",
428        K::ReplaceStatistic => "replace_statistic",
429        K::AlterStatisticSetTarget => "alter_statistic_set_target",
430        K::CommentOnStatistic => "comment_on_statistic",
431        K::CreateCollation => "create_collation",
432        K::DropCollation => "drop_collation",
433        K::RenameCollation => "rename_collation",
434        K::ReplaceCollation => "replace_collation",
435        K::CommentOnCollation => "comment_on_collation",
436    }
437}
438
439/// Parse [`kind_name`]'s output back into [`crate::plan::StepKind`].
440#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
441pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
442    use crate::plan::raw_step::StepKind as K;
443    Some(match s {
444        "create_schema" => K::CreateSchema,
445        "drop_schema" => K::DropSchema,
446        "alter_schema_comment" => K::AlterSchemaComment,
447        "create_table" => K::CreateTable,
448        "drop_table" => K::DropTable,
449        "alter_table_set_comment" => K::AlterTableSetComment,
450        "add_column" => K::AddColumn,
451        "drop_column" => K::DropColumn,
452        "alter_column_type" => K::AlterColumnType,
453        "set_column_nullable" => K::SetColumnNullable,
454        "set_column_default" => K::SetColumnDefault,
455        "set_column_comment" => K::SetColumnComment,
456        "set_column_identity" => K::SetColumnIdentity,
457        "set_column_generated" => K::SetColumnGenerated,
458        "set_column_storage" => K::SetColumnStorage,
459        "set_column_compression" => K::SetColumnCompression,
460        "add_constraint" => K::AddConstraint,
461        "add_constraint_not_valid" => K::AddConstraintNotValid,
462        "validate_constraint" => K::ValidateConstraint,
463        "drop_constraint" => K::DropConstraint,
464        "set_constraint_comment" => K::SetConstraintComment,
465        "create_index" => K::CreateIndex,
466        "create_index_concurrent" => K::CreateIndexConcurrent,
467        "drop_index" => K::DropIndex,
468        "drop_index_concurrent" => K::DropIndexConcurrent,
469        "create_sequence" => K::CreateSequence,
470        "drop_sequence" => K::DropSequence,
471        "alter_sequence" => K::AlterSequence,
472        "add_check_for_not_null" => K::AddCheckForNotNull,
473        "create_view" => K::CreateView,
474        "drop_view" => K::DropView,
475        "alter_view_set_check_option" => K::AlterViewSetCheckOption,
476        "create_materialized_view" => K::CreateMaterializedView,
477        "drop_materialized_view" => K::DropMaterializedView,
478        "refresh_materialized_view" => K::RefreshMaterializedView,
479        "alter_view_set_reloption" => K::AlterViewSetReloption,
480        "comment_on_view" => K::CommentOnView,
481        "create_type" => K::CreateType,
482        "drop_type" => K::DropType,
483        "alter_type_add_value" => K::AlterTypeAddValue,
484        "alter_type_rename_value" => K::AlterTypeRenameValue,
485        "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
486        "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
487        "alter_domain_set_default" => K::AlterDomainSetDefault,
488        "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
489        "alter_type_add_attribute" => K::AlterTypeAddAttribute,
490        "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
491        "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
492        "comment_on_type" => K::CommentOnType,
493        "create_or_replace_function" => K::CreateOrReplaceFunction,
494        "drop_function" => K::DropFunction,
495        "comment_on_function" => K::CommentOnFunction,
496        "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
497        "drop_procedure" => K::DropProcedure,
498        "comment_on_procedure" => K::CommentOnProcedure,
499        "create_extension" => K::CreateExtension,
500        "drop_extension" => K::DropExtension,
501        "alter_extension_update" => K::AlterExtensionUpdate,
502        "comment_on_extension" => K::CommentOnExtension,
503        "create_trigger" => K::CreateTrigger,
504        "drop_trigger" => K::DropTrigger,
505        "comment_on_trigger" => K::CommentOnTrigger,
506        "attach_partition" => K::AttachPartition,
507        "detach_partition" => K::DetachPartition,
508        "create_role" => K::CreateRole,
509        "drop_role" => K::DropRole,
510        "alter_role" => K::AlterRole,
511        "grant_role_membership" => K::GrantRoleMembership,
512        "revoke_role_membership" => K::RevokeRoleMembership,
513        "comment_on_role" => K::CommentOnRole,
514        "alter_object_owner" => K::AlterObjectOwner,
515        "grant_object_privilege" => K::GrantObjectPrivilege,
516        "revoke_object_privilege" => K::RevokeObjectPrivilege,
517        "grant_column_privilege" => K::GrantColumnPrivilege,
518        "revoke_column_privilege" => K::RevokeColumnPrivilege,
519        "alter_default_privileges" => K::AlterDefaultPrivileges,
520        "create_policy" => K::CreatePolicy,
521        "drop_policy" => K::DropPolicy,
522        "alter_policy" => K::AlterPolicy,
523        "set_table_row_security" => K::SetTableRowSecurity,
524        "set_table_force_row_security" => K::SetTableForceRowSecurity,
525        "set_table_storage" => K::SetTableStorage,
526        "set_index_storage" => K::SetIndexStorage,
527        "set_materialized_view_storage" => K::SetMaterializedViewStorage,
528        "create_publication" => K::CreatePublication,
529        "drop_publication" => K::DropPublication,
530        "replace_publication" => K::ReplacePublication,
531        "alter_publication_add_table" => K::AlterPublicationAddTable,
532        "alter_publication_drop_table" => K::AlterPublicationDropTable,
533        "alter_publication_set_table" => K::AlterPublicationSetTable,
534        "alter_publication_add_schema" => K::AlterPublicationAddSchema,
535        "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
536        "alter_publication_set_publish" => K::AlterPublicationSetPublish,
537        "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
538        "comment_on_publication" => K::CommentOnPublication,
539        "create_subscription" => K::CreateSubscription,
540        "drop_subscription" => K::DropSubscription,
541        "alter_subscription_connection" => K::AlterSubscriptionConnection,
542        "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
543        "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
544        "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
545        "comment_on_subscription" => K::CommentOnSubscription,
546        "create_statistic" => K::CreateStatistic,
547        "drop_statistic" => K::DropStatistic,
548        "replace_statistic" => K::ReplaceStatistic,
549        "alter_statistic_set_target" => K::AlterStatisticSetTarget,
550        "comment_on_statistic" => K::CommentOnStatistic,
551        "create_collation" => K::CreateCollation,
552        "drop_collation" => K::DropCollation,
553        "rename_collation" => K::RenameCollation,
554        "replace_collation" => K::ReplaceCollation,
555        "comment_on_collation" => K::CommentOnCollation,
556        _ => return None,
557    })
558}
559
560/// Render a step's `targets` list as a comma-separated string of qnames.
561fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
562    let mut s = String::new();
563    for (i, t) in targets.iter().enumerate() {
564        if i > 0 {
565            s.push(',');
566        }
567        s.push_str(&t.render_sql());
568    }
569    s
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::identifier::Identifier;
576    use crate::ir::schema::Schema;
577
578    fn id_id(s: &str) -> Identifier {
579        Identifier::from_unquoted(s).unwrap()
580    }
581
582    fn cat_with_schema(name: &str) -> Catalog {
583        let mut c = Catalog::empty();
584        c.schemas.push(Schema::new(id_id(name)));
585        c
586    }
587
588    #[test]
589    fn plan_id_is_deterministic_across_calls() {
590        let s = cat_with_schema("app");
591        let t = Catalog::empty();
592        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
593        let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
594        assert_eq!(a, b);
595    }
596
597    #[test]
598    fn plan_id_differs_when_target_differs() {
599        let s = cat_with_schema("app");
600        let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
601        let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
602        assert_ne!(a, b);
603    }
604
605    #[test]
606    fn plan_id_differs_when_version_differs() {
607        let s = cat_with_schema("app");
608        let t = Catalog::empty();
609        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
610        let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
611        assert_ne!(a, b);
612    }
613
614    #[test]
615    fn plan_id_differs_when_ruleset_differs() {
616        let s = cat_with_schema("app");
617        let t = Catalog::empty();
618        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
619        let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
620        assert_ne!(a, b);
621    }
622
623    #[test]
624    fn plan_id_short_is_sixteen_hex_chars() {
625        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
626        let short = id.short();
627        assert_eq!(short.len(), 16);
628        assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
629    }
630
631    #[test]
632    fn plan_id_full_hex_round_trips() {
633        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
634        let hex = id.to_hex();
635        assert_eq!(hex.len(), 64);
636        let back = PlanId::from_full_hex(&hex).unwrap();
637        assert_eq!(id, back);
638    }
639
640    // ---- Plan::from_grouped (Task 7.2) ----
641
642    use crate::identifier::QualifiedName;
643    use crate::plan::grouping::TransactionGroup;
644    use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
645
646    fn qn(schema: &str, name: &str) -> QualifiedName {
647        QualifiedName::new(id_id(schema), id_id(name))
648    }
649
650    fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
651        RawStep {
652            step_no: 0,
653            kind,
654            destructive,
655            destructive_reason: destructive.then(|| "test".to_string()),
656            intent_id: None,
657            targets,
658            sql: String::new(),
659            transactional: TransactionConstraint::InTransaction,
660        }
661    }
662
663    fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
664        TransactionGroup {
665            id,
666            transactional: true,
667            steps,
668        }
669    }
670
671    #[test]
672    fn from_grouped_assigns_step_numbers_contiguously() {
673        let groups = vec![
674            group(
675                1,
676                vec![
677                    step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
678                    step(StepKind::CreateTable, false, vec![qn("app", "users")]),
679                ],
680            ),
681            group(
682                2,
683                vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
684            ),
685        ];
686        let plan = Plan::from_grouped(
687            groups,
688            &Catalog::empty(),
689            &Catalog::empty(),
690            "tid".into(),
691            None,
692            "0.1.0",
693            1,
694        )
695        .unwrap();
696        let nos: Vec<u32> = plan
697            .groups
698            .iter()
699            .flat_map(|g| g.steps.iter().map(|s| s.step_no))
700            .collect();
701        assert_eq!(nos, vec![1, 2, 3]);
702    }
703
704    #[test]
705    fn from_grouped_allocates_one_intent_per_destructive_step() {
706        let groups = vec![group(
707            1,
708            vec![
709                step(StepKind::CreateTable, false, vec![qn("app", "x")]),
710                step(StepKind::DropColumn, true, vec![qn("app", "x")]),
711                step(StepKind::DropTable, true, vec![qn("app", "y")]),
712            ],
713        )];
714        let plan = Plan::from_grouped(
715            groups,
716            &Catalog::empty(),
717            &Catalog::empty(),
718            "tid".into(),
719            None,
720            "0.1.0",
721            1,
722        )
723        .unwrap();
724        assert_eq!(plan.intents.len(), 2);
725        assert_eq!(plan.intents[0].id, 1);
726        assert_eq!(plan.intents[0].step, 2);
727        assert_eq!(plan.intents[0].kind, "drop_column");
728        assert_eq!(plan.intents[1].id, 2);
729        assert_eq!(plan.intents[1].step, 3);
730        assert_eq!(plan.intents[1].kind, "drop_table");
731        // The destructive steps carry back their intent ids.
732        let intent_ids: Vec<Option<u32>> = plan
733            .groups
734            .iter()
735            .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
736            .collect();
737        assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
738    }
739
740    #[test]
741    fn from_grouped_metadata_captures_target_snapshot() {
742        let target = cat_with_schema("legacy");
743        let plan = Plan::from_grouped(
744            Vec::new(),
745            &Catalog::empty(),
746            &target,
747            "tid".into(),
748            Some("git:abc".into()),
749            "0.1.0",
750            1,
751        )
752        .unwrap();
753        assert_eq!(plan.metadata.target_snapshot, target);
754        assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
755        assert_eq!(plan.metadata.target_identity, "tid");
756    }
757
758    #[test]
759    fn kind_name_round_trips_via_parse() {
760        for k in [
761            StepKind::CreateSchema,
762            StepKind::DropColumn,
763            StepKind::CreateIndexConcurrent,
764            StepKind::AddCheckForNotNull,
765        ] {
766            assert_eq!(parse_kind_name(kind_name(k)), Some(k));
767        }
768    }
769
770    #[test]
771    fn user_type_step_kinds_round_trip_through_kind_name() {
772        for k in [
773            StepKind::CreateType,
774            StepKind::DropType,
775            StepKind::AlterTypeAddValue,
776            StepKind::AlterTypeRenameValue,
777            StepKind::AlterDomainAddConstraint,
778            StepKind::AlterDomainDropConstraint,
779            StepKind::AlterDomainSetDefault,
780            StepKind::AlterDomainSetNotNull,
781            StepKind::AlterTypeAddAttribute,
782            StepKind::AlterTypeDropAttribute,
783            StepKind::AlterTypeAlterAttributeType,
784            StepKind::CommentOnType,
785        ] {
786            let n = kind_name(k);
787            let parsed = parse_kind_name(n).unwrap();
788            assert_eq!(parsed, k, "round-trip failed for {n}");
789        }
790    }
791
792    #[test]
793    fn routine_step_kinds_round_trip_through_kind_name() {
794        for k in [
795            StepKind::CreateOrReplaceFunction,
796            StepKind::DropFunction,
797            StepKind::CommentOnFunction,
798            StepKind::CreateOrReplaceProcedure,
799            StepKind::DropProcedure,
800            StepKind::CommentOnProcedure,
801        ] {
802            let n = kind_name(k);
803            let parsed = parse_kind_name(n).unwrap();
804            assert_eq!(parsed, k, "round-trip failed for {n}");
805        }
806    }
807
808    #[test]
809    fn plan_id_from_invalid_hex_errors() {
810        assert!(PlanId::from_full_hex("not-hex").is_err());
811        assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); // wrong length
812    }
813
814    // ---- StepOverride round-trip (Task 9) ----
815
816    #[test]
817    fn step_override_round_trips() {
818        let override_ = StepOverride {
819            kind: "refresh_materialized_view".to_string(),
820            target: "app.daily_revenue".to_string(),
821            suppress: true,
822        };
823        // Serialize a single StepOverride as TOML and confirm it parses back equal.
824        let toml_text = toml::to_string(&override_).unwrap();
825        let back: StepOverride = toml::from_str(&toml_text).unwrap();
826        assert_eq!(back, override_);
827    }
828
829    #[test]
830    fn step_override_suppress_defaults_to_false() {
831        let toml_text = r#"kind = "refresh_materialized_view"
832target = "app.daily_revenue"
833"#;
834        let back: StepOverride = toml::from_str(toml_text).unwrap();
835        assert!(!back.suppress);
836    }
837
838    #[test]
839    fn step_override_round_trips_inside_intent_doc() {
840        #[derive(serde::Deserialize)]
841        #[allow(dead_code)]
842        struct Doc {
843            plan_id: String,
844            #[serde(default, rename = "step_override")]
845            step_overrides: Vec<StepOverride>,
846        }
847
848        let toml_text = r#"
849plan_id = "abc1234567890abc"
850
851[[step_override]]
852kind = "refresh_materialized_view"
853target = "app.daily_revenue"
854suppress = true
855"#;
856        let doc: Doc = toml::from_str(toml_text).unwrap();
857        assert_eq!(doc.step_overrides.len(), 1);
858        assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
859        assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
860        assert!(doc.step_overrides[0].suppress);
861    }
862
863    // ---- LintWaiver round-trip (Task 8) ----
864
865    #[test]
866    fn lint_waiver_round_trips() {
867        let waiver = LintWaiver {
868            rule: "column-position-drift".to_string(),
869            target: "app.users".to_string(),
870            reason: "applied via rewrite-table; see PR #234".to_string(),
871        };
872
873        // Serialize a single waiver as TOML and confirm it parses back equal.
874        let toml_text = toml::to_string(&waiver).unwrap();
875        let back: LintWaiver = toml::from_str(&toml_text).unwrap();
876        assert_eq!(back, waiver);
877    }
878
879    #[test]
880    fn lint_waiver_round_trips_inside_intent_doc() {
881        // The deserializer must accept the full intent.toml shape (including
882        // [[intent]] rows) alongside [[lint_waiver]] rows. We use local structs
883        // that mirror the real IntentDocDe shape. Declared before any `let`
884        // statements to satisfy the `items_after_statements` lint.
885        #[derive(serde::Deserialize)]
886        #[allow(dead_code)]
887        struct IntentRow {
888            id: u32,
889            step: u32,
890            kind: String,
891            target: String,
892            reason: String,
893            #[serde(default)]
894            approved: bool,
895        }
896        #[derive(serde::Deserialize)]
897        #[allow(dead_code)]
898        struct Doc {
899            plan_id: String,
900            #[serde(default, rename = "intent")]
901            intents: Vec<IntentRow>,
902            #[serde(default, rename = "lint_waiver")]
903            lint_waivers: Vec<LintWaiver>,
904        }
905
906        // Simulate the shape that intent.toml produces: a document with a
907        // `plan_id` key and one or more `[[lint_waiver]]` array rows.
908        let toml_text = r#"
909plan_id = "abc1234567890abc"
910
911[[intent]]
912id = 1
913step = 2
914kind = "drop_table"
915target = "app.legacy"
916reason = "drop old table"
917approved = false
918
919[[lint_waiver]]
920rule = "column-position-drift"
921target = "app.users"
922reason = "rewrite-table applied; PR #234"
923"#;
924        let doc: Doc = toml::from_str(toml_text).unwrap();
925        assert_eq!(doc.lint_waivers.len(), 1);
926        assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
927        assert_eq!(doc.lint_waivers[0].target, "app.users");
928    }
929
930    #[test]
931    fn approve_all_intents_flips_every_intent_to_approved() {
932        let mut plan = sample_plan_with_two_unapproved_intents();
933        assert!(!plan.intents[0].approved);
934        assert!(!plan.intents[1].approved);
935        plan.approve_all_intents();
936        assert!(plan.intents[0].approved);
937        assert!(plan.intents[1].approved);
938    }
939
940    fn sample_plan_with_two_unapproved_intents() -> Plan {
941        Plan {
942            id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
943            groups: Vec::new(),
944            intents: vec![
945                DestructiveIntent {
946                    id: 1,
947                    step: 1,
948                    kind: "drop_column".into(),
949                    target: "app.users.legacy_email".into(),
950                    reason: "test".into(),
951                    approved: false,
952                },
953                DestructiveIntent {
954                    id: 2,
955                    step: 2,
956                    kind: "drop_table".into(),
957                    target: "app.old_users".into(),
958                    reason: "test".into(),
959                    approved: false,
960                },
961            ],
962            lint_waivers: Vec::new(),
963            step_overrides: Vec::new(),
964            metadata: PlanMetadata {
965                pgevolve_version: crate::VERSION.to_string(),
966                planner_ruleset_version: 1,
967                source_rev: None,
968                target_identity: "test-identity".into(),
969                target_snapshot: Catalog::empty(),
970                created_at: OffsetDateTime::UNIX_EPOCH,
971                lint_at_plan_findings: Vec::new(),
972            },
973            advisory_findings: Vec::new(),
974        }
975    }
976}