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
+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);
}
}