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