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
+102 -11
View File
@@ -216,6 +216,12 @@ pub struct App {
/// flag; the `undo` / `redo` commands then report undo is off
/// rather than emitting a prepare action.
pub undo_enabled: bool,
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath
/// `[ok]`), within the same synchronous `update()` call. `None` when
/// the command has no echo.
pending_echo: Option<String>,
}
/// Dialogs that take over keyboard input when active.
@@ -346,6 +352,7 @@ impl App {
// Undo is on by default; the runtime flips this off for
// a `--no-undo` session (ADR-0006 Amendment 1).
undo_enabled: true,
pending_echo: None,
}
}
@@ -438,7 +445,11 @@ impl App {
AppEvent::DslSucceeded {
command,
description,
echo,
} => {
// Stash the teaching echo (ADR-0038) for `note_ok_summary`
// to render beneath `[ok]` — consumed synchronously below.
self.pending_echo = echo;
self.handle_dsl_success(&command, description);
Vec::new()
}
@@ -1113,12 +1124,16 @@ impl App {
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
// mode is unchanged.
let (effective_mode, effective_input) =
// mode is unchanged. The three-way `EffectiveMode` (ADR-0037) is
// carried through dispatch so the runtime can gate the DSL → SQL
// teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(Mode::Advanced, trimmed[1..].trim().to_string())
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
} else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else {
(self.mode, trimmed.to_string())
(EffectiveMode::Simple, trimmed.to_string())
};
if effective_input.is_empty() {
@@ -1143,7 +1158,7 @@ impl App {
// form in advanced mode runs and a SQL form in simple
// mode yields the precise "this is SQL" hint through the
// walker's mode gate — no separate placeholder branch.
self.dispatch_dsl(&effective_input, effective_mode)
self.dispatch_dsl(&effective_input, submission_mode)
}
/// Dispatch a parsed app-lifecycle command. Works in both
@@ -1237,7 +1252,11 @@ impl App {
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
// The two-way mode the walker + the `[mode]` render tag read; the
// three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for
// the runtime's echo gate (ADR-0038).
let mode = submission_mode.as_mode();
// ADR-0024 §Phase D: parse with the live schema so typed
// value slots (insert-into-T-values-…) dispatch on the
// column's actual user-facing type instead of accepting
@@ -1250,7 +1269,7 @@ impl App {
match crate::dsl::parser::parse_command_with_schema_in_mode(
input,
&self.schema_cache,
submission_mode,
mode,
) {
Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the
@@ -1270,7 +1289,7 @@ impl App {
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
mode_at_submission: mode,
styled_runs: None,
});
vec![Action::Replay { path }]
@@ -1279,12 +1298,13 @@ impl App {
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
mode_at_submission: mode,
styled_runs: None,
});
vec![Action::ExecuteDsl {
command: cmd,
source: input.to_string(),
submission_mode,
}]
}
Err(ParseError::Empty) => Vec::new(),
@@ -1294,7 +1314,7 @@ impl App {
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
mode_at_submission: mode,
styled_runs: None,
});
// Caret pointer at the failure position, when we
@@ -1334,7 +1354,7 @@ impl App {
// covers SQL constructs that surface only on submit
// (e.g. `delete … returning`, where the live hint
// shows WHERE-completion rather than an error).
if submission_mode == Mode::Simple
if mode == Mode::Simple
&& let Some(note) =
crate::input_render::advanced_alternative_note(input, &self.schema_cache)
{
@@ -1367,6 +1387,12 @@ impl App {
verb = command.verb(),
subject = command.display_subject()
));
// ADR-0038: the DSL → SQL teaching echo, beneath `[ok]`. Set on
// the success event when a DSL-form command ran in an advanced
// effective mode (ADR-0037); `None` otherwise. De-emphasised.
if let Some(sql) = self.pending_echo.take() {
self.note_system(crate::t!("echo.executing_sql", sql = sql));
}
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
@@ -2789,6 +2815,70 @@ mod tests {
);
}
#[test]
fn submit_carries_the_three_way_effective_submission_mode() {
// ADR-0037: ExecuteDsl carries the effective mode so the runtime
// can gate the teaching echo (ADR-0038).
let case = |mode: Mode, input: &str| -> EffectiveMode {
let mut app = App::new();
app.mode = mode;
type_str(&mut app, input);
match submit(&mut app).as_slice() {
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
other => panic!("expected one ExecuteDsl; got {other:?}"),
}
};
assert_eq!(
case(Mode::Advanced, "create table T with pk"),
EffectiveMode::AdvancedPersistent
);
assert_eq!(
case(Mode::Simple, ":create table T with pk"),
EffectiveMode::AdvancedOneShot
);
assert_eq!(
case(Mode::Simple, "create table T with pk"),
EffectiveMode::Simple
);
}
#[test]
fn dsl_success_renders_the_teaching_echo_beneath_ok() {
// ADR-0038: the echo carried on the success event renders as a
// line immediately beneath the `[ok]` summary.
let cmd = Command::CreateTable {
name: "Other".to_string(),
columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
primary_key: vec!["id".to_string()],
};
let mut app = App::new();
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some("CREATE TABLE Other (id serial PRIMARY KEY)".to_string()),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
let ok_idx = texts.iter().position(|t| t.starts_with("[ok]")).expect("an [ok] line");
let echo_idx = texts
.iter()
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, ok_idx + 1, "echo sits immediately beneath [ok]: {texts:?}");
assert!(texts[echo_idx].contains("CREATE TABLE Other (id serial PRIMARY KEY)"));
// No echo line when the event carries none (simple mode etc.).
let mut app = App::new();
app.update(AppEvent::DslSucceeded {
command: cmd,
description: None,
echo: None,
});
assert!(
!app.output.iter().any(|l| l.text.contains("Executing SQL:")),
"no echo line when echo is None"
);
}
#[test]
fn mode_command_switches_persistently() {
let mut app = App::new();
@@ -2960,6 +3050,7 @@ mod tests {
app.update(AppEvent::DslSucceeded {
command: cmd,
description: Some(desc.clone()),
echo: None,
});
assert_eq!(app.current_table, Some(desc));
// Some line in the output buffer is the structure