From 86d6e8c5b05a4dadc35ff1c8e5d2f992c687ee97 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 12 Nov 2026 16:05:07 +0100 Subject: [PATCH] feat(flags): shared feature flags for the Craft apps and the launcher craft-flags keeps one per-user file, ArtCraft/flags.toml beside the AI layer's ArtCraft/ai, that the launcher and every app read; CRAFT_FLAGS overrides it for one run. Every flag is off by default. The first two: AI-MCP (AI agents through MCP) and AI-MODELS (AI models and services). Loading never fails: a missing or damaged file means defaults, with the reason in Flags::problems. set() changes one flag atomically and keeps everything else in the file, and never overwrites a file it can't read. In the browser every flag is at its default. Closes #1118 --------- Co-authored-by: David Mann --- Cargo.lock | 10 ++ Cargo.toml | 1 + README.md | 1 + crates/flags/Cargo.toml | 21 ++++ crates/flags/README.md | 36 +++++++ crates/flags/src/lib.rs | 220 ++++++++++++++++++++++++++++++++++++++ crates/flags/src/store.rs | 86 +++++++++++++++ crates/flags/src/tests.rs | 118 ++++++++++++++++++++ 8 files changed, 493 insertions(+) create mode 100644 crates/flags/Cargo.toml create mode 100644 crates/flags/README.md create mode 100644 crates/flags/src/lib.rs create mode 100644 crates/flags/src/store.rs create mode 100644 crates/flags/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2dac6e3ba..5d950f640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1454,6 +1454,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-flags" +version = "0.1.0" +dependencies = [ + "craft-atomic", + "dirs", + "tempfile", + "toml_edit", +] + [[package]] name = "craft-flow" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3a7c5c9f3..24e26a384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ craft-dpx = { path = "crates/dpx" } craft-ecproj-format = { path = "crates/ecproj-format" } craft-figma-rest = { path = "crates/figma-rest" } craft-exr = { path = "crates/exr" } +craft-flags = { path = "crates/flags" } craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } diff --git a/README.md b/README.md index d68b972f8..825ff5f8c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " bool { + !id.is_empty() && id.len() <= MAX_ID_LEN && id.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-') +} + +/// The flags file: `CRAFT_FLAGS_FILE`, otherwise `ArtCraft/flags.toml` in the per-user local +/// configuration folder. `None` when there is no such folder (and always in the browser). +pub fn default_path() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + match std::env::var_os(FILE_ENV) { + Some(p) if !p.is_empty() => Some(PathBuf::from(p)), + _ => dirs::config_local_dir().map(|d| d.join("ArtCraft").join(FILE_NAME)), + } + } +} + +/// The flags as read: what the file says, what `CRAFT_FLAGS` says, and what could not be read. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Flags { + file: BTreeMap, + env: BTreeMap, + /// What was left out and why (a damaged file, an entry that is not `true` or `false`, a word + /// in `CRAFT_FLAGS` that is not a flag). For logs; never fatal. + pub problems: Vec, +} + +impl Flags { + /// Flags from a file's text and a `CRAFT_FLAGS` value. Either may be absent. + pub fn from_parts(file: Option<&str>, env: Option<&str>) -> Self { + let mut flags = Flags::default(); + if let Some(text) = file { + flags.read_file(text); + } + if let Some(value) = env { + flags.read_env(value); + } + flags + } + + fn read_file(&mut self, text: &str) { + let doc = match text.parse::() { + Ok(d) => d, + Err(e) => { + self.problems.push(format!("the flags file does not parse, so every flag is at its default: {e}")); + return; + } + }; + let Some(table) = doc.get("flags").and_then(toml_edit::Item::as_table_like) else { + if doc.get("flags").is_some() { + self.problems.push("`flags` in the flags file is not a table".into()); + } + return; + }; + for (key, item) in table.iter() { + if self.file.len() >= MAX_ENTRIES { + self.problems.push(format!("more than {MAX_ENTRIES} flags in the file; the rest are left out")); + break; + } + if !valid_id(key) { + self.problems.push(format!("`{}` in the flags file is not a flag name", key.escape_debug())); + continue; + } + match item.as_bool() { + Some(on) => { + self.file.insert(key.to_string(), on); + } + None => self.problems.push(format!("`{key}` in the flags file is not true or false")), + } + } + } + + fn read_env(&mut self, value: &str) { + for word in value.split([',', ';', ' ']).map(str::trim).filter(|w| !w.is_empty()).take(MAX_ENTRIES) { + let (id, on) = if let Some(rest) = word.strip_prefix('-') { + (rest, Some(false)) + } else if let Some((id, v)) = word.split_once('=') { + (id, parse_bool(v)) + } else { + (word, Some(true)) + }; + match on { + Some(on) if valid_id(id) => { + self.env.insert(id.to_string(), on); + } + _ => self.problems.push(format!("`{}` in {ENV} is not a flag setting", word.escape_debug())), + } + } + } + + /// Whether `flag` is on: `CRAFT_FLAGS` first, then the file, then the flag's default. + pub fn enabled(&self, flag: &Flag) -> bool { + self.lookup(flag.id).unwrap_or(flag.default) + } + + /// Whether the flag named `id` is on; a name nothing sets is off. + pub fn enabled_id(&self, id: &str) -> bool { + match ALL.iter().find(|f| f.id == id) { + Some(f) => self.enabled(f), + None => self.lookup(id).unwrap_or(false), + } + } + + /// Whether `CRAFT_FLAGS` decides `id` for this run, so a switch in a settings page would have + /// no effect until the variable is gone. + pub fn forced(&self, id: &str) -> Option { + self.env.get(id).copied() + } + + fn lookup(&self, id: &str) -> Option { + self.env.get(id).or_else(|| self.file.get(id)).copied() + } +} + +fn parse_bool(v: &str) -> Option { + match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "on" | "yes" => Some(true), + "0" | "false" | "off" | "no" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/flags/src/store.rs b/crates/flags/src/store.rs new file mode 100644 index 000000000..a601d8731 --- /dev/null +++ b/crates/flags/src/store.rs @@ -0,0 +1,86 @@ +//! Reading and changing the flags file (desktop only). + +use std::fmt; +use std::path::Path; + +use crate::{ENV, Flags, MAX_FILE_BYTES, default_path, valid_id}; + +/// Why a flag could not be changed. +#[derive(Debug)] +pub enum FlagError { + /// Not a flag name (see [`valid_id`]). + BadId(String), + /// The file exists but is not a flags file this crate can change without losing what is in + /// it (it does not parse, is too big, or `flags` is not a table). It is left as it is. + Unreadable(String), + /// Reading or writing the file failed. + Io(std::io::Error), +} + +impl fmt::Display for FlagError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FlagError::BadId(id) => write!(f, "`{}` is not a flag name", id.escape_debug()), + FlagError::Unreadable(why) => write!(f, "the flags file was left unchanged: {why}"), + FlagError::Io(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for FlagError {} + +/// The file's text, `None` when it does not exist. A file that isn't a regular file, is too big +/// or isn't UTF-8 is an error. +fn read_text(path: &Path) -> Result, FlagError> { + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(FlagError::Io(e)), + }; + if !meta.is_file() { + return Err(FlagError::Unreadable(format!("{} is not a regular file", path.display()))); + } + if meta.len() > MAX_FILE_BYTES { + return Err(FlagError::Unreadable(format!("{} is larger than {MAX_FILE_BYTES} bytes", path.display()))); + } + let bytes = std::fs::read(path).map_err(FlagError::Io)?; + String::from_utf8(bytes).map(Some).map_err(|_| FlagError::Unreadable(format!("{} is not UTF-8 text", path.display()))) +} + +/// The flags of this run: [`default_path`] and `CRAFT_FLAGS`. Never fails; what could not be +/// read is in [`Flags::problems`]. +pub fn load() -> Flags { + let env = std::env::var(ENV).ok(); + let Some(path) = default_path() else { + return Flags::from_parts(None, env.as_deref()); + }; + match read_text(&path) { + Ok(text) => Flags::from_parts(text.as_deref(), env.as_deref()), + Err(e) => { + let mut flags = Flags::from_parts(None, env.as_deref()); + flags.problems.insert(0, format!("{}: {e}; every flag is at its default", path.display())); + flags + } + } +} + +/// Turn the flag `id` on or off in the file at `path` (made if missing). Everything else in the +/// file, comments included, is kept; the write is atomic. +pub fn set(path: &Path, id: &str, on: bool) -> Result<(), FlagError> { + if !valid_id(id) { + return Err(FlagError::BadId(id.to_string())); + } + let mut doc = match read_text(path)? { + Some(text) => text.parse::().map_err(|e| FlagError::Unreadable(e.to_string()))?, + None => toml_edit::DocumentMut::new(), + }; + let item = doc.entry("flags").or_insert_with(toml_edit::table); + let Some(table) = item.as_table_like_mut() else { + return Err(FlagError::Unreadable("`flags` is not a table".into())); + }; + table.insert(id, toml_edit::value(on)); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(FlagError::Io)?; + } + craft_atomic::atomic_write(path, doc.to_string().as_bytes()).map_err(FlagError::Io) +} diff --git a/crates/flags/src/tests.rs b/crates/flags/src/tests.rs new file mode 100644 index 000000000..3f4bef667 --- /dev/null +++ b/crates/flags/src/tests.rs @@ -0,0 +1,118 @@ +use super::*; + +#[test] +fn every_flag_is_off_by_default_and_has_a_valid_unique_id() { + let flags = Flags::default(); + for f in ALL { + assert!(!f.default, "{} must ship off", f.id); + assert!(!flags.enabled(f)); + assert!(valid_id(f.id), "{}", f.id); + assert!(!f.title.is_empty() && !f.description.is_empty()); + assert_eq!(ALL.iter().filter(|g| g.id == f.id).count(), 1, "{} twice", f.id); + } +} + +#[test] +fn the_file_turns_flags_on_and_off() { + let flags = Flags::from_parts(Some("# mine\n[flags]\nAI-MCP = true\nAI-MODELS = false\n"), None); + assert!(flags.enabled(&AI_MCP)); + assert!(!flags.enabled(&AI_MODELS)); + assert!(flags.problems.is_empty(), "{:?}", flags.problems); +} + +#[test] +fn the_environment_wins_over_the_file() { + let file = Some("[flags]\nAI-MCP = true\n"); + let flags = Flags::from_parts(file, Some("-AI-MCP, AI-MODELS")); + assert!(!flags.enabled(&AI_MCP)); + assert!(flags.enabled(&AI_MODELS)); + assert_eq!(flags.forced("AI-MCP"), Some(false)); + let flags = Flags::from_parts(None, Some("AI-MCP=1;AI-MODELS=off")); + assert!(flags.enabled(&AI_MCP)); + assert!(!flags.enabled(&AI_MODELS)); + assert_eq!(flags.forced("OTHER"), None); +} + +#[test] +fn bad_entries_are_reported_and_left_out_never_fatal() { + let flags = Flags::from_parts(Some("[flags]\nAI-MCP = \"yes\"\nlower = true\nAI-MODELS = true\n"), Some("AI-MCP=maybe,bad id,")); + assert!(!flags.enabled(&AI_MCP), "a value that is not true or false is not on"); + assert!(flags.enabled(&AI_MODELS)); + assert_eq!(flags.problems.len(), 5, "{:?}", flags.problems); // "bad id" is two words + + for text in ["not toml [[[", "flags = 3", "\u{feff}[flags]\nAI-MCP = true", ""] { + let flags = Flags::from_parts(Some(text), None); + assert!(!flags.enabled(&AI_MCP) || text.contains("AI-MCP = true"), "{text:?}"); + } + assert!(!Flags::from_parts(Some("not toml [[["), None).problems.is_empty()); + assert!(!Flags::from_parts(Some("flags = 3"), None).problems.is_empty()); +} + +#[test] +fn many_entries_are_capped() { + let mut text = String::from("[flags]\n"); + for i in 0..(MAX_ENTRIES + 10) { + text.push_str(&format!("F{i} = true\n")); + } + let flags = Flags::from_parts(Some(&text), None); + assert_eq!(flags.file.len(), MAX_ENTRIES); + assert!(flags.problems.iter().any(|p| p.contains("more than"))); +} + +#[test] +fn unknown_ids_are_off_unless_set() { + let flags = Flags::from_parts(Some("[flags]\nNEW-THING = true\n"), None); + assert!(flags.enabled_id("NEW-THING")); + assert!(!flags.enabled_id("OTHER-THING")); + assert!(!flags.enabled_id("AI-MCP")); +} + +#[test] +fn ids_are_checked() { + assert!(valid_id("AI-MCP")); + assert!(!valid_id("")); + assert!(!valid_id("ai-mcp")); + assert!(!valid_id("AI MCP")); + assert!(!valid_id(&"A".repeat(MAX_ID_LEN + 1))); +} + +#[cfg(not(target_arch = "wasm32"))] +mod file { + use super::super::*; + + #[test] + fn set_creates_the_file_and_keeps_what_else_is_in_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ArtCraft").join(FILE_NAME); + set(&path, "AI-MCP", true).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(Flags::from_parts(Some(&text), None).enabled(&AI_MCP)); + + std::fs::write(&path, "# keep me\nother = 1\n[flags]\nAI-MCP = true # on for testing\nX-OLD = false\n").unwrap(); + set(&path, "AI-MODELS", true).unwrap(); + set(&path, "AI-MCP", false).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("# keep me") && text.contains("other = 1") && text.contains("X-OLD = false"), "{text}"); + let flags = Flags::from_parts(Some(&text), None); + assert!(!flags.enabled(&AI_MCP)); + assert!(flags.enabled(&AI_MODELS)); + } + + #[test] + fn set_never_overwrites_a_file_it_cannot_read() { + let dir = tempfile::tempdir().unwrap(); + for (name, text) in [("broken.toml", "[[[ not toml"), ("scalar.toml", "flags = 3\n")] { + let path = dir.path().join(name); + std::fs::write(&path, text).unwrap(); + assert!(matches!(set(&path, "AI-MCP", true), Err(FlagError::Unreadable(_))), "{name}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{name} changed"); + } + let big = dir.path().join("big.toml"); + std::fs::write(&big, vec![b'#'; MAX_FILE_BYTES as usize + 1]).unwrap(); + assert!(matches!(set(&big, "AI-MCP", true), Err(FlagError::Unreadable(_)))); + let folder = dir.path().join("folder.toml"); + std::fs::create_dir(&folder).unwrap(); + assert!(set(&folder, "AI-MCP", true).is_err()); + assert!(matches!(set(&dir.path().join("ok.toml"), "bad id", true), Err(FlagError::BadId(_)))); + } +}