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:
@@ -109,20 +109,24 @@ effective mode it ran under. The value is **output-only**: no executor
|
||||
branches its *effect* on it (that would be a behavioural mode dependency,
|
||||
which ADR-0033 Amendment 3 forbids — identity and effect are intrinsic).
|
||||
|
||||
### 3. The worker produces mode-dependent output; the App renders it
|
||||
### 3. The runtime's execution dispatcher produces the echo; the App renders it
|
||||
|
||||
For the first consumer (ADR-0038): when the command is a **DSL-form**
|
||||
command (`Command::CreateTable`/`Insert`/… — *not* the `Sql*` variants)
|
||||
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the worker
|
||||
builds the teaching echo (equivalent SQL + any category-3 expansion
|
||||
data — ADR-0038) and returns it on the result event. In `Simple` mode,
|
||||
or for a command typed as SQL, no echo is produced. The App renders the
|
||||
returned echo as de-emphasised `OutputLine`(s) beneath `[ok]`.
|
||||
and `submission_mode` is `Advanced` or `AdvancedOneShot`, the teaching
|
||||
echo (equivalent SQL + any category-3 expansion data — ADR-0038) is built
|
||||
from the `Command` **plus the worker's execution result**, and the App
|
||||
renders it as de-emphasised `OutputLine`(s) beneath `[ok]`. In `Simple`
|
||||
mode, or for a command typed as SQL, no echo is produced.
|
||||
|
||||
Co-locating echo construction with execution is deliberate: the echo's
|
||||
harder forms (resolved auto-names, generated `shortid`s, conversion
|
||||
counts) are facts the worker already computes. Gating on the threaded
|
||||
mode means the work happens **only when an echo will be shown**.
|
||||
**Where it is built (build correction — see Implementation notes).** Not
|
||||
in the db.rs worker: the worker receives *decomposed* calls, not the
|
||||
`Command`, so it cannot render `Command → SQL`. The echo is built at the
|
||||
**runtime's `ExecuteDsl` handler**, the one place where the `Command`,
|
||||
the threaded `EffectiveMode`, and the worker's result (resolved
|
||||
auto-names, generated `shortid`s, conversion counts) all converge. This
|
||||
is still **execution-time aware** — it consumes the execution *results* —
|
||||
it just lives at the dispatch layer, not inside the storage worker.
|
||||
|
||||
**Non-interactive re-execution does not echo.** `replay` (ADR-0034)
|
||||
re-runs recorded commands through the dispatch pipeline in advanced mode
|
||||
@@ -172,6 +176,26 @@ the gating contract in §3.
|
||||
`Action` → worker round-trip; a Simple-mode DSL command yields no echo
|
||||
request while an Advanced / one-shot one does (the gating contract).
|
||||
|
||||
## Implementation notes (2026-05-27, during build)
|
||||
|
||||
Two refinements found when building, recorded so the ADR matches reality:
|
||||
|
||||
- **Reuse the existing `EffectiveMode`, do not add `SubmissionMode`.** The
|
||||
codebase already has `EffectiveMode { Simple, AdvancedPersistent,
|
||||
AdvancedOneShot }` (`app.rs`), computed by `effective_mode()` and used
|
||||
today for the `:` one-shot UI feedback. It is exactly the three-way,
|
||||
per-submission, *separate-from-`Mode`* enum §1 argued for — so §1's
|
||||
"new enum" is already satisfied; the build reuses `EffectiveMode`
|
||||
(`AdvancedPersistent` is the ADR's `Advanced`). No new type.
|
||||
- **The channel ships with its consumer (merged with ADR-0038).** A
|
||||
threaded-but-unread `EffectiveMode` on the worker request is dead code,
|
||||
which this project's `-D warnings` (nursery) rejects. The side-channel
|
||||
has no consumer other than the echo, so the `Action`→worker threading
|
||||
is built **together with ADR-0038** rather than as a standalone commit
|
||||
— the submit-side resolution (which `Action` carries which
|
||||
`EffectiveMode`) is Tier-1 testable, and the worker-side threading
|
||||
becomes live + end-to-end testable the moment the echo reads it.
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-0033 Amendment 3 — deferred this side-channel; defines the
|
||||
|
||||
@@ -97,13 +97,17 @@ echoes would bury the replay summary — ADR-0037 §3).
|
||||
|
||||
### 4. Where it is built and rendered
|
||||
|
||||
The **worker builds** the echo (ADR-0037 §3) — it alone holds the facts
|
||||
several echoes need: auto-resolved index / relationship names, generated
|
||||
`shortid` values, and lossy-conversion counts. It returns the echo
|
||||
payload on the result event. The **App renders** it as one or more
|
||||
**de-emphasised** `OutputLine`s beneath the `[ok]` summary, using the
|
||||
ADR-0028 styled-runs mechanism (a dimmed `Executing SQL:` prefix; the SQL
|
||||
itself in a code-ish run). One statement per line (§6 category 2).
|
||||
The echo is built at the **runtime's `ExecuteDsl` handler** (build
|
||||
correction, ADR-0037 §3 + Implementation notes): the db.rs worker
|
||||
receives *decomposed* calls, not the `Command`, so it cannot render
|
||||
`Command → SQL`. The runtime is the one place where the `Command`, the
|
||||
threaded `EffectiveMode`, and the worker's **result** (resolved auto-
|
||||
names, generated `shortid`s, conversion counts) all converge — so it
|
||||
builds the echo there, still **execution-time aware** (it consumes the
|
||||
results). The **App renders** it as one or more **de-emphasised**
|
||||
`OutputLine`s beneath the `[ok]` summary, using the ADR-0028 styled-runs
|
||||
mechanism (a dimmed `Executing SQL:` prefix; the SQL in a code-ish run).
|
||||
One statement per line (§6 category 2).
|
||||
|
||||
### 5. `Value → SQL-literal` rendering
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! itself, which keeps update directly testable without a Tokio
|
||||
//! runtime, a real terminal, or a database.
|
||||
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::dsl::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -23,6 +24,12 @@ pub enum Action {
|
||||
ExecuteDsl {
|
||||
command: Command,
|
||||
source: String,
|
||||
/// The effective mode the line was submitted under (ADR-0037):
|
||||
/// `Simple` / `AdvancedPersistent` / `AdvancedOneShot`. Output-only
|
||||
/// — execution semantics do not depend on it; the runtime uses it
|
||||
/// to gate the DSL → SQL teaching echo (ADR-0038), which fires for
|
||||
/// DSL-form commands submitted in an advanced effective mode.
|
||||
submission_mode: EffectiveMode,
|
||||
},
|
||||
/// Record a *failed* submission to `history.log` as an `err`
|
||||
/// record (ADR-0034 §1/§2). Emitted by the pure-sync `App`
|
||||
|
||||
+102
-11
@@ -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
|
||||
|
||||
+160
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ pub enum AppEvent {
|
||||
DslSucceeded {
|
||||
command: Command,
|
||||
description: Option<TableDescription>,
|
||||
/// The DSL → SQL teaching echo (ADR-0038): equivalent advanced-mode
|
||||
/// SQL, built by the runtime when a DSL-form command ran in an
|
||||
/// advanced effective mode (ADR-0037). `None` when no echo applies
|
||||
/// (simple mode, SQL-entered, or a form with no echo). The App
|
||||
/// renders it beneath `[ok]`.
|
||||
echo: Option<String>,
|
||||
},
|
||||
/// A SQL `CREATE TABLE IF NOT EXISTS` matched an existing table —
|
||||
/// a no-op (ADR-0035 §4). Renders the existing structure plus an
|
||||
|
||||
@@ -497,6 +497,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("ok.rows_inserted", &["count"]),
|
||||
("ok.rows_updated", &["count"]),
|
||||
("ok.summary", &["verb", "subject"]),
|
||||
// ---- DSL → SQL teaching echo (ADR-0038) ----
|
||||
("echo.executing_sql", &["sql"]),
|
||||
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
|
||||
("client_side.auto_fill_add_serial", &["count"]),
|
||||
("client_side.auto_fill_add_shortid", &["count"]),
|
||||
|
||||
@@ -874,6 +874,12 @@ db:
|
||||
Cannot add this CHECK to `{table}.{column}`: {total} row(s) do not satisfy `{rule}`.
|
||||
|
||||
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
|
||||
# DSL → SQL teaching echo (ADR-0038): the equivalent advanced-mode SQL,
|
||||
# rendered beneath `[ok]` for a DSL-form command run in an advanced
|
||||
# effective mode (ADR-0037).
|
||||
echo:
|
||||
executing_sql: "Executing SQL: {sql}"
|
||||
|
||||
ok:
|
||||
# Generic `[ok] <verb> <subject>` header used for every
|
||||
# successful DSL command. Verbs come from `Command::verb()`
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod cli;
|
||||
pub mod completion;
|
||||
pub mod db;
|
||||
pub mod dsl;
|
||||
pub mod echo;
|
||||
pub mod event;
|
||||
pub mod friendly;
|
||||
pub mod input_render;
|
||||
|
||||
+15
-1
@@ -384,12 +384,17 @@ async fn run_loop(
|
||||
debug!("quit action received");
|
||||
should_quit = true;
|
||||
}
|
||||
Action::ExecuteDsl { command, source } => {
|
||||
Action::ExecuteDsl {
|
||||
command,
|
||||
source,
|
||||
submission_mode,
|
||||
} => {
|
||||
spawn_dsl_dispatch(
|
||||
session.database().clone(),
|
||||
event_tx.clone(),
|
||||
command,
|
||||
source,
|
||||
submission_mode,
|
||||
);
|
||||
}
|
||||
Action::JournalFailure { source } => {
|
||||
@@ -1251,16 +1256,25 @@ fn spawn_dsl_dispatch(
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
command: Command,
|
||||
source: String,
|
||||
submission_mode: crate::app::EffectiveMode,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
// Retain the source for `DslFailed` so the App can journal a
|
||||
// rejected command as `err` (ADR-0034 §1/§2).
|
||||
let source_for_journal = source.clone();
|
||||
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||
// command submitted in an advanced effective mode (ADR-0037).
|
||||
// `replay` bypasses this spawn (it calls `execute_command_typed`
|
||||
// directly), so replayed lines never echo. Built before execution
|
||||
// from the Command; resolved-name / category-3 forms (Bucket B/§6,
|
||||
// later slices) will additionally read the execution result.
|
||||
let echo = crate::echo::echo_for(&command, submission_mode);
|
||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||
let event = match outcome {
|
||||
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
||||
command: command.clone(),
|
||||
description,
|
||||
echo,
|
||||
},
|
||||
Ok(CommandOutcome::SchemaSkipped(description)) => AppEvent::DslCreateSkipped {
|
||||
command: command.clone(),
|
||||
|
||||
@@ -60,6 +60,7 @@ fn advanced_mode_select_dispatches_as_command_select() {
|
||||
[Action::ExecuteDsl {
|
||||
command: Command::Select { sql },
|
||||
source,
|
||||
..
|
||||
}] => {
|
||||
assert!(
|
||||
sql.contains("select 1"),
|
||||
|
||||
@@ -294,6 +294,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: expected_cmd,
|
||||
description: Some(desc.clone()),
|
||||
echo: None,
|
||||
});
|
||||
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
|
||||
|
||||
@@ -358,6 +359,7 @@ fn add_column_flow_updates_structure_view() {
|
||||
check: None,
|
||||
},
|
||||
description: Some(updated.clone()),
|
||||
echo: None,
|
||||
});
|
||||
assert_eq!(app.current_table, Some(updated));
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
@@ -387,6 +389,7 @@ fn drop_table_flow_clears_items_list() {
|
||||
name: "Customers".to_string(),
|
||||
},
|
||||
description: None,
|
||||
echo: None,
|
||||
});
|
||||
app.update(AppEvent::TablesRefreshed(Vec::new()));
|
||||
|
||||
@@ -460,6 +463,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
create_fk: false,
|
||||
},
|
||||
description: Some(customers),
|
||||
echo: None,
|
||||
});
|
||||
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
@@ -513,6 +517,7 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||
check: None,
|
||||
},
|
||||
description: Some(customers),
|
||||
echo: None,
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
|
||||
Reference in New Issue
Block a user