pgmt 0.4.8

PostgreSQL migration tool that keeps your schema files as the source of truth
Documentation
//! Shared output formatting for diff commands
//!
//! This module provides reusable output formatting for both `pgmt diff` and `pgmt migrate diff`.

use crate::catalog::Catalog;
use crate::diff::operations::{FunctionOperation, MigrationStep, SqlRenderer, ViewOperation};
use anyhow::Result;
use std::collections::HashMap;

/// Output format for diff results
#[derive(Debug, Clone, PartialEq, Default, clap::ValueEnum)]
pub enum DiffFormat {
    /// Detailed before/after comparison
    #[default]
    Detailed,
    /// Quick summary of changes
    Summary,
    /// SQL operations to fix differences
    Sql,
    /// JSON output for CI/CD
    Json,
}

/// Context for diff output - describes what's being compared
pub struct DiffContext {
    pub from_description: String,
    pub to_description: String,
}

impl DiffContext {
    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
        Self {
            from_description: from.into(),
            to_description: to.into(),
        }
    }
}

/// Output diff results based on format
pub fn output_diff(
    steps: &[MigrationStep],
    format: &DiffFormat,
    context: &DiffContext,
    from_catalog: &Catalog,
    to_catalog: &Catalog,
    output_file: Option<&str>,
) -> Result<()> {
    match format {
        DiffFormat::Sql => output_sql_format(steps, context, output_file),
        DiffFormat::Summary => {
            output_summary_format(steps);
            Ok(())
        }
        DiffFormat::Detailed => output_detailed_format(steps, from_catalog, to_catalog),
        DiffFormat::Json => output_json_format(steps, context),
    }
}

/// Check if there are differences and return appropriate exit code behavior
pub fn has_differences(steps: &[MigrationStep]) -> bool {
    !steps.is_empty()
}

fn output_sql_format(
    steps: &[MigrationStep],
    context: &DiffContext,
    output_file: Option<&str>,
) -> Result<()> {
    let mut output = String::new();
    output.push_str(&format!(
        "-- SQL to bring {} in sync with {}\n",
        context.from_description, context.to_description
    ));
    output.push_str("-- Generated by pgmt\n\n");

    for step in steps {
        for rendered in step.to_sql() {
            output.push_str(&rendered.sql);
            output.push_str(";\n\n");
        }
    }

    if let Some(file_path) = output_file {
        std::fs::write(file_path, &output)?;
        println!("SQL saved to {}", file_path);
    } else {
        println!("{}", output);
    }

    Ok(())
}

fn output_summary_format(steps: &[MigrationStep]) {
    if steps.is_empty() {
        println!("No differences found.");
        return;
    }

    println!("Schema Diff Summary:\n");

    // Count changes by type
    let mut counts: HashMap<&str, usize> = HashMap::new();

    for step in steps {
        let type_name = match step {
            MigrationStep::Table(_) => "Tables",
            MigrationStep::View(_) => "Views",
            MigrationStep::Function(_) => "Functions",
            MigrationStep::Aggregate(_) => "Aggregates",
            MigrationStep::Index(_) => "Indexes",
            MigrationStep::Sequence(_) => "Sequences",
            MigrationStep::Schema(_) => "Schemas",
            MigrationStep::Extension(_) => "Extensions",
            MigrationStep::Trigger(_) => "Triggers",
            MigrationStep::Policy(_) => "Policies",
            MigrationStep::Type(_) => "Custom Types",
            MigrationStep::Domain(_) => "Domains",
            MigrationStep::Grant(_) => "Grants",
            MigrationStep::Constraint(_) => "Constraints",
        };

        *counts.entry(type_name).or_insert(0) += 1;
    }

    // Print summary
    for (type_name, count) in counts.iter() {
        println!("{:<15} {} changes", format!("{}:", type_name), count);
    }

    let destructive_count = steps.iter().filter(|s| s.has_destructive_sql()).count();
    let safe_count = steps.len() - destructive_count;

    println!("\nTotal changes: {}", steps.len());
    println!("  Safe: {}", safe_count);
    println!("  Destructive: {}", destructive_count);

    println!("\nRun with --format detailed to see what changed");
    println!("Run with --format sql to generate remediation SQL");
}

fn output_detailed_format(
    steps: &[MigrationStep],
    from_catalog: &Catalog,
    to_catalog: &Catalog,
) -> Result<()> {
    if steps.is_empty() {
        println!("No differences found.");
        return Ok(());
    }

    println!("Found {} differences:\n", steps.len());
    println!("{}", "".repeat(70));

    for (i, step) in steps.iter().enumerate() {
        println!("\n{}. {:?}", i + 1, step.id());
        println!(
            "Status: {}",
            if step.has_destructive_sql() {
                "DESTRUCTIVE"
            } else {
                "SAFE"
            }
        );

        // Show actual diff for views and functions
        match step {
            MigrationStep::View(op) => {
                if let Some(diff) = get_view_diff(op, from_catalog, to_catalog) {
                    println!("\n{}", diff);
                } else {
                    print_sql_for_step(step);
                }
            }
            MigrationStep::Function(op) => {
                if let Some(diff) = get_function_diff(op, from_catalog, to_catalog) {
                    println!("\n{}", diff);
                } else {
                    print_sql_for_step(step);
                }
            }
            _ => {
                print_sql_for_step(step);
            }
        }

        println!("\n{}", "".repeat(70));
    }

    let destructive_count = steps.iter().filter(|s| s.has_destructive_sql()).count();
    let safe_count = steps.len() - destructive_count;

    println!("\nSummary:");
    println!("  Safe operations: {}", safe_count);
    println!("  Destructive operations: {}", destructive_count);

    Ok(())
}

fn print_sql_for_step(step: &MigrationStep) {
    for rendered in step.to_sql() {
        println!("\nSQL:");
        println!("{}", rendered.sql);
    }
}

fn get_view_diff(
    op: &ViewOperation,
    from_catalog: &Catalog,
    to_catalog: &Catalog,
) -> Option<String> {
    use similar::{ChangeTag, TextDiff};

    match op {
        ViewOperation::Replace { schema, name, .. } => {
            let from_view = from_catalog
                .views
                .iter()
                .find(|v| v.schema == *schema && v.name == *name)?;
            let to_view = to_catalog
                .views
                .iter()
                .find(|v| v.schema == *schema && v.name == *name)?;

            let diff = TextDiff::from_lines(&from_view.definition, &to_view.definition);

            let mut output = String::new();
            output.push_str("Diff:\n");

            for change in diff.iter_all_changes() {
                let sign = match change.tag() {
                    ChangeTag::Delete => "-",
                    ChangeTag::Insert => "+",
                    ChangeTag::Equal => " ",
                };
                output.push_str(&format!("{} {}", sign, change));
            }

            Some(output)
        }
        _ => None,
    }
}

fn get_function_diff(
    op: &FunctionOperation,
    from_catalog: &Catalog,
    to_catalog: &Catalog,
) -> Option<String> {
    use similar::{ChangeTag, TextDiff};

    match op {
        FunctionOperation::Replace {
            schema,
            name,
            parameters,
            ..
        } => {
            let from_func = from_catalog.functions.iter().find(|f| {
                f.schema == *schema
                    && f.name == *name
                    && format_parameters(&f.parameters) == *parameters
            })?;
            let to_func = to_catalog.functions.iter().find(|f| {
                f.schema == *schema
                    && f.name == *name
                    && format_parameters(&f.parameters) == *parameters
            })?;

            let diff = TextDiff::from_lines(&from_func.definition, &to_func.definition);

            let mut output = String::new();
            output.push_str("Diff:\n");

            for change in diff.iter_all_changes() {
                let sign = match change.tag() {
                    ChangeTag::Delete => "-",
                    ChangeTag::Insert => "+",
                    ChangeTag::Equal => " ",
                };
                output.push_str(&format!("{} {}", sign, change));
            }

            Some(output)
        }
        _ => None,
    }
}

fn format_parameters(params: &[crate::catalog::function::FunctionParam]) -> String {
    params
        .iter()
        .map(|p| {
            let mode = p.mode.as_deref().unwrap_or("");
            let name = p.name.as_deref().unwrap_or("");
            format!("{} {} {}", mode, name, p.data_type)
                .trim()
                .to_string()
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn output_json_format(steps: &[MigrationStep], context: &DiffContext) -> Result<()> {
    use serde_json::json;

    let changes: Vec<serde_json::Value> = steps
        .iter()
        .map(|step| {
            json!({
                "type": format!("{:?}", step.id()),
                "destructive": step.has_destructive_sql(),
                "sql": step.to_sql().iter().map(|r| &r.sql).collect::<Vec<_>>(),
            })
        })
        .collect();

    let output = json!({
        "has_differences": !steps.is_empty(),
        "from": context.from_description,
        "to": context.to_description,
        "summary": {
            "total_changes": steps.len(),
            "destructive_changes": steps.iter().filter(|s| s.has_destructive_sql()).count(),
            "safe_changes": steps.iter().filter(|s| !s.has_destructive_sql()).count(),
        },
        "changes": changes,
    });

    println!("{}", serde_json::to_string_pretty(&output)?);

    Ok(())
}