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::AlterTableSetTablespace => "alter_table_set_tablespace",
412        K::AddColumn => "add_column",
413        K::DropColumn => "drop_column",
414        K::AlterColumnType => "alter_column_type",
415        K::SetColumnNullable => "set_column_nullable",
416        K::SetColumnDefault => "set_column_default",
417        K::SetColumnComment => "set_column_comment",
418        K::SetColumnIdentity => "set_column_identity",
419        K::SetColumnGenerated => "set_column_generated",
420        K::SetColumnStorage => "set_column_storage",
421        K::SetColumnCompression => "set_column_compression",
422        K::AddConstraint => "add_constraint",
423        K::AddConstraintNotValid => "add_constraint_not_valid",
424        K::ValidateConstraint => "validate_constraint",
425        K::DropConstraint => "drop_constraint",
426        K::SetConstraintComment => "set_constraint_comment",
427        K::CreateIndex => "create_index",
428        K::CreateIndexConcurrent => "create_index_concurrent",
429        K::DropIndex => "drop_index",
430        K::DropIndexConcurrent => "drop_index_concurrent",
431        K::CreateSequence => "create_sequence",
432        K::DropSequence => "drop_sequence",
433        K::AlterSequence => "alter_sequence",
434        K::AddCheckForNotNull => "add_check_for_not_null",
435        K::CreateView => "create_view",
436        K::DropView => "drop_view",
437        K::AlterViewSetCheckOption => "alter_view_set_check_option",
438        K::CreateMaterializedView => "create_materialized_view",
439        K::DropMaterializedView => "drop_materialized_view",
440        K::RefreshMaterializedView => "refresh_materialized_view",
441        K::AlterViewSetReloption => "alter_view_set_reloption",
442        K::CommentOnView => "comment_on_view",
443        K::CreateType => "create_type",
444        K::DropType => "drop_type",
445        K::AlterTypeAddValue => "alter_type_add_value",
446        K::AlterTypeRenameValue => "alter_type_rename_value",
447        K::AlterDomainAddConstraint => "alter_domain_add_constraint",
448        K::AlterDomainDropConstraint => "alter_domain_drop_constraint",
449        K::AlterDomainSetDefault => "alter_domain_set_default",
450        K::AlterDomainSetNotNull => "alter_domain_set_not_null",
451        K::AlterTypeAddAttribute => "alter_type_add_attribute",
452        K::AlterTypeDropAttribute => "alter_type_drop_attribute",
453        K::AlterTypeAlterAttributeType => "alter_type_alter_attribute_type",
454        K::CommentOnType => "comment_on_type",
455        K::CreateOrReplaceFunction => "create_or_replace_function",
456        K::DropFunction => "drop_function",
457        K::CommentOnFunction => "comment_on_function",
458        K::CreateOrReplaceProcedure => "create_or_replace_procedure",
459        K::DropProcedure => "drop_procedure",
460        K::CommentOnProcedure => "comment_on_procedure",
461        K::CreateExtension => "create_extension",
462        K::DropExtension => "drop_extension",
463        K::AlterExtensionUpdate => "alter_extension_update",
464        K::CommentOnExtension => "comment_on_extension",
465        K::CreateTrigger => "create_trigger",
466        K::DropTrigger => "drop_trigger",
467        K::CommentOnTrigger => "comment_on_trigger",
468        K::AttachPartition => "attach_partition",
469        K::DetachPartition => "detach_partition",
470        K::CreateRole => "create_role",
471        K::DropRole => "drop_role",
472        K::AlterRole => "alter_role",
473        K::GrantRoleMembership => "grant_role_membership",
474        K::RevokeRoleMembership => "revoke_role_membership",
475        K::CommentOnRole => "comment_on_role",
476        K::CreateTablespace => "create_tablespace",
477        K::DropTablespace => "drop_tablespace",
478        K::AlterTablespaceOwner => "alter_tablespace_owner",
479        K::SetTablespaceOptions => "set_tablespace_options",
480        K::CommentOnTablespace => "comment_on_tablespace",
481        K::AlterObjectOwner => "alter_object_owner",
482        K::GrantObjectPrivilege => "grant_object_privilege",
483        K::RevokeObjectPrivilege => "revoke_object_privilege",
484        K::GrantColumnPrivilege => "grant_column_privilege",
485        K::RevokeColumnPrivilege => "revoke_column_privilege",
486        K::AlterDefaultPrivileges => "alter_default_privileges",
487        K::CreatePolicy => "create_policy",
488        K::DropPolicy => "drop_policy",
489        K::AlterPolicy => "alter_policy",
490        K::SetTableRowSecurity => "set_table_row_security",
491        K::SetTableForceRowSecurity => "set_table_force_row_security",
492        K::SetTableStorage => "set_table_storage",
493        K::SetIndexStorage => "set_index_storage",
494        K::SetMaterializedViewStorage => "set_materialized_view_storage",
495        K::CreatePublication => "create_publication",
496        K::DropPublication => "drop_publication",
497        K::ReplacePublication => "replace_publication",
498        K::AlterPublicationAddTable => "alter_publication_add_table",
499        K::AlterPublicationDropTable => "alter_publication_drop_table",
500        K::AlterPublicationSetTable => "alter_publication_set_table",
501        K::AlterPublicationAddSchema => "alter_publication_add_schema",
502        K::AlterPublicationDropSchema => "alter_publication_drop_schema",
503        K::AlterPublicationSetPublish => "alter_publication_set_publish",
504        K::AlterPublicationSetViaRoot => "alter_publication_set_via_root",
505        K::CommentOnPublication => "comment_on_publication",
506        K::CreateSubscription => "create_subscription",
507        K::DropSubscription => "drop_subscription",
508        K::AlterSubscriptionConnection => "alter_subscription_connection",
509        K::AlterSubscriptionAddPublication => "alter_subscription_add_publication",
510        K::AlterSubscriptionDropPublication => "alter_subscription_drop_publication",
511        K::AlterSubscriptionSetOptions => "alter_subscription_set_options",
512        K::CommentOnSubscription => "comment_on_subscription",
513        K::CreateStatistic => "create_statistic",
514        K::DropStatistic => "drop_statistic",
515        K::ReplaceStatistic => "replace_statistic",
516        K::AlterStatisticSetTarget => "alter_statistic_set_target",
517        K::CommentOnStatistic => "comment_on_statistic",
518        K::CreateCollation => "create_collation",
519        K::DropCollation => "drop_collation",
520        K::RenameCollation => "rename_collation",
521        K::ReplaceCollation => "replace_collation",
522        K::CommentOnCollation => "comment_on_collation",
523        K::CreateEventTrigger => "create_event_trigger",
524        K::DropEventTrigger => "drop_event_trigger",
525        K::AlterEventTriggerEnable => "alter_event_trigger_enable",
526        K::AlterEventTriggerOwner => "alter_event_trigger_owner",
527        K::CommentOnEventTrigger => "comment_on_event_trigger",
528        K::CreateAggregate => "create_aggregate",
529        K::DropAggregate => "drop_aggregate",
530        K::AlterAggregateOwner => "alter_aggregate_owner",
531        K::CommentOnAggregate => "comment_on_aggregate",
532        K::CreateCast => "create_cast",
533        K::DropCast => "drop_cast",
534        K::CommentOnCast => "comment_on_cast",
535        K::CreateTsDictionary => "create_ts_dictionary",
536        K::DropTsDictionary => "drop_ts_dictionary",
537        K::AlterTsDictionary => "alter_ts_dictionary",
538        K::AlterTsDictionaryOwner => "alter_ts_dictionary_owner",
539        K::CommentOnTsDictionary => "comment_on_ts_dictionary",
540        K::CreateTsConfiguration => "create_ts_configuration",
541        K::DropTsConfiguration => "drop_ts_configuration",
542        K::AddTsConfigMapping => "add_ts_config_mapping",
543        K::AlterTsConfigMapping => "alter_ts_config_mapping",
544        K::DropTsConfigMapping => "drop_ts_config_mapping",
545        K::AlterTsConfigurationOwner => "alter_ts_configuration_owner",
546        K::CommentOnTsConfiguration => "comment_on_ts_configuration",
547    }
548}
549
550/// Parse [`kind_name`]'s output back into [`crate::plan::StepKind`].
551#[allow(clippy::too_many_lines)] // One arm per StepKind variant — extraction would obscure intent.
552pub fn parse_kind_name(s: &str) -> Option<crate::plan::raw_step::StepKind> {
553    use crate::plan::raw_step::StepKind as K;
554    Some(match s {
555        "create_schema" => K::CreateSchema,
556        "drop_schema" => K::DropSchema,
557        "alter_schema_comment" => K::AlterSchemaComment,
558        "create_table" => K::CreateTable,
559        "drop_table" => K::DropTable,
560        "alter_table_set_comment" => K::AlterTableSetComment,
561        "alter_table_set_tablespace" => K::AlterTableSetTablespace,
562        "add_column" => K::AddColumn,
563        "drop_column" => K::DropColumn,
564        "alter_column_type" => K::AlterColumnType,
565        "set_column_nullable" => K::SetColumnNullable,
566        "set_column_default" => K::SetColumnDefault,
567        "set_column_comment" => K::SetColumnComment,
568        "set_column_identity" => K::SetColumnIdentity,
569        "set_column_generated" => K::SetColumnGenerated,
570        "set_column_storage" => K::SetColumnStorage,
571        "set_column_compression" => K::SetColumnCompression,
572        "add_constraint" => K::AddConstraint,
573        "add_constraint_not_valid" => K::AddConstraintNotValid,
574        "validate_constraint" => K::ValidateConstraint,
575        "drop_constraint" => K::DropConstraint,
576        "set_constraint_comment" => K::SetConstraintComment,
577        "create_index" => K::CreateIndex,
578        "create_index_concurrent" => K::CreateIndexConcurrent,
579        "drop_index" => K::DropIndex,
580        "drop_index_concurrent" => K::DropIndexConcurrent,
581        "create_sequence" => K::CreateSequence,
582        "drop_sequence" => K::DropSequence,
583        "alter_sequence" => K::AlterSequence,
584        "add_check_for_not_null" => K::AddCheckForNotNull,
585        "create_view" => K::CreateView,
586        "drop_view" => K::DropView,
587        "alter_view_set_check_option" => K::AlterViewSetCheckOption,
588        "create_materialized_view" => K::CreateMaterializedView,
589        "drop_materialized_view" => K::DropMaterializedView,
590        "refresh_materialized_view" => K::RefreshMaterializedView,
591        "alter_view_set_reloption" => K::AlterViewSetReloption,
592        "comment_on_view" => K::CommentOnView,
593        "create_type" => K::CreateType,
594        "drop_type" => K::DropType,
595        "alter_type_add_value" => K::AlterTypeAddValue,
596        "alter_type_rename_value" => K::AlterTypeRenameValue,
597        "alter_domain_add_constraint" => K::AlterDomainAddConstraint,
598        "alter_domain_drop_constraint" => K::AlterDomainDropConstraint,
599        "alter_domain_set_default" => K::AlterDomainSetDefault,
600        "alter_domain_set_not_null" => K::AlterDomainSetNotNull,
601        "alter_type_add_attribute" => K::AlterTypeAddAttribute,
602        "alter_type_drop_attribute" => K::AlterTypeDropAttribute,
603        "alter_type_alter_attribute_type" => K::AlterTypeAlterAttributeType,
604        "comment_on_type" => K::CommentOnType,
605        "create_or_replace_function" => K::CreateOrReplaceFunction,
606        "drop_function" => K::DropFunction,
607        "comment_on_function" => K::CommentOnFunction,
608        "create_or_replace_procedure" => K::CreateOrReplaceProcedure,
609        "drop_procedure" => K::DropProcedure,
610        "comment_on_procedure" => K::CommentOnProcedure,
611        "create_extension" => K::CreateExtension,
612        "drop_extension" => K::DropExtension,
613        "alter_extension_update" => K::AlterExtensionUpdate,
614        "comment_on_extension" => K::CommentOnExtension,
615        "create_trigger" => K::CreateTrigger,
616        "drop_trigger" => K::DropTrigger,
617        "comment_on_trigger" => K::CommentOnTrigger,
618        "attach_partition" => K::AttachPartition,
619        "detach_partition" => K::DetachPartition,
620        "create_role" => K::CreateRole,
621        "drop_role" => K::DropRole,
622        "alter_role" => K::AlterRole,
623        "grant_role_membership" => K::GrantRoleMembership,
624        "revoke_role_membership" => K::RevokeRoleMembership,
625        "comment_on_role" => K::CommentOnRole,
626        "create_tablespace" => K::CreateTablespace,
627        "drop_tablespace" => K::DropTablespace,
628        "alter_tablespace_owner" => K::AlterTablespaceOwner,
629        "set_tablespace_options" => K::SetTablespaceOptions,
630        "comment_on_tablespace" => K::CommentOnTablespace,
631        "alter_object_owner" => K::AlterObjectOwner,
632        "grant_object_privilege" => K::GrantObjectPrivilege,
633        "revoke_object_privilege" => K::RevokeObjectPrivilege,
634        "grant_column_privilege" => K::GrantColumnPrivilege,
635        "revoke_column_privilege" => K::RevokeColumnPrivilege,
636        "alter_default_privileges" => K::AlterDefaultPrivileges,
637        "create_policy" => K::CreatePolicy,
638        "drop_policy" => K::DropPolicy,
639        "alter_policy" => K::AlterPolicy,
640        "set_table_row_security" => K::SetTableRowSecurity,
641        "set_table_force_row_security" => K::SetTableForceRowSecurity,
642        "set_table_storage" => K::SetTableStorage,
643        "set_index_storage" => K::SetIndexStorage,
644        "set_materialized_view_storage" => K::SetMaterializedViewStorage,
645        "create_publication" => K::CreatePublication,
646        "drop_publication" => K::DropPublication,
647        "replace_publication" => K::ReplacePublication,
648        "alter_publication_add_table" => K::AlterPublicationAddTable,
649        "alter_publication_drop_table" => K::AlterPublicationDropTable,
650        "alter_publication_set_table" => K::AlterPublicationSetTable,
651        "alter_publication_add_schema" => K::AlterPublicationAddSchema,
652        "alter_publication_drop_schema" => K::AlterPublicationDropSchema,
653        "alter_publication_set_publish" => K::AlterPublicationSetPublish,
654        "alter_publication_set_via_root" => K::AlterPublicationSetViaRoot,
655        "comment_on_publication" => K::CommentOnPublication,
656        "create_subscription" => K::CreateSubscription,
657        "drop_subscription" => K::DropSubscription,
658        "alter_subscription_connection" => K::AlterSubscriptionConnection,
659        "alter_subscription_add_publication" => K::AlterSubscriptionAddPublication,
660        "alter_subscription_drop_publication" => K::AlterSubscriptionDropPublication,
661        "alter_subscription_set_options" => K::AlterSubscriptionSetOptions,
662        "comment_on_subscription" => K::CommentOnSubscription,
663        "create_statistic" => K::CreateStatistic,
664        "drop_statistic" => K::DropStatistic,
665        "replace_statistic" => K::ReplaceStatistic,
666        "alter_statistic_set_target" => K::AlterStatisticSetTarget,
667        "comment_on_statistic" => K::CommentOnStatistic,
668        "create_collation" => K::CreateCollation,
669        "drop_collation" => K::DropCollation,
670        "rename_collation" => K::RenameCollation,
671        "replace_collation" => K::ReplaceCollation,
672        "comment_on_collation" => K::CommentOnCollation,
673        "create_event_trigger" => K::CreateEventTrigger,
674        "drop_event_trigger" => K::DropEventTrigger,
675        "alter_event_trigger_enable" => K::AlterEventTriggerEnable,
676        "alter_event_trigger_owner" => K::AlterEventTriggerOwner,
677        "comment_on_event_trigger" => K::CommentOnEventTrigger,
678        "create_aggregate" => K::CreateAggregate,
679        "drop_aggregate" => K::DropAggregate,
680        "alter_aggregate_owner" => K::AlterAggregateOwner,
681        "comment_on_aggregate" => K::CommentOnAggregate,
682        "create_cast" => K::CreateCast,
683        "drop_cast" => K::DropCast,
684        "comment_on_cast" => K::CommentOnCast,
685        "create_ts_dictionary" => K::CreateTsDictionary,
686        "drop_ts_dictionary" => K::DropTsDictionary,
687        "alter_ts_dictionary" => K::AlterTsDictionary,
688        "alter_ts_dictionary_owner" => K::AlterTsDictionaryOwner,
689        "comment_on_ts_dictionary" => K::CommentOnTsDictionary,
690        "create_ts_configuration" => K::CreateTsConfiguration,
691        "drop_ts_configuration" => K::DropTsConfiguration,
692        "add_ts_config_mapping" => K::AddTsConfigMapping,
693        "alter_ts_config_mapping" => K::AlterTsConfigMapping,
694        "drop_ts_config_mapping" => K::DropTsConfigMapping,
695        "alter_ts_configuration_owner" => K::AlterTsConfigurationOwner,
696        "comment_on_ts_configuration" => K::CommentOnTsConfiguration,
697        _ => return None,
698    })
699}
700
701/// Render a step's `targets` list as a comma-separated string of qnames.
702fn render_targets(targets: &[crate::identifier::QualifiedName]) -> String {
703    let mut s = String::new();
704    for (i, t) in targets.iter().enumerate() {
705        if i > 0 {
706            s.push(',');
707        }
708        s.push_str(&t.render_sql());
709    }
710    s
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::identifier::Identifier;
717    use crate::ir::schema::Schema;
718
719    fn id_id(s: &str) -> Identifier {
720        Identifier::from_unquoted(s).unwrap()
721    }
722
723    fn cat_with_schema(name: &str) -> Catalog {
724        let mut c = Catalog::empty();
725        c.schemas.push(Schema::new(id_id(name)));
726        c
727    }
728
729    #[test]
730    fn plan_id_is_deterministic_across_calls() {
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", 1).unwrap();
735        assert_eq!(a, b);
736    }
737
738    #[test]
739    fn plan_id_differs_when_target_differs() {
740        let s = cat_with_schema("app");
741        let a = PlanId::compute(&s, &Catalog::empty(), "0.1.0", 1).unwrap();
742        let b = PlanId::compute(&s, &cat_with_schema("legacy"), "0.1.0", 1).unwrap();
743        assert_ne!(a, b);
744    }
745
746    #[test]
747    fn plan_id_differs_when_version_differs() {
748        let s = cat_with_schema("app");
749        let t = Catalog::empty();
750        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
751        let b = PlanId::compute(&s, &t, "0.2.0", 1).unwrap();
752        assert_ne!(a, b);
753    }
754
755    #[test]
756    fn plan_id_differs_when_ruleset_differs() {
757        let s = cat_with_schema("app");
758        let t = Catalog::empty();
759        let a = PlanId::compute(&s, &t, "0.1.0", 1).unwrap();
760        let b = PlanId::compute(&s, &t, "0.1.0", 2).unwrap();
761        assert_ne!(a, b);
762    }
763
764    #[test]
765    fn plan_id_short_is_sixteen_hex_chars() {
766        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
767        let short = id.short();
768        assert_eq!(short.len(), 16);
769        assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
770    }
771
772    #[test]
773    fn plan_id_full_hex_round_trips() {
774        let id = PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap();
775        let hex = id.to_hex();
776        assert_eq!(hex.len(), 64);
777        let back = PlanId::from_full_hex(&hex).unwrap();
778        assert_eq!(id, back);
779    }
780
781    // ---- Plan::from_grouped (Task 7.2) ----
782
783    use crate::identifier::QualifiedName;
784    use crate::plan::grouping::TransactionGroup;
785    use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
786
787    fn qn(schema: &str, name: &str) -> QualifiedName {
788        QualifiedName::new(id_id(schema), id_id(name))
789    }
790
791    fn step(kind: StepKind, destructive: bool, targets: Vec<QualifiedName>) -> RawStep {
792        RawStep {
793            step_no: 0,
794            kind,
795            destructive,
796            destructive_reason: destructive.then(|| "test".to_string()),
797            intent_id: None,
798            targets,
799            sql: String::new(),
800            transactional: TransactionConstraint::InTransaction,
801        }
802    }
803
804    fn group(id: u32, steps: Vec<RawStep>) -> TransactionGroup {
805        TransactionGroup {
806            id,
807            transactional: true,
808            steps,
809        }
810    }
811
812    #[test]
813    fn from_grouped_assigns_step_numbers_contiguously() {
814        let groups = vec![
815            group(
816                1,
817                vec![
818                    step(StepKind::CreateSchema, false, vec![qn("app", "app")]),
819                    step(StepKind::CreateTable, false, vec![qn("app", "users")]),
820                ],
821            ),
822            group(
823                2,
824                vec![step(StepKind::DropColumn, true, vec![qn("app", "users")])],
825            ),
826        ];
827        let plan = Plan::from_grouped(
828            groups,
829            &Catalog::empty(),
830            &Catalog::empty(),
831            "tid".into(),
832            None,
833            "0.1.0",
834            1,
835        )
836        .unwrap();
837        let nos: Vec<u32> = plan
838            .groups
839            .iter()
840            .flat_map(|g| g.steps.iter().map(|s| s.step_no))
841            .collect();
842        assert_eq!(nos, vec![1, 2, 3]);
843    }
844
845    #[test]
846    fn from_grouped_allocates_one_intent_per_destructive_step() {
847        let groups = vec![group(
848            1,
849            vec![
850                step(StepKind::CreateTable, false, vec![qn("app", "x")]),
851                step(StepKind::DropColumn, true, vec![qn("app", "x")]),
852                step(StepKind::DropTable, true, vec![qn("app", "y")]),
853            ],
854        )];
855        let plan = Plan::from_grouped(
856            groups,
857            &Catalog::empty(),
858            &Catalog::empty(),
859            "tid".into(),
860            None,
861            "0.1.0",
862            1,
863        )
864        .unwrap();
865        assert_eq!(plan.intents.len(), 2);
866        assert_eq!(plan.intents[0].id, 1);
867        assert_eq!(plan.intents[0].step, 2);
868        assert_eq!(plan.intents[0].kind, "drop_column");
869        assert_eq!(plan.intents[1].id, 2);
870        assert_eq!(plan.intents[1].step, 3);
871        assert_eq!(plan.intents[1].kind, "drop_table");
872        // The destructive steps carry back their intent ids.
873        let intent_ids: Vec<Option<u32>> = plan
874            .groups
875            .iter()
876            .flat_map(|g| g.steps.iter().map(|s| s.intent_id))
877            .collect();
878        assert_eq!(intent_ids, vec![None, Some(1), Some(2)]);
879    }
880
881    #[test]
882    fn from_grouped_metadata_captures_target_snapshot() {
883        let target = cat_with_schema("legacy");
884        let plan = Plan::from_grouped(
885            Vec::new(),
886            &Catalog::empty(),
887            &target,
888            "tid".into(),
889            Some("git:abc".into()),
890            "0.1.0",
891            1,
892        )
893        .unwrap();
894        assert_eq!(plan.metadata.target_snapshot, target);
895        assert_eq!(plan.metadata.source_rev.as_deref(), Some("git:abc"));
896        assert_eq!(plan.metadata.target_identity, "tid");
897    }
898
899    #[test]
900    fn kind_name_round_trips_via_parse() {
901        for k in [
902            StepKind::CreateSchema,
903            StepKind::DropColumn,
904            StepKind::CreateIndexConcurrent,
905            StepKind::AddCheckForNotNull,
906        ] {
907            assert_eq!(parse_kind_name(kind_name(k)), Some(k));
908        }
909    }
910
911    #[test]
912    fn ts_configuration_step_kinds_round_trip_through_kind_name() {
913        for k in [
914            StepKind::CreateTsConfiguration,
915            StepKind::DropTsConfiguration,
916            StepKind::AddTsConfigMapping,
917            StepKind::AlterTsConfigMapping,
918            StepKind::DropTsConfigMapping,
919            StepKind::AlterTsConfigurationOwner,
920            StepKind::CommentOnTsConfiguration,
921        ] {
922            let n = kind_name(k);
923            let parsed = parse_kind_name(n).unwrap();
924            assert_eq!(parsed, k, "round-trip failed for {n}");
925        }
926    }
927
928    #[test]
929    fn user_type_step_kinds_round_trip_through_kind_name() {
930        for k in [
931            StepKind::CreateType,
932            StepKind::DropType,
933            StepKind::AlterTypeAddValue,
934            StepKind::AlterTypeRenameValue,
935            StepKind::AlterDomainAddConstraint,
936            StepKind::AlterDomainDropConstraint,
937            StepKind::AlterDomainSetDefault,
938            StepKind::AlterDomainSetNotNull,
939            StepKind::AlterTypeAddAttribute,
940            StepKind::AlterTypeDropAttribute,
941            StepKind::AlterTypeAlterAttributeType,
942            StepKind::CommentOnType,
943        ] {
944            let n = kind_name(k);
945            let parsed = parse_kind_name(n).unwrap();
946            assert_eq!(parsed, k, "round-trip failed for {n}");
947        }
948    }
949
950    #[test]
951    fn routine_step_kinds_round_trip_through_kind_name() {
952        for k in [
953            StepKind::CreateOrReplaceFunction,
954            StepKind::DropFunction,
955            StepKind::CommentOnFunction,
956            StepKind::CreateOrReplaceProcedure,
957            StepKind::DropProcedure,
958            StepKind::CommentOnProcedure,
959        ] {
960            let n = kind_name(k);
961            let parsed = parse_kind_name(n).unwrap();
962            assert_eq!(parsed, k, "round-trip failed for {n}");
963        }
964    }
965
966    #[test]
967    fn tablespace_step_kinds_round_trip_through_kind_name() {
968        for k in [
969            StepKind::CreateTablespace,
970            StepKind::DropTablespace,
971            StepKind::AlterTablespaceOwner,
972            StepKind::SetTablespaceOptions,
973            StepKind::CommentOnTablespace,
974        ] {
975            let n = kind_name(k);
976            let parsed = parse_kind_name(n).unwrap();
977            assert_eq!(parsed, k, "round-trip failed for {n}");
978        }
979    }
980
981    #[test]
982    fn plan_id_from_invalid_hex_errors() {
983        assert!(PlanId::from_full_hex("not-hex").is_err());
984        assert!(PlanId::from_full_hex(&"ab".repeat(10)).is_err()); // wrong length
985    }
986
987    // ---- StepOverride round-trip (Task 9) ----
988
989    #[test]
990    fn step_override_round_trips() {
991        let override_ = StepOverride {
992            kind: "refresh_materialized_view".to_string(),
993            target: "app.daily_revenue".to_string(),
994            suppress: true,
995        };
996        // Serialize a single StepOverride as TOML and confirm it parses back equal.
997        let toml_text = toml::to_string(&override_).unwrap();
998        let back: StepOverride = toml::from_str(&toml_text).unwrap();
999        assert_eq!(back, override_);
1000    }
1001
1002    #[test]
1003    fn step_override_suppress_defaults_to_false() {
1004        let toml_text = r#"kind = "refresh_materialized_view"
1005target = "app.daily_revenue"
1006"#;
1007        let back: StepOverride = toml::from_str(toml_text).unwrap();
1008        assert!(!back.suppress);
1009    }
1010
1011    #[test]
1012    fn step_override_round_trips_inside_intent_doc() {
1013        #[derive(serde::Deserialize)]
1014        #[allow(dead_code)]
1015        struct Doc {
1016            plan_id: String,
1017            #[serde(default, rename = "step_override")]
1018            step_overrides: Vec<StepOverride>,
1019        }
1020
1021        let toml_text = r#"
1022plan_id = "abc1234567890abc"
1023
1024[[step_override]]
1025kind = "refresh_materialized_view"
1026target = "app.daily_revenue"
1027suppress = true
1028"#;
1029        let doc: Doc = toml::from_str(toml_text).unwrap();
1030        assert_eq!(doc.step_overrides.len(), 1);
1031        assert_eq!(doc.step_overrides[0].kind, "refresh_materialized_view");
1032        assert_eq!(doc.step_overrides[0].target, "app.daily_revenue");
1033        assert!(doc.step_overrides[0].suppress);
1034    }
1035
1036    // ---- LintWaiver round-trip (Task 8) ----
1037
1038    #[test]
1039    fn lint_waiver_round_trips() {
1040        let waiver = LintWaiver {
1041            rule: "column-position-drift".to_string(),
1042            target: "app.users".to_string(),
1043            reason: "applied via rewrite-table; see PR #234".to_string(),
1044        };
1045
1046        // Serialize a single waiver as TOML and confirm it parses back equal.
1047        let toml_text = toml::to_string(&waiver).unwrap();
1048        let back: LintWaiver = toml::from_str(&toml_text).unwrap();
1049        assert_eq!(back, waiver);
1050    }
1051
1052    #[test]
1053    fn lint_waiver_round_trips_inside_intent_doc() {
1054        // The deserializer must accept the full intent.toml shape (including
1055        // [[intent]] rows) alongside [[lint_waiver]] rows. We use local structs
1056        // that mirror the real IntentDocDe shape. Declared before any `let`
1057        // statements to satisfy the `items_after_statements` lint.
1058        #[derive(serde::Deserialize)]
1059        #[allow(dead_code)]
1060        struct IntentRow {
1061            id: u32,
1062            step: u32,
1063            kind: String,
1064            target: String,
1065            reason: String,
1066            #[serde(default)]
1067            approved: bool,
1068        }
1069        #[derive(serde::Deserialize)]
1070        #[allow(dead_code)]
1071        struct Doc {
1072            plan_id: String,
1073            #[serde(default, rename = "intent")]
1074            intents: Vec<IntentRow>,
1075            #[serde(default, rename = "lint_waiver")]
1076            lint_waivers: Vec<LintWaiver>,
1077        }
1078
1079        // Simulate the shape that intent.toml produces: a document with a
1080        // `plan_id` key and one or more `[[lint_waiver]]` array rows.
1081        let toml_text = r#"
1082plan_id = "abc1234567890abc"
1083
1084[[intent]]
1085id = 1
1086step = 2
1087kind = "drop_table"
1088target = "app.legacy"
1089reason = "drop old table"
1090approved = false
1091
1092[[lint_waiver]]
1093rule = "column-position-drift"
1094target = "app.users"
1095reason = "rewrite-table applied; PR #234"
1096"#;
1097        let doc: Doc = toml::from_str(toml_text).unwrap();
1098        assert_eq!(doc.lint_waivers.len(), 1);
1099        assert_eq!(doc.lint_waivers[0].rule, "column-position-drift");
1100        assert_eq!(doc.lint_waivers[0].target, "app.users");
1101    }
1102
1103    // ---- Plan::from_grouped_with_id (Task 1) ----
1104
1105    #[test]
1106    fn from_grouped_with_id_uses_provided_plan_id() {
1107        // PlanId::from_hex does not exist yet (deferred to Task 6).
1108        // Fallback: compute a known id via PlanId::compute and verify
1109        // round-trip equality through from_grouped_with_id.
1110        let pre_id =
1111            PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 99).unwrap();
1112        let plan = Plan::from_grouped_with_id(
1113            vec![],
1114            pre_id,
1115            "test-cluster-id".into(),
1116            None,
1117            "0.0.0-test",
1118            99,
1119        )
1120        .expect("build empty cluster-style plan");
1121        assert_eq!(plan.id, pre_id);
1122        assert_eq!(plan.metadata.target_identity, "test-cluster-id");
1123        assert_eq!(plan.metadata.planner_ruleset_version, 99);
1124        assert!(plan.groups.is_empty());
1125        assert!(plan.intents.is_empty());
1126    }
1127
1128    #[test]
1129    fn from_grouped_with_id_target_snapshot_is_empty() {
1130        // from_grouped_with_id should always leave target_snapshot empty;
1131        // only from_grouped populates it (for per-DB drift recheck).
1132        let pre_id =
1133            PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.0.0-test", 0).unwrap();
1134        let plan =
1135            Plan::from_grouped_with_id(vec![], pre_id, "cluster:abc".into(), None, "0.0.0-test", 0)
1136                .unwrap();
1137        assert_eq!(plan.metadata.target_snapshot, Catalog::empty());
1138    }
1139
1140    #[test]
1141    fn from_grouped_still_populates_target_snapshot() {
1142        // Regression guard: from_grouped must still override target_snapshot
1143        // after delegating to from_grouped_with_id.
1144        let target = cat_with_schema("legacy");
1145        let plan = Plan::from_grouped(
1146            Vec::new(),
1147            &Catalog::empty(),
1148            &target,
1149            "tid".into(),
1150            None,
1151            "0.1.0",
1152            1,
1153        )
1154        .unwrap();
1155        assert_eq!(plan.metadata.target_snapshot, target);
1156    }
1157
1158    #[test]
1159    fn approve_all_intents_flips_every_intent_to_approved() {
1160        let mut plan = sample_plan_with_two_unapproved_intents();
1161        assert!(!plan.intents[0].approved);
1162        assert!(!plan.intents[1].approved);
1163        plan.approve_all_intents();
1164        assert!(plan.intents[0].approved);
1165        assert!(plan.intents[1].approved);
1166    }
1167
1168    fn sample_plan_with_two_unapproved_intents() -> Plan {
1169        Plan {
1170            id: PlanId::compute(&Catalog::empty(), &Catalog::empty(), "0.1.0", 1).unwrap(),
1171            groups: Vec::new(),
1172            intents: vec![
1173                DestructiveIntent {
1174                    id: 1,
1175                    step: 1,
1176                    kind: "drop_column".into(),
1177                    target: "app.users.legacy_email".into(),
1178                    reason: "test".into(),
1179                    approved: false,
1180                },
1181                DestructiveIntent {
1182                    id: 2,
1183                    step: 2,
1184                    kind: "drop_table".into(),
1185                    target: "app.old_users".into(),
1186                    reason: "test".into(),
1187                    approved: false,
1188                },
1189            ],
1190            lint_waivers: Vec::new(),
1191            step_overrides: Vec::new(),
1192            metadata: PlanMetadata {
1193                pgevolve_version: crate::VERSION.to_string(),
1194                planner_ruleset_version: 1,
1195                source_rev: None,
1196                target_identity: "test-identity".into(),
1197                target_snapshot: Catalog::empty(),
1198                created_at: OffsetDateTime::UNIX_EPOCH,
1199                lint_at_plan_findings: Vec::new(),
1200            },
1201            advisory_findings: Vec::new(),
1202        }
1203    }
1204}