feat: DSL→SQL teaching echo — channel + create-table slice (ADR-0037 + ADR-0038)

Walking skeleton validating the whole echo architecture end to end; the
Command→SQL renderer currently covers `create table`, with the rest of
Bucket A / B / category-3 to follow (ADR-0038 §8).

- Channel (ADR-0037): the three-way EffectiveMode (reusing the existing
  enum, not a new SubmissionMode — recorded in the ADR) rides on
  Action::ExecuteDsl to the runtime. `replay` bypasses the interactive
  spawn, so it never echoes (silent, for free).
- Echo (ADR-0038): built at the runtime's ExecuteDsl dispatch — the worker
  gets decomposed calls, not the Command, so ADR §4's "worker builds it"
  was corrected to the dispatch layer. Gated by echo_for (advanced
  effective mode + DSL-form). Carried on DslSucceeded; rendered by
  note_ok_summary as `Executing SQL: …` immediately beneath `[ok]`. New
  src/echo.rs renderer; echo.executing_sql i18n key.
- command_to_sql: `create table` → `CREATE TABLE T (id serial PRIMARY KEY)`
  (single inline / compound table-level PK), playground type vocabulary,
  round-trip-verified against the advanced walker (the §1 contract).

Tests: echo.rs (render, round-trip contract, mode gate, Sql*-not-echoed);
app.rs (submit carries the 3-way mode; echo renders beneath [ok]).
Suite 1970/0/1; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-27 22:09:54 +00:00
parent 9a23e28f30
commit 04c8e4295f
12 changed files with 350 additions and 29 deletions
+160
View File
@@ -0,0 +1,160 @@
//! 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::command::ColumnSpec;
/// 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)
#[must_use]
pub fn echo_for(command: &Command, mode: EffectiveMode) -> Option<String> {
if mode.is_advanced() {
command_to_sql(command)
} else {
None
}
}
/// Render the equivalent advanced-mode SQL for a DSL-form command, or
/// `None` when it has no echo.
#[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)),
// Remaining Bucket A/B forms land in follow-up slices (ADR-0038 §8).
_ => 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");
}
if c.not_null {
s.push_str(" NOT NULL");
}
if c.unique {
s.push_str(" UNIQUE");
}
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(", "))
}
}
#[cfg(test)]
mod tests {
use super::*;
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(),
}
}
#[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_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");
let reparsed = crate::dsl::parser::parse_command_in_mode(&sql, crate::mode::Mode::Advanced);
assert!(
matches!(reparsed, Ok(Command::SqlCreateTable { .. })),
"echo must round-trip as runnable advanced SQL; got {reparsed:?}"
);
}
#[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 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());
}
}