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