1use 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
15pub 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 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#[derive(Serialize)]
96struct IntentDoc<'a> {
97 plan_id: String,
98 #[serde(rename = "intent")]
99 intents: Vec<IntentRow<'a>>,
100 #[serde(rename = "lint_waiver", skip_serializing_if = "Vec::is_empty")]
104 lint_waivers: Vec<&'a LintWaiver>,
105 #[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
120pub 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#[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 target_snapshot_json: String,
168 #[serde(skip_serializing_if = "Vec::is_empty")]
171 lint_at_plan_findings: Vec<&'a RecordedFinding>,
172}
173
174pub 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
209pub 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
236impl From<std::io::Error> for PlanIoError {
241 fn from(e: std::io::Error) -> Self {
242 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 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 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 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 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 fn _meta_kept_in_scope(m: PlanMetadata) -> PlanMetadata {
438 m
439 }
440}