diff --git a/docs/adr/0015-project-storage-runtime.md b/docs/adr/0015-project-storage-runtime.md index 48c5877..f1bc0ec 100644 --- a/docs/adr/0015-project-storage-runtime.md +++ b/docs/adr/0015-project-storage-runtime.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 638cc00..db1048a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/requirements.md b/docs/requirements.md index 9cecd19..e384aec 100644 --- a/docs/requirements.md +++ b/docs/requirements.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. diff --git a/src/action.rs b/src/action.rs index d08d68c..4fab0e3 100644 --- a/src/action.rs +++ b/src/action.rs @@ -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), } diff --git a/src/app.rs b/src/app.rs index 9e046c1..872dba7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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(); diff --git a/src/archive.rs b/src/archive.rs index 6dd3eda..a128773 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -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(); diff --git a/src/cli.rs b/src/cli.rs index c5c15ea..29c4311 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, } /// 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 = 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(); diff --git a/src/db.rs b/src/db.rs index 9b09c94..661e5f6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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>, + }, } 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 { 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, 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, diff --git a/src/event.rs b/src/event.rs index b5ffe94..9266a09 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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, + mode: crate::mode::Mode, }, /// A project switch failed in a non-fatal way (target /// already exists, path unreadable, …). Surfaced as an diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 315feff..add6eee 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -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 + 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. diff --git a/src/mode.rs b/src/mode.rs index 366b2ca..0cafab1 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -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 ` 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 { + 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, stored: Option) -> 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); + } +} diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index ee09937..fb0be07 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -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, } #[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, pub relationships: Vec, /// 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 { + 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); + } } diff --git a/src/persistence/yaml.rs b/src/persistence/yaml.rs index c5ac1e3..1dfef61 100644 --- a/src/persistence/yaml.rs +++ b/src/persistence/yaml.rs @@ -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 { .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 { + 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, } #[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); + } } diff --git a/src/runtime.rs b/src/runtime.rs index 62de196..e82fcb0 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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>, theme: Theme, @@ -315,6 +330,7 @@ async fn run_loop( project_is_temp: bool, initial_events: Vec, undo_enabled: bool, + initial_mode: crate::mode::Mode, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(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, 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`: diff --git a/tests/iteration4b_lifecycle_commands.rs b/tests/iteration4b_lifecycle_commands.rs index b26a7bc..a323799 100644 --- a/tests/iteration4b_lifecycle_commands.rs +++ b/tests/iteration4b_lifecycle_commands.rs @@ -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); diff --git a/tests/iteration6_resume_history.rs b/tests/iteration6_resume_history.rs index 148e479..49b47b2 100644 --- a/tests/iteration6_resume_history.rs +++ b/tests/iteration6_resume_history.rs @@ -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.