feat: DSL→SQL teaching echo — §4 styled-runs polish (ADR-0038)

Lands the last open item on ADR-0038: the de-emphasised styled-runs
rendering treatment for the echo + every category-3 prose line. The
echoed SQL now reads as code — the dimmed `Executing SQL:` label
plus the SQL portion lexed and coloured the same way the input echo
treats user-typed input (ADR-0028 §5 styled-runs over
input_render::lex_to_runs in advanced mode). Category-3 prose lines
(the DontConvert caveat and the existing illuminating
`client_side.*` notes — shortid auto-fill, type-conversion
transforms) all render dimmed too, per §6's "de-emphasised prose
line" wording, so every cat-3 line is visually consistent.

* New `OutputKind::TeachingEcho` variant + a custom branch in
  `ui::render_output_line` mirroring the OutputKind::Echo input-echo
  path: strip the canonical `Executing SQL:` prefix, render it with
  `theme.muted`, then lex the rest in `Mode::Advanced` and emit one
  span per token. Tag stays `[system]` for visual consistency with
  other system output.
* New `OutputStyleClass::Hint` styled-runs class, resolved to
  `theme.muted` in `output_span_style`. Used for the cat-3 prose
  lines (dont_convert caveat + the existing client_side notes).
* New const `crate::echo::TEACHING_ECHO_LABEL = "Executing SQL: "` —
  the byte boundary the ui.rs branch needs is fixed (an i18n template
  can't provide that), so the label moves from i18n to a constant.
  The `echo.executing_sql` i18n key is retired (en-US.yaml + keys.rs);
  a comment in en-US.yaml points future locales at re-introducing it
  if needed.
* App-side helpers: `push_teaching_echo(sql)` builds the
  TeachingEcho line; `push_category_three_prose(text)` builds a
  System line with a whole-text Hint span. `note_ok_summary` and
  `handle_dsl_change_column_success` / `handle_dsl_add_column_success`
  use these instead of plain `note_system` for the echo, the caveat,
  and the illuminating notes.

Existing tests pass unchanged — text content is the same; only
styling changes. New tests pin the polish:

* `ui::tests::teaching_echo_line_renders_dim_prefix_and_lexed_sql`
  asserts the TeachingEcho rendering produces a dim prefix span +
  keyword-coloured SQL spans (confirming the lexer ran in advanced
  mode).
* `ui::tests::category_three_prose_line_renders_all_dim` pins the
  whole-text Hint coverage.
* `ui::tests::hint_class_resolves_to_muted_foreground` pins the
  theme resolution across both light and dark.
* `app::tests::polished_echo_carries_teaching_echo_kind_and_caveat_a_hint_span`
  pins the App-side wiring (kind + styled_runs shape).

Tests: 2019 passed / 0 failed / 1 ignored (pre-existing); clippy
clean (`--all-targets -D warnings`, nursery).

ADR-0038 is now feature-complete — every catalogue row implemented,
round-tripped, AND polished per §4.
This commit is contained in:
claude@clouddev1
2026-05-28 12:16:28 +00:00
parent df5c4e2a55
commit 2aab457c44
5 changed files with 286 additions and 27 deletions
+139 -17
View File
@@ -28,6 +28,13 @@ pub enum OutputKind {
Echo,
System,
Error,
/// The DSL → SQL teaching echo (ADR-0038 §4). Visually a `[system]`
/// line, but rendered with a custom path: a dimmed `Executing SQL:`
/// prefix followed by the SQL re-lexed through `input_render::
/// lex_to_runs_in_mode(Advanced)` — same syntax highlighting the
/// input echo gets, so the suggested SQL reads like code (ADR-0028
/// §5 styled-runs).
TeachingEcho,
}
/// The semantic style class of an [`OutputSpan`] (ADR-0028 §5).
@@ -49,6 +56,11 @@ pub enum OutputStyleClass {
/// index because none existed; the strongest "add an index
/// here" signal.
AutomaticIndex,
/// De-emphasised text — `Executing SQL:` prefix on teaching
/// echo lines (ADR-0038 §4), the DontConvert caveat, and
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
/// Resolves to `theme.muted`.
Hint,
}
/// A styled span of an output line: a byte range over the
@@ -1420,16 +1432,16 @@ 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
// (styled-runs polish per ADR-0038 §4 still pending). One line
// per statement — single-statement echoes render one line;
// multi-statement (`drop column --cascade`, `add relationship
// --create-fk`) render one per entry (ADR-0038 §6 category 2).
// ADR-0038 §4: 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. One
// `OutputKind::TeachingEcho` line per statement (§6 category
// 2): the dimmed `Executing SQL:` prefix + the SQL portion
// re-lexed in advanced mode for syntax highlighting — see
// `ui::render_output_line`'s `TeachingEcho` branch.
if let Some(lines) = self.pending_echo.take() {
for line in lines {
self.note_system(crate::t!("echo.executing_sql", sql = line));
self.push_teaching_echo(&line);
}
}
}
@@ -1493,12 +1505,12 @@ impl App {
result: AddColumnResult,
) {
self.note_ok_summary(command);
// ADR-0018 §9: emit auto-fill note(s) before the
// structure render, so the pedagogical "the tool did
// this for you" line is in the user's eye-line next to
// the success summary.
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
// before the structure render so the pedagogical "the tool did
// this for you" line is in the user's eye-line next to the
// success summary. De-emphasised per §6 (illuminating prose).
for note in result.client_side_notes {
self.note_system(note);
self.push_category_three_prose(note);
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
@@ -1541,6 +1553,9 @@ impl App {
// When both transformations and auto-fills happen
// in the same operation, both note lines are
// emitted in order (ADR-0018 §9 explicit rule).
// ADR-0038 §6 category 3: both lines are illuminating prose
// (the SQL line is equivalent; the note merely reveals a
// value-add SQL doesn't show). De-emphasised.
if note.transformed > 0 {
let line = if note.lossy > 0 {
crate::t!(
@@ -1554,7 +1569,7 @@ impl App {
count = note.transformed
)
};
self.note_system(line);
self.push_category_three_prose(line);
}
if note.auto_filled > 0 {
let kind = match note.auto_fill_kind {
@@ -1562,7 +1577,7 @@ impl App {
Some(crate::db::AutoFillKind::ShortId) => "shortid",
None => "auto-generated",
};
self.note_system(crate::t!(
self.push_category_three_prose(crate::t!(
"client_side.auto_fill_transition",
count = note.auto_filled,
kind = kind
@@ -1574,9 +1589,10 @@ impl App {
// nearest SQL but *not* equivalent (the only Bucket A caveat —
// every other category-3 line is illuminating). Sits between
// the client-side notes and the structure render so it reads
// alongside the echo, not after the table view.
// alongside the echo, not after the table view. De-emphasised
// prose per §6.
if dont_convert_caveat {
self.note_system(crate::t!("client_side.dont_convert_caveat"));
self.push_category_three_prose(crate::t!("client_side.dont_convert_caveat"));
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
@@ -2224,6 +2240,46 @@ impl App {
self.push_multiline(text.into(), OutputKind::System);
}
/// Push one teaching-echo line (ADR-0038 §4 styled-runs polish).
/// The text is `"<TEACHING_ECHO_LABEL><sql>"`; the `TeachingEcho`
/// kind triggers `ui::render_output_line`'s custom branch, which
/// renders the prefix dimmed (`theme.muted`) and lexes the SQL in
/// advanced mode for syntax highlighting — the same treatment the
/// input echo receives.
fn push_teaching_echo(&mut self, sql: &str) {
let text = format!("{}{sql}", crate::echo::TEACHING_ECHO_LABEL);
self.push_output(OutputLine {
text,
kind: OutputKind::TeachingEcho,
mode_at_submission: self.mode,
styled_runs: None,
});
}
/// Push one category-3 prose line (ADR-0038 §6) — the
/// `--dont-convert` caveat and the existing illuminating
/// `client_side.*` notes (shortid auto-fill, type-conversion
/// transforms). The whole line text is rendered dimmed
/// (`OutputStyleClass::Hint` → `theme.muted`); the `[system]`
/// tag keeps its kind styling. De-emphasised, per §6.
fn push_category_three_prose(&mut self, text: impl Into<String>) {
let text = text.into();
let runs = if text.is_empty() {
Vec::new()
} else {
vec![OutputSpan {
byte_range: (0, text.len()),
class: OutputStyleClass::Hint,
}]
};
self.push_output(OutputLine::styled(
text,
OutputKind::System,
self.mode,
runs,
));
}
fn note_error(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::Error);
}
@@ -3066,6 +3122,72 @@ mod tests {
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
}
#[test]
fn polished_echo_carries_teaching_echo_kind_and_caveat_a_hint_span() {
// ADR-0038 §4 styled-runs polish: the App-side wiring places
// every echo line as an OutputKind::TeachingEcho (so
// `ui::render_output_line`'s custom branch fires — dim prefix
// + lexed SQL) and every category-3 prose line as a System
// line with a single Hint span covering the whole text (so
// the body renders dimmed via `output_span_style`).
use crate::db::ChangeColumnTypeResult;
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
mode: crate::dsl::ChangeColumnMode::DontConvert,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE int".to_string(),
]),
dont_convert_caveat: true,
});
// The echo line is a TeachingEcho.
let echo_line = app
.output
.iter()
.find(|l| l.text.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(
echo_line.kind,
OutputKind::TeachingEcho,
"echo line carries TeachingEcho so ui.rs fires the dim-prefix + lex-rest branch",
);
// The echo line carries no styled_runs payload — the custom
// ui.rs branch builds its own spans from the kind alone.
assert!(
echo_line.styled_runs.is_none(),
"echo line uses kind-driven custom rendering, not styled-runs",
);
// The caveat is a System line with a single Hint span covering
// the whole text — the whole prose body renders dim.
let caveat_line = app
.output
.iter()
.find(|l| l.text.contains("`--dont-convert` kept the stored values"))
.expect("a caveat line");
assert_eq!(caveat_line.kind, OutputKind::System);
let runs = caveat_line
.styled_runs
.as_ref()
.expect("caveat carries a styled-runs payload");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].class, OutputStyleClass::Hint);
assert_eq!(
runs[0].byte_range,
(0, caveat_line.text.len()),
"the dim span covers the entire prose body",
);
}
#[test]
fn change_column_dont_convert_renders_the_caveat_between_notes_and_structure() {
// ADR-0038 §6 category 3 (Phase 3): when `change column …