Skip to main content

pgevolve_core/plan/
plan.rs

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