mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
83d5ad516c
commit
86d6e8c5b0
8 files changed
+493
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user