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

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

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

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

ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
This commit is contained in:
claude@clouddev1
2026-06-02 06:47:34 +00:00
parent ae57c6fc82
commit 4cd574b909
16 changed files with 769 additions and 14 deletions
+7
View File
@@ -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
View File
@@ -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();
+29
View File
@@ -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
View File
@@ -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();
+134 -3
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+96
View File
@@ -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
View File
@@ -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`: