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:
@@ -561,3 +561,130 @@ note pointing to this ADR.
|
||||
- `history.log` becomes the persistent history surface.
|
||||
Once `replay` (OOS-2) and `undo` (OOS-1) land, they read
|
||||
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
@@ -8,7 +8,7 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
||||
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.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-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`
|
||||
@@ -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-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-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-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)
|
||||
|
||||
@@ -598,6 +598,16 @@ since ADR-0027.)
|
||||
exclusive with a positional path argument (ADR-0015 §7).
|
||||
`last_project` is rewritten on every successful project
|
||||
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,
|
||||
not v1.
|
||||
|
||||
|
||||
@@ -129,4 +129,11 @@ pub enum Action {
|
||||
/// refreshes the table list + schema cache.
|
||||
Undo,
|
||||
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
@@ -736,6 +736,7 @@ impl App {
|
||||
display_name,
|
||||
is_temp,
|
||||
history_entries,
|
||||
mode,
|
||||
} => {
|
||||
self.note_system(crate::t!(
|
||||
"project.switched_ok",
|
||||
@@ -746,6 +747,9 @@ impl App {
|
||||
self.tables.clear();
|
||||
self.current_table = None;
|
||||
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()
|
||||
}
|
||||
AppEvent::ProjectSwitchFailed { error } => {
|
||||
@@ -1311,7 +1315,9 @@ impl App {
|
||||
ModeValue::Advanced => "advanced",
|
||||
};
|
||||
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 } => {
|
||||
let raw = match value {
|
||||
@@ -2906,6 +2912,54 @@ mod tests {
|
||||
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]
|
||||
fn bare_create_table_emits_friendly_parse_error() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -583,6 +583,35 @@ mod tests {
|
||||
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]
|
||||
fn inspect_rejects_zip_without_project_yaml() {
|
||||
let tmp = tempdir();
|
||||
|
||||
+58
@@ -7,6 +7,7 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::mode::Mode;
|
||||
use crate::theme::Theme;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -35,6 +36,12 @@ pub struct Args {
|
||||
/// report that undo is turned off. The escape hatch for small
|
||||
/// hardware where per-command snapshotting is too heavy.
|
||||
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`.
|
||||
@@ -116,6 +123,7 @@ impl Args {
|
||||
let mut resume = false;
|
||||
let mut help = false;
|
||||
let mut no_undo = false;
|
||||
let mut mode: Option<Mode> = None;
|
||||
let mut iter = iter.into_iter().map(Into::into);
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
@@ -150,6 +158,16 @@ impl Args {
|
||||
let value = iter.next().ok_or(ArgsError::MissingValue("data-dir"))?;
|
||||
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("--") => {
|
||||
return Err(ArgsError::Unknown(other.to_string()));
|
||||
}
|
||||
@@ -175,6 +193,7 @@ impl Args {
|
||||
resume,
|
||||
help,
|
||||
no_undo,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -232,6 +251,45 @@ mod tests {
|
||||
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]
|
||||
fn unknown_flag_errors() {
|
||||
let err = Args::parse(["--bogus"]).unwrap_err();
|
||||
|
||||
@@ -39,6 +39,7 @@ use crate::dsl::ColumnSpec;
|
||||
use crate::dsl::shortid;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::value::{Bound, Value, ValueError};
|
||||
use crate::mode::Mode;
|
||||
use crate::output_render::{Alignment, render_diagnostic_table};
|
||||
use crate::type_change;
|
||||
use crate::persistence::{
|
||||
@@ -829,6 +830,14 @@ enum Request {
|
||||
EndBatch {
|
||||
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 {
|
||||
@@ -1741,6 +1750,16 @@ impl Database {
|
||||
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> {
|
||||
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
||||
}
|
||||
@@ -1876,6 +1895,18 @@ fn worker_loop(
|
||||
end_batch(snap, &mut batch);
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -2640,8 +2671,9 @@ fn handle_request(
|
||||
| Request::PeekUndo { .. }
|
||||
| Request::PeekRedo { .. }
|
||||
| Request::BeginBatch { .. }
|
||||
| Request::EndBatch { .. } => {
|
||||
unreachable!("undo/redo/peek/batch are handled in worker_loop")
|
||||
| Request::EndBatch { .. }
|
||||
| Request::SetMode { .. } => {
|
||||
unreachable!("undo/redo/peek/batch/set-mode are handled in worker_loop")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2835,7 +2867,12 @@ fn finalize_persistence(
|
||||
return Ok(());
|
||||
};
|
||||
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)?;
|
||||
}
|
||||
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)?;
|
||||
Ok(SchemaSnapshot {
|
||||
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,
|
||||
relationships,
|
||||
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> {
|
||||
let mut stmt = conn
|
||||
.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]
|
||||
async fn unique_flag_round_trips_through_rebuild() {
|
||||
// End-to-end: create a table with a non-PK serial,
|
||||
|
||||
+6
-2
@@ -214,13 +214,17 @@ pub enum AppEvent {
|
||||
},
|
||||
/// A project switch (load / new / save-as / import)
|
||||
/// 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
|
||||
/// 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 {
|
||||
display_name: String,
|
||||
is_temp: bool,
|
||||
history_entries: Vec<String>,
|
||||
mode: crate::mode::Mode,
|
||||
},
|
||||
/// A project switch failed in a non-fatal way (target
|
||||
/// already exists, path unreadable, …). Surfaced as an
|
||||
|
||||
@@ -199,6 +199,11 @@ help:
|
||||
no snapshot is taken before each change
|
||||
(no per-command overhead), and undo/redo
|
||||
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):
|
||||
quit Exit cleanly.
|
||||
|
||||
+88
@@ -24,6 +24,42 @@ impl Mode {
|
||||
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 {
|
||||
@@ -31,3 +67,55 @@ impl fmt::Display for Mode {
|
||||
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
@@ -14,12 +14,14 @@
|
||||
//! responsible for translating that into a fatal error and
|
||||
//! letting the SQLite tx roll back.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::mode::Mode;
|
||||
use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
|
||||
|
||||
// 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
|
||||
/// 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)]
|
||||
pub struct Persistence {
|
||||
project_path: PathBuf,
|
||||
current_mode: Cell<Mode>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -126,6 +138,14 @@ impl PersistenceError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SchemaSnapshot {
|
||||
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 relationships: Vec<RelationshipSchema>,
|
||||
/// Indexes across all tables (ADR-0025). Carried as a flat
|
||||
@@ -260,7 +280,47 @@ pub enum CellValue {
|
||||
impl Persistence {
|
||||
#[must_use]
|
||||
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.
|
||||
@@ -461,6 +521,7 @@ mod tests {
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
let schema = SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -513,4 +574,30 @@ mod tests {
|
||||
assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::mode::Mode;
|
||||
|
||||
use super::{
|
||||
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, "project:");
|
||||
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() {
|
||||
let _ = writeln!(out, "tables: []");
|
||||
@@ -323,12 +328,33 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
.collect();
|
||||
Ok(SchemaSnapshot {
|
||||
created_at: raw.project.created_at,
|
||||
mode: raw
|
||||
.project
|
||||
.mode
|
||||
.as_deref()
|
||||
.and_then(Mode::from_keyword)
|
||||
.unwrap_or_default(),
|
||||
tables,
|
||||
relationships,
|
||||
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)]
|
||||
pub(crate) enum YamlError {
|
||||
Syntax(String),
|
||||
@@ -395,6 +421,12 @@ struct RawProject {
|
||||
#[derive(Deserialize)]
|
||||
struct RawProjectMeta {
|
||||
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)]
|
||||
@@ -493,6 +525,7 @@ mod tests {
|
||||
fn snapshot() -> SchemaSnapshot {
|
||||
SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![
|
||||
TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
@@ -558,6 +591,7 @@ mod tests {
|
||||
fn empty_lists_use_inline_brackets() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -571,6 +605,7 @@ mod tests {
|
||||
fn quotes_yaml_keywords_used_as_identifiers() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "true".to_string(), // reserved keyword
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -613,6 +648,7 @@ mod tests {
|
||||
// index emits `unique: true`.
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -695,6 +731,7 @@ indexes:
|
||||
// parse cycle (ADR-0029 §7).
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-19T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Books".to_string(),
|
||||
primary_key: vec!["isbn".to_string()],
|
||||
@@ -742,6 +779,7 @@ indexes:
|
||||
// §4a.2 / §4a.3).
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "T".to_string(),
|
||||
primary_key: vec![],
|
||||
@@ -773,6 +811,7 @@ indexes:
|
||||
// name}` mapping form and round-trips, mixed with an unnamed one.
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "T".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -910,6 +949,7 @@ relationships:
|
||||
fn preserves_compound_primary_key_order() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Items".to_string(),
|
||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||
@@ -925,4 +965,60 @@ relationships:
|
||||
});
|
||||
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
@@ -196,7 +196,20 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
let display_name = project.display_name().to_string();
|
||||
let project_path = project.path().to_path_buf();
|
||||
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 —
|
||||
// sqlite creates it on connect, so this is the only honest
|
||||
// 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,
|
||||
initial_events,
|
||||
undo_enabled,
|
||||
resolved_mode,
|
||||
)
|
||||
.await;
|
||||
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(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
theme: Theme,
|
||||
@@ -315,6 +330,7 @@ async fn run_loop(
|
||||
project_is_temp: bool,
|
||||
initial_events: Vec<AppEvent>,
|
||||
undo_enabled: bool,
|
||||
initial_mode: crate::mode::Mode,
|
||||
) -> Result<Option<String>> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
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_is_temp = project_is_temp;
|
||||
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
|
||||
// initial project's history.log (I2-persist, ADR-0015
|
||||
// §12). Subsequent project switches re-seed via the
|
||||
@@ -384,6 +405,14 @@ async fn run_loop(
|
||||
match action {
|
||||
Action::Quit => {
|
||||
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;
|
||||
}
|
||||
Action::ExecuteDsl {
|
||||
@@ -446,6 +475,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -456,6 +486,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -466,6 +497,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -493,6 +525,7 @@ async fn run_loop(
|
||||
source,
|
||||
&event_tx,
|
||||
undo_enabled,
|
||||
app.mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -516,6 +549,14 @@ async fn run_loop(
|
||||
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
|
||||
@@ -612,15 +653,25 @@ async fn handle_project_switch(
|
||||
source: String,
|
||||
event_tx: &mpsc::Sender<AppEvent>,
|
||||
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 {
|
||||
Ok((display_name, is_temp)) => {
|
||||
Ok((display_name, is_temp, mode)) => {
|
||||
let history_entries = read_history_seed(session.project().path());
|
||||
let _ = event_tx
|
||||
.send(AppEvent::ProjectSwitched {
|
||||
display_name,
|
||||
is_temp,
|
||||
history_entries,
|
||||
mode,
|
||||
})
|
||||
.await;
|
||||
if let Ok(tables) = session.database().list_tables().await {
|
||||
@@ -663,7 +714,7 @@ async fn perform_switch(
|
||||
req: SwitchRequest,
|
||||
source: String,
|
||||
undo_enabled: bool,
|
||||
) -> Result<(String, bool), String> {
|
||||
) -> Result<(String, bool, crate::mode::Mode), String> {
|
||||
use crate::persistence::Persistence;
|
||||
|
||||
// For SaveAs we need a resolved target path up front
|
||||
@@ -807,7 +858,13 @@ async fn perform_switch(
|
||||
// had been deleted).
|
||||
let db_path = new_project.db_path();
|
||||
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 =
|
||||
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
|
||||
.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");
|
||||
}
|
||||
|
||||
Ok((display_name, is_temp))
|
||||
Ok((display_name, is_temp, restored_mode))
|
||||
}
|
||||
|
||||
/// Resolve the destination directory for an `import`:
|
||||
|
||||
@@ -304,6 +304,7 @@ fn project_switched_event_updates_state() {
|
||||
display_name: "New Name".to_string(),
|
||||
is_temp: false,
|
||||
history_entries: Vec::new(),
|
||||
mode: rdbms_playground::mode::Mode::Simple,
|
||||
});
|
||||
assert_eq!(app.project_name.as_deref(), Some("New Name"));
|
||||
assert!(!app.project_is_temp);
|
||||
|
||||
@@ -219,6 +219,7 @@ fn project_switched_event_seeds_history_from_payload() {
|
||||
display_name: "Foo".to_string(),
|
||||
is_temp: false,
|
||||
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()]);
|
||||
// Up navigates within the seeded entries.
|
||||
|
||||
Reference in New Issue
Block a user