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:
+88
-1
@@ -14,12 +14,14 @@
|
||||
//! responsible for translating that into a fatal error and
|
||||
//! letting the SQLite tx roll back.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::mode::Mode;
|
||||
use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
|
||||
|
||||
// Submodules are private; the few items the db worker needs
|
||||
@@ -42,9 +44,19 @@ pub(crate) fn utc_iso8601_now() -> String {
|
||||
|
||||
/// Owns persistence to a single project on disk. Cheap to
|
||||
/// move; the db worker holds one instance for its lifetime.
|
||||
///
|
||||
/// Carries the **current input mode** (ADR-0015 mode-restore
|
||||
/// amendment, issue #14). Mode is live UI state, not schema, so
|
||||
/// it is not stored in the database — instead the worker holds
|
||||
/// the current value here and stamps it into `project.yaml` on
|
||||
/// every write, so the file always reflects the mode the user is
|
||||
/// actually in. Interior mutability (`Cell`) lets the worker
|
||||
/// update it through the `&self` write path; the worker thread
|
||||
/// owns the single instance, so no synchronisation is needed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Persistence {
|
||||
project_path: PathBuf,
|
||||
current_mode: Cell<Mode>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -126,6 +138,14 @@ impl PersistenceError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SchemaSnapshot {
|
||||
pub created_at: String,
|
||||
/// The input mode recorded in `project.yaml` (ADR-0015
|
||||
/// mode-restore amendment, issue #14). On **read** this is
|
||||
/// the project's stored mode (defaulting to `Simple` for
|
||||
/// pre-#14 files with no `mode:` field). On **write** the
|
||||
/// persister stamps the live `Persistence::current_mode`
|
||||
/// here before serialising, so the file always reflects the
|
||||
/// mode the user is actually in.
|
||||
pub mode: Mode,
|
||||
pub tables: Vec<TableSchema>,
|
||||
pub relationships: Vec<RelationshipSchema>,
|
||||
/// Indexes across all tables (ADR-0025). Carried as a flat
|
||||
@@ -260,7 +280,47 @@ pub enum CellValue {
|
||||
impl Persistence {
|
||||
#[must_use]
|
||||
pub const fn new(project_path: PathBuf) -> Self {
|
||||
Self { project_path }
|
||||
Self {
|
||||
project_path,
|
||||
current_mode: Cell::new(Mode::Simple),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: set the initial input mode this handle stamps into
|
||||
/// `project.yaml`. Used at boot / project-switch once the
|
||||
/// mode to restore has been resolved (ADR-0015 mode-restore
|
||||
/// amendment, issue #14).
|
||||
#[must_use]
|
||||
pub fn with_mode(self, mode: Mode) -> Self {
|
||||
self.current_mode.set(mode);
|
||||
self
|
||||
}
|
||||
|
||||
/// The input mode this handle currently stamps into
|
||||
/// `project.yaml` writes.
|
||||
#[must_use]
|
||||
pub const fn current_mode(&self) -> Mode {
|
||||
self.current_mode.get()
|
||||
}
|
||||
|
||||
/// Update the current input mode. The next `project.yaml`
|
||||
/// write records it. Called by the worker when the user
|
||||
/// changes mode mid-session (the `mode` command).
|
||||
pub fn set_mode(&self, mode: Mode) {
|
||||
self.current_mode.set(mode);
|
||||
}
|
||||
|
||||
/// Read the mode recorded in an existing `project.yaml`, for
|
||||
/// restore-on-open (issue #14). Best-effort: a missing file,
|
||||
/// a parse failure, or an absent `mode:` field all yield
|
||||
/// `None` so the caller falls back to the default. A pre-#14
|
||||
/// project (no `mode:` field) parses with the default mode,
|
||||
/// which we report as `None` to keep "no stored preference"
|
||||
/// distinct from an explicit `simple`.
|
||||
#[must_use]
|
||||
pub fn read_stored_mode(project_path: &Path) -> Option<Mode> {
|
||||
let body = fs::read_to_string(project_path.join(PROJECT_YAML)).ok()?;
|
||||
yaml::parse_stored_mode(&body)
|
||||
}
|
||||
|
||||
/// Project root directory. Used in tests and diagnostics.
|
||||
@@ -461,6 +521,7 @@ mod tests {
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
let schema = SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -513,4 +574,30 @@ mod tests {
|
||||
assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)"));
|
||||
assert!(lines[1].ends_with("|ok|insert into Foo (1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_stored_mode_round_trips_a_written_project_yaml() {
|
||||
// ADR-0015 mode-restore amendment (issue #14): a mode
|
||||
// written into project.yaml reads back via read_stored_mode.
|
||||
let dir = tempdir();
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
let schema = SchemaSnapshot {
|
||||
created_at: "2026-05-31T00:00:00Z".to_string(),
|
||||
mode: Mode::Advanced,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
};
|
||||
p.write_schema(&schema).unwrap();
|
||||
assert_eq!(
|
||||
Persistence::read_stored_mode(dir.path()),
|
||||
Some(Mode::Advanced),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_stored_mode_is_none_for_a_missing_project_yaml() {
|
||||
let dir = tempdir();
|
||||
assert_eq!(Persistence::read_stored_mode(dir.path()), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::mode::Mode;
|
||||
|
||||
use super::{
|
||||
ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema,
|
||||
@@ -34,6 +35,10 @@ pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
|
||||
let _ = writeln!(out, "version: 1");
|
||||
let _ = writeln!(out, "project:");
|
||||
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
|
||||
// ADR-0015 mode-restore amendment (issue #14): the input mode
|
||||
// lives alongside `created_at` as project-level metadata, not
|
||||
// schema. `rebuild` ignores it; restore-on-open reads it.
|
||||
let _ = writeln!(out, " mode: {}", schema.mode.keyword());
|
||||
|
||||
if schema.tables.is_empty() {
|
||||
let _ = writeln!(out, "tables: []");
|
||||
@@ -323,12 +328,33 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
||||
.collect();
|
||||
Ok(SchemaSnapshot {
|
||||
created_at: raw.project.created_at,
|
||||
mode: raw
|
||||
.project
|
||||
.mode
|
||||
.as_deref()
|
||||
.and_then(Mode::from_keyword)
|
||||
.unwrap_or_default(),
|
||||
tables,
|
||||
relationships,
|
||||
indexes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read just the stored input mode from a `project.yaml` body,
|
||||
/// for restore-on-open (ADR-0015 mode-restore amendment, issue
|
||||
/// #14). Returns `None` when the file has no `mode:` field (a
|
||||
/// pre-#14 project, or a hand-written one) — distinct from an
|
||||
/// explicit `mode: simple` — so the caller can tell "no stored
|
||||
/// preference" from a deliberate choice. An unrecognised value
|
||||
/// is also `None` (fall back to the default rather than reject
|
||||
/// the whole file over a UI hint). Tolerant of an otherwise
|
||||
/// unparseable body for the same reason.
|
||||
#[must_use]
|
||||
pub(super) fn parse_stored_mode(body: &str) -> Option<Mode> {
|
||||
let raw: RawProject = serde_yml::from_str(body).ok()?;
|
||||
raw.project.mode.as_deref().and_then(Mode::from_keyword)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum YamlError {
|
||||
Syntax(String),
|
||||
@@ -395,6 +421,12 @@ struct RawProject {
|
||||
#[derive(Deserialize)]
|
||||
struct RawProjectMeta {
|
||||
created_at: String,
|
||||
/// Optional: pre-#14 project files carry no `mode:` field and
|
||||
/// default to the app's startup mode. Stored as a raw string
|
||||
/// so an unrecognised value degrades to the default rather
|
||||
/// than failing the parse (ADR-0015 mode-restore amendment).
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -493,6 +525,7 @@ mod tests {
|
||||
fn snapshot() -> SchemaSnapshot {
|
||||
SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![
|
||||
TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
@@ -558,6 +591,7 @@ mod tests {
|
||||
fn empty_lists_use_inline_brackets() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
@@ -571,6 +605,7 @@ mod tests {
|
||||
fn quotes_yaml_keywords_used_as_identifiers() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "true".to_string(), // reserved keyword
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -613,6 +648,7 @@ mod tests {
|
||||
// index emits `unique: true`.
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -695,6 +731,7 @@ indexes:
|
||||
// parse cycle (ADR-0029 §7).
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-19T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Books".to_string(),
|
||||
primary_key: vec!["isbn".to_string()],
|
||||
@@ -742,6 +779,7 @@ indexes:
|
||||
// §4a.2 / §4a.3).
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "T".to_string(),
|
||||
primary_key: vec![],
|
||||
@@ -773,6 +811,7 @@ indexes:
|
||||
// name}` mapping form and round-trips, mixed with an unnamed one.
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-25T00:00:00Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "T".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
@@ -910,6 +949,7 @@ relationships:
|
||||
fn preserves_compound_primary_key_order() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode: Mode::Simple,
|
||||
tables: vec![TableSchema {
|
||||
name: "Items".to_string(),
|
||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||
@@ -925,4 +965,60 @@ relationships:
|
||||
});
|
||||
assert!(body.contains("primary_key: [a, b]"));
|
||||
}
|
||||
|
||||
// ---- ADR-0015 mode-restore amendment (issue #14) ----
|
||||
|
||||
#[test]
|
||||
fn mode_round_trips_through_serialize_and_parse() {
|
||||
for mode in [Mode::Simple, Mode::Advanced] {
|
||||
let snap = SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
mode,
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
indexes: vec![],
|
||||
};
|
||||
let body = serialize_schema(&snap);
|
||||
assert!(
|
||||
body.contains(&format!("mode: {}", mode.keyword())),
|
||||
"serialized body carries the mode keyword: {body}"
|
||||
);
|
||||
let parsed = parse_schema(&body).expect("round-trips");
|
||||
assert_eq!(parsed.mode, mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_schema_defaults_mode_to_simple_when_field_absent() {
|
||||
// A pre-#14 project file carries no `mode:` field; it must
|
||||
// parse with the default mode, not fail.
|
||||
let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
|
||||
let parsed = parse_schema(body).expect("legacy file parses");
|
||||
assert_eq!(parsed.mode, Mode::Simple);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stored_mode_distinguishes_absent_from_explicit() {
|
||||
// `None` (no stored preference) must be distinct from an
|
||||
// explicit `simple`, so restore-on-open precedence can tell
|
||||
// "fall back to default" from "the user chose simple".
|
||||
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(absent), None);
|
||||
|
||||
let explicit_simple =
|
||||
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
|
||||
|
||||
let advanced =
|
||||
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
|
||||
// An unrecognised mode keyword degrades to "no preference"
|
||||
// rather than rejecting the whole file over a UI hint.
|
||||
let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n";
|
||||
assert_eq!(parse_stored_mode(body), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user