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 <daaavid.mann@gmail.com>
This commit is contained in:
David Mann authored and GitHub committed 2026-11-12 10:05:07 -05:00
1 parent 83d5ad516c
commit 86d6e8c5b0
8 files changed
+493

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -111,6 +111,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
and channel names, the channels that make a colour picture, and Cryptomatte IDs, metadata keys,
manifests and rank channels. The optional `decode` feature adds bounded part listing, flat
decoding (scan lines, tiles, every compression of the `exr` crate) and writing.
- [`craft-flags`](crates/flags/README.md): Feature flags shared by the Craft apps and the ArtCraft Launcher: one per-user `ArtCraft/flags.toml`, a `CRAFT_FLAGS` override, every flag off by default (`AI-MCP`, `AI-MODELS`).
- [`craft-flow`](crates/flow/README.md): the flow model every text format maps to (paragraphs with a kind, lists and formatted spans; tables; pictures, fields, notes, equations), converted to and from a craft-wordmodel `Document`.
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "craft-flags"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
description = "Feature flags shared by the Craft apps and the ArtCraft Launcher: one per-user file, an environment override, off by default"
[lints]
workspace = true
[dependencies]
toml_edit = { version = "0.25" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-atomic = { workspace = true }
dirs = "7"
[dev-dependencies]
tempfile = "3"
+36
View File
@@ -0,0 +1,36 @@
# Craft Flags
`craft-flags` lets a feature ship in every Craft app and the ArtCraft Launcher while it stays off
until a person turns it on. Every flag is off by default.
- **One file for the suite:** `ArtCraft/flags.toml` in the per-user local configuration folder
(`%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` on
Linux), beside the AI layer's `ArtCraft/ai`. `CRAFT_FLAGS_FILE` names another file.
- **One run:** `CRAFT_FLAGS=AI-MCP,-AI-MODELS` overrides the file.
- **Never crashes:** a missing or damaged file means every flag is at its default, with the
reason in `Flags::problems`. `set` changes one flag atomically and keeps everything else in the
file; it refuses to touch a file it cannot read.
- **Browser:** no file, no environment: every flag is at its default.
## Flags
| Constant | Id | What it gates |
|---|---|---|
| `AI_MCP` | `AI-MCP` | AI agents using the Craft apps through MCP: the launcher's AI agents settings, the `craft-ai-mcp` gateway, the apps' agent connection settings |
| `AI_MODELS` | `AI-MODELS` | Choosing AI models and services for AI tools such as Generative Fill |
A new flag is a new constant here and a row in `ALL`, so the launcher can list it and every app
names it the same way.
## Use
```rust
let flags = craft_flags::load();
if flags.enabled(&craft_flags::AI_MCP) {
// show the agent connection settings
}
```
The launcher turns a flag on with `craft_flags::set(&path, craft_flags::AI_MCP.id, true)`, where
`path` is `craft_flags::default_path()`. Apps read the flags when they start; a flag changed
while an app runs takes effect at its next start, unless the app loads them again.
+220
View File
@@ -0,0 +1,220 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Feature flags shared by the Craft apps and the ArtCraft Launcher.
//!
//! A flag lets a feature land on `main` and ship in every app while it stays off until a person
//! (or a release) turns it on. Every flag is **off by default**. One per-user file holds the
//! choices, so the launcher can show a flag once and every app follows it:
//!
//! | Platform | File |
//! |---|---|
//! | Windows | `%LOCALAPPDATA%\ArtCraft\flags.toml` |
//! | macOS | `~/Library/Application Support/ArtCraft/flags.toml` |
//! | Linux | `$XDG_CONFIG_HOME/ArtCraft/flags.toml` (default `~/.config/ArtCraft/flags.toml`) |
//!
//! beside the AI layer's `ArtCraft/ai`. `CRAFT_FLAGS_FILE` names another file (tests, portable
//! installs). The file looks like this; keys and comments the reader does not know are kept when
//! a flag is changed:
//!
//! ```toml
//! [flags]
//! AI-MCP = true
//! ```
//!
//! `CRAFT_FLAGS` overrides the file for one run: `CRAFT_FLAGS=AI-MCP,-AI-MODELS` turns `AI-MCP`
//! on and `AI-MODELS` off (`AI-MCP=1` and `AI-MODELS=0` work too).
//!
//! ```
//! let flags = craft_flags::Flags::from_parts(Some("[flags]\nAI-MCP = true\n"), None);
//! assert!(flags.enabled(&craft_flags::AI_MCP));
//! assert!(!flags.enabled(&craft_flags::AI_MODELS));
//! ```
//!
//! Loading never fails: a missing file means every flag is at its default, and a damaged one is
//! reported in [`Flags::problems`] and read as if it were missing. In the browser (wasm) there is
//! no file and no environment; every flag is at its default.
use std::collections::BTreeMap;
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
mod store;
#[cfg(not(target_arch = "wasm32"))]
pub use store::{FlagError, load, set};
/// One feature flag. Declared here, so the launcher can list every flag and each app names the
/// same one by the same constant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Flag {
/// The key in the file and in `CRAFT_FLAGS` (upper case, digits and `-`).
pub id: &'static str,
/// A short name a person reads.
pub title: &'static str,
/// What turning it on does.
pub description: &'static str,
/// Its value when neither the file nor `CRAFT_FLAGS` names it.
pub default: bool,
}
/// AI agents reach the Craft apps through MCP: the launcher's AI agents settings, the
/// `craft-ai-mcp` gateway and the apps' agent connection settings.
pub const AI_MCP: Flag = Flag {
id: "AI-MCP",
title: "AI agents (MCP)",
description: "Let AI agents such as Claude, Cursor or LM Studio use the Craft apps through MCP.",
default: false,
};
/// AI models and services in the apps: choosing a model or a service for AI tools such as
/// Generative Fill.
pub const AI_MODELS: Flag = Flag {
id: "AI-MODELS",
title: "AI models and services",
description: "Choose AI models and services for AI tools such as Generative Fill.",
default: false,
};
/// Every flag, in the order the launcher lists them.
pub const ALL: &[Flag] = &[AI_MCP, AI_MODELS];
/// The environment variable that overrides the file for one run.
pub const ENV: &str = "CRAFT_FLAGS";
/// The environment variable that names another flags file.
pub const FILE_ENV: &str = "CRAFT_FLAGS_FILE";
/// The file's name in the `ArtCraft` folder.
pub const FILE_NAME: &str = "flags.toml";
/// Largest flags file read; anything bigger is not a flags file.
pub const MAX_FILE_BYTES: u64 = 64 * 1024;
/// Most flags read from the file or the environment.
const MAX_ENTRIES: usize = 256;
/// Longest flag id.
const MAX_ID_LEN: usize = 40;
/// Whether `id` can name a flag: 1 to 40 of `A`-`Z`, `0`-`9` and `-`.
pub fn valid_id(id: &str) -> 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<PathBuf> {
#[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<String, bool>,
env: BTreeMap<String, bool>,
/// 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<String>,
}
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::<toml_edit::DocumentMut>() {
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<bool> {
self.env.get(id).copied()
}
fn lookup(&self, id: &str) -> Option<bool> {
self.env.get(id).or_else(|| self.file.get(id)).copied()
}
}
fn parse_bool(v: &str) -> Option<bool> {
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;
+86
View File
@@ -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<Option<String>, 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::<toml_edit::DocumentMut>().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)
}
+118
View File
@@ -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(_))));
}
}