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    /// Parse a short 16-char lowercase hex string (8 bytes) into a `PlanId`.
100    ///
101    /// Used by the cluster plan CLI, which derives an 8-byte plan id from a
102    /// BLAKE3 hash of the cluster catalogs. The 8 decoded bytes occupy
103    /// positions 0–7 of the internal 32-byte array; bytes 8–31 are zeroed.
104    /// This guarantees that `plan_id.short() == s` after construction, which
105    /// is the invariant that `write_plan_dir`/`read_plan_dir` rely on for
106    /// the cross-file `plan_id` consistency check.
107    ///
108    /// # Errors
109    ///
110    /// Returns `PlanError::Internal` if `s` is not valid hex or not exactly
111    /// 16 characters (8 bytes) long.
112    pub fn from_hex(s: &str) -> Result<Self, PlanError> {
113        let decoded = hex::decode(s).map_err(|e| {
114            PlanError::Internal(format!("plan_id hex decode failed for {s:?}: {e}"))
115        })?;
116        if decoded.len() != 8 {
117            return Err(PlanError::Internal(format!(
118                "plan_id hex length mismatch: expected 16 chars (8 bytes), got {} chars ({} bytes)",
119                s.len(),
120                decoded.len(),
121            )));
122        }
123        let mut arr = [0u8; 32];
124        arr[..8].copy_from_slice(&decoded);
125        Ok(Self(arr))
126    }
127}
128
129impl std::fmt::Display for PlanId {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.write_str(&self.to_hex())
132    }
133}
134
135/// Error returned by [`PlanId::from_full_hex`] when the input is not a valid
136/// 64-character lowercase hex string.
137#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
138#[error("invalid plan hash: {0}")]
139pub struct InvalidPlanHash(pub String);
140
141/// One `[[step_override]]` row in `intent.toml`.
142///
143/// Non-destructive per-step modifier — the user can suppress an
144/// auto-emitted step (e.g., the REFRESH MATERIALIZED VIEW that follows
145/// every CREATE MATERIALIZED VIEW).
146#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
147pub struct StepOverride {
148    /// `StepKind` wire-form tag (`snake_case`): `"refresh_materialized_view"`,
149    /// `"create_view"`, etc.
150    pub kind: String,
151    /// Target qname (matches the step's primary target).
152    pub target: String,
153    /// When true, the executor skips the step entirely.
154    #[serde(default)]
155    pub suppress: bool,
156}
157
158/// One `[[lint_waiver]]` row in `intent.toml`. Acknowledges a `LintAtPlan`
159/// finding so that `pgevolve plan` can proceed despite the detected drift.
160///
161/// Waivers are matched against findings by (`rule`, `target`). The `target`
162/// must appear as a substring of the finding's message (findings always lead
163/// with the qualified table name, e.g. `"app.users: column position drift…"`).
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct LintWaiver {
166    /// The lint rule ID being waived, e.g. `"column-position-drift"`.
167    pub rule: String,
168    /// The qualified target the finding pointed at, e.g. `"app.users"`.
169    pub target: String,
170    /// Free-text reason; surfaces in audit logs.
171    pub reason: String,
172}
173
174/// One destructive intent — a step whose execution requires the user to flip
175/// the `approved` flag in `intent.toml` before the executor will run it.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct DestructiveIntent {
178    /// 1-indexed intent id, unique within a plan.
179    pub id: u32,
180    /// Step number (1-indexed across the whole plan) that this intent gates.
181    pub step: u32,
182    /// Human kind name (e.g., `drop_column`). Same vocabulary as
183    /// [`StepKind`](crate::plan::raw_step::StepKind) serialized.
184    pub kind: String,
185    /// Rendered target (e.g., `app.users.legacy_email`).
186    pub target: String,
187    /// Human-readable reason copied from the diff `Destructiveness`.
188    pub reason: String,
189    /// Whether the user has set `approved = true` in `intent.toml`.
190    ///
191    /// Populated by `read_plan_dir` / `read_intent_toml`. Defaults to `false`
192    /// (every newly written `intent.toml` starts with `approved = false`).
193    #[serde(default)]
194    pub approved: bool,
195}
196
197/// Metadata produced alongside a `Plan` and embedded into `manifest.toml`.
198#[derive(Debug, Clone, PartialEq)]
199pub struct PlanMetadata {
200    /// pgevolve crate version string at plan time.
201    pub pgevolve_version: String,
202    /// Planner ruleset version (from `PlannerPolicy`) at plan time.
203    pub planner_ruleset_version: u32,
204    /// Optional source-tree revision identifier (e.g., `git:abc1234`).
205    pub source_rev: Option<String>,
206    /// Stable identifier for the target database
207    /// (hash of `host/port/dbname/system_identifier`, computed by the apply path).
208    pub target_identity: String,
209    /// Catalog snapshot used as the diff pre-image; the executor uses it for
210    /// drift detection at apply time.
211    pub target_snapshot: Catalog,
212    /// UTC timestamp when the plan was constructed.
213    pub created_at: OffsetDateTime,
214    /// `LintAtPlan` findings present at plan time. Populated by `pgevolve plan`
215    /// whenever drift lints fire. Empty when no `LintAtPlan` findings exist.
216    /// Used by apply-time preflight to detect waiver removal between plan and apply.
217    pub lint_at_plan_findings: Vec<RecordedFinding>,
218}
219
220/// The canonical in-memory representation of a plan.
221#[derive(Debug, Clone, PartialEq)]
222pub struct Plan {
223    /// Deterministic identity hash; see [`PlanId::compute`].
224    pub id: PlanId,
225    /// Steps partitioned into transaction groups; each step's `step_no` and
226    /// `intent_id` are filled in by [`Plan::from_grouped`].
227    pub groups: Vec<TransactionGroup>,
228    /// Destructive intents, one per destructive step, in step order.
229    pub intents: Vec<DestructiveIntent>,
230    /// Lint waivers loaded from `[[lint_waiver]]` rows in `intent.toml`.
231    ///
232    /// When `pgevolve plan` detects unwaived `LintAtPlan` findings, it prints
233    /// an example `[[lint_waiver]]` row to stderr for the user to copy into
234    /// `intent.toml`; the field is omitted from serialized output when empty
235    /// (`skip_serializing_if = "Vec::is_empty"`). The field is populated when
236    /// reading back a plan directory whose `intent.toml` already contains
237    /// `[[lint_waiver]]` rows.
238    pub lint_waivers: Vec<LintWaiver>,
239    /// Step overrides loaded from `[[step_override]]` rows in `intent.toml`.
240    ///
241    /// Each row can suppress a specific auto-emitted step at apply time.
242    /// The executor checks this list before running each step and skips
243    /// (recording as `skipped` in the audit log) any step whose `kind`
244    /// and primary `target` match an override with `suppress = true`.
245    pub step_overrides: Vec<StepOverride>,
246    /// Plan metadata.
247    pub metadata: PlanMetadata,
248    /// Advisory (Warning-severity) lint findings produced at plan time.
249    ///
250    /// These are informational — they never block plan construction — and are
251    /// **not** persisted to disk. Callers (CLI, conformance tests) can inspect
252    /// this field and print or assert on them as needed.
253    ///
254    /// Currently populated by the `pgevolve::api::build_plan` shim from
255    /// [`crate::lint::check_changeset`] (changeset-level rules such as
256    /// `storage-downgrade-not-retroactive` and
257    /// `compression-change-not-retroactive`).
258    pub advisory_findings: Vec<RecordedFinding>,
259}
260
261impl Plan {
262    /// Assemble a `Plan` from a step-grouped output of the rewrite pass.
263    ///
264    /// Walks `groups` in order to:
265    /// 1. Assign 1-indexed `step_no` to every step (continuous across groups).
266    /// 2. Allocate a `DestructiveIntent` (and `intent_id`) for every
267    ///    destructive step, in step order.
268    /// 3. Compute the deterministic `PlanId` over `(source, target, version,
269    ///    ruleset_version)`.
270    ///
271    /// `target_identity` is opaque to the planner — the executor binary
272    /// computes it from `(host, port, dbname, system_identifier)` at apply time.
273    ///
274    /// # Errors
275    ///
276    /// Returns `PlanError::Internal` if the source or target catalog cannot be
277    /// serialized to JSON for hashing (see [`PlanId::compute`]).
278    /// Assemble a `Plan` from a step-grouped output of the rewrite pass,
279    /// with a caller-supplied `PlanId`.
280    ///
281    /// Used by cluster apply, which hashes `ClusterCatalog` (not per-DB
282    /// `Catalog`) for its plan id and so cannot use [`Plan::from_grouped`].
283    ///
284    /// `target_snapshot` is left empty — per-DB snapshots only serve the
285    /// per-DB drift recheck, which cluster apply does not perform (see
286    /// design doc §3.1).
287    ///
288    /// # Errors
289    ///
290    /// Currently infallible; the `Result` shape matches `from_grouped` for
291    /// future-proofing.
292    #[allow(clippy::too_many_arguments)]
293    #[allow(clippy::missing_const_for_fn)] // construct-only; never const-callable
294    pub fn from_grouped_with_id(
295        mut groups: Vec<TransactionGroup>,
296        id: PlanId,
297        target_identity: String,
298        source_rev: Option<String>,
299        pgevolve_version: &str,
300        planner_ruleset_version: u32,
301    ) -> Result<Self, PlanError> {
302        let mut step_no: u32 = 0;
303        let mut intent_no: u32 = 0;
304        let mut intents: Vec<DestructiveIntent> = Vec::new();
305        for group in &mut groups {
306            for step in &mut group.steps {
307                step_no += 1;
308                step.step_no = step_no;
309                if step.destructive {
310                    intent_no += 1;
311                    step.intent_id = Some(intent_no);
312                    intents.push(DestructiveIntent {
313                        id: intent_no,
314                        step: step_no,
315                        kind: kind_name(step.kind).to_string(),
316                        target: render_targets(&step.targets),
317                        reason: step
318                            .destructive_reason
319                            .clone()
320                            .unwrap_or_else(|| "destructive".to_string()),
321                        approved: false,
322                    });
323                }
324            }
325        }
326        let metadata = PlanMetadata {
327            pgevolve_version: pgevolve_version.to_string(),
328            planner_ruleset_version,
329            source_rev,
330            target_identity,
331            target_snapshot: Catalog::empty(),
332            created_at: OffsetDateTime::now_utc(),
333            lint_at_plan_findings: Vec::new(),
334        };
335        Ok(Self {
336            id,
337            groups,
338            intents,
339            lint_waivers: Vec::new(),
340            step_overrides: Vec::new(),
341            metadata,
342            advisory_findings: Vec::new(),
343        })
344    }
345
346    /// Assemble a `Plan` from a step-grouped output of the rewrite pass.
347    ///
348    /// Walks `groups` in order to:
349    /// 1. Assign 1-indexed `step_no` to every step (continuous across groups).
350    /// 2. Allocate a `DestructiveIntent` (and `intent_id`) for every
351    ///    destructive step, in step order.
352    /// 3. Compute the deterministic `PlanId` over `(source, target, version,
353    ///    ruleset_version)`.
354    ///
355    /// `target_identity` is opaque to the planner — the executor binary
356    /// computes it from `(host, port, dbname, system_identifier)` at apply time.
357    ///
358    /// # Errors
359    ///
360    /// Returns `PlanError::Internal` if the source or target catalog cannot be
361    /// serialized to JSON for hashing (see [`PlanId::compute`]).
362    #[allow(clippy::too_many_arguments)]
363    pub fn from_grouped(
364        groups: Vec<TransactionGroup>,
365        source: &Catalog,
366        target: &Catalog,
367        target_identity: String,
368        source_rev: Option<String>,
369        pgevolve_version: &str,
370        planner_ruleset_version: u32,
371    ) -> Result<Self, PlanError> {
372        let id = PlanId::compute(source, target, pgevolve_version, planner_ruleset_version)?;
373        let mut plan = Self::from_grouped_with_id(
374            groups,
375            id,
376            target_identity,
377            source_rev,
378            pgevolve_version,
379            planner_ruleset_version,
380        )?;
381        plan.metadata.target_snapshot = target.clone();
382        Ok(plan)
383    }
384
385    /// Mark every destructive intent as `approved = true`.
386    ///
387    /// Intended for test harnesses that build plans programmatically and
388    /// want to bypass the `intent.toml`-based approval workflow. Production
389    /// apply must continue to require explicit approval in `intent.toml`.
390    pub fn approve_all_intents(&mut self) {
391        for intent in &mut self.intents {
392            intent.approved = true;
393        }
394    }
395}
396
397/// Human-readable kind name used in directive comments and intent rows.
398///
399/// The vocabulary matches [`crate::plan::StepKind`]'s `snake_case` serde encoding; this
400/// `const fn` exists so callers do not pay for a serde round-trip.
401#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
402pub const fn kind_name(k: crate::plan::raw_step::StepKind) -> &'static str {
403    use crate::plan::raw_step::StepKind as K;
404    match k {
405        K::CreateSchema => "create_schema",
406        K::DropSchema => "drop_schema",
407        K::AlterSchemaComment => "alter_schema_comment",
408        K::CreateTable => "create_table",
409        K::DropTable => "drop_table",
410        K::AlterTableSetComment => "alter_table_set_comment",
411        K::AddColumn => "add_column",
412        K::DropColumn => "drop_column",
413        K::AlterColumnType => "alter_column_type",
414        K::SetColumnNullable => "set_column_nullable",
415        K::SetColumnDefault => "set_column_default",
416        K::SetColumnComment => "set_column_comment",
417        K::SetColumnIdentity => "set_column_identity",
418        K::SetColumnGenerated => "set_column_generated",
419        K::SetColumnStorage => "set_column_storage",
420        K::SetColumnCompression => "set_column_compression",
421        K::AddConstraint => "add_constraint",
422        K::AddConstraintNotValid => "add_constraint_not_valid",
423        K::ValidateConstraint => "validate_constraint",
424        K::DropConstraint => "drop_constraint",
425        K::SetConstraintComment => "set_constraint_comment",
426        K::CreateIndex => "create_index",
427        K::CreateIndexConcurrent => "create_index_concurrent",
428        K::DropIndex => "drop_index",
429        K::DropIndexConcurrent => "drop_index_concurrent",
430        K::CreateSequence => "create_sequence",
431        K::DropSequence => "drop_sequence",
432        K::AlterSequence => "alter_sequence",
433        K::AddCheckForNotNull => "add_check_for_not_null",
434        K::CreateView => "create_view",
435        K::DropView => "drop_view",
436        K::AlterViewSetCheckOption => "alter_view_set_check_option",
437        K::CreateMaterializedView => "create_materialized_view",
438        K::DropMaterializedView => "drop_materialized_view",
439        K::RefreshMaterializedView => "refresh_materialized_view",
440        K::AlterViewSetReloption => "alter_view_set_reloption",
441        K::CommentOnView => "comment_on_view",
442        K::CreateType => "create_type",
443        K::DropType => "drop_type",
444        K::AlterTypeAddValue => "alter_type_add_value",
445        K::AlterTypeRenameValue => "alter_type_rename_value",
446        K::AlterDomainAddConstraint => "alter_domain_add_constraint",
447        K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
448        K::AlterDomainSetDefault => "alter_domain_set_default",
449        K::AlterDomainSetNotNull => "alter_domain_set_not_null",
450        K::AlterTypeAddAttribute => "alter_type_add_attribute",
451        K::AlterTypeDropAttribute => "alter_type_drop_attribute",
452        K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
453        K::CommentOnType => "comment_on_type",
454        K::CreateOrReplaceFunction => "create_or_replace_function",
455        K::DropFunction => "drop_function",
456        K::CommentOnFunction => "comment_on_function",
457        K::CreateOrReplaceProcedure => "create_or_replace_procedure",
458        K::DropProcedure => "drop_procedure",
459        K::CommentOnProcedure => "comment_on_procedure",
460        K::CreateExtension => "create_extension",
461        K::DropExtension => "drop_extension",
462        K::AlterExtensionUpdate => "alter_extension_update",
463        K::CommentOnExtension => "comment_on_extension",
464        K::CreateTrigger => "create_trigger",
465        K::DropTrigger => "drop_trigger",
466        K::CommentOnTrigger => "comment_on_trigger",
467        K::AttachPartition => "attach_partition",
468        K::DetachPartition => "detach_partition",
469        K::CreateRole => "create_role",
470        K::DropRole => "drop_role",
471        K::AlterRole => "alter_role",
472        K::GrantRoleMembership => "grant_role_membership",
473        K::RevokeRoleMembership => "revoke_role_membership",
474        K::CommentOnRole => "comment_on_role",
475        K::CreateTablespace => "create_tablespace",
476        K::DropTablespace => "drop_tablespace",
477        K::AlterTablespaceOwner => "alter_tablespace_owner",
478        K::SetTablespaceOptions => "set_tablespace_options",
479        K::CommentOnTablespace => "comment_on_tablespace",
480        K::AlterObjectOwner => "alter_object_owner",
481        K::GrantObjectPrivilege => "grant_object_privilege",
482        K::RevokeObjectPrivilege => "revoke_object_privilege",
483        K::GrantColumnPrivilege => "grant_column_privilege",
484        K::RevokeColumnPrivilege => "revoke_column_privilege",
485        K::AlterDefaultPrivileges => "alter_default_privileges",
486        K::CreatePolicy => "create_policy",
487        K::DropPolicy => "drop_policy",
488        K::AlterPolicy => "alter_policy",
489        K::SetTableRowSecurity => "set_table_row_security",
490        K::SetTableForceRowSecurity => "set_table_force_row_security",
491        K::SetTableStorage => "set_table_storage",
492        K::SetIndexStorage => "set_index_storage",
493        K::SetMaterializedViewStorage => "set_materialized_view_storage",
494        K::CreatePublication => "create_publication",
495        K::DropPublication => "drop_publication",
496        K::ReplacePublication => "replace_publication",
497        K::AlterPublicationAddTable => "alter_publication_add_table",
498        K::AlterPublicationDropTable => "alter_publication_drop_table",
499        K::AlterPublicationSetTable => "alter_publication_set_table",
500        K::AlterPublicationAddSchema => "alter_publication_add_schema",
501        K::AlterPublicationDropSchema => "alter_publication_drop_schema",
502        K::AlterPublicationSetPublish => "alter_publication_set_publish",
503        K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
504        K::CommentOnPublication => "comment_on_publication",
505        K::CreateSubscription => "create_subscription",
506        K::DropSubscription => "drop_subscription",
507        K::AlterSubscriptionConnection => "alter_subscription_connection",
508        K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
509        K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
510        K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
511        K::CommentOnSubscription => "comment_on_subscription",
512        K::CreateStatistic => "create_statistic",
513        K::DropStatistic => "drop_statistic",
514        K::ReplaceStatistic => "replace_statistic",
515        K::AlterStatisticSetTarget => "alter_statistic_set_target",
516        K::CommentOnStatistic => "comment_on_statistic",
517        K::CreateCollation => "create_collation",
518        K::DropCollation => "drop_collation",
519        K::RenameCollation => "rename_collation",
520        K::ReplaceCollation => "replace_collation",
521        K::CommentOnCollation => "comment_on_collation",
522        K::CreateEventTrigger => "create_event_trigger",
523        K::DropEventTrigger => "drop_event_trigger",
524        K::AlterEventTriggerEnable => "alter_event_trigger_enable",
525        K::AlterEventTriggerOwner => "alter_event_trigger_owner",
526        K::CommentOnEventTrigger => "comment_on_event_trigger",
527        K::CreateAggregate => "create_aggregate",
528        K::DropAggregate => "drop_aggregate",
529        K::AlterAggregateOwner => "alter_aggregate_owner",
530        K::CommentOnAggregate => "comment_on_aggregate",
531        K::CreateCast => "create_cast",
532        K::DropCast => "drop_cast",
533        K::CommentOnCast => "comment_on_cast",
534    }
535}
536
537/// Parse [`kind_name`]'s output back into [`crate::plan::StepKind`].
538#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
539pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
540    use crate::plan::raw_step::StepKind as K;
541    Some(match s {
542        "create_schema" => K::CreateSchema,
543        "drop_schema" => K::DropSchema,
544        "alter_schema_comment" => K::AlterSchemaComment,
545        "create_table" => K::CreateTable,
546        "drop_table" => K::DropTable,
547        "alter_table_set_comment" => K::AlterTableSetComment,
548        "add_column" => K::AddColumn,
549        "drop_column" => K::DropColumn,
550        "alter_column_type" => K::AlterColumnType,
551        "set_column_nullable" => K::SetColumnNullable,
552        "set_column_default" => K::SetColumnDefault,
553        "set_column_comment" => K::SetColumnComment,
554        "set_column_identity" => K::SetColumnIdentity,
555        "set_column_generated" => K::SetColumnGenerated,
556        "set_column_storage" => K::SetColumnStorage,
557        "set_column_compression" => K::SetColumnCompression,
558        "add_constraint" => K::AddConstraint,
559        "add_constraint_not_valid" => K::AddConstraintNotValid,
560        "validate_constraint" => K::ValidateConstraint,
561        "drop_constraint" => K::DropConstraint,
562        "set_constraint_comment" => K::SetConstraintComment,
563        "create_index" => K::CreateIndex,
564        "create_index_concurrent" => K::CreateIndexConcurrent,
565        "drop_index" => K::DropIndex,
566        "drop_index_concurrent" => K::DropIndexConcurrent,
567        "create_sequence" => K::CreateSequence,
568        "drop_sequence" => K::DropSequence,
569        "alter_sequence" => K::AlterSequence,
570        "add_check_for_not_null" => K::AddCheckForNotNull,
571        "create_view" => K::CreateView,
572        "drop_view" => K::DropView,
573        "alter_view_set_check_option" => K::AlterViewSetCheckOption,
574        "create_materialized_view" => K::CreateMaterializedView,
575        "drop_materialized_view" => K::DropMaterializedView,
576        "refresh_materialized_view" => K::RefreshMaterializedView,
577        "alter_view_set_reloption" => K::AlterViewSetReloption,
578        "comment_on_view" => K::CommentOnView,
579        "create_type" => K::CreateType,
580        "drop_type" => K::DropType,
581        "alter_type_add_value" => K::AlterTypeAddValue,
582        "alter_type_rename_value" => K::AlterTypeRenameValue,
583        "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
584        "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
585        "alter_domain_set_default" => K::AlterDomainSetDefault,
586        "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
587        "alter_type_add_attribute" => K::AlterTypeAddAttribute,
588        "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
589        "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
590        "comment_on_type" => K::CommentOnType,
591        "create_or_replace_function" => K::CreateOrReplaceFunction,
592        "drop_function" => K::DropFunction,
593        "comment_on_function" => K::CommentOnFunction,
594        "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
595        "drop_procedure" => K::DropProcedure,
596        "comment_on_procedure" => K::CommentOnProcedure,
597        "create_extension" => K::CreateExtension,
598        "drop_extension" => K::DropExtension,
599        "alter_extension_update" => K::AlterExtensionUpdate,
600        "comment_on_extension" => K::CommentOnExtension,
601        "create_trigger" => K::CreateTrigger,
602        "drop_trigger" => K::DropTrigger,
603        "comment_on_trigger" => K::CommentOnTrigger,
604        "attach_partition" => K::AttachPartition,
605        "detach_partition" => K::DetachPartition,
606        "create_role" => K::CreateRole,
607        "drop_role" => K::DropRole,
608        "alter_role" => K::AlterRole,
609        "grant_role_membership" => K::GrantRoleMembership,
610        "revoke_role_membership" => K::RevokeRoleMembership,
611        "comment_on_role" => K::CommentOnRole,
612        "create_tablespace" => K::CreateTablespace,
613        "drop_tablespace" => K::DropTablespace,
614        "alter_tablespace_owner" => K::AlterTablespaceOwner,
615        "set_tablespace_options" => K::SetTablespaceOptions,
616        "comment_on_tablespace" => K::CommentOnTablespace,
617        "alter_object_owner" => K::AlterObjectOwner,
618        "grant_object_privilege" => K::GrantObjectPrivilege,
619        "revoke_object_privilege" => K::RevokeObjectPrivilege,
620        "grant_column_privilege" => K::GrantColumnPrivilege,
621        "revoke_column_privilege" => K::RevokeColumnPrivilege,
622        "alter_default_privileges" => K::AlterDefaultPrivileges,
623        "create_policy" => K::CreatePolicy,
624        "drop_policy" => K::DropPolicy,
625        "alter_policy" => K::AlterPolicy,
626        "set_table_row_security" => K::SetTableRowSecurity,
627        "set_table_force_row_security" => K::SetTableForceRowSecurity,
628        "set_table_storage" => K::SetTableStorage,
629        "set_index_storage" => K::SetIndexStorage,
630        "set_materialized_view_storage" => K::SetMaterializedViewStorage,
631        "create_publication" => K::CreatePublication,
632        "drop_publication" => K::DropPublication,
633        "replace_publication" => K::ReplacePublication,
634        "alter_publication_add_table" => K::AlterPublicationAddTable,
635        "alter_publication_drop_table" => K::AlterPublicationDropTable,
636        "alter_publication_set_table" => K::AlterPublicationSetTable,
637        "alter_publication_add_schema" => K::AlterPublicationAddSchema,
638        "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
639        "alter_publication_set_publish" => K::AlterPublicationSetPublish,
640        "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
641        "comment_on_publication" => K::CommentOnPublication,
642        "create_subscription" => K::CreateSubscription,
643        "drop_subscription" => K::DropSubscription,
644        "alter_subscription_connection" => K::AlterSubscriptionConnection,
645        "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
646        "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
647        "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
648        "comment_on_subscription" => K::CommentOnSubscription,
649        "create_statistic" => K::CreateStatistic,
650        "drop_statistic" => K::DropStatistic,
651        "replace_statistic" => K::ReplaceStatistic,
652        "alter_statistic_set_target" => K::AlterStatisticSetTarget,
653        "comment_on_statistic" => K::CommentOnStatistic,
654        "create_collation" => K::CreateCollation,
655        "drop_collation" => K::DropCollation,
656        "rename_collation" => K::RenameCollation,
657        "replace_collation" => K::ReplaceCollation,
658        "comment_on_collation" => K::CommentOnCollation,
659        "create_event_trigger" => K::CreateEventTrigger,
660        "drop_event_trigger" => K::DropEventTrigger,
661        "alter_event_trigger_enable" => K::AlterEventTriggerEnable,
662        "alter_event_trigger_owner" => K::AlterEventTriggerOwner,
663        "comment_on_event_trigger" => K::CommentOnEventTrigger,
664        "create_aggregate" => K::CreateAggregate,
665        "drop_aggregate" => K::DropAggregate,
666        "alter_aggregate_owner" => K::AlterAggregateOwner,
667        "comment_on_aggregate" => K::CommentOnAggregate,
668        "create_cast" => K::CreateCast,
669        "drop_cast" => K::DropCast,
670        "comment_on_cast" => K::CommentOnCast,
671        _ => return None,
672    })
673}
674
675/// Render a step's `targets` list as a comma-separated string of qnames.
676fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
677    let mut s = String::new();
678    for (i, t) in targets.iter().enumerate() {
679        if i > 0 {
680            s.push(',');
681        }
682        s.push_str(&t.render_sql());
683    }
684    s
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::identifier::Identifier;
691    use crate::ir::schema::Schema;
692
693    fn id_id(s: &str) -> Identifier {
694        Identifier::from_unquoted(s).unwrap()
695    }
696
697    fn cat_with_schema(name: &str) -> Catalog {
698        let mut c = Catalog::empty();
699        c.schemas.push(Schema::new(id_id(name)));
700        c
701    }
702
703    #[test]
704    fn plan_id_is_deterministic_across_calls() {
705        let s = cat_with_schema("app");
706        let t = Catalog::empty();
707        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
708        let b = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
709        assert_eq!(a, b);
710    }
711
712    #[test]
713    fn plan_id_differs_when_target_differs() {
714        let s = cat_with_schema("app");
715        let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
716        let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
717        assert_ne!(a, b);
718    }
719
720    #[test]
721    fn plan_id_differs_when_version_differs() {
722        let s = cat_with_schema("app");
723        let t = Catalog::empty();
724        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
725        let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
726        assert_ne!(a, b);
727    }
728
729    #[test]
730    fn plan_id_differs_when_ruleset_differs() {
731        let s = cat_with_schema("app");
732        let t = Catalog::empty();
733        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
734        let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
735        assert_ne!(a, b);
736    }
737
738    #[test]
739    fn plan_id_short_is_sixteen_hex_chars() {
740        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
741        let short = id.short();
742        assert_eq!(short.len(), 16);
743        assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
744    }
745
746    #[test]
747    fn plan_id_full_hex_round_trips() {
748        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
749        let hex = id.to_hex();
750        assert_eq!(hex.len(), 64);
751        let back = PlanId::from_full_hex(&hex).unwrap();
752        assert_eq!(id, back);
753    }
754
755    // ---- Plan::from_grouped (Task 7.2) ----
756
757    use crate::identifier::QualifiedName;
758    use crate::plan::grouping::TransactionGroup;
759    use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
760
761    fn qn(schema: &str, name: &str) -> QualifiedName {
762        QualifiedName::new(id_id(schema), id_id(name))
763    }
764
765    fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
766        RawStep {
767            step_no: 0,
768            kind,
769            destructive,
770            destructive_reason: destructive.then(|| "test".to_string()),
771            intent_id: None,
772            targets,
773            sql: String::new(),
774            transactional: TransactionConstraint::InTransaction,
775        }
776    }
777
778    fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
779        TransactionGroup {
780            id,
781            transactional: true,
782            steps,
783        }
784    }
785
786    #[test]
787    fn from_grouped_assigns_step_numbers_contiguously() {
788        let groups = vec![
789            group(
790                1,
791                vec![
792                    step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
793                    step(StepKind::CreateTable, false, vec![qn("app", "users")]),
794                ],
795            ),
796            group(
797                2,
798                vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
799            ),
800        ];
801        let plan = Plan::from_grouped(
802            groups,
803            &Catalog::empty(),
804            &Catalog::empty(),
805            "tid".into(),
806            None,
807            "0.1.0",
808            1,
809        )
810        .unwrap();
811        let nos: Vec<u32> = plan
812            .groups
813            .iter()
814            .flat_map(|g| g.steps.iter().map(|s| s.step_no))
815            .collect();
816        assert_eq!(nos, vec![1, 2, 3]);
817    }
818
819    #[test]
820    fn from_grouped_allocates_one_intent_per_destructive_step() {
821        let groups = vec![group(
822            1,
823            vec![
824                step(StepKind::CreateTable, false, vec![qn("app", "x")]),
825                step(StepKind::DropColumn, true, vec![qn("app", "x")]),
826                step(StepKind::DropTable, true, vec![qn("app", "y")]),
827            ],
828        )];
829        let plan = Plan::from_grouped(
830            groups,
831            &Catalog::empty(),
832            &Catalog::empty(),
833            "tid".into(),
834            None,
835            "0.1.0",
836            1,
837        )
838        .unwrap();
839        assert_eq!(plan.intents.len(), 2);
840        assert_eq!(plan.intents[0].id, 1);
841        assert_eq!(plan.intents[0].step, 2);
842        assert_eq!(plan.intents[0].kind, "drop_column");
843        assert_eq!(plan.intents[1].id, 2);
844        assert_eq!(plan.intents[1].step, 3);
845        assert_eq!(plan.intents[1].kind, "drop_table");
846        // The destructive steps carry back their intent ids.
847        let intent_ids: Vec<Option<u32>> = plan
848            .groups
849            .iter()
850            .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
851            .collect();
852        assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
853    }
854
855    #[test]
856    fn from_grouped_metadata_captures_target_snapshot() {
857        let target = cat_with_schema("legacy");
858        let plan = Plan::from_grouped(
859            Vec::new(),
860            &Catalog::empty(),
861            &target,
862            "tid".into(),
863            Some("git:abc".into()),
864            "0.1.0",
865            1,
866        )
867        .unwrap();
868        assert_eq!(plan.metadata.target_snapshot, target);
869        assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
870        assert_eq!(plan.metadata.target_identity, "tid");
871    }
872
873    #[test]
874    fn kind_name_round_trips_via_parse() {
875        for k in [
876            StepKind::CreateSchema,
877            StepKind::DropColumn,
878            StepKind::CreateIndexConcurrent,
879            StepKind::AddCheckForNotNull,
880        ] {
881            assert_eq!(parse_kind_name(kind_name(k)), Some(k));
882        }
883    }
884
885    #[test]
886    fn user_type_step_kinds_round_trip_through_kind_name() {
887        for k in [
888            StepKind::CreateType,
889            StepKind::DropType,
890            StepKind::AlterTypeAddValue,
891            StepKind::AlterTypeRenameValue,
892            StepKind::AlterDomainAddConstraint,
893            StepKind::AlterDomainDropConstraint,
894            StepKind::AlterDomainSetDefault,
895            StepKind::AlterDomainSetNotNull,
896            StepKind::AlterTypeAddAttribute,
897            StepKind::AlterTypeDropAttribute,
898            StepKind::AlterTypeAlterAttributeType,
899            StepKind::CommentOnType,
900        ] {
901            let n = kind_name(k);
902            let parsed = parse_kind_name(n).unwrap();
903            assert_eq!(parsed, k, "round-trip failed for {n}");
904        }
905    }
906
907    #[test]
908    fn routine_step_kinds_round_trip_through_kind_name() {
909        for k in [
910            StepKind::CreateOrReplaceFunction,
911            StepKind::DropFunction,
912            StepKind::CommentOnFunction,
913            StepKind::CreateOrReplaceProcedure,
914            StepKind::DropProcedure,
915            StepKind::CommentOnProcedure,
916        ] {
917            let n = kind_name(k);
918            let parsed = parse_kind_name(n).unwrap();
919            assert_eq!(parsed, k, "round-trip failed for {n}");
920        }
921    }
922
923    #[test]
924    fn tablespace_step_kinds_round_trip_through_kind_name() {
925        for k in [
926            StepKind::CreateTablespace,
927            StepKind::DropTablespace,
928            StepKind::AlterTablespaceOwner,
929            StepKind::SetTablespaceOptions,
930            StepKind::CommentOnTablespace,
931        ] {
932            let n = kind_name(k);
933            let parsed = parse_kind_name(n).unwrap();
934            assert_eq!(parsed, k, "round-trip failed for {n}");
935        }
936    }
937
938    #[test]
939    fn plan_id_from_invalid_hex_errors() {
940        assert!(PlanId::from_full_hex("not-hex").is_err());
941        assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); // wrong length
942    }
943
944    // ---- StepOverride round-trip (Task 9) ----
945
946    #[test]
947    fn step_override_round_trips() {
948        let override_ = StepOverride {
949            kind: "refresh_materialized_view".to_string(),
950            target: "app.daily_revenue".to_string(),
951            suppress: true,
952        };
953        // Serialize a single StepOverride as TOML and confirm it parses back equal.
954        let toml_text = toml::to_string(&override_).unwrap();
955        let back: StepOverride = toml::from_str(&toml_text).unwrap();
956        assert_eq!(back, override_);
957    }
958
959    #[test]
960    fn step_override_suppress_defaults_to_false() {
961        let toml_text = r#"kind = "refresh_materialized_view"
962target = "app.daily_revenue"
963"#;
964        let back: StepOverride = toml::from_str(toml_text).unwrap();
965        assert!(!back.suppress);
966    }
967
968    #[test]
969    fn step_override_round_trips_inside_intent_doc() {
970        #[derive(serde::Deserialize)]
971        #[allow(dead_code)]
972        struct Doc {
973            plan_id: String,
974            #[serde(default, rename = "step_override")]
975            step_overrides: Vec<StepOverride>,
976        }
977
978        let toml_text = r#"
979plan_id = "abc1234567890abc"
980
981[[step_override]]
982kind = "refresh_materialized_view"
983target = "app.daily_revenue"
984suppress = true
985"#;
986        let doc: Doc = toml::from_str(toml_text).unwrap();
987        assert_eq!(doc.step_overrides.len(), 1);
988        assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
989        assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
990        assert!(doc.step_overrides[0].suppress);
991    }
992
993    // ---- LintWaiver round-trip (Task 8) ----
994
995    #[test]
996    fn lint_waiver_round_trips() {
997        let waiver = LintWaiver {
998            rule: "column-position-drift".to_string(),
999            target: "app.users".to_string(),
1000            reason: "applied via rewrite-table; see PR #234".to_string(),
1001        };
1002
1003        // Serialize a single waiver as TOML and confirm it parses back equal.
1004        let toml_text = toml::to_string(&waiver).unwrap();
1005        let back: LintWaiver = toml::from_str(&toml_text).unwrap();
1006        assert_eq!(back, waiver);
1007    }
1008
1009    #[test]
1010    fn lint_waiver_round_trips_inside_intent_doc() {
1011        // The deserializer must accept the full intent.toml shape (including
1012        // [[intent]] rows) alongside [[lint_waiver]] rows. We use local structs
1013        // that mirror the real IntentDocDe shape. Declared before any `let`
1014        // statements to satisfy the `items_after_statements` lint.
1015        #[derive(serde::Deserialize)]
1016        #[allow(dead_code)]
1017        struct IntentRow {
1018            id: u32,
1019            step: u32,
1020            kind: String,
1021            target: String,
1022            reason: String,
1023            #[serde(default)]
1024            approved: bool,
1025        }
1026        #[derive(serde::Deserialize)]
1027        #[allow(dead_code)]
1028        struct Doc {
1029            plan_id: String,
1030            #[serde(default, rename = "intent")]
1031            intents: Vec<IntentRow>,
1032            #[serde(default, rename = "lint_waiver")]
1033            lint_waivers: Vec<LintWaiver>,
1034        }
1035
1036        // Simulate the shape that intent.toml produces: a document with a
1037        // `plan_id` key and one or more `[[lint_waiver]]` array rows.
1038        let toml_text = r#"
1039plan_id = "abc1234567890abc"
1040
1041[[intent]]
1042id = 1
1043step = 2
1044kind = "drop_table"
1045target = "app.legacy"
1046reason = "drop old table"
1047approved = false
1048
1049[[lint_waiver]]
1050rule = "column-position-drift"
1051target = "app.users"
1052reason = "rewrite-table applied; PR #234"
1053"#;
1054        let doc: Doc = toml::from_str(toml_text).unwrap();
1055        assert_eq!(doc.lint_waivers.len(), 1);
1056        assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
1057        assert_eq!(doc.lint_waivers[0].target, "app.users");
1058    }
1059
1060    // ---- Plan::from_grouped_with_id (Task 1) ----
1061
1062    #[test]
1063    fn from_grouped_with_id_uses_provided_plan_id() {
1064        // PlanId::from_hex does not exist yet (deferred to Task 6).
1065        // Fallback: compute a known id via PlanId::compute and verify
1066        // round-trip equality through from_grouped_with_id.
1067        let pre_id =
1068            PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 99).unwrap();
1069        let plan = Plan::from_grouped_with_id(
1070            vec![],
1071            pre_id,
1072            "test-cluster-id".into(),
1073            None,
1074            "0.0.0-test",
1075            99,
1076        )
1077        .expect("build empty cluster-style plan");
1078        assert_eq!(plan.id, pre_id);
1079        assert_eq!(plan.metadata.target_identity, "test-cluster-id");
1080        assert_eq!(plan.metadata.planner_ruleset_version, 99);
1081        assert!(plan.groups.is_empty());
1082        assert!(plan.intents.is_empty());
1083    }
1084
1085    #[test]
1086    fn from_grouped_with_id_target_snapshot_is_empty() {
1087        // from_grouped_with_id should always leave target_snapshot empty;
1088        // only from_grouped populates it (for per-DB drift recheck).
1089        let pre_id =
1090            PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 0).unwrap();
1091        let plan =
1092            Plan::from_grouped_with_id(vec![], pre_id, "cluster:abc".into(), None, "0.0.0-test", 0)
1093                .unwrap();
1094        assert_eq!(plan.metadata.target_snapshot, Catalog::empty());
1095    }
1096
1097    #[test]
1098    fn from_grouped_still_populates_target_snapshot() {
1099        // Regression guard: from_grouped must still override target_snapshot
1100        // after delegating to from_grouped_with_id.
1101        let target = cat_with_schema("legacy");
1102        let plan = Plan::from_grouped(
1103            Vec::new(),
1104            &Catalog::empty(),
1105            &target,
1106            "tid".into(),
1107            None,
1108            "0.1.0",
1109            1,
1110        )
1111        .unwrap();
1112        assert_eq!(plan.metadata.target_snapshot, target);
1113    }
1114
1115    #[test]
1116    fn approve_all_intents_flips_every_intent_to_approved() {
1117        let mut plan = sample_plan_with_two_unapproved_intents();
1118        assert!(!plan.intents[0].approved);
1119        assert!(!plan.intents[1].approved);
1120        plan.approve_all_intents();
1121        assert!(plan.intents[0].approved);
1122        assert!(plan.intents[1].approved);
1123    }
1124
1125    fn sample_plan_with_two_unapproved_intents() -> Plan {
1126        Plan {
1127            id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
1128            groups: Vec::new(),
1129            intents: vec![
1130                DestructiveIntent {
1131                    id: 1,
1132                    step: 1,
1133                    kind: "drop_column".into(),
1134                    target: "app.users.legacy_email".into(),
1135                    reason: "test".into(),
1136                    approved: false,
1137                },
1138                DestructiveIntent {
1139                    id: 2,
1140                    step: 2,
1141                    kind: "drop_table".into(),
1142                    target: "app.old_users".into(),
1143                    reason: "test".into(),
1144                    approved: false,
1145                },
1146            ],
1147            lint_waivers: Vec::new(),
1148            step_overrides: Vec::new(),
1149            metadata: PlanMetadata {
1150                pgevolve_version: crate::VERSION.to_string(),
1151                planner_ruleset_version: 1,
1152                source_rev: None,
1153                target_identity: "test-identity".into(),
1154                target_snapshot: Catalog::empty(),
1155                created_at: OffsetDateTime::UNIX_EPOCH,
1156                lint_at_plan_findings: Vec::new(),
1157            },
1158            advisory_findings: Vec::new(),
1159        }
1160    }
1161}