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