Skip to main content

pgevolve_core/plan/
plan.rs

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