//! The DSL → SQL teaching echo renderer (ADR-0038).
//!
//! Maps a **DSL-form** `Command` to the equivalent advanced-mode SQL, so
//! a learner who typed the simple-mode form reads off how to spell it in
//! SQL (ADR-0030 §10). The output obeys the **copy-paste contract**
//! (ADR-0038 §1): it is runnable advanced-mode SQL in the playground's
//! own type vocabulary (`Type::keyword()`), so it round-trips through the
//! advanced walker. The standard-first dialect (ADR-0035 Amendment 2)
//! governs statement shape; the playground keywords fill the type slots.
//!
//! `None` means "no echo" — a command in Bucket C (ADR-0038 §7) or a form
//! not yet covered by the renderer. The caller (the runtime's `ExecuteDsl`
//! dispatch) only invokes this for DSL-form commands submitted in an
//! advanced effective mode (ADR-0037).

use crate::app::EffectiveMode;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
    ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;

/// The dimmed `Executing SQL:` prefix on a teaching-echo line
/// (ADR-0038 §4 styled-runs polish).
///
/// The App appends the SQL to this prefix when building a
/// `OutputKind::TeachingEcho` line; `ui::render_output_line` strips
/// it back off, renders the prefix dimmed (`theme.muted`), and lexes
/// the rest in advanced mode for syntax highlighting — same treatment
/// the input echo gets. The trailing space is part of the constant so
/// the SQL position is predictable for the lexer's byte ranges.
pub const TEACHING_ECHO_LABEL: &str = "Executing SQL: ";

/// The teaching echo for a command submitted under `mode`, or `None`.
///
/// Fires only in an advanced effective mode (`AdvancedPersistent` /
/// `AdvancedOneShot`) — simple mode stays uncluttered (ADR-0030 §10) — and
/// only for a DSL-form command that has an echo (`command_to_sql`; a
/// `Sql*` / app command returns `None`). This is the runtime's gate;
/// replay never reaches it (it bypasses the spawn). (ADR-0037 + ADR-0038)
///
/// This pre-execution path covers every Bucket A row whose echo is a pure
/// function of the `Command`. The one exception is `show data`, whose
/// limited form orders by the table's primary key (not on the `Command`);
/// that is built post-execution by [`echo_for_query`] (ADR-0038 §4).
#[must_use]
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<Vec<String>> {
    if mode.is_advanced() {
        command_to_sql(command).map(|sql| vec![sql])
    } else {
        None
    }
}

/// The teaching echo for a `Query`-outcome command (ADR-0038).
///
/// `show data` is the only DSL-form query that echoes — a `Command::Select`
/// is already SQL, so it has none. Fires only in an advanced effective mode.
///
/// `primary_key` is the table's primary-key columns, sourced from the
/// schema *after* execution: the limited form (`show data T limit n`)
/// orders by the primary key for a stable "first n", and that column list
/// is not carried on the `Command`. It is unused when the query is
/// unlimited. This is the one Bucket A row that needs schema info beyond
/// the `Command` (handoff §5 / ADR-0038 §4).
#[must_use]
pub fn echo_for_query(
    command: &Command,
    mode: EffectiveMode,
    primary_key: &[String],
) -> Option<Vec<String>> {
    if !mode.is_advanced() {
        return None;
    }
    match command {
        Command::ShowData {
            name,
            filter,
            limit,
        } => Some(vec![render_show_data(
            name,
            filter.as_ref(),
            *limit,
            primary_key,
        )]),
        _ => None,
    }
}

/// Render the equivalent advanced-mode SQL for a DSL-form command, or
/// `None` when it has no echo (Bucket C, or a form covered elsewhere —
/// `show data` goes through [`echo_for_query`]).
#[must_use]
pub fn command_to_sql(command: &Command) -> Option<String> {
    match command {
        Command::CreateTable {
            name,
            columns,
            primary_key,
        } => Some(render_create_table(name, columns, primary_key)),
        Command::AddColumn {
            table,
            column,
            ty,
            not_null,
            unique,
            default,
            check,
        } => {
            let mut s = format!("ALTER TABLE {table} ADD COLUMN {column} {}", ty.keyword());
            append_constraints(&mut s, *not_null, *unique, default.as_ref(), check.as_ref());
            Some(s)
        }
        // `--cascade` also drops the column's covering indexes — a
        // multi-statement echo (Bucket B / category 2, Phase 2). The plain
        // drop is a single statement.
        Command::DropColumn {
            table,
            column,
            cascade,
        } => (!cascade).then(|| format!("ALTER TABLE {table} DROP COLUMN {column}")),
        Command::RenameColumn { table, old, new } => {
            Some(format!("ALTER TABLE {table} RENAME COLUMN {old} TO {new}"))
        }
        // The headline form (every conversion mode emits it); the
        // `--dont-convert` *caveat* line is category-3 (ADR-0038 §6, Phase 3).
        // `SET DATA TYPE` is the canonical ISO spelling (ADR-0035 Amendment 2).
        Command::ChangeColumnType {
            table, column, ty, ..
        } => Some(format!(
            "ALTER TABLE {table} ALTER COLUMN {column} SET DATA TYPE {}",
            ty.keyword()
        )),
        Command::AddConstraint {
            table,
            column,
            constraint,
        } => Some(match constraint {
            Constraint::NotNull => {
                format!("ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL")
            }
            Constraint::Default(v) => format!(
                "ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {}",
                value_to_sql_literal(v)
            ),
            Constraint::Unique => format!("ALTER TABLE {table} ADD UNIQUE ({column})"),
            Constraint::Check(e) => {
                format!("ALTER TABLE {table} ADD CHECK ({})", expr_to_sql(e))
            }
        }),
        Command::DropConstraint {
            table,
            column,
            kind,
        } => match kind {
            ConstraintKind::NotNull => Some(format!(
                "ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
            )),
            ConstraintKind::Default => Some(format!(
                "ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
            )),
            // A column-level UNIQUE / CHECK is anonymous in our model —
            // no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
            // ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
            ConstraintKind::Unique | ConstraintKind::Check => None,
        },
        // Only the `--all-rows` fall-throughs echo: a WHERE-filtered
        // update / delete routes SQL-first in advanced mode (`Sql*`), so it
        // is already SQL and never reaches here as a DSL-form command
        // (ADR-0033 Amendment 3/4, ADR-0038 §7).
        Command::Update {
            table,
            assignments,
            filter: RowFilter::AllRows,
        } => Some(format!(
            "UPDATE {table} SET {}",
            render_assignments(assignments)
        )),
        Command::Delete {
            table,
            filter: RowFilter::AllRows,
        } => Some(format!("DELETE FROM {table}")),
        // Remaining forms: Bucket B (resolved names / multi-line — Phase 2),
        // category-3 prose (Phase 3), Bucket C (no echo), `show data`
        // (`echo_for_query`), and the `Sql*` / `App` variants.
        _ => None,
    }
}

/// `CREATE TABLE <name> (<col defs>[, PRIMARY KEY (…)])` in the
/// playground's type vocabulary. A single first-column primary key is
/// rendered inline (`id serial PRIMARY KEY`), matching the rebuild
/// generator's rule (ADR-0035 §4a) and the round-trip surface; a compound
/// key becomes a table-level `PRIMARY KEY (a, b)`.
fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String]) -> String {
    let inline_pk =
        primary_key.len() == 1 && columns.first().is_some_and(|c| c.name == primary_key[0]);
    let col_defs: Vec<String> = columns
        .iter()
        .map(|c| {
            let mut s = format!("{} {}", c.name, c.ty.keyword());
            if inline_pk && c.name == primary_key[0] {
                s.push_str(" PRIMARY KEY");
            }
            // The same column-constraint suffix `add column` emits (ADR-0029):
            // simple-mode `create table` can carry `default` / `check` too, so
            // the echo must render them or it is not equivalent (§1 contract).
            append_constraints(
                &mut s,
                c.not_null,
                c.unique,
                c.default.as_ref(),
                c.check.as_ref(),
            );
            s
        })
        .collect();
    if primary_key.len() > 1 {
        format!(
            "CREATE TABLE {name} ({}, PRIMARY KEY ({}))",
            col_defs.join(", "),
            primary_key.join(", "),
        )
    } else {
        format!("CREATE TABLE {name} ({})", col_defs.join(", "))
    }
}

/// `SELECT * FROM <name> [WHERE …] [ORDER BY <pk> LIMIT n]` — the `show
/// data` echo (ADR-0038 §7). When `limit` is set the worker orders by the
/// primary key for a stable "first n" (`build_query_data_sql`); the echo
/// reproduces that `ORDER BY` so it has the same effect (§1). The
/// `ORDER BY` is dropped when the table has no primary key, matching the
/// worker.
fn render_show_data(
    name: &str,
    filter: Option<&Expr>,
    limit: Option<u64>,
    primary_key: &[String],
) -> String {
    let mut s = format!("SELECT * FROM {name}");
    if let Some(expr) = filter {
        s.push_str(&format!(" WHERE {}", expr_to_sql(expr)));
    }
    if let Some(n) = limit {
        if !primary_key.is_empty() {
            s.push_str(&format!(" ORDER BY {}", primary_key.join(", ")));
        }
        s.push_str(&format!(" LIMIT {n}"));
    }
    s
}

/// `CREATE INDEX <name> ON <table> (col, …)` — the `add index` echo
/// (ADR-0038 §7 Bucket B). `name` is the resolved index name (the
/// user-given `as N` or the worker's auto-name `<table>_<cols>_idx`);
/// the runtime sources it from the post-execution table description.
pub(crate) fn render_create_index(name: &str, table: &str, columns: &[String]) -> String {
    format!("CREATE INDEX {name} ON {table} ({})", columns.join(", "))
}

/// `DROP INDEX <name>` — the positional-form `drop index` echo
/// (ADR-0038 §7 Bucket B). The runtime resolves the name **pre-execution**
/// (the index is gone post-exec) by describing the table and matching by
/// column set.
pub(crate) fn render_drop_index(name: &str) -> String {
    format!("DROP INDEX {name}")
}

/// `ALTER TABLE <C> ADD CONSTRAINT <name> FOREIGN KEY (<cc>) REFERENCES
/// <P> (<pc>) [ON DELETE …] [ON UPDATE …]` — the `add relationship` echo
/// (ADR-0038 §7 Bucket B), without `--create-fk`. Multi-line `--create-fk`
/// is a separate renderer (Slice 2b). The `ON DELETE` / `ON UPDATE`
/// clauses are emitted only when the action is non-default — the standard
/// (`NO ACTION`) is the implicit default in the SQL grammar, and emitting
/// it would clutter the echo without changing meaning.
pub(crate) fn render_add_relationship(
    name: &str,
    parent_table: &str,
    parent_columns: &[String],
    child_table: &str,
    child_columns: &[String],
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
) -> String {
    // Multi-column FK (ADR-0043): comma-join each side; a
    // single-column FK is the one-element case.
    let child_column = child_columns.join(", ");
    let parent_column = parent_columns.join(", ");
    let mut s = format!(
        "ALTER TABLE {child_table} ADD CONSTRAINT {name} FOREIGN KEY ({child_column}) REFERENCES {parent_table} ({parent_column})"
    );
    if on_delete != ReferentialAction::default_action() {
        s.push_str(&format!(" ON DELETE {}", on_delete.sql_clause()));
    }
    if on_update != ReferentialAction::default_action() {
        s.push_str(&format!(" ON UPDATE {}", on_update.sql_clause()));
    }
    s
}

/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n
/// relationship` (ADR-0045): the single `CREATE TABLE` the junction
/// expands to — every FK column, the compound primary key over them,
/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built
/// from the post-exec junction description (the resolved columns don't
/// exist on the command), so it shows exactly what was created.
pub(crate) fn render_create_m2n(
    junction: &str,
    columns: &[(String, Type)],
    primary_key: &[String],
    foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
    let mut parts: Vec<String> = columns
        .iter()
        .map(|(n, ty)| format!("{n} {}", ty.keyword()))
        .collect();
    parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
    for (child_columns, parent_table, parent_columns) in foreign_keys {
        parts.push(format!(
            "FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
            child_columns.join(", "),
            parent_columns.join(", "),
        ));
    }
    format!("CREATE TABLE {junction} ({})", parts.join(", "))
}

/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
/// execution** via a describe — for a `Named` drop the worker resolves
/// the child table from metadata, which is gone after the drop.
pub(crate) fn render_drop_relationship(name: &str, child_table: &str) -> String {
    format!("ALTER TABLE {child_table} DROP CONSTRAINT {name}")
}

/// Multi-line echo for `drop column T.c --cascade` (ADR-0038 §7 Bucket B,
/// category 2). Emits one `DROP INDEX <name>` line per covering index
/// (ADR-0025) followed by the final `ALTER TABLE T DROP COLUMN c`. The
/// SQL `DROP COLUMN` refuses an indexed column, so the indexes must come
/// first — the lines *are* the explanation, no prose (§6 category 2).
/// With zero dropped indexes (`--cascade` set on an unindexed column) the
/// result is a single line, still correct.
pub(crate) fn render_drop_column_cascade(
    table: &str,
    column: &str,
    dropped_indexes: &[String],
) -> Vec<String> {
    let mut lines: Vec<String> = dropped_indexes
        .iter()
        .map(|name| format!("DROP INDEX {name}"))
        .collect();
    lines.push(format!("ALTER TABLE {table} DROP COLUMN {column}"));
    lines
}

/// Multi-line echo for `add 1:n relationship … --create-fk` when the
/// child column was *newly created* (ADR-0038 §7 Bucket B, category 2).
/// Emits the `ALTER TABLE … ADD COLUMN …` line first (with the FK
/// child-side type — `Type::fk_target_type` of the parent's PK type),
/// then the `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …` line. When
/// the column already existed, the runtime instead uses
/// [`render_add_relationship`] for a single-line echo (the `ADD COLUMN`
/// line would be a no-op-with-error in advanced SQL — "column already
/// exists" — and the catalogue specifies "one line if the column already
/// existed").
#[allow(clippy::too_many_arguments)] // the SQL FK has many slots — all inherent.
pub(crate) fn render_add_relationship_create_fk(
    name: &str,
    parent_table: &str,
    parent_columns: &[String],
    child_table: &str,
    child_columns: &[String],
    on_delete: ReferentialAction,
    on_update: ReferentialAction,
    // The child columns `--create-fk` newly creates, with their types
    // (ADR-0043: one per missing column, typed to the matching parent
    // PK column's `fk_target_type`). Columns that already existed are
    // omitted — no `ADD COLUMN` line for them.
    new_columns: &[(String, crate::dsl::types::Type)],
) -> Vec<String> {
    let mut lines: Vec<String> = new_columns
        .iter()
        .map(|(col, ty)| {
            format!(
                "ALTER TABLE {child_table} ADD COLUMN {col} {}",
                ty.keyword()
            )
        })
        .collect();
    lines.push(render_add_relationship(
        name,
        parent_table,
        parent_columns,
        child_table,
        child_columns,
        on_delete,
        on_update,
    ));
    lines
}

/// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint
/// suffix (ADR-0029). The advanced-mode column-constraint grammar is
/// order-independent (`Repeated(Choice…)`, ADR-0035 §4a), so this fixed
/// order round-trips. Used by both `create table` and `add column`.
fn append_constraints(
    s: &mut String,
    not_null: bool,
    unique: bool,
    default: Option<&Value>,
    check: Option<&Expr>,
) {
    if not_null {
        s.push_str(" NOT NULL");
    }
    if unique {
        s.push_str(" UNIQUE");
    }
    if let Some(v) = default {
        s.push_str(&format!(" DEFAULT {}", value_to_sql_literal(v)));
    }
    if let Some(e) = check {
        s.push_str(&format!(" CHECK ({})", expr_to_sql(e)));
    }
}

/// `<col> = <literal>, …` — an `UPDATE … SET` assignment list.
fn render_assignments(assignments: &[(String, Value)]) -> String {
    assignments
        .iter()
        .map(|(col, val)| format!("{col} = {}", value_to_sql_literal(val)))
        .collect::<Vec<_>>()
        .join(", ")
}

/// A `Value` as a runnable SQL literal (ADR-0038 §5). Most forms reuse
/// `Value`'s own `Display` (`'O''Hara'`, bare numbers, `true`/`false`,
/// quoted ISO dates); only `NULL` differs — `Display` writes lowercase
/// `null`, but §5 (and the advanced grammar's canonical form) want `NULL`.
fn value_to_sql_literal(value: &Value) -> String {
    match value {
        Value::Null => "NULL".to_string(),
        other => other.to_string(),
    }
}

/// A WHERE / CHECK [`Expr`] as advanced-mode SQL. Mirrors the worker's
/// `compile_expr` operator spellings (`<>`, `LIKE`, `BETWEEN`, `IN`,
/// `IS NULL`, parenthesised `AND` / `OR` / `NOT`) but emits **bare column
/// identifiers** (the echo's unquoted style, matching `render_create_table`)
/// and **inline literals** instead of `?` placeholders, so the line is
/// runnable (§1).
fn expr_to_sql(expr: &Expr) -> String {
    match expr {
        Expr::Or(terms) => join_terms(terms, "OR"),
        Expr::And(terms) => join_terms(terms, "AND"),
        Expr::Not(inner) => format!("(NOT {})", expr_to_sql(inner)),
        Expr::Predicate(p) => predicate_to_sql(p),
    }
}

fn join_terms(terms: &[Expr], op: &str) -> String {
    let parts: Vec<String> = terms.iter().map(expr_to_sql).collect();
    format!("({})", parts.join(&format!(" {op} ")))
}

fn predicate_to_sql(predicate: &Predicate) -> String {
    match predicate {
        Predicate::Compare { left, op, right } => format!(
            "{} {} {}",
            operand_to_sql(left),
            compare_op_sql(*op),
            operand_to_sql(right)
        ),
        Predicate::Like {
            target,
            pattern,
            negated,
        } => {
            let not = if *negated { "NOT " } else { "" };
            format!(
                "{} {not}LIKE {}",
                operand_to_sql(target),
                operand_to_sql(pattern)
            )
        }
        Predicate::Between {
            target,
            low,
            high,
            negated,
        } => {
            let not = if *negated { "NOT " } else { "" };
            format!(
                "{} {not}BETWEEN {} AND {}",
                operand_to_sql(target),
                operand_to_sql(low),
                operand_to_sql(high)
            )
        }
        Predicate::In {
            target,
            items,
            negated,
        } => {
            let not = if *negated { "NOT " } else { "" };
            let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
            format!(
                "{} {not}IN ({})",
                operand_to_sql(target),
                rendered.join(", ")
            )
        }
        Predicate::IsNull { target, negated } => {
            let not = if *negated { "NOT " } else { "" };
            format!("{} IS {not}NULL", operand_to_sql(target))
        }
    }
}

fn operand_to_sql(operand: &Operand) -> String {
    match operand {
        Operand::Column { name, .. } => name.clone(),
        Operand::Literal { value, .. } => value_to_sql_literal(value),
    }
}

/// The SQL spelling of a comparison operator — `<>` for inequality, the
/// standard form (ADR-0026 §6), matching the worker's `compare_op_sql`.
const fn compare_op_sql(op: CompareOp) -> &'static str {
    match op {
        CompareOp::Eq => "=",
        CompareOp::NotEq => "<>",
        CompareOp::Lt => "<",
        CompareOp::LtEq => "<=",
        CompareOp::Gt => ">",
        CompareOp::GtEq => ">=",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dsl::command::{ChangeColumnMode, ConstraintKind};
    use crate::dsl::types::Type;

    fn create_table(name: &str, cols: Vec<ColumnSpec>, pk: &[&str]) -> Command {
        Command::CreateTable {
            name: name.to_string(),
            columns: cols,
            primary_key: pk.iter().map(|s| (*s).to_string()).collect(),
        }
    }

    /// A `column = value` equality predicate, the most common WHERE leaf.
    fn eq(column: &str, value: Value) -> Expr {
        Expr::Predicate(Predicate::Compare {
            left: Operand::Column {
                name: column.to_string(),
                span: Operand::NO_SPAN,
            },
            op: CompareOp::Eq,
            right: Operand::Literal {
                value,
                span: Operand::NO_SPAN,
            },
        })
    }

    /// Parse `sql` through the advanced-mode walker (the round-trip target).
    fn reparse(sql: &str) -> Result<Command, crate::dsl::parser::ParseError> {
        crate::dsl::parser::parse_command_in_mode(sql, crate::mode::Mode::Advanced)
    }

    // --- create table (ADR-0038 §7 Bucket A) -------------------------

    #[test]
    fn create_table_single_serial_pk_renders_inline() {
        let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
        assert_eq!(
            command_to_sql(&cmd).as_deref(),
            Some("CREATE TABLE Other (id serial PRIMARY KEY)")
        );
    }

    #[test]
    fn create_table_compound_pk_renders_table_level() {
        let cmd = create_table(
            "T",
            vec![
                ColumnSpec::new("a", Type::Int),
                ColumnSpec::new("b", Type::Int),
            ],
            &["a", "b"],
        );
        assert_eq!(
            command_to_sql(&cmd).as_deref(),
            Some("CREATE TABLE T (a int, b int, PRIMARY KEY (a, b))")
        );
    }

    #[test]
    fn create_table_renders_default_and_check_constraints() {
        // §1 copy-paste contract: simple-mode `create table` can carry
        // per-column `default` / `check` (ADR-0029), so the echo must too,
        // or it is not equivalent.
        let age = ColumnSpec {
            check: Some(Expr::Predicate(Predicate::Compare {
                left: Operand::Column {
                    name: "age".to_string(),
                    span: Operand::NO_SPAN,
                },
                op: CompareOp::GtEq,
                right: Operand::Literal {
                    value: Value::Number("0".to_string()),
                    span: Operand::NO_SPAN,
                },
            })),
            ..ColumnSpec::new("age", Type::Int)
        };
        let grade = ColumnSpec {
            default: Some(Value::Text("A".to_string())),
            ..ColumnSpec::new("grade", Type::Text)
        };
        let cmd = create_table(
            "T",
            vec![ColumnSpec::new("id", Type::Serial), age, grade],
            &["id"],
        );
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(
            sql,
            "CREATE TABLE T (id serial PRIMARY KEY, age int CHECK (age >= 0), grade text DEFAULT 'A')"
        );
        assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
    }

    #[test]
    fn create_table_echo_round_trips_in_advanced_mode() {
        // ADR-0038 §1 copy-paste contract: the echo is runnable advanced SQL.
        let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
        let sql = command_to_sql(&cmd).expect("echo");
        assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
    }

    // --- add column --------------------------------------------------

    #[test]
    fn add_column_renders_type_and_constraints_and_round_trips() {
        let cmd = Command::AddColumn {
            table: "T".to_string(),
            column: "note".to_string(),
            ty: Type::Text,
            not_null: true,
            unique: false,
            default: Some(Value::Text("n/a".to_string())),
            check: None,
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(
            sql,
            "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
        );
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn add_column_with_unique_and_check_round_trips() {
        // ADD COLUMN's constraint grammar is its own production — confirm
        // the full UNIQUE / CHECK suffix round-trips there too, not just on
        // CREATE TABLE.
        let cmd = Command::AddColumn {
            table: "T".to_string(),
            column: "score".to_string(),
            ty: Type::Int,
            not_null: false,
            unique: true,
            default: None,
            check: Some(Expr::Predicate(Predicate::Compare {
                left: Operand::Column {
                    name: "score".to_string(),
                    span: Operand::NO_SPAN,
                },
                op: CompareOp::GtEq,
                right: Operand::Literal {
                    value: Value::Number("0".to_string()),
                    span: Operand::NO_SPAN,
                },
            })),
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(
            sql,
            "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
        );
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    // --- drop column -------------------------------------------------

    #[test]
    fn drop_column_plain_round_trips() {
        let cmd = Command::DropColumn {
            table: "T".to_string(),
            column: "c".to_string(),
            cascade: false,
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(sql, "ALTER TABLE T DROP COLUMN c");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn drop_column_cascade_has_no_echo_yet() {
        // Multi-statement (drops covering indexes too) — Bucket B, Phase 2.
        let cmd = Command::DropColumn {
            table: "T".to_string(),
            column: "c".to_string(),
            cascade: true,
        };
        assert!(command_to_sql(&cmd).is_none());
    }

    // --- rename column -----------------------------------------------

    #[test]
    fn rename_column_round_trips() {
        let cmd = Command::RenameColumn {
            table: "T".to_string(),
            old: "a".to_string(),
            new: "b".to_string(),
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(sql, "ALTER TABLE T RENAME COLUMN a TO b");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    // --- change column type ------------------------------------------

    #[test]
    fn change_column_renders_set_data_type_for_every_mode() {
        for mode in [
            ChangeColumnMode::Default,
            ChangeColumnMode::ForceConversion,
            ChangeColumnMode::DontConvert,
        ] {
            let cmd = Command::ChangeColumnType {
                table: "T".to_string(),
                column: "c".to_string(),
                ty: Type::Text,
                mode,
            };
            let sql = command_to_sql(&cmd).expect("echo");
            assert_eq!(sql, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
            assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
        }
    }

    #[test]
    fn change_column_to_playground_type_keyword_round_trips() {
        let cmd = Command::ChangeColumnType {
            table: "T".to_string(),
            column: "code".to_string(),
            ty: Type::ShortId,
            mode: ChangeColumnMode::Default,
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ALTER COLUMN code SET DATA TYPE shortid");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    // --- add constraint ----------------------------------------------

    fn add_constraint(constraint: Constraint) -> Command {
        Command::AddConstraint {
            table: "T".to_string(),
            column: "c".to_string(),
            constraint,
        }
    }

    #[test]
    fn add_constraint_not_null_round_trips() {
        let sql = command_to_sql(&add_constraint(Constraint::NotNull)).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ALTER COLUMN c SET NOT NULL");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn add_constraint_default_round_trips() {
        let sql = command_to_sql(&add_constraint(Constraint::Default(Value::Number(
            "0".to_string(),
        ))))
        .expect("echo");
        assert_eq!(sql, "ALTER TABLE T ALTER COLUMN c SET DEFAULT 0");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn add_constraint_unique_round_trips() {
        let sql = command_to_sql(&add_constraint(Constraint::Unique)).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ADD UNIQUE (c)");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn add_constraint_check_round_trips() {
        let expr = Expr::Predicate(Predicate::Compare {
            left: Operand::Column {
                name: "c".to_string(),
                span: Operand::NO_SPAN,
            },
            op: CompareOp::Gt,
            right: Operand::Literal {
                value: Value::Number("0".to_string()),
                span: Operand::NO_SPAN,
            },
        });
        let sql = command_to_sql(&add_constraint(Constraint::Check(expr))).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ADD CHECK (c > 0)");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    // --- drop constraint ---------------------------------------------

    fn drop_constraint(kind: ConstraintKind) -> Command {
        Command::DropConstraint {
            table: "T".to_string(),
            column: "c".to_string(),
            kind,
        }
    }

    #[test]
    fn drop_constraint_not_null_round_trips() {
        let sql = command_to_sql(&drop_constraint(ConstraintKind::NotNull)).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ALTER COLUMN c DROP NOT NULL");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn drop_constraint_default_round_trips() {
        let sql = command_to_sql(&drop_constraint(ConstraintKind::Default)).expect("echo");
        assert_eq!(sql, "ALTER TABLE T ALTER COLUMN c DROP DEFAULT");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn drop_constraint_unique_and_check_have_no_echo() {
        // Column-level UNIQUE / CHECK is anonymous — Bucket C (ADR-0038 §7).
        assert!(command_to_sql(&drop_constraint(ConstraintKind::Unique)).is_none());
        assert!(command_to_sql(&drop_constraint(ConstraintKind::Check)).is_none());
    }

    // --- update / delete --all-rows ----------------------------------

    #[test]
    fn update_all_rows_round_trips() {
        let cmd = Command::Update {
            table: "T".to_string(),
            assignments: vec![
                ("status".to_string(), Value::Text("done".to_string())),
                ("score".to_string(), Value::Number("10".to_string())),
            ],
            filter: RowFilter::AllRows,
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(sql, "UPDATE T SET status = 'done', score = 10");
        assert!(matches!(reparse(&sql), Ok(Command::SqlUpdate { .. })));
    }

    #[test]
    fn update_with_where_has_no_echo() {
        // A WHERE-filtered update is SQL-first in advanced mode (SqlUpdate).
        let cmd = Command::Update {
            table: "T".to_string(),
            assignments: vec![("a".to_string(), Value::Number("1".to_string()))],
            filter: RowFilter::Where(eq("id", Value::Number("1".to_string()))),
        };
        assert!(command_to_sql(&cmd).is_none());
    }

    #[test]
    fn delete_all_rows_round_trips() {
        let cmd = Command::Delete {
            table: "T".to_string(),
            filter: RowFilter::AllRows,
        };
        let sql = command_to_sql(&cmd).expect("echo");
        assert_eq!(sql, "DELETE FROM T");
        assert!(matches!(reparse(&sql), Ok(Command::SqlDelete { .. })));
    }

    #[test]
    fn delete_with_where_has_no_echo() {
        let cmd = Command::Delete {
            table: "T".to_string(),
            filter: RowFilter::Where(eq("id", Value::Number("1".to_string()))),
        };
        assert!(command_to_sql(&cmd).is_none());
    }

    // --- show data (echo_for_query) ----------------------------------

    #[test]
    fn show_data_plain_round_trips() {
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: None,
            limit: None,
        };
        let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
        assert_eq!(lines.as_slice(), &["SELECT * FROM T"]);
        assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
    }

    #[test]
    fn show_data_with_where_round_trips() {
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: Some(eq("name", Value::Text("Bob".to_string()))),
            limit: None,
        };
        let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
        assert_eq!(lines.as_slice(), &["SELECT * FROM T WHERE name = 'Bob'"]);
        assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
    }

    #[test]
    fn show_data_with_limit_orders_by_primary_key() {
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: None,
            limit: Some(5),
        };
        let pk = vec!["id".to_string()];
        let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
        assert_eq!(lines.as_slice(), &["SELECT * FROM T ORDER BY id LIMIT 5"]);
        assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
    }

    #[test]
    fn show_data_with_limit_and_compound_pk_orders_by_all_pk_columns() {
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: Some(eq("active", Value::Bool(true))),
            limit: Some(3),
        };
        let pk = vec!["a".to_string(), "b".to_string()];
        let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &pk).expect("echo");
        assert_eq!(
            lines.as_slice(),
            &["SELECT * FROM T WHERE active = true ORDER BY a, b LIMIT 3"]
        );
        assert!(matches!(reparse(&lines[0]), Ok(Command::Select { .. })));
    }

    #[test]
    fn show_data_with_limit_but_no_primary_key_omits_order_by() {
        // Matches the worker: no PK → no ORDER BY (build_query_data_sql).
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: None,
            limit: Some(2),
        };
        let lines = echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).expect("echo");
        assert_eq!(lines.as_slice(), &["SELECT * FROM T LIMIT 2"]);
    }

    #[test]
    fn show_data_is_silent_in_simple_mode() {
        let cmd = Command::ShowData {
            name: "T".to_string(),
            filter: None,
            limit: None,
        };
        assert!(echo_for_query(&cmd, EffectiveMode::Simple, &[]).is_none());
    }

    #[test]
    fn select_is_not_echoed_as_a_query() {
        // A `Command::Select` is already SQL — no echo (ADR-0038 §7).
        let cmd = Command::Select {
            sql: "select * from T".to_string(),
        };
        assert!(echo_for_query(&cmd, EffectiveMode::AdvancedPersistent, &[]).is_none());
    }

    // --- Bucket B single-statement renderers (Phase 2, Slice 2a) -----

    #[test]
    fn add_index_renders_and_round_trips() {
        // Named form — the name is what was passed in.
        let sql = render_create_index("MyIdx", "T", &["a".to_string(), "b".to_string()]);
        assert_eq!(sql, "CREATE INDEX MyIdx ON T (a, b)");
        assert!(matches!(reparse(&sql), Ok(Command::SqlCreateIndex { .. })));
    }

    #[test]
    fn add_index_auto_name_format_matches_worker() {
        // Mirrors the worker's `resolve_index_name` (`{table}_{cols}_idx`) —
        // not directly used by the renderer (the runtime sources the resolved
        // name from the description), but pins the expected auto-name shape.
        let sql = render_create_index("Customers_Email_idx", "Customers", &["Email".to_string()]);
        assert_eq!(sql, "CREATE INDEX Customers_Email_idx ON Customers (Email)");
        assert!(matches!(reparse(&sql), Ok(Command::SqlCreateIndex { .. })));
    }

    #[test]
    fn drop_index_round_trips() {
        let sql = render_drop_index("Customers_Email_idx");
        assert_eq!(sql, "DROP INDEX Customers_Email_idx");
        assert!(matches!(reparse(&sql), Ok(Command::SqlDropIndex { .. })));
    }

    #[test]
    fn add_relationship_no_referential_actions_round_trips() {
        // Default `NoAction` / `NoAction` → no `ON DELETE` / `ON UPDATE`
        // clauses (the implicit standard default — emitting them would
        // clutter the echo without changing meaning).
        let sql = render_add_relationship(
            "Orders_CustId_to_Customers_id",
            "Customers",
            &["id".to_string()],
            "Orders",
            &["CustId".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
        );
        assert_eq!(
            sql,
            "ALTER TABLE Orders ADD CONSTRAINT Orders_CustId_to_Customers_id FOREIGN KEY (CustId) REFERENCES Customers (id)"
        );
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn add_relationship_with_cascade_and_set_null_round_trips() {
        let sql = render_add_relationship(
            "places",
            "Customers",
            &["id".to_string()],
            "Orders",
            &["CustId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::SetNull,
        );
        assert_eq!(
            sql,
            "ALTER TABLE Orders ADD CONSTRAINT places FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE ON UPDATE SET NULL"
        );
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    #[test]
    fn drop_relationship_round_trips() {
        let sql = render_drop_relationship("places", "Orders");
        assert_eq!(sql, "ALTER TABLE Orders DROP CONSTRAINT places");
        assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
    }

    // --- Bucket B multi-statement renderers (Phase 2, Slice 2b) ------

    #[test]
    fn drop_column_cascade_emits_drop_indexes_then_drop_column_and_each_round_trips() {
        let lines = render_drop_column_cascade(
            "Orders",
            "CustId",
            &[
                "Orders_CustId_idx".to_string(),
                "Orders_CustId_Day_idx".to_string(),
            ],
        );
        assert_eq!(
            lines.as_slice(),
            &[
                "DROP INDEX Orders_CustId_idx",
                "DROP INDEX Orders_CustId_Day_idx",
                "ALTER TABLE Orders DROP COLUMN CustId",
            ]
        );
        // Each line is itself runnable advanced-mode SQL (the §1 contract
        // holds per line for category 2).
        assert!(matches!(
            reparse(&lines[0]),
            Ok(Command::SqlDropIndex { .. })
        ));
        assert!(matches!(
            reparse(&lines[1]),
            Ok(Command::SqlDropIndex { .. })
        ));
        assert!(matches!(
            reparse(&lines[2]),
            Ok(Command::SqlAlterTable { .. })
        ));
    }

    #[test]
    fn drop_column_cascade_with_no_covering_indexes_is_single_line() {
        // `--cascade` flagged on an unindexed column collapses to the
        // plain `DROP COLUMN` — still semantically equivalent.
        let lines = render_drop_column_cascade("T", "c", &[]);
        assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
        assert!(matches!(
            reparse(&lines[0]),
            Ok(Command::SqlAlterTable { .. })
        ));
    }

    #[test]
    fn add_relationship_create_fk_emits_add_column_then_fk_and_each_round_trips() {
        let lines = render_add_relationship_create_fk(
            "Customers_id_to_Orders_CustId",
            "Customers",
            &["id".to_string()],
            "Orders",
            &["CustId".to_string()],
            ReferentialAction::Cascade,
            ReferentialAction::NoAction,
            // Parent PK is `serial` → child FK column is `int`
            // (`Type::fk_target_type` strips auto-gen semantics; ADR-0011).
            &[("CustId".to_string(), crate::dsl::types::Type::Int)],
        );
        assert_eq!(
            lines.as_slice(),
            &[
                "ALTER TABLE Orders ADD COLUMN CustId int",
                "ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
            ]
        );
        assert!(matches!(
            reparse(&lines[0]),
            Ok(Command::SqlAlterTable { .. })
        ));
        assert!(matches!(
            reparse(&lines[1]),
            Ok(Command::SqlAlterTable { .. })
        ));
    }

    #[test]
    fn add_relationship_create_fk_with_shortid_parent_targets_text_column() {
        // `Type::fk_target_type(ShortId)` → Text (ADR-0011).
        let lines = render_add_relationship_create_fk(
            "Items_code_to_Lines_code",
            "Items",
            &["code".to_string()],
            "Lines",
            &["code".to_string()],
            ReferentialAction::NoAction,
            ReferentialAction::NoAction,
            &[("code".to_string(), crate::dsl::types::Type::Text)],
        );
        assert_eq!(lines[0], "ALTER TABLE Lines ADD COLUMN code text");
        // No referential clauses when both default.
        assert_eq!(
            lines[1],
            "ALTER TABLE Lines ADD CONSTRAINT Items_code_to_Lines_code FOREIGN KEY (code) REFERENCES Items (code)"
        );
    }

    #[test]
    fn create_m2n_echo_renders_junction_and_round_trips() {
        // The advanced-mode teaching echo for `create m:n relationship`
        // (ADR-0045): the single CREATE TABLE the junction expands to,
        // compound PK + the two CASCADE FKs — and it is valid SQL.
        let sql = render_create_m2n(
            "Students_Courses",
            &[
                ("Students_id".to_string(), Type::Int),
                ("Courses_id".to_string(), Type::Int),
            ],
            &["Students_id".to_string(), "Courses_id".to_string()],
            &[
                (
                    vec!["Students_id".to_string()],
                    "Students".to_string(),
                    vec!["id".to_string()],
                ),
                (
                    vec!["Courses_id".to_string()],
                    "Courses".to_string(),
                    vec!["id".to_string()],
                ),
            ],
        );
        assert_eq!(
            sql,
            "CREATE TABLE Students_Courses (Students_id int, Courses_id int, \
             PRIMARY KEY (Students_id, Courses_id), \
             FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \
             FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)"
        );
        // The echoed SQL is valid advanced-mode SQL (round-trips).
        assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
    }

    // --- expr / literal rendering ------------------------------------

    #[test]
    fn expr_renders_boolean_combinators_and_operators() {
        // age >= 18 AND (name <> 'x' OR active = true)
        let expr = Expr::And(vec![
            Expr::Predicate(Predicate::Compare {
                left: Operand::Column {
                    name: "age".to_string(),
                    span: Operand::NO_SPAN,
                },
                op: CompareOp::GtEq,
                right: Operand::Literal {
                    value: Value::Number("18".to_string()),
                    span: Operand::NO_SPAN,
                },
            }),
            Expr::Or(vec![
                Expr::Predicate(Predicate::Compare {
                    left: Operand::Column {
                        name: "name".to_string(),
                        span: Operand::NO_SPAN,
                    },
                    op: CompareOp::NotEq,
                    right: Operand::Literal {
                        value: Value::Text("x".to_string()),
                        span: Operand::NO_SPAN,
                    },
                }),
                eq("active", Value::Bool(true)),
            ]),
        ]);
        assert_eq!(
            expr_to_sql(&expr),
            "(age >= 18 AND (name <> 'x' OR active = true))"
        );
    }

    #[test]
    fn value_literal_renders_null_uppercase_and_quotes_text() {
        assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
        assert_eq!(
            value_to_sql_literal(&Value::Text("O'Hara".to_string())),
            "'O''Hara'"
        );
        assert_eq!(
            value_to_sql_literal(&Value::Number("3.14".to_string())),
            "3.14"
        );
        assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
    }

    // --- gating ------------------------------------------------------

    #[test]
    fn echo_for_gates_on_advanced_mode() {
        let cmd = create_table("Other", vec![ColumnSpec::new("id", Type::Serial)], &["id"]);
        assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_some());
        assert!(echo_for(&cmd, EffectiveMode::AdvancedOneShot).is_some());
        assert!(
            echo_for(&cmd, EffectiveMode::Simple).is_none(),
            "simple mode stays uncluttered (ADR-0030 §10)"
        );
    }

    #[test]
    fn bucket_c_no_echo_commands_all_return_none() {
        // ADR-0038 §7 Bucket C: pin the no-echo cases that flow through
        // the `command_to_sql` catch-all (alongside the already-tested
        // `Sql*`/`Select` and column-level UNIQUE/CHECK drop). A drift
        // here — say, a renderer arm grown into a Bucket C command —
        // would silently leak echoes the §10 / §13 contract forbids.
        use crate::dsl::command::AppCommand;

        // `show table T` — structure display, no SQL spelling in the
        // surface (ADR-0038 §7 Bucket C).
        assert!(
            command_to_sql(&Command::ShowTable {
                name: "T".to_string(),
            })
            .is_none(),
            "show table is Bucket C — no echo"
        );

        // `explain …` — EXPLAIN of advanced SQL is the deferred OOS-2
        // follow-up (ADR-0039); the DSL `explain` wrapper itself echoes
        // nothing (ADR-0038 §7).
        assert!(
            command_to_sql(&Command::Explain {
                query: Box::new(Command::ShowData {
                    name: "T".to_string(),
                    filter: None,
                    limit: None,
                }),
            })
            .is_none(),
            "explain is Bucket C — no echo"
        );

        // `replay <path>` — app-lifecycle, no SQL form (ADR-0038 §7).
        // (Also: replay bypasses the echo-bearing spawn entirely, so it
        // never reaches command_to_sql in practice — but pinning the
        // catch-all here guards against a future arm sneaking in.)
        assert!(
            command_to_sql(&Command::Replay {
                path: "history.log".to_string(),
            })
            .is_none(),
            "replay is Bucket C — no echo"
        );

        // `Command::App(_)` — every app-lifecycle command, regardless of
        // verb. ADR-0030 §10: "app-level commands have no SQL form and
        // are not echoed." Sample two: `quit` (verb-only) and `mode
        // advanced` (verb + payload).
        for app in [
            AppCommand::Quit,
            AppCommand::Help { topic: None },
            AppCommand::Rebuild,
            AppCommand::Save,
            AppCommand::New,
            AppCommand::Load,
            AppCommand::Undo,
            AppCommand::Redo,
            AppCommand::Mode {
                value: crate::dsl::command::ModeValue::Advanced,
            },
        ] {
            assert!(
                command_to_sql(&Command::App(app.clone())).is_none(),
                "Command::App({app:?}) is Bucket C — no echo"
            );
            // Also confirm echo_for gates the same in advanced mode.
            assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
        }
    }

    #[test]
    fn sql_entered_command_is_not_echoed() {
        // A command the user typed as SQL (SqlCreateTable) is not echoed
        // back (ADR-0030 §10) — command_to_sql covers only DSL-form variants.
        let cmd = Command::SqlCreateTable {
            name: "T".to_string(),
            columns: vec![ColumnSpec::new("id", Type::Serial)],
            primary_key: vec!["id".to_string()],
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
            foreign_keys: Vec::new(),
            if_not_exists: false,
        };
        assert!(command_to_sql(&cmd).is_none());
        assert!(echo_for(&cmd, EffectiveMode::AdvancedPersistent).is_none());
    }
}
