Skip to main content

pgevolve_core/plan/
serialize.rs

1//! Writers for the three on-disk plan files: `plan.sql`, `intent.toml`,
2//! `manifest.toml`. See spec §7.
3
4use std::io::Write;
5use std::path::Path;
6
7use serde::Serialize;
8use time::format_description::well_known::Rfc3339;
9
10use crate::ir::catalog::Catalog;
11use crate::plan::io_error::PlanIoError;
12use crate::plan::plan::{LintWaiver, Plan, RecordedFinding, StepOverride, kind_name};
13use crate::plan::raw_step::RawStep;
14
15// ---------------------------------------------------------------------------
16// plan.sql
17// ---------------------------------------------------------------------------
18
19/// Write a plan's `plan.sql` to `w`.
20///
21/// Output is canonical bytes-out: the same plan always produces the same
22/// bytes. The only non-determinism would be `metadata.created_at`, which is
23/// captured at `Plan::from_grouped` time.
24pub fn write_plan_sql(plan: &Plan, w: &mut dyn Write) -> Result<(), PlanIoError> {
25    let created = plan
26        .metadata
27        .created_at
28        .format(&Rfc3339)
29        .map_err(|e| PlanIoError::MalformedDirective(format!("created_at format: {e}")))?;
30    writeln!(
31        w,
32        "-- @pgevolve plan id={} version={} ruleset={} created={}",
33        plan.id.short(),
34        plan.metadata.pgevolve_version,
35        plan.metadata.planner_ruleset_version,
36        created,
37    )?;
38    if let Some(rev) = &plan.metadata.source_rev {
39        writeln!(w, "-- @pgevolve source_rev={rev}")?;
40    }
41    writeln!(w, "-- @pgevolve target={}", plan.metadata.target_identity)?;
42    writeln!(w, "-- @pgevolve intents_required={}", plan.intents.len())?;
43    writeln!(w)?;
44
45    for group in &plan.groups {
46        writeln!(
47            w,
48            "-- @pgevolve group id={} transactional={}",
49            group.id, group.transactional,
50        )?;
51        if group.transactional {
52            writeln!(w, "BEGIN;")?;
53        }
54        for step in &group.steps {
55            write_step_directive(w, step)?;
56            // Steps always end their SQL with `;` (every sql:: helper appends
57            // a trailing semicolon). Trailing newline gives one statement per
58            // line block for readability.
59            writeln!(w, "{}", step.sql)?;
60        }
61        if group.transactional {
62            writeln!(w, "COMMIT;")?;
63        }
64        writeln!(w)?;
65    }
66    Ok(())
67}
68
69fn write_step_directive(w: &mut dyn Write, s: &RawStep) -> Result<(), PlanIoError> {
70    write!(
71        w,
72        "-- @pgevolve step={} kind={} destructive={}",
73        s.step_no,
74        kind_name(s.kind),
75        s.destructive,
76    )?;
77    if let Some(intent_id) = s.intent_id {
78        write!(w, " intent_id={intent_id}")?;
79    }
80    write!(w, " targets=")?;
81    for (i, t) in s.targets.iter().enumerate() {
82        if i > 0 {
83            write!(w, ",")?;
84        }
85        write!(w, "{t}")?;
86    }
87    writeln!(w)?;
88    Ok(())
89}
90
91// ---------------------------------------------------------------------------
92// intent.toml
93// ---------------------------------------------------------------------------
94
95#[derive(Serialize)]
96struct IntentDoc<'a> {
97    plan_id: String,
98    #[serde(rename = "intent")]
99    intents: Vec<IntentRow<'a>>,
100    /// `[[lint_waiver]]` rows; the field is omitted from TOML when empty.
101    /// When the plan is freshly written, this slice comes from
102    /// `plan.lint_waivers` (empty on first write; populated by the user).
103    #[serde(rename = "lint_waiver", skip_serializing_if = "Vec::is_empty")]
104    lint_waivers: Vec<&'a LintWaiver>,
105    /// `[[step_override]]` rows; omitted from TOML when empty.
106    #[serde(rename = "step_override", skip_serializing_if = "Vec::is_empty")]
107    step_overrides: Vec<&'a StepOverride>,
108}
109
110#[derive(Serialize)]
111struct IntentRow<'a> {
112    id: u32,
113    step: u32,
114    kind: &'a str,
115    target: &'a str,
116    reason: &'a str,
117    approved: bool,
118}
119
120/// Write `intent.toml` to `w`. Every row's `approved` field starts as `false`;
121/// the user must flip it explicitly before applying.
122///
123/// If `plan.lint_waivers` is non-empty (because the plan was loaded from a
124/// directory that already had waivers), those waivers are preserved in the
125/// output under `[[lint_waiver]]`.
126pub fn write_intent_toml(plan: &Plan, w: &mut dyn Write) -> Result<(), PlanIoError> {
127    let doc = IntentDoc {
128        plan_id: plan.id.short(),
129        intents: plan
130            .intents
131            .iter()
132            .map(|i| IntentRow {
133                id: i.id,
134                step: i.step,
135                kind: &i.kind,
136                target: &i.target,
137                reason: &i.reason,
138                approved: false,
139            })
140            .collect(),
141        lint_waivers: plan.lint_waivers.iter().collect(),
142        step_overrides: plan.step_overrides.iter().collect(),
143    };
144    let s = toml::to_string_pretty(&doc)?;
145    w.write_all(s.as_bytes())
146        .map_err(|e| PlanIoError::Io("intent.toml".into(), e))?;
147    Ok(())
148}
149
150// ---------------------------------------------------------------------------
151// manifest.toml
152// ---------------------------------------------------------------------------
153
154#[derive(Serialize)]
155struct ManifestDoc<'a> {
156    plan_id: String,
157    plan_hash: String,
158    pgevolve_version: &'a str,
159    planner_ruleset_version: u32,
160    source_rev: Option<&'a str>,
161    target_identity: &'a str,
162    created_at: String,
163    /// Embedded pre-image `Catalog` as a pretty-printed JSON string. (v0.1
164    /// used YAML here; we switched to JSON to drop the archived
165    /// `serde_yaml` crate. The field is still a TOML string — only the
166    /// payload format inside it changed.)
167    target_snapshot_json: String,
168    /// `LintAtPlan` findings captured at plan time. Omitted from TOML when
169    /// empty so older manifest.toml files are unchanged.
170    #[serde(skip_serializing_if = "Vec::is_empty")]
171    lint_at_plan_findings: Vec<&'a RecordedFinding>,
172}
173
174/// Write `manifest.toml` to `w`.
175///
176/// The `target_snapshot_json` field embeds the pre-image `Catalog` as
177/// pretty-printed JSON — recoverable by
178/// [`read_manifest_toml`](crate::plan::deserialize::read_manifest_toml).
179///
180/// `lint_at_plan_findings` is omitted when empty (`skip_serializing_if`).
181pub fn write_manifest_toml(plan: &Plan, w: &mut dyn Write) -> Result<(), PlanIoError> {
182    let created = plan
183        .metadata
184        .created_at
185        .format(&Rfc3339)
186        .map_err(|e| PlanIoError::MalformedDirective(format!("created_at format: {e}")))?;
187    let snapshot_json = render_catalog_json(&plan.metadata.target_snapshot)?;
188    let doc = ManifestDoc {
189        plan_id: plan.id.short(),
190        plan_hash: plan.id.to_hex(),
191        pgevolve_version: &plan.metadata.pgevolve_version,
192        planner_ruleset_version: plan.metadata.planner_ruleset_version,
193        source_rev: plan.metadata.source_rev.as_deref(),
194        target_identity: &plan.metadata.target_identity,
195        created_at: created,
196        target_snapshot_json: snapshot_json,
197        lint_at_plan_findings: plan.metadata.lint_at_plan_findings.iter().collect(),
198    };
199    let s = toml::to_string_pretty(&doc)?;
200    w.write_all(s.as_bytes())
201        .map_err(|e| PlanIoError::Io("manifest.toml".into(), e))?;
202    Ok(())
203}
204
205fn render_catalog_json(c: &Catalog) -> Result<String, PlanIoError> {
206    serde_json::to_string_pretty(c).map_err(PlanIoError::Json)
207}
208
209// ---------------------------------------------------------------------------
210// Plan::write_to_dir
211// ---------------------------------------------------------------------------
212
213/// Write a `Plan` to a directory as `plan.sql` + `intent.toml` + `manifest.toml`.
214///
215/// Creates the directory if missing. Overwrites existing files of the same name.
216pub fn write_plan_dir(plan: &Plan, dir: &Path) -> Result<(), PlanIoError> {
217    std::fs::create_dir_all(dir).map_err(|e| PlanIoError::io(dir, e))?;
218
219    let sql_path = dir.join("plan.sql");
220    let mut sql = std::fs::File::create(&sql_path).map_err(|e| PlanIoError::io(&sql_path, e))?;
221    write_plan_sql(plan, &mut sql)?;
222
223    let intent_path = dir.join("intent.toml");
224    let mut intent =
225        std::fs::File::create(&intent_path).map_err(|e| PlanIoError::io(&intent_path, e))?;
226    write_intent_toml(plan, &mut intent)?;
227
228    let manifest_path = dir.join("manifest.toml");
229    let mut manifest =
230        std::fs::File::create(&manifest_path).map_err(|e| PlanIoError::io(&manifest_path, e))?;
231    write_manifest_toml(plan, &mut manifest)?;
232
233    Ok(())
234}
235
236// ---------------------------------------------------------------------------
237// Convenience: convert io::Error → PlanIoError where the path is captured up-front.
238// ---------------------------------------------------------------------------
239
240impl From<std::io::Error> for PlanIoError {
241    fn from(e: std::io::Error) -> Self {
242        // Path-less variant; callers that know the path should use
243        // [`PlanIoError::io`] for better diagnostics. This impl is here so
244        // [`writeln!`] inside the SQL writer can return `?` cleanly.
245        Self::Io(std::path::PathBuf::new(), e)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::identifier::{Identifier, QualifiedName};
253    use crate::ir::schema::Schema;
254    use crate::plan::grouping::TransactionGroup;
255    use crate::plan::plan::{Plan, PlanMetadata};
256    use crate::plan::raw_step::{RawStep, StepKind, TransactionConstraint};
257
258    fn id_id(s: &str) -> Identifier {
259        Identifier::from_unquoted(s).unwrap()
260    }
261
262    fn qn(schema: &str, name: &str) -> QualifiedName {
263        QualifiedName::new(id_id(schema), id_id(name))
264    }
265
266    fn step(
267        kind: StepKind,
268        sql: &str,
269        destructive: bool,
270        targets: Vec<QualifiedName>,
271        c: TransactionConstraint,
272    ) -> RawStep {
273        RawStep {
274            step_no: 0,
275            kind,
276            destructive,
277            destructive_reason: destructive.then(|| "test reason".to_string()),
278            intent_id: None,
279            targets,
280            sql: sql.to_string(),
281            transactional: c,
282        }
283    }
284
285    fn simple_plan() -> Plan {
286        let groups = vec![
287            TransactionGroup {
288                id: 1,
289                transactional: true,
290                steps: vec![
291                    step(
292                        StepKind::CreateSchema,
293                        "CREATE SCHEMA app;",
294                        false,
295                        vec![qn("app", "app")],
296                        TransactionConstraint::InTransaction,
297                    ),
298                    step(
299                        StepKind::DropTable,
300                        "DROP TABLE app.legacy;",
301                        true,
302                        vec![qn("app", "legacy")],
303                        TransactionConstraint::InTransaction,
304                    ),
305                ],
306            },
307            TransactionGroup {
308                id: 2,
309                transactional: false,
310                steps: vec![step(
311                    StepKind::CreateIndexConcurrent,
312                    "CREATE INDEX CONCURRENTLY users_idx ON app.users USING btree (id);",
313                    false,
314                    vec![qn("app", "users_idx"), qn("app", "users")],
315                    TransactionConstraint::OutsideTransaction,
316                )],
317            },
318        ];
319        let mut snapshot = Catalog::empty();
320        snapshot.schemas.push(Schema::new(id_id("app")));
321        Plan::from_grouped(
322            groups,
323            &Catalog::empty(),
324            &snapshot,
325            "tid-xyz".into(),
326            Some("git:abcdef0".into()),
327            "0.1.0",
328            1,
329        )
330        .unwrap()
331    }
332
333    #[test]
334    fn plan_sql_header_contains_id_version_ruleset_and_created() {
335        let plan = simple_plan();
336        let mut out = Vec::new();
337        write_plan_sql(&plan, &mut out).unwrap();
338        let s = String::from_utf8(out).unwrap();
339        let header = s.lines().next().unwrap();
340        assert!(header.starts_with("-- @pgevolve plan id="));
341        assert!(header.contains("version=0.1.0"));
342        assert!(header.contains("ruleset=1"));
343        assert!(header.contains("created="));
344    }
345
346    #[test]
347    fn plan_sql_emits_source_rev_when_present() {
348        let plan = simple_plan();
349        let mut out = Vec::new();
350        write_plan_sql(&plan, &mut out).unwrap();
351        let s = String::from_utf8(out).unwrap();
352        assert!(s.contains("-- @pgevolve source_rev=git:abcdef0"));
353        assert!(s.contains("-- @pgevolve target=tid-xyz"));
354        assert!(s.contains("-- @pgevolve intents_required=1"));
355    }
356
357    #[test]
358    fn plan_sql_wraps_transactional_groups_in_begin_commit() {
359        let plan = simple_plan();
360        let mut out = Vec::new();
361        write_plan_sql(&plan, &mut out).unwrap();
362        let s = String::from_utf8(out).unwrap();
363        // First group is transactional, second is not.
364        let g1_pos = s.find("group id=1 transactional=true").unwrap();
365        let g2_pos = s.find("group id=2 transactional=false").unwrap();
366        let begin_pos = s.find("BEGIN;").unwrap();
367        let commit_pos = s.find("COMMIT;").unwrap();
368        assert!(g1_pos < begin_pos);
369        assert!(begin_pos < commit_pos);
370        assert!(commit_pos < g2_pos);
371        // Second group has no BEGIN/COMMIT after it.
372        assert!(!s[g2_pos..].contains("BEGIN;"));
373        assert!(!s[g2_pos..].contains("COMMIT;"));
374    }
375
376    #[test]
377    fn plan_sql_emits_step_directives_with_intent_when_destructive() {
378        let plan = simple_plan();
379        let mut out = Vec::new();
380        write_plan_sql(&plan, &mut out).unwrap();
381        let s = String::from_utf8(out).unwrap();
382        assert!(s.contains("step=1 kind=create_schema destructive=false"));
383        assert!(s.contains("step=2 kind=drop_table destructive=true intent_id=1"));
384        assert!(s.contains("step=3 kind=create_index_concurrent destructive=false"));
385        assert!(s.contains("targets=app.users_idx,app.users"));
386    }
387
388    #[test]
389    fn intent_toml_contains_one_row_per_destructive_step() {
390        let plan = simple_plan();
391        let mut out = Vec::new();
392        write_intent_toml(&plan, &mut out).unwrap();
393        let s = String::from_utf8(out).unwrap();
394        assert!(s.starts_with("plan_id ="));
395        assert!(s.contains("[[intent]]"));
396        assert!(s.contains("id = 1"));
397        assert!(s.contains("step = 2"));
398        assert!(s.contains("kind = \"drop_table\""));
399        assert!(s.contains("approved = false"));
400    }
401
402    #[test]
403    fn manifest_toml_contains_full_hex_and_embedded_json() {
404        let plan = simple_plan();
405        let mut out = Vec::new();
406        write_manifest_toml(&plan, &mut out).unwrap();
407        let s = String::from_utf8(out).unwrap();
408        assert!(s.contains("plan_id ="));
409        assert!(s.contains("plan_hash ="));
410        // 64 hex chars somewhere in the doc
411        assert!(s.lines().any(|l| {
412            l.contains("plan_hash") && l.chars().filter(char::is_ascii_hexdigit).count() >= 64
413        }));
414        assert!(s.contains("target_snapshot_json ="));
415        // Embedded JSON body — TOML emits the field as a multi-line
416        // basic-string literal, so quote-escape style depends on its
417        // chosen form. Check for substring fragments that survive either
418        // form ("schemas" + "name" appear as bare tokens with quote chars
419        // around them).
420        assert!(s.contains("schemas"));
421        assert!(s.contains("\"app\"") || s.contains("\\\"app\\\""));
422    }
423
424    #[test]
425    fn write_plan_dir_creates_three_files() {
426        let plan = simple_plan();
427        let dir = tempfile::tempdir().unwrap();
428        write_plan_dir(&plan, dir.path()).unwrap();
429        assert!(dir.path().join("plan.sql").exists());
430        assert!(dir.path().join("intent.toml").exists());
431        assert!(dir.path().join("manifest.toml").exists());
432    }
433
434    // Suppress unused-fn warnings while `PlanMetadata` isn't referenced
435    // outside via this module (the test imports it as a sanity check that
436    // it stays public).
437    fn _meta_kept_in_scope(m: PlanMetadata) -> PlanMetadata {
438        m
439    }
440}