feat: persist & restore per-project input mode (#14)

The input mode always started in simple; a learner who quit in advanced
had to re-toggle every launch. Store the mode per-project in project.yaml
(project.mode:, optional, default simple) and restore it on every open.

Mode is live UI state, not schema: the worker stamps the current mode
into project.yaml on every write, so a later command rewrites the live
value rather than clobbering it — no db round-trip needed. The mode is
persisted on unload (quit + project switch) so the mode you leave a
project in is always what reopens; the `mode` command also persists
immediately. A switch saves the outgoing mode, then restores the
incoming project's stored mode.

New --mode simple|advanced CLI flag (precedence --mode > stored >
simple; combines with --resume). A teacher can ship a project that
opens in advanced mode and export it to students (the mode travels in
the zip).

ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
This commit is contained in:
claude@clouddev1
2026-06-02 06:47:34 +00:00
parent ae57c6fc82
commit 4cd574b909
16 changed files with 769 additions and 14 deletions
+127
View File
@@ -561,3 +561,130 @@ note pointing to this ADR.
- `history.log` becomes the persistent history surface. - `history.log` becomes the persistent history surface.
Once `replay` (OOS-2) and `undo` (OOS-1) land, they read Once `replay` (OOS-2) and `undo` (OOS-1) land, they read
from the same file with no schema changes. from the same file with no schema changes.
## Amendment 1 — Persist & restore the input mode (2026-05-31, issue #14)
### Problem
`--resume` (and any project open) always started in the default
`simple` input mode. A learner who quit in advanced mode had to
re-toggle on every launch. The mode the user was in is a real,
restorable preference; losing it each session is a small but
repeated UX cost.
### Decision
The **input mode is per-project state stored in `project.yaml`**,
restored on every open and persisted as it changes. A teacher can
prepare a project that opens ready in advanced mode and hand it to
students; a learner who works across projects has each one's mode
restored when it loads ("loading triggers the mode switch each
time" — the user's framing). This is a deliberate, useful form of
the "mode travels with the project" property, *not* an accidental
leak (the alternative — a private per-user file — was rejected for
exactly that reason: it would prevent the teacher-prep use case).
**1. Storage — `project.yaml`, alongside `created_at`.** A new
optional `mode:` key under the `project:` mapping:
```yaml
version: 1
project:
created_at: 2026-05-31T00:00:00Z
mode: advanced
```
It is **project metadata, not schema**`rebuild` ignores it
(reconstructs the db from tables/data; the mode plays no part).
The field is **optional with a `simple` default**: pre-#14 files
(no `mode:`) parse unchanged, no version bump, no migrator — the
same backward-compatible pattern as the `unique` index flag. It
**does** travel in an `export` zip (it is part of `project.yaml`),
which is the intended teacher→student behaviour.
**2. Mode is live UI state, not stored in the database.** Unlike
`created_at` (whose source of truth is gone after creation, so it
round-trips through the internal metadata table), the mode's
source of truth is always live — it is `App.mode`. So it is **not**
put in the database. Instead the persistence handle carries the
**current mode**, and the worker **stamps it into `project.yaml`
on every write**. Because every schema-mutating command rewrites
the whole file, writing the *current* mode each time means a later
command can never clobber it back to the default — there is
nothing to "preserve", only the live value to write. (This
replaced an initial over-engineered design that mirrored the mode
in the db metadata table; the simpler "write what we're in"
approach is correct because the mode is never reconstructed from
text.)
**3. Restore precedence: `--mode` > stored > `simple`.** A new
CLI flag `--mode simple|advanced` overrides the stored mode at
startup; it combines with `--resume` and a positional path (not
mutually exclusive — on collision the flag wins). At boot the
runtime reads the stored mode (`Persistence::read_stored_mode`,
which returns `None` for an absent field so "no preference" stays
distinct from an explicit `simple`), applies the flag, sets
`App.mode`, and seeds the persistence handle so the resolved mode
is what subsequent writes record. The `--mode` override applies
**only at boot** — a later project switch restores that project's
own stored mode.
**4. Mid-session changes.** The `mode` command emits a new
`Action::PersistMode`; the runtime records it through the worker
(`Database::set_mode`), which updates the live mode and writes
`project.yaml` immediately (crash-safe). Persisting the mode is
**best-effort** throughout: a failure must never escalate a UI
action into a fatal (the in-memory mode has already changed).
**5. Persist on unload (the deciding rule).** The mode is written
whenever the current project is **unloaded** — on quit and on a
project switch (load / new / save-as / import), the runtime calls
`set_mode(App.mode)` on the outgoing database before it is dropped.
This is what makes the stored mode **deterministic and
non-confusing**: by the time you leave a project, the mode you were
in is always recorded — including a bare `--mode` override or a
read-only session that ran no command. (An earlier "persist only on
the `mode` command or a schema-changing command" rule was rejected
as confusingly *selective* — whether a `--mode` override stuck
depended on whether you happened to run a DDL. "On unload" was
chosen over "on every command" to avoid rewriting `project.yaml`
and bumping its mtime, which orders the load picker — on every
read-only `select`/`show data`.) On a switch the **outgoing**
project's mode is saved first, then the **incoming** project's
stored mode is restored and carried to the `App` via the
`ProjectSwitched` event ("loading triggers the mode switch each
time").
### Scope / non-changes
- No new project file, no database schema change, no migration.
- `rebuild` is unaffected (mode is not schema; the round-trip
through text never touches it).
- The default for a brand-new project is unchanged (`simple`).
- Coverage: `mode.rs` keyword parse/round-trip +
`resolve_startup_applies_flag_then_stored_then_default`
(precedence helper);
`yaml.rs::{mode_round_trips_through_serialize_and_parse,
parse_schema_defaults_mode_to_simple_when_field_absent,
parse_stored_mode_distinguishes_absent_from_explicit,
parse_stored_mode_falls_back_to_none_on_unknown_value}`;
`persistence::{read_stored_mode_round_trips_a_written_project_yaml,
read_stored_mode_is_none_for_a_missing_project_yaml}`;
`db::{set_mode_persists_and_survives_a_later_ddl_command (the
core no-clobber guarantee),
set_mode_persists_even_with_no_prior_command (the persist-on-unload
guarantee)}`; `archive::export_carries_the_stored_input_mode`
(the teacher-export round-trip); `cli` `--mode` parse/precedence;
`app::{mode_command_changes_mode_and_emits_persist_action,
mode_command_via_one_shot_escape_persists_advanced,
project_switched_event_restores_the_stored_mode}`. The runtime's
unload call sites (quit + `handle_project_switch`) are thin
wiring over the tested `Database::set_mode`.
### Relationship to the Iteration 6 backlog
Issue #14 named this an Iteration 6 piece (persistent input
history / `--resume`). `--resume` itself already shipped; this
amendment adds the mode dimension. It is independent of the
`history.log`-based input-history hydration (§12), which remains
its own piece of Iteration 6.
+2 -2
View File
@@ -8,7 +8,7 @@ This directory contains the project's ADRs, recorded per
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md) - [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md) - [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md)
- [ADR-0002 — Database engine](0002-database-engine.md) - [ADR-0002 — Database engine](0002-database-engine.md)
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) - [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14)
- [ADR-0004 — Project file format](0004-project-file-format.md) - [ADR-0004 — Project file format](0004-project-file-format.md)
- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md) - [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md)
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite` - [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite`
@@ -20,7 +20,7 @@ This directory contains the project's ADRs, recorded per
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md) - [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md) - [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
- [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md) - [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md)
- [ADR-0015 — Project storage runtime](0015-project-storage-runtime.md) - [ADR-0015 — Project storage runtime](0015-project-storage-runtime.md)**Amendment 1 (2026-05-31, issue #14):** the **input mode is per-project state in `project.yaml`** (a new optional `mode:` key under `project:`, alongside `created_at`), restored on every open and persisted as it changes — so a teacher can ship a project that opens in advanced mode, and a learner's last-used mode is restored per project. Mode is **live UI state, not schema** (`rebuild` ignores it) and **not stored in the database**: the persistence handle carries the current mode and the worker **stamps it into `project.yaml` on every write**, so a later command rewrites the live value rather than clobbering it (no preserve-the-old-value dance needed). Backward-compatible optional field (pre-#14 files default to `simple`, no migration). New CLI flag **`--mode simple|advanced`**, precedence **`--mode` > stored > `simple`**; combines with `--resume`. Mid-session `mode` changes persist via `Action::PersistMode``Database::set_mode` (immediate, crash-safe), and the mode is **persisted on unload** (quit + project switch) so the mode you leave a project in is always restored — deterministic, not selectively dependent on whether you ran a DDL (rejected as confusing) nor rewriting `project.yaml` on every read command (rejected for load-picker mtime churn). Switches save the outgoing project's mode, then restore the incoming project's stored mode via the `ProjectSwitched` event
- [ADR-0016 — Pretty table rendering for data and structure views](0016-pretty-table-rendering.md) - [ADR-0016 — Pretty table rendering for data and structure views](0016-pretty-table-rendering.md)
- [ADR-0017 — Column type-change compatibility](0017-column-type-change-compatibility.md) - [ADR-0017 — Column type-change compatibility](0017-column-type-change-compatibility.md)
- [ADR-0018 — Auto-fill contracts for `serial` and `shortid` columns](0018-auto-fill-contracts-for-serial-and-shortid.md) - [ADR-0018 — Auto-fill contracts for `serial` and `shortid` columns](0018-auto-fill-contracts-for-serial-and-shortid.md)
+10
View File
@@ -598,6 +598,16 @@ since ADR-0027.)
exclusive with a positional path argument (ADR-0015 §7). exclusive with a positional path argument (ADR-0015 §7).
`last_project` is rewritten on every successful project `last_project` is rewritten on every successful project
open (startup, load, new, save as, import). open (startup, load, new, save as, import).
- [x] **L1b** Per-project input-mode restore (issue #14,
ADR-0015 Amendment 1). The input mode is stored in
`project.yaml` (`project.mode:`), restored on every open, and
persisted on a `mode` change and on unload (quit / project
switch) — so the mode you leave a project in is what reopens.
A teacher can ship a project that opens in advanced mode; a
learner's last-used mode is restored per project. New
`--mode simple|advanced` CLI flag (precedence `--mode` >
stored > `simple`; combines with `--resume`). Independent of
the `history.log` input-history hydration piece of Iteration 6.
- [~] **L2** Submit a command alongside project load — deferred, - [~] **L2** Submit a command alongside project load — deferred,
not v1. not v1.
+7
View File
@@ -129,4 +129,11 @@ pub enum Action {
/// refreshes the table list + schema cache. /// refreshes the table list + schema cache.
Undo, Undo,
Redo, Redo,
/// User changed the input mode mid-session (the `mode` command).
/// The runtime records it through the worker so `project.yaml`
/// reflects the live mode and it is restored on the next open
/// (ADR-0015 mode-restore amendment, issue #14). Best-effort:
/// a persistence failure here must not escalate a UI mode toggle
/// into a fatal — the in-memory mode has already changed.
PersistMode(crate::mode::Mode),
} }
+55 -1
View File
@@ -736,6 +736,7 @@ impl App {
display_name, display_name,
is_temp, is_temp,
history_entries, history_entries,
mode,
} => { } => {
self.note_system(crate::t!( self.note_system(crate::t!(
"project.switched_ok", "project.switched_ok",
@@ -746,6 +747,9 @@ impl App {
self.tables.clear(); self.tables.clear();
self.current_table = None; self.current_table = None;
self.seed_history(history_entries); self.seed_history(history_entries);
// Restore the switched-to project's stored input
// mode (ADR-0015 mode-restore amendment, issue #14).
self.mode = mode;
Vec::new() Vec::new()
} }
AppEvent::ProjectSwitchFailed { error } => { AppEvent::ProjectSwitchFailed { error } => {
@@ -1311,7 +1315,9 @@ impl App {
ModeValue::Advanced => "advanced", ModeValue::Advanced => "advanced",
}; };
self.handle_mode_command(&format!("mode {arg}")); self.handle_mode_command(&format!("mode {arg}"));
Vec::new() // Persist the new mode so it is restored on the next
// open (ADR-0015 mode-restore amendment, issue #14).
vec![Action::PersistMode(self.mode)]
} }
AppCommand::Messages { value } => { AppCommand::Messages { value } => {
let raw = match value { let raw = match value {
@@ -2906,6 +2912,54 @@ mod tests {
assert!(!app.output.is_empty()); assert!(!app.output.is_empty());
} }
// ---- ADR-0015 mode-restore amendment (issue #14) ----
#[test]
fn mode_command_changes_mode_and_emits_persist_action() {
// The `mode` command flips the live mode AND emits
// `PersistMode` so the runtime records it to project.yaml.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "mode simple");
let actions = submit(&mut app);
assert_eq!(app.mode, Mode::Simple, "live mode flipped");
assert_eq!(
actions,
vec![Action::PersistMode(Mode::Simple)],
"the mode change is persisted",
);
}
#[test]
fn mode_command_via_one_shot_escape_persists_advanced() {
// Reaching `mode advanced` from simple via the `:` one-shot
// (ADR-0003) still emits the persist action for advanced.
let mut app = App::new();
assert_eq!(app.mode, Mode::Simple);
type_str(&mut app, ":mode advanced");
let actions = submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
assert!(
actions.contains(&Action::PersistMode(Mode::Advanced)),
"expected PersistMode(Advanced), got {actions:?}",
);
}
#[test]
fn project_switched_event_restores_the_stored_mode() {
// A switch carries the target project's stored mode; the
// App adopts it ("loading triggers the mode switch").
let mut app = App::new();
assert_eq!(app.mode, Mode::Simple);
app.update(AppEvent::ProjectSwitched {
display_name: "Other".to_string(),
is_temp: false,
history_entries: Vec::new(),
mode: Mode::Advanced,
});
assert_eq!(app.mode, Mode::Advanced);
}
#[test] #[test]
fn bare_create_table_emits_friendly_parse_error() { fn bare_create_table_emits_friendly_parse_error() {
let mut app = App::new(); let mut app = App::new();
+29
View File
@@ -583,6 +583,35 @@ mod tests {
assert_eq!(inspect.top_folder, "MyProject"); assert_eq!(inspect.top_folder, "MyProject");
} }
#[test]
fn export_carries_the_stored_input_mode() {
// ADR-0015 mode-restore amendment (issue #14): the input
// mode is part of project.yaml, so it travels in the export
// verbatim — this is the teacher-prepares-an-advanced-mode
// project, hands it to students workflow.
use std::io::Read as _;
let tmp = tempdir();
let project = make_project(tmp.path(), "Advanced");
// Re-write project.yaml with an explicit advanced mode.
fs::write(
project.join(PROJECT_YAML),
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\n mode: advanced\ntables: []\nrelationships: []\n",
)
.unwrap();
let zip_path = tmp.path().join("export.zip");
export_project(&project, "Advanced", &zip_path).unwrap();
let f = fs::File::open(&zip_path).unwrap();
let mut archive = ZipArchive::new(f).unwrap();
let mut entry = archive.by_name("Advanced/project.yaml").unwrap();
let mut body = String::new();
entry.read_to_string(&mut body).unwrap();
assert!(
body.contains("mode: advanced"),
"exported project.yaml must carry the stored mode: {body}"
);
}
#[test] #[test]
fn inspect_rejects_zip_without_project_yaml() { fn inspect_rejects_zip_without_project_yaml() {
let tmp = tempdir(); let tmp = tempdir();
+58
View File
@@ -7,6 +7,7 @@
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use crate::mode::Mode;
use crate::theme::Theme; use crate::theme::Theme;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -35,6 +36,12 @@ pub struct Args {
/// report that undo is turned off. The escape hatch for small /// report that undo is turned off. The escape hatch for small
/// hardware where per-command snapshotting is too heavy. /// hardware where per-command snapshotting is too heavy.
pub no_undo: bool, pub no_undo: bool,
/// `--mode simple|advanced`: start in this input mode,
/// overriding the project's stored mode (ADR-0015 mode-restore
/// amendment, issue #14). Precedence: `--mode` > stored project
/// mode > the default (`simple`). Combines with `--resume` and
/// a positional path; on collision the flag wins.
pub mode: Option<Mode>,
} }
/// Usage banner printed by `--help`. /// Usage banner printed by `--help`.
@@ -116,6 +123,7 @@ impl Args {
let mut resume = false; let mut resume = false;
let mut help = false; let mut help = false;
let mut no_undo = false; let mut no_undo = false;
let mut mode: Option<Mode> = None;
let mut iter = iter.into_iter().map(Into::into); let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
match arg.as_str() { match arg.as_str() {
@@ -150,6 +158,16 @@ impl Args {
let value = iter.next().ok_or(ArgsError::MissingValue("data-dir"))?; let value = iter.next().ok_or(ArgsError::MissingValue("data-dir"))?;
data_dir = Some(PathBuf::from(value)); data_dir = Some(PathBuf::from(value));
} }
"--mode" => {
let value = iter.next().ok_or(ArgsError::MissingValue("mode"))?;
mode = Some(Mode::from_keyword(&value).ok_or_else(|| {
ArgsError::InvalidValue {
flag: "mode",
value: value.clone(),
expected: "simple, advanced",
}
})?);
}
other if other.starts_with("--") => { other if other.starts_with("--") => {
return Err(ArgsError::Unknown(other.to_string())); return Err(ArgsError::Unknown(other.to_string()));
} }
@@ -175,6 +193,7 @@ impl Args {
resume, resume,
help, help,
no_undo, no_undo,
mode,
}) })
} }
} }
@@ -232,6 +251,45 @@ mod tests {
assert!(matches!(err, ArgsError::MissingValue("theme"))); assert!(matches!(err, ArgsError::MissingValue("theme")));
} }
// ---- ADR-0015 mode-restore amendment (issue #14): --mode ----
#[test]
fn no_mode_flag_yields_none() {
// Absent `--mode` is "no startup override" — the runtime
// then falls back to the project's stored mode.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert_eq!(args.mode, None);
}
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
}
#[test]
fn mode_flag_invalid_value() {
let err = Args::parse(["--mode", "expert"]).unwrap_err();
assert!(matches!(err, ArgsError::InvalidValue { flag: "mode", .. }));
}
#[test]
fn mode_flag_missing_value() {
let err = Args::parse(["--mode"]).unwrap_err();
assert!(matches!(err, ArgsError::MissingValue("mode")));
}
#[test]
fn mode_flag_combines_with_resume() {
// `--mode` is not mutually exclusive with `--resume`; the
// flag is the startup override, resume picks the project.
let args = Args::parse(["--resume", "--mode", "advanced"]).unwrap();
assert!(args.resume);
assert_eq!(args.mode, Some(Mode::Advanced));
}
#[test] #[test]
fn unknown_flag_errors() { fn unknown_flag_errors() {
let err = Args::parse(["--bogus"]).unwrap_err(); let err = Args::parse(["--bogus"]).unwrap_err();
+134 -3
View File
@@ -39,6 +39,7 @@ use crate::dsl::ColumnSpec;
use crate::dsl::shortid; use crate::dsl::shortid;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::value::{Bound, Value, ValueError}; use crate::dsl::value::{Bound, Value, ValueError};
use crate::mode::Mode;
use crate::output_render::{Alignment, render_diagnostic_table}; use crate::output_render::{Alignment, render_diagnostic_table};
use crate::type_change; use crate::type_change;
use crate::persistence::{ use crate::persistence::{
@@ -829,6 +830,14 @@ enum Request {
EndBatch { EndBatch {
reply: oneshot::Sender<()>, reply: oneshot::Sender<()>,
}, },
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Sent at boot / project-switch to seed the mode and
/// whenever the user changes mode mid-session.
SetMode {
mode: Mode,
reply: oneshot::Sender<Result<(), DbError>>,
},
} }
impl Database { impl Database {
@@ -1741,6 +1750,16 @@ impl Database {
recv.await.map_err(|_| DbError::WorkerGone) recv.await.map_err(|_| DbError::WorkerGone)
} }
/// Record the current input mode and persist it to
/// `project.yaml` (ADR-0015 mode-restore amendment, issue
/// #14). Idempotent and cheap; a no-op for databases opened
/// without persistence.
pub async fn set_mode(&self, mode: Mode) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::SetMode { mode, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
async fn send(&self, req: Request) -> Result<(), DbError> { async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone) self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
} }
@@ -1876,6 +1895,18 @@ fn worker_loop(
end_batch(snap, &mut batch); end_batch(snap, &mut batch);
let _ = reply.send(()); let _ = reply.send(());
} }
// ADR-0015 mode-restore amendment (issue #14): record the
// current input mode so `project.yaml` reflects it. We
// persist immediately (not just update the in-memory
// value) so a mode change followed by quit — with no
// intervening command — is still saved.
Request::SetMode { mode, reply } => {
let result = persistence.as_ref().map_or(Ok(()), |p| {
p.set_mode(mode);
persist_current_mode(&conn, p)
});
let _ = reply.send(result);
}
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other), other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
} }
} }
@@ -2640,8 +2671,9 @@ fn handle_request(
| Request::PeekUndo { .. } | Request::PeekUndo { .. }
| Request::PeekRedo { .. } | Request::PeekRedo { .. }
| Request::BeginBatch { .. } | Request::BeginBatch { .. }
| Request::EndBatch { .. } => { | Request::EndBatch { .. }
unreachable!("undo/redo/peek/batch are handled in worker_loop") | Request::SetMode { .. } => {
unreachable!("undo/redo/peek/batch/set-mode are handled in worker_loop")
} }
} }
} }
@@ -2835,7 +2867,12 @@ fn finalize_persistence(
return Ok(()); return Ok(());
}; };
if changes.schema_dirty { if changes.schema_dirty {
let schema = read_schema_snapshot(conn)?; let mut schema = read_schema_snapshot(conn)?;
// Stamp the live input mode (ADR-0015 mode-restore
// amendment, issue #14). Mode is not stored in the db, so
// `read_schema_snapshot` leaves a placeholder; the
// persister is authoritative and writes the current value.
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)?; p.write_schema(&schema).map_err(DbError::from_persistence)?;
} }
for table in &changes.rewritten_tables { for table in &changes.rewritten_tables {
@@ -2902,12 +2939,30 @@ fn read_schema_snapshot(conn: &Connection) -> Result<SchemaSnapshot, DbError> {
let created_at = read_project_created_at(conn)?; let created_at = read_project_created_at(conn)?;
Ok(SchemaSnapshot { Ok(SchemaSnapshot {
created_at, created_at,
// Mode is live UI state, not stored in the database
// (ADR-0015 mode-restore amendment, issue #14). This is a
// placeholder the persister overwrites with the current
// mode; the field exists for the read/restore path, where
// `parse_schema` fills it from `project.yaml`.
mode: Mode::default(),
tables, tables,
relationships, relationships,
indexes, indexes,
}) })
} }
/// Write `project.yaml` now with the current schema and the
/// persister's current input mode (ADR-0015 mode-restore
/// amendment, issue #14). Used by the `SetMode` request so a mode
/// change is saved immediately, without waiting for the next
/// schema-mutating command. A no-op when persistence is absent
/// (in-memory test databases) or the schema is otherwise clean.
fn persist_current_mode(conn: &Connection, p: &Persistence) -> Result<(), DbError> {
let mut schema = read_schema_snapshot(conn)?;
schema.mode = p.current_mode();
p.write_schema(&schema).map_err(DbError::from_persistence)
}
fn read_all_relationships(conn: &Connection) -> Result<Vec<RelationshipSchema>, DbError> { fn read_all_relationships(conn: &Connection) -> Result<Vec<RelationshipSchema>, DbError> {
let mut stmt = conn let mut stmt = conn
.prepare(&format!( .prepare(&format!(
@@ -13622,6 +13677,82 @@ mod tests {
} }
} }
#[tokio::test]
async fn set_mode_persists_and_survives_a_later_ddl_command() {
// ADR-0015 mode-restore amendment (issue #14): the worker
// stamps the *current* input mode into `project.yaml` on
// every write, so a mode change is saved immediately AND a
// later schema-mutating command re-writes the same live
// mode rather than clobbering it back to the default. This
// is the core guarantee the whole feature rests on.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf());
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
// A table exists first so there is a schema to rewrite.
db.create_table(
"T".to_string(),
vec![col("id", Type::Serial), col("Name", Type::Text)],
vec!["id".to_string()],
None,
)
.await
.unwrap();
// Switch to advanced and confirm it lands in the file.
db.set_mode(Mode::Advanced).await.unwrap();
let yaml = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml.contains("mode: advanced"),
"set_mode should record the mode in project.yaml: {yaml}"
);
// A later DDL command rewrites the whole project.yaml —
// the mode must NOT regress to the default.
db.add_column(
"T".to_string(),
ColumnSpec::new("extra".to_string(), Type::Text),
None,
)
.await
.unwrap();
let yaml_after = std::fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
assert!(
yaml_after.contains("mode: advanced"),
"a later DDL command must preserve the live mode, not clobber it: {yaml_after}"
);
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"the stored mode reads back as advanced"
);
}
#[tokio::test]
async fn set_mode_persists_even_with_no_prior_command() {
// ADR-0015 mode-restore amendment (issue #14), persist on
// unload: leaving a project must record the mode even if the
// session ran NO command (e.g. a bare `--mode advanced` plus
// read-only browsing). The unload calls `set_mode`, which
// writes project.yaml from the empty schema + the live mode.
use crate::persistence::Persistence;
let dir = tempfile::tempdir().unwrap();
let persistence = Persistence::new(dir.path().to_path_buf()).with_mode(Mode::Advanced);
let db_path = dir.path().join("playground.db");
let db = Database::open_with_persistence(&db_path, persistence).unwrap();
// No command runs — straight to the unload-style persist.
db.set_mode(Mode::Advanced).await.unwrap();
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
"an unload persists the mode with no prior command",
);
}
#[tokio::test] #[tokio::test]
async fn unique_flag_round_trips_through_rebuild() { async fn unique_flag_round_trips_through_rebuild() {
// End-to-end: create a table with a non-PK serial, // End-to-end: create a table with a non-PK serial,
+6 -2
View File
@@ -214,13 +214,17 @@ pub enum AppEvent {
}, },
/// A project switch (load / new / save-as / import) /// A project switch (load / new / save-as / import)
/// succeeded. Carries the new display name, the temp /// succeeded. Carries the new display name, the temp
/// flag (drives the `[TEMP]` status-bar prefix), and the /// flag (drives the `[TEMP]` status-bar prefix), the
/// seed entries for input-history hydration off the new /// seed entries for input-history hydration off the new
/// project's `history.log` (I2-persist, ADR-0015 §12). /// project's `history.log` (I2-persist, ADR-0015 §12), and
/// the mode to restore for the switched-to project (its
/// stored mode, ADR-0015 mode-restore amendment, issue #14 —
/// "loading triggers the mode switch each time").
ProjectSwitched { ProjectSwitched {
display_name: String, display_name: String,
is_temp: bool, is_temp: bool,
history_entries: Vec<String>, history_entries: Vec<String>,
mode: crate::mode::Mode,
}, },
/// A project switch failed in a non-fatal way (target /// A project switch failed in a non-fatal way (target
/// already exists, path unreadable, …). Surfaced as an /// already exists, path unreadable, …). Surfaced as an
+5
View File
@@ -199,6 +199,11 @@ help:
no snapshot is taken before each change no snapshot is taken before each change
(no per-command overhead), and undo/redo (no per-command overhead), and undo/redo
report that undo is turned off. report that undo is turned off.
--mode <simple|advanced>
Start in this input mode, overriding the
project's stored mode. Without it, the
project's last-used mode is restored
(default: simple).
App-level commands (typed inside the app, available in both modes): App-level commands (typed inside the app, available in both modes):
quit Exit cleanly. quit Exit cleanly.
+88
View File
@@ -24,6 +24,42 @@ impl Mode {
Self::Advanced => "ADVANCED", Self::Advanced => "ADVANCED",
} }
} }
/// The lowercase keyword form used wherever the mode is
/// written or read as plain text — the `--mode` CLI flag, the
/// `mode <value>` command, and the `project.yaml` `mode:`
/// field (ADR-0015 mode-restore amendment, issue #14). Kept
/// distinct from `label()` (the uppercase UI banner form) so
/// the on-disk / CLI vocabulary is stable and case-consistent.
pub const fn keyword(self) -> &'static str {
match self {
Self::Simple => "simple",
Self::Advanced => "advanced",
}
}
/// Parse a mode keyword, case-insensitively. `None` for any
/// other string. The single source of truth for "simple" /
/// "advanced" text recognition across the CLI flag, the
/// `mode` command, and the `project.yaml` reader.
#[must_use]
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"simple" => Some(Self::Simple),
"advanced" => Some(Self::Advanced),
_ => None,
}
}
/// Resolve the startup input mode (ADR-0015 mode-restore
/// amendment, issue #14). Precedence: the `--mode` CLI override
/// (`flag`) wins; otherwise the project's stored mode
/// (`stored`); otherwise the default (`Simple`). `stored` is
/// `None` for a project with no recorded preference.
#[must_use]
pub fn resolve_startup(flag: Option<Self>, stored: Option<Self>) -> Self {
flag.or(stored).unwrap_or_default()
}
} }
impl fmt::Display for Mode { impl fmt::Display for Mode {
@@ -31,3 +67,55 @@ impl fmt::Display for Mode {
f.write_str(self.label()) f.write_str(self.label())
} }
} }
#[cfg(test)]
mod tests {
use super::Mode;
#[test]
fn keyword_round_trips_through_from_keyword() {
for mode in [Mode::Simple, Mode::Advanced] {
assert_eq!(Mode::from_keyword(mode.keyword()), Some(mode));
}
}
#[test]
fn from_keyword_is_case_insensitive_and_trims() {
assert_eq!(Mode::from_keyword(" Advanced "), Some(Mode::Advanced));
assert_eq!(Mode::from_keyword("SIMPLE"), Some(Mode::Simple));
}
#[test]
fn from_keyword_rejects_unknown() {
assert_eq!(Mode::from_keyword("expert"), None);
assert_eq!(Mode::from_keyword(""), None);
}
#[test]
fn keyword_is_lowercase_distinct_from_label() {
// `keyword()` is the on-disk/CLI form; `label()` is the
// uppercase UI banner. They must not be conflated.
assert_eq!(Mode::Advanced.keyword(), "advanced");
assert_eq!(Mode::Advanced.label(), "ADVANCED");
}
#[test]
fn resolve_startup_applies_flag_then_stored_then_default() {
// Flag wins over everything.
assert_eq!(
Mode::resolve_startup(Some(Mode::Advanced), Some(Mode::Simple)),
Mode::Advanced,
);
assert_eq!(
Mode::resolve_startup(Some(Mode::Simple), Some(Mode::Advanced)),
Mode::Simple,
);
// No flag → stored mode.
assert_eq!(
Mode::resolve_startup(None, Some(Mode::Advanced)),
Mode::Advanced,
);
// No flag, no stored preference → default (Simple).
assert_eq!(Mode::resolve_startup(None, None), Mode::Simple);
}
}
+88 -1
View File
@@ -14,12 +14,14 @@
//! responsible for translating that into a fatal error and //! responsible for translating that into a fatal error and
//! letting the SQLite tx roll back. //! letting the SQLite tx roll back.
use std::cell::Cell;
use std::fs; use std::fs;
use std::io::Write as _; use std::io::Write as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::mode::Mode;
use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML}; use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
// Submodules are private; the few items the db worker needs // Submodules are private; the few items the db worker needs
@@ -42,9 +44,19 @@ pub(crate) fn utc_iso8601_now() -> String {
/// Owns persistence to a single project on disk. Cheap to /// Owns persistence to a single project on disk. Cheap to
/// move; the db worker holds one instance for its lifetime. /// move; the db worker holds one instance for its lifetime.
///
/// Carries the **current input mode** (ADR-0015 mode-restore
/// amendment, issue #14). Mode is live UI state, not schema, so
/// it is not stored in the database — instead the worker holds
/// the current value here and stamps it into `project.yaml` on
/// every write, so the file always reflects the mode the user is
/// actually in. Interior mutability (`Cell`) lets the worker
/// update it through the `&self` write path; the worker thread
/// owns the single instance, so no synchronisation is needed.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Persistence { pub struct Persistence {
project_path: PathBuf, project_path: PathBuf,
current_mode: Cell<Mode>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -126,6 +138,14 @@ impl PersistenceError {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaSnapshot { pub struct SchemaSnapshot {
pub created_at: String, pub created_at: String,
/// The input mode recorded in `project.yaml` (ADR-0015
/// mode-restore amendment, issue #14). On **read** this is
/// the project's stored mode (defaulting to `Simple` for
/// pre-#14 files with no `mode:` field). On **write** the
/// persister stamps the live `Persistence::current_mode`
/// here before serialising, so the file always reflects the
/// mode the user is actually in.
pub mode: Mode,
pub tables: Vec<TableSchema>, pub tables: Vec<TableSchema>,
pub relationships: Vec<RelationshipSchema>, pub relationships: Vec<RelationshipSchema>,
/// Indexes across all tables (ADR-0025). Carried as a flat /// Indexes across all tables (ADR-0025). Carried as a flat
@@ -260,7 +280,47 @@ pub enum CellValue {
impl Persistence { impl Persistence {
#[must_use] #[must_use]
pub const fn new(project_path: PathBuf) -> Self { pub const fn new(project_path: PathBuf) -> Self {
Self { project_path } Self {
project_path,
current_mode: Cell::new(Mode::Simple),
}
}
/// Builder: set the initial input mode this handle stamps into
/// `project.yaml`. Used at boot / project-switch once the
/// mode to restore has been resolved (ADR-0015 mode-restore
/// amendment, issue #14).
#[must_use]
pub fn with_mode(self, mode: Mode) -> Self {
self.current_mode.set(mode);
self
}
/// The input mode this handle currently stamps into
/// `project.yaml` writes.
#[must_use]
pub const fn current_mode(&self) -> Mode {
self.current_mode.get()
}
/// Update the current input mode. The next `project.yaml`
/// write records it. Called by the worker when the user
/// changes mode mid-session (the `mode` command).
pub fn set_mode(&self, mode: Mode) {
self.current_mode.set(mode);
}
/// Read the mode recorded in an existing `project.yaml`, for
/// restore-on-open (issue #14). Best-effort: a missing file,
/// a parse failure, or an absent `mode:` field all yield
/// `None` so the caller falls back to the default. A pre-#14
/// project (no `mode:` field) parses with the default mode,
/// which we report as `None` to keep "no stored preference"
/// distinct from an explicit `simple`.
#[must_use]
pub fn read_stored_mode(project_path: &Path) -> Option<Mode> {
let body = fs::read_to_string(project_path.join(PROJECT_YAML)).ok()?;
yaml::parse_stored_mode(&body)
} }
/// Project root directory. Used in tests and diagnostics. /// Project root directory. Used in tests and diagnostics.
@@ -461,6 +521,7 @@ mod tests {
let p = Persistence::new(dir.path().to_path_buf()); let p = Persistence::new(dir.path().to_path_buf());
let schema = SchemaSnapshot { let schema = SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(), created_at: "2026-05-07T14:30:12Z".to_string(),
mode: Mode::Simple,
tables: vec![], tables: vec![],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
@@ -513,4 +574,30 @@ mod tests {
assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)")); assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)"));
assert!(lines[1].ends_with("|ok|insert into Foo (1)")); assert!(lines[1].ends_with("|ok|insert into Foo (1)"));
} }
#[test]
fn read_stored_mode_round_trips_a_written_project_yaml() {
// ADR-0015 mode-restore amendment (issue #14): a mode
// written into project.yaml reads back via read_stored_mode.
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
let schema = SchemaSnapshot {
created_at: "2026-05-31T00:00:00Z".to_string(),
mode: Mode::Advanced,
tables: vec![],
relationships: vec![],
indexes: vec![],
};
p.write_schema(&schema).unwrap();
assert_eq!(
Persistence::read_stored_mode(dir.path()),
Some(Mode::Advanced),
);
}
#[test]
fn read_stored_mode_is_none_for_a_missing_project_yaml() {
let dir = tempdir();
assert_eq!(Persistence::read_stored_mode(dir.path()), None);
}
} }
+96
View File
@@ -22,6 +22,7 @@ use serde::Deserialize;
use crate::dsl::action::ReferentialAction; use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::mode::Mode;
use super::{ use super::{
ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema, ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema,
@@ -34,6 +35,10 @@ pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
let _ = writeln!(out, "version: 1"); let _ = writeln!(out, "version: 1");
let _ = writeln!(out, "project:"); let _ = writeln!(out, "project:");
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at)); let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
// ADR-0015 mode-restore amendment (issue #14): the input mode
// lives alongside `created_at` as project-level metadata, not
// schema. `rebuild` ignores it; restore-on-open reads it.
let _ = writeln!(out, " mode: {}", schema.mode.keyword());
if schema.tables.is_empty() { if schema.tables.is_empty() {
let _ = writeln!(out, "tables: []"); let _ = writeln!(out, "tables: []");
@@ -323,12 +328,33 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
.collect(); .collect();
Ok(SchemaSnapshot { Ok(SchemaSnapshot {
created_at: raw.project.created_at, created_at: raw.project.created_at,
mode: raw
.project
.mode
.as_deref()
.and_then(Mode::from_keyword)
.unwrap_or_default(),
tables, tables,
relationships, relationships,
indexes, indexes,
}) })
} }
/// Read just the stored input mode from a `project.yaml` body,
/// for restore-on-open (ADR-0015 mode-restore amendment, issue
/// #14). Returns `None` when the file has no `mode:` field (a
/// pre-#14 project, or a hand-written one) — distinct from an
/// explicit `mode: simple` — so the caller can tell "no stored
/// preference" from a deliberate choice. An unrecognised value
/// is also `None` (fall back to the default rather than reject
/// the whole file over a UI hint). Tolerant of an otherwise
/// unparseable body for the same reason.
#[must_use]
pub(super) fn parse_stored_mode(body: &str) -> Option<Mode> {
let raw: RawProject = serde_yml::from_str(body).ok()?;
raw.project.mode.as_deref().and_then(Mode::from_keyword)
}
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum YamlError { pub(crate) enum YamlError {
Syntax(String), Syntax(String),
@@ -395,6 +421,12 @@ struct RawProject {
#[derive(Deserialize)] #[derive(Deserialize)]
struct RawProjectMeta { struct RawProjectMeta {
created_at: String, created_at: String,
/// Optional: pre-#14 project files carry no `mode:` field and
/// default to the app's startup mode. Stored as a raw string
/// so an unrecognised value degrades to the default rather
/// than failing the parse (ADR-0015 mode-restore amendment).
#[serde(default)]
mode: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -493,6 +525,7 @@ mod tests {
fn snapshot() -> SchemaSnapshot { fn snapshot() -> SchemaSnapshot {
SchemaSnapshot { SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(), created_at: "2026-05-07T14:30:12Z".to_string(),
mode: Mode::Simple,
tables: vec![ tables: vec![
TableSchema { TableSchema {
name: "Customers".to_string(), name: "Customers".to_string(),
@@ -558,6 +591,7 @@ mod tests {
fn empty_lists_use_inline_brackets() { fn empty_lists_use_inline_brackets() {
let body = serialize_schema(&SchemaSnapshot { let body = serialize_schema(&SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(), created_at: "2026-05-07T14:30:12Z".to_string(),
mode: Mode::Simple,
tables: vec![], tables: vec![],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
@@ -571,6 +605,7 @@ mod tests {
fn quotes_yaml_keywords_used_as_identifiers() { fn quotes_yaml_keywords_used_as_identifiers() {
let body = serialize_schema(&SchemaSnapshot { let body = serialize_schema(&SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(), created_at: "2026-05-07T14:30:12Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "true".to_string(), // reserved keyword name: "true".to_string(), // reserved keyword
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
@@ -613,6 +648,7 @@ mod tests {
// index emits `unique: true`. // index emits `unique: true`.
let snap = SchemaSnapshot { let snap = SchemaSnapshot {
created_at: "2026-05-25T00:00:00Z".to_string(), created_at: "2026-05-25T00:00:00Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "Customers".to_string(), name: "Customers".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
@@ -695,6 +731,7 @@ indexes:
// parse cycle (ADR-0029 §7). // parse cycle (ADR-0029 §7).
let snap = SchemaSnapshot { let snap = SchemaSnapshot {
created_at: "2026-05-19T00:00:00Z".to_string(), created_at: "2026-05-19T00:00:00Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "Books".to_string(), name: "Books".to_string(),
primary_key: vec!["isbn".to_string()], primary_key: vec!["isbn".to_string()],
@@ -742,6 +779,7 @@ indexes:
// §4a.2 / §4a.3). // §4a.2 / §4a.3).
let snap = SchemaSnapshot { let snap = SchemaSnapshot {
created_at: "2026-05-25T00:00:00Z".to_string(), created_at: "2026-05-25T00:00:00Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "T".to_string(), name: "T".to_string(),
primary_key: vec![], primary_key: vec![],
@@ -773,6 +811,7 @@ indexes:
// name}` mapping form and round-trips, mixed with an unnamed one. // name}` mapping form and round-trips, mixed with an unnamed one.
let snap = SchemaSnapshot { let snap = SchemaSnapshot {
created_at: "2026-05-25T00:00:00Z".to_string(), created_at: "2026-05-25T00:00:00Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "T".to_string(), name: "T".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
@@ -910,6 +949,7 @@ relationships:
fn preserves_compound_primary_key_order() { fn preserves_compound_primary_key_order() {
let body = serialize_schema(&SchemaSnapshot { let body = serialize_schema(&SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(), created_at: "2026-05-07T14:30:12Z".to_string(),
mode: Mode::Simple,
tables: vec![TableSchema { tables: vec![TableSchema {
name: "Items".to_string(), name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()], primary_key: vec!["a".to_string(), "b".to_string()],
@@ -925,4 +965,60 @@ relationships:
}); });
assert!(body.contains("primary_key: [a, b]")); assert!(body.contains("primary_key: [a, b]"));
} }
// ---- ADR-0015 mode-restore amendment (issue #14) ----
#[test]
fn mode_round_trips_through_serialize_and_parse() {
for mode in [Mode::Simple, Mode::Advanced] {
let snap = SchemaSnapshot {
created_at: "2026-05-07T14:30:12Z".to_string(),
mode,
tables: vec![],
relationships: vec![],
indexes: vec![],
};
let body = serialize_schema(&snap);
assert!(
body.contains(&format!("mode: {}", mode.keyword())),
"serialized body carries the mode keyword: {body}"
);
let parsed = parse_schema(&body).expect("round-trips");
assert_eq!(parsed.mode, mode);
}
}
#[test]
fn parse_schema_defaults_mode_to_simple_when_field_absent() {
// A pre-#14 project file carries no `mode:` field; it must
// parse with the default mode, not fail.
let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
let parsed = parse_schema(body).expect("legacy file parses");
assert_eq!(parsed.mode, Mode::Simple);
}
#[test]
fn parse_stored_mode_distinguishes_absent_from_explicit() {
// `None` (no stored preference) must be distinct from an
// explicit `simple`, so restore-on-open precedence can tell
// "fall back to default" from "the user chose simple".
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None);
let explicit_simple =
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced =
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
}
#[test]
fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
// An unrecognised mode keyword degrades to "no preference"
// rather than rejecting the whole file over a UI hint.
let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n";
assert_eq!(parse_stored_mode(body), None);
}
} }
+62 -5
View File
@@ -196,7 +196,20 @@ pub async fn run(args: Args) -> Result<()> {
let display_name = project.display_name().to_string(); let display_name = project.display_name().to_string();
let project_path = project.path().to_path_buf(); let project_path = project.path().to_path_buf();
let project_is_temp = matches!(project.kind(), ProjectKind::Temp); let project_is_temp = matches!(project.kind(), ProjectKind::Temp);
let persistence = crate::persistence::Persistence::new(project_path.clone()); // Resolve the startup input mode (ADR-0015 mode-restore
// amendment, issue #14). Precedence: `--mode` flag > the
// project's stored mode > the default (`Simple`). A pre-#14
// project, or one with no `mode:` field, reads as `None` and
// falls through to the default. The resolved mode is given to
// `Persistence` so every `project.yaml` write records it, and
// set on the `App` so the first render shows the right mode.
let resolved_mode = crate::mode::Mode::resolve_startup(
args.mode,
crate::persistence::Persistence::read_stored_mode(&project_path),
);
info!(mode = %resolved_mode, "resolved startup input mode");
let persistence =
crate::persistence::Persistence::new(project_path.clone()).with_mode(resolved_mode);
// Capture whether the .db file existed BEFORE we open it — // Capture whether the .db file existed BEFORE we open it —
// sqlite creates it on connect, so this is the only honest // sqlite creates it on connect, so this is the only honest
// signal that we need to rebuild from text (ADR-0015 §7). // signal that we need to rebuild from text (ADR-0015 §7).
@@ -259,6 +272,7 @@ pub async fn run(args: Args) -> Result<()> {
project_is_temp, project_is_temp,
initial_events, initial_events,
undo_enabled, undo_enabled,
resolved_mode,
) )
.await; .await;
if let Err(e) = teardown_terminal(&mut terminal) { if let Err(e) = teardown_terminal(&mut terminal) {
@@ -307,6 +321,7 @@ impl Session {
} }
} }
#[allow(clippy::too_many_arguments)] // boot params; all inherent to one session
async fn run_loop( async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme, theme: Theme,
@@ -315,6 +330,7 @@ async fn run_loop(
project_is_temp: bool, project_is_temp: bool,
initial_events: Vec<AppEvent>, initial_events: Vec<AppEvent>,
undo_enabled: bool, undo_enabled: bool,
initial_mode: crate::mode::Mode,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY); let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone()); let reader_handle = spawn_event_reader(event_tx.clone());
@@ -323,6 +339,11 @@ async fn run_loop(
app.project_name = Some(project_display_name); app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp; app.project_is_temp = project_is_temp;
app.undo_enabled = undo_enabled; app.undo_enabled = undo_enabled;
// Start in the resolved input mode (ADR-0015 mode-restore
// amendment, issue #14): `--mode` > stored project mode >
// default. `Persistence` already carries the same value, so the
// worker records it on the next write.
app.mode = initial_mode;
// Seed the in-memory navigable history from the // Seed the in-memory navigable history from the
// initial project's history.log (I2-persist, ADR-0015 // initial project's history.log (I2-persist, ADR-0015
// §12). Subsequent project switches re-seed via the // §12). Subsequent project switches re-seed via the
@@ -384,6 +405,14 @@ async fn run_loop(
match action { match action {
Action::Quit => { Action::Quit => {
debug!("quit action received"); debug!("quit action received");
// Persist the mode we're leaving in, so it is
// restored next time the project opens (ADR-0015
// mode-restore amendment, issue #14 — persist on
// unload). Best-effort: a write failure must not
// block quitting.
if let Err(e) = session.database().set_mode(app.mode).await {
tracing::warn!(error = %e, "could not persist input mode on quit");
}
should_quit = true; should_quit = true;
} }
Action::ExecuteDsl { Action::ExecuteDsl {
@@ -446,6 +475,7 @@ async fn run_loop(
source, source,
&event_tx, &event_tx,
undo_enabled, undo_enabled,
app.mode,
) )
.await; .await;
} }
@@ -456,6 +486,7 @@ async fn run_loop(
source, source,
&event_tx, &event_tx,
undo_enabled, undo_enabled,
app.mode,
) )
.await; .await;
} }
@@ -466,6 +497,7 @@ async fn run_loop(
source, source,
&event_tx, &event_tx,
undo_enabled, undo_enabled,
app.mode,
) )
.await; .await;
} }
@@ -493,6 +525,7 @@ async fn run_loop(
source, source,
&event_tx, &event_tx,
undo_enabled, undo_enabled,
app.mode,
) )
.await; .await;
} }
@@ -516,6 +549,14 @@ async fn run_loop(
event_tx.clone(), event_tx.clone(),
); );
} }
Action::PersistMode(mode) => {
// Best-effort: the in-memory mode already changed;
// a failure to record it must not fatal a UI toggle
// (ADR-0015 mode-restore amendment, issue #14).
if let Err(e) = session.database().set_mode(mode).await {
tracing::warn!(error = %e, "could not persist input mode");
}
}
} }
} }
// A keystroke hides the indicator and re-arms the // A keystroke hides the indicator and re-arms the
@@ -612,15 +653,25 @@ async fn handle_project_switch(
source: String, source: String,
event_tx: &mpsc::Sender<AppEvent>, event_tx: &mpsc::Sender<AppEvent>,
undo_enabled: bool, undo_enabled: bool,
outgoing_mode: crate::mode::Mode,
) { ) {
// Persist the outgoing project's mode before it is unloaded
// (ADR-0015 mode-restore amendment, issue #14 — persist on
// unload). Best-effort, and before `perform_switch` drops the
// outgoing database. The switched-to project's own stored mode
// is restored separately, via the `ProjectSwitched` event.
if let Err(e) = session.database().set_mode(outgoing_mode).await {
tracing::warn!(error = %e, "could not persist input mode on switch");
}
match perform_switch(session, req, source, undo_enabled).await { match perform_switch(session, req, source, undo_enabled).await {
Ok((display_name, is_temp)) => { Ok((display_name, is_temp, mode)) => {
let history_entries = read_history_seed(session.project().path()); let history_entries = read_history_seed(session.project().path());
let _ = event_tx let _ = event_tx
.send(AppEvent::ProjectSwitched { .send(AppEvent::ProjectSwitched {
display_name, display_name,
is_temp, is_temp,
history_entries, history_entries,
mode,
}) })
.await; .await;
if let Ok(tables) = session.database().list_tables().await { if let Ok(tables) = session.database().list_tables().await {
@@ -663,7 +714,7 @@ async fn perform_switch(
req: SwitchRequest, req: SwitchRequest,
source: String, source: String,
undo_enabled: bool, undo_enabled: bool,
) -> Result<(String, bool), String> { ) -> Result<(String, bool, crate::mode::Mode), String> {
use crate::persistence::Persistence; use crate::persistence::Persistence;
// For SaveAs we need a resolved target path up front // For SaveAs we need a resolved target path up front
@@ -807,7 +858,13 @@ async fn perform_switch(
// had been deleted). // had been deleted).
let db_path = new_project.db_path(); let db_path = new_project.db_path();
let db_existed = db_path.exists(); let db_existed = db_path.exists();
let persistence = Persistence::new(new_path.clone()); // Restore the switched-to project's stored input mode (ADR-0015
// mode-restore amendment, issue #14): "loading triggers the mode
// switch each time." A switch uses the target's stored mode
// directly — the startup `--mode` override applies only at boot,
// not to subsequent loads. Absent/pre-#14 → default.
let restored_mode = Persistence::read_stored_mode(&new_path).unwrap_or_default();
let persistence = Persistence::new(new_path.clone()).with_mode(restored_mode);
let new_database = let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled) Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -843,7 +900,7 @@ async fn perform_switch(
tracing::warn!(error = %e, "could not update last_project after switch"); tracing::warn!(error = %e, "could not update last_project after switch");
} }
Ok((display_name, is_temp)) Ok((display_name, is_temp, restored_mode))
} }
/// Resolve the destination directory for an `import`: /// Resolve the destination directory for an `import`:
+1
View File
@@ -304,6 +304,7 @@ fn project_switched_event_updates_state() {
display_name: "New Name".to_string(), display_name: "New Name".to_string(),
is_temp: false, is_temp: false,
history_entries: Vec::new(), history_entries: Vec::new(),
mode: rdbms_playground::mode::Mode::Simple,
}); });
assert_eq!(app.project_name.as_deref(), Some("New Name")); assert_eq!(app.project_name.as_deref(), Some("New Name"));
assert!(!app.project_is_temp); assert!(!app.project_is_temp);
+1
View File
@@ -219,6 +219,7 @@ fn project_switched_event_seeds_history_from_payload() {
display_name: "Foo".to_string(), display_name: "Foo".to_string(),
is_temp: false, is_temp: false,
history_entries: vec!["aa".to_string(), "bb".to_string()], history_entries: vec!["aa".to_string(), "bb".to_string()],
mode: rdbms_playground::mode::Mode::Simple,
}); });
assert_eq!(app.history, vec!["aa".to_string(), "bb".to_string()]); assert_eq!(app.history, vec!["aa".to_string(), "bb".to_string()]);
// Up navigates within the seeded entries. // Up navigates within the seeded entries.