From 162bb421f17d0c3c95e2f1647b88f45ef069f4ab Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:49:19 -0400 Subject: [PATCH] Engine: anonymous usage events, counted in Session::execute (#238) fieldcraft_engine::usage holds the events docs/privacy.md lists, typed so that only registry names (command ids, sample names, requirements) and durations can go in them, a bounded queue that records nothing until the host enables it, and the PostHog batch body. Session::execute counts each top-level command of a session with an origin (ui, palette, script, mcp): its id, kind, duration and error class, and the funnel's milestones and opened samples. Nothing here touches the network. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/file.rs | 3 +- crates/engine/src/lib.rs | 21 +- crates/engine/src/usage.rs | 593 ++++++++++++++++++++++++++++++++++ 3 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/usage.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d14c44a..bd24adc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -236,12 +236,13 @@ impl Session { } /// Replace this session with `opened`, keeping its undo memory limit, its work caps and its - /// file access; the history starts empty. + /// file access (and where its commands come from); the history starts empty. fn replace_with(&mut self, mut opened: Session) { opened.history = std::mem::take(&mut self.history); opened.limits = self.limits; opened.file_access = std::mem::take(&mut self.file_access); opened.crash_journal = self.crash_journal; + opened.origin = self.origin; opened.clear_history(); opened.mark_opened(); *self = opened; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e6f8299..b9bb7ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub mod requirement; pub mod results; pub mod sample; pub mod schema; +pub mod usage; pub mod view; use std::collections::BTreeMap; @@ -250,6 +251,10 @@ pub struct Session { /// This is the app's own model: its top-level commands go into the crash journal /// ([`crash`]), so an unclean exit can be replayed. Off for scratch sessions and tests. pub crash_journal: bool, + /// Where this session's commands come from, for the usage analytics ([`usage`]): the app + /// sets it on its own model (and the control channel and the command search while they + /// run); `None` (scratch sessions, the CLI, tests) counts nothing. + pub origin: Option, } impl Session { @@ -258,7 +263,21 @@ impl Session { pub fn execute(&mut self, id: &str, params: &Value) -> Result { // An id from hostile input is not echoed in full. let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?; - self.availability(spec).map_err(EngineError::Unavailable)?; + let timer = usage::Timer::start(self.origin); + let elements = self.model.elements().len(); + let result = self.availability(spec).map_err(EngineError::Unavailable).and_then(|()| self.run_command(spec, params)); + if timer.counting() { + let milestone = usage::milestone(spec.id, (elements, self.model.elements().len())); + let sample = + (spec.id == "file.sample").then(|| params.get("name").and_then(Value::as_str).and_then(sample::find)).flatten().map(|s| s.name); + timer.done(spec.id, spec.kind, result.as_ref().err(), milestone, sample); + } + result + } + + /// [`Session::execute`] once the command is found and can run. + fn run_command(&mut self, spec: &'static CommandSpec, params: &Value) -> Result { + let id = spec.id; // Session commands (open, save, undo, redo) change the session only in their last step, // after everything that can fail, so they need no copy of the model to restore. let before = (spec.kind == CommandKind::Model).then(|| (self.snapshot(), self.document())); diff --git a/crates/engine/src/usage.rs b/crates/engine/src/usage.rs new file mode 100644 index 0000000..7cc0803 --- /dev/null +++ b/crates/engine/src/usage.rs @@ -0,0 +1,593 @@ +//! Anonymous usage analytics, off until the user agrees (docs/privacy.md). +//! +//! The app records [`Event`]s here; a host (the desktop app's sender thread, the web page's +//! `fetch`) takes them in batches ([`take_batch`]) and posts [`Batch::body`] to PostHog's batch +//! API. Nothing here touches the network. +//! +//! What an event can hold is fixed by its type: command ids, sample names and requirements come +//! from the registries (`&'static str`), never from parameters, paths, file names or messages. +//! Recording does nothing until [`enable`] (the user agreed, and the host may send); [`disable`] +//! drops what is queued. The queue is bounded ([`MAX_QUEUED`]): when full, new events are dropped. +//! +//! [`Session::execute`](crate::Session::execute) counts every top-level command of a session +//! whose [`Session::origin`](crate::Session::origin) is set (the app's own model): its id, kind, +//! duration, whether it failed and how, and where it came from ([`Origin`]); the funnel's +//! [`Milestone`]s and [`Event::SampleOpened`] follow from the same commands. + +use std::cell::Cell; +use std::collections::VecDeque; +use std::sync::Mutex; + +use serde_json::{Map, Value, json}; + +use crate::requirement::Requirement; +use crate::{CommandKind, EngineError}; + +/// PostHog Cloud's EU ingestion host. +pub const DEFAULT_HOST: &str = "https://eu.i.posthog.com"; +/// Events kept waiting to be sent; more are dropped. +pub const MAX_QUEUED: usize = 1000; +/// Events in one request. +pub const MAX_BATCH: usize = 100; + +/// Where a command came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Origin { + /// A click: the ribbon, a menu, a form, the browser. + Ui, + /// The command search or the command line. + Palette, + /// A script or the control channel. + Script, + /// An AI agent through the MCP server. + Mcp, +} + +impl Origin { + pub fn as_str(self) -> &'static str { + match self { + Origin::Ui => "ui", + Origin::Palette => "palette", + Origin::Script => "script", + Origin::Mcp => "mcp", + } + } + + /// Scripts and agents, which the user can leave out on their own. + pub fn automated(self) -> bool { + matches!(self, Origin::Script | Origin::Mcp) + } +} + +/// A step of the way from a design to results, counted once per run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Milestone { + DesignOpened, + Meshed, + Solved, + ResultsViewed, +} + +impl Milestone { + pub fn as_str(self) -> &'static str { + match self { + Milestone::DesignOpened => "design_opened", + Milestone::Meshed => "meshed", + Milestone::Solved => "solved", + Milestone::ResultsViewed => "results_viewed", + } + } + + fn bit(self) -> u8 { + 1 << self as u8 + } +} + +/// Something that happened, with only what docs/privacy.md lists. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + AppStarted, + /// The window closed after `seconds`. + AppClosed { + seconds: u64, + }, + CommandRun { + command: &'static str, + kind: CommandKind, + duration_ms: u64, + error: Option<&'static str>, + from: Origin, + }, + Milestone(Milestone), + SampleOpened { + name: &'static str, + }, + /// A command's form closed without running it. + FormCancelled { + command: &'static str, + }, + /// A command clicked before the model had what it needs. + UnavailableClicked { + command: &'static str, + missing: Vec, + }, +} + +impl Event { + pub fn name(&self) -> &'static str { + match self { + Event::AppStarted => "app_started", + Event::AppClosed { .. } => "app_closed", + Event::CommandRun { .. } => "command_run", + Event::Milestone(_) => "milestone", + Event::SampleOpened { .. } => "sample_opened", + Event::FormCancelled { .. } => "form_cancelled", + Event::UnavailableClicked { .. } => "unavailable_clicked", + } + } + + /// The event's own properties. + pub fn properties(&self) -> Map { + let v = match self { + Event::AppStarted => json!({}), + Event::AppClosed { seconds } => json!({"duration_s": seconds}), + Event::CommandRun { command, kind, duration_ms, error, from } => { + let kind = match kind { + CommandKind::Model => "model", + CommandKind::Session => "session", + }; + let mut v = json!({"command": command, "kind": kind, "duration_ms": duration_ms, "ok": error.is_none(), "from": from.as_str()}); + if let Some(e) = error { + v["error"] = json!(e); + } + v + } + Event::Milestone(m) => json!({"name": m.as_str()}), + Event::SampleOpened { name } => json!({"name": name}), + Event::FormCancelled { command } => json!({"command": command}), + Event::UnavailableClicked { command, missing } => json!({"command": command, "missing": missing}), + }; + match v { + Value::Object(m) => m, + _ => Map::new(), + } + } +} + +/// The class of an error, without its message (which can name the user's files and values). +pub fn error_class(e: &EngineError) -> &'static str { + match e { + EngineError::UnknownCommand(_) => "unknown_command", + EngineError::Params(_) | EngineError::Param(_) => "params", + EngineError::Model(_) => "model", + EngineError::Geometry(_) => "geometry", + EngineError::Mesh(_) => "mesh", + EngineError::File(_) => "file", + EngineError::Deck(_) => "deck", + EngineError::Solver(_) => "solver", + EngineError::Explicit(_) => "explicit", + EngineError::Fluid(_) => "fluid", + EngineError::Unavailable(_) => "unavailable", + EngineError::Internal { .. } => "internal", + } +} + +/// Who is sending: the anonymous install and the build. The same for every event of a run. +#[derive(Clone, Debug, PartialEq)] +pub struct Identity { + /// A random id made for this install (not derived from the machine or the user). + pub install_id: String, + /// `desktop` or `web`. + pub platform: &'static str, + /// The operating system (`macos`, `linux`, `windows`; `web` in a browser). + pub os: &'static str, + pub arch: &'static str, + pub app_version: &'static str, +} + +/// What [`take_batch`] hands the sender. +#[derive(Clone, Debug, PartialEq)] +pub struct Batch { + pub identity: Identity, + pub events: Vec, +} + +impl Batch { + /// The body of a POST to PostHog's `/batch/` endpoint with the project's API key: each event + /// with the anonymous install id, the build, no person profile and no IP-based location. + pub fn body(&self, api_key: &str) -> String { + let id = &self.identity; + let batch: Vec = self + .events + .iter() + .map(|e| { + let mut p = e.properties(); + p.insert("app_version".into(), json!(id.app_version)); + p.insert("platform".into(), json!(id.platform)); + p.insert("os".into(), json!(id.os)); + p.insert("arch".into(), json!(id.arch)); + p.insert("$process_person_profile".into(), json!(false)); + p.insert("$geoip_disable".into(), json!(true)); + json!({"event": e.name(), "distinct_id": id.install_id, "properties": p}) + }) + .collect(); + json!({"api_key": api_key, "batch": batch}).to_string() + } +} + +/// Whether this run may record at all, before asking the user: the build has a project key, +/// `FIELDCRAFT_TELEMETRY` (`telemetry`) is not `0`, and a run driven by automation (`--control`) +/// or CI records only when `FIELDCRAFT_TELEMETRY=1` says so. +pub fn allowed(has_key: bool, telemetry: Option<&str>, automation: bool, ci: bool) -> bool { + let set = telemetry.map(|v| v.trim().to_ascii_lowercase()); + match set.as_deref() { + Some("0" | "false" | "off" | "no") => false, + Some("1" | "true" | "on" | "yes") => has_key, + _ => has_key && !automation && !ci, + } +} + +struct State { + identity: Identity, + /// Count scripts and MCP runs too. + automation: bool, + queue: VecDeque, + /// Milestones already sent this run. + reached: u8, + /// When recording started ([`now_ms`]), for the session's length. + started: Option, +} + +static STATE: Mutex> = Mutex::new(None); + +fn with_state(f: impl FnOnce(&mut Option) -> R) -> R { + // A poisoned lock still holds a usable queue. + let mut g = STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut g) +} + +/// Start recording, as `identity`, with [`Event::AppStarted`]; `automation` counts scripts and +/// MCP runs too. Already recording, only the settings change. +pub fn enable(identity: Identity, automation: bool) { + let now = now_ms(); + with_state(|s| match s { + Some(s) => { + s.identity = identity; + s.automation = automation; + } + None => *s = Some(State { identity, automation, queue: VecDeque::from([Event::AppStarted]), reached: 0, started: now }), + }); +} + +/// The app is closing: [`Event::AppClosed`] with how long it recorded. +pub fn closing() { + let now = now_ms(); + let started = with_state(|s| s.as_ref().map(|s| s.started)); + if let Some(started) = started { + let seconds = match (started, now) { + (Some(a), Some(b)) if b >= a => ((b - a) / 1000.0).round() as u64, + _ => 0, + }; + record(Event::AppClosed { seconds }); + } +} + +/// Stop recording and drop everything queued. +pub fn disable() { + with_state(|s| *s = None); +} + +pub fn enabled() -> bool { + with_state(|s| s.is_some()) +} + +/// Queue an event, when recording (and when it is no script or MCP run the user left out). +pub fn record(event: Event) { + with_state(|s| { + let Some(s) = s else { return }; + match &event { + Event::CommandRun { from, .. } if from.automated() && !s.automation => return, + Event::Milestone(m) if s.reached & m.bit() != 0 => return, + Event::Milestone(m) => s.reached |= m.bit(), + _ => {} + } + if s.queue.len() < MAX_QUEUED { + s.queue.push_back(event); + } + }); +} + +/// Up to [`MAX_BATCH`] queued events to send, oldest first; `None` when there are none. +pub fn take_batch() -> Option { + with_state(|s| { + let s = s.as_mut()?; + if s.queue.is_empty() { + return None; + } + let n = s.queue.len().min(MAX_BATCH); + Some(Batch { identity: s.identity.clone(), events: s.queue.drain(..n).collect() }) + }) +} + +/// Events waiting to be sent. +pub fn queued() -> usize { + with_state(|s| s.as_ref().map_or(0, |s| s.queue.len())) +} + +/// A clock in milliseconds for command durations. The plain wasm target has none: the web page +/// sets one ([`set_clock`]). +static CLOCK: Mutex f64>> = Mutex::new(None); + +/// Use `clock` (milliseconds, any origin) for command durations. +pub fn set_clock(clock: fn() -> f64) { + *CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(clock); +} + +fn now_ms() -> Option { + if let Some(c) = *CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + Some(START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0) + } + #[cfg(target_arch = "wasm32")] + None +} + +thread_local! { + /// Commands running on this thread: only the outermost is counted. + static DEPTH: Cell = const { Cell::new(0) }; +} + +/// A command being counted, from [`Session::execute`](crate::Session::execute): `None` when it +/// is not (not recording, no origin, or a command inside another). +pub(crate) struct Timer { + started: Option, + from: Option, +} + +impl Timer { + pub(crate) fn start(from: Option) -> Timer { + let outer = DEPTH.with(|d| { + d.set(d.get() + 1); + d.get() == 1 + }); + let from = from.filter(|_| outer && enabled()); + Timer { started: from.and_then(|_| now_ms()), from } + } + + /// Whether this command is counted. + pub(crate) fn counting(&self) -> bool { + self.from.is_some() + } + + /// The command ended: count it, and the milestone it reached. + pub(crate) fn done( + &self, + command: &'static str, + kind: CommandKind, + error: Option<&EngineError>, + milestone: Option, + sample: Option<&'static str>, + ) { + let Some(from) = self.from else { return }; + let duration_ms = match (self.started, now_ms()) { + (Some(a), Some(b)) if b >= a => (b - a).round() as u64, + _ => 0, + }; + record(Event::CommandRun { command, kind, duration_ms, error: error.map(error_class), from }); + if error.is_none() { + if let Some(m) = milestone { + record(Event::Milestone(m)); + } + if let Some(name) = sample { + record(Event::SampleOpened { name }); + } + } + } +} + +impl Drop for Timer { + fn drop(&mut self) { + DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + +/// The milestone a successful `command` reaches; `elements` is the element count before and +/// after it. +pub(crate) fn milestone(command: &str, elements: (usize, usize)) -> Option { + match command { + "geometry.open" | "geometry.import.mesh" | "file.open" => Some(Milestone::DesignOpened), + c if c.starts_with("mesh.") && elements.1 > elements.0 => Some(Milestone::Meshed), + // Solvers, not the queries on a solved step. + c if c.starts_with("solve.") && !matches!(c, "solve.probe" | "solve.history") => Some(Milestone::Solved), + c if c.starts_with("results.") && !matches!(c, "results.reactions" | "results.clear") => Some(Milestone::ResultsViewed), + _ => None, + } +} + +/// Serialises the tests (and other code) that turn the process-wide recording on. +#[doc(hidden)] +pub static TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + fn identity() -> Identity { + Identity { install_id: "00000000-0000-4000-8000-000000000000".into(), platform: "desktop", os: "test", arch: "test", app_version: "0.0.0" } + } + + fn take_all() -> Vec { + std::iter::from_fn(take_batch).flat_map(|b| b.events).collect() + } + + /// Every string in a payload is a key, a registry name or the identity, and every number a + /// duration: no path, file name, parameter value or message can be in it. + pub(crate) fn assert_clean(body: &str) { + let v: Value = serde_json::from_str(body).unwrap(); + let mut allowed: Vec = crate::commands().iter().map(|c| c.id.to_string()).collect(); + allowed.extend(crate::sample::SAMPLES.iter().map(|s| s.name.to_string())); + for e in [ + "app_started", + "app_closed", + "command_run", + "milestone", + "sample_opened", + "form_cancelled", + "unavailable_clicked", + "model", + "session", + "ui", + "palette", + "script", + "mcp", + "design_opened", + "meshed", + "solved", + "results_viewed", + "unknown_command", + "params", + "geometry", + "mesh", + "file", + "deck", + "solver", + "explicit", + "fluid", + "unavailable", + "internal", + "KEY", + ] { + allowed.push(e.into()); + } + let id = identity(); + allowed.extend([id.install_id, id.platform.into(), id.os.into(), id.arch.into(), id.app_version.into()]); + fn walk(key: &str, v: &Value, allowed: &[String]) { + match v { + Value::String(s) => { + // A requirement's snake_case name. + let requirement = key == "missing" && s.chars().all(|c| c.is_ascii_lowercase() || c == '_'); + assert!(allowed.contains(s) || requirement, "`{key}`: `{s}` is not a name FieldCraft defines"); + } + Value::Number(_) => assert!(["duration_ms", "duration_s"].contains(&key), "`{key}`: a number that is not a duration"), + Value::Array(a) => a.iter().for_each(|x| walk(key, x, allowed)), + Value::Object(m) => m.iter().for_each(|(k, x)| walk(k, x, allowed)), + Value::Bool(_) | Value::Null => {} + } + } + walk("", &v, &allowed); + } + + #[test] + fn nothing_is_queued_without_consent() { + let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + disable(); + let mut s = Session { origin: Some(Origin::Ui), ..Default::default() }; + s.run_script(&crate::sample::cantilever()).unwrap(); + record(Event::AppStarted); + assert_eq!(queued(), 0); + assert!(take_batch().is_none()); + } + + #[test] + fn a_scripted_session_sends_only_names_and_durations() { + let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + disable(); + enable(identity(), true); + let dir = std::env::temp_dir().join(format!("fieldcraft-usage-secret-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("my-secret-bracket.solidcraft"); + std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); + let mut s = Session { origin: Some(Origin::Script), ..Default::default() }; + s.execute("geometry.open", &json!({"path": design.to_string_lossy()})).unwrap(); + s.run_script(&crate::sample::cantilever()).unwrap(); + s.execute("model.material.create", &json!({"name": "Secret alloy 7", "elastic": {"young": 123456.75, "poisson": 0.3125}})).ok(); + s.execute("solve.run", &json!({})).unwrap(); + s.execute("results.contour", &json!({"field": "von_mises"})).unwrap(); + // A failure whose message names a path, and a sample. + assert!(s.execute("file.open", &json!({"path": dir.join("missing-secret.fieldcraft").to_string_lossy()})).is_err()); + s.execute("file.sample", &json!({"name": "cantilever"})).unwrap(); + // An unknown id is user text: not counted. + assert!(s.execute("secret.command", &json!({})).is_err()); + let events = take_all(); + let body = Batch { identity: identity(), events: events.clone() }.body("KEY"); + assert_clean(&body); + for secret in ["secret", "Secret", "123456", "0.3125", "1000", dir.to_string_lossy().as_ref()] { + assert!(!body.contains(secret), "{secret} in {body}"); + } + let names: Vec<&str> = events.iter().map(Event::name).collect(); + assert!(names.contains(&"command_run") && names.contains(&"sample_opened"), "{names:?}"); + // Inner commands (the script's steps run by execute, a sample's own script) count once. + let runs = events.iter().filter(|e| matches!(e, Event::CommandRun { command: "file.sample", .. })).count(); + assert_eq!(runs, 1); + let reached: Vec<_> = events.iter().filter_map(|e| if let Event::Milestone(m) = e { Some(*m) } else { None }).collect(); + for m in [Milestone::DesignOpened, Milestone::Meshed, Milestone::Solved, Milestone::ResultsViewed] { + assert_eq!(reached.iter().filter(|r| **r == m).count(), 1, "{m:?} once: {reached:?}"); + } + assert!( + events.iter().any(|e| matches!(e, Event::CommandRun { command: "file.open", error: Some("file"), from: Origin::Script, .. })), + "{events:?}" + ); + disable(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scripts_and_agents_can_be_left_out_and_the_queue_is_bounded() { + let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + disable(); + enable(identity(), false); + let run = |from| Event::CommandRun { command: "model.info", kind: CommandKind::Model, duration_ms: 1, error: None, from }; + record(run(Origin::Mcp)); + record(run(Origin::Script)); + record(run(Origin::Palette)); + assert_eq!(take_all(), [Event::AppStarted, run(Origin::Palette)]); + for _ in 0..MAX_QUEUED + 50 { + record(Event::AppStarted); + } + assert_eq!(queued(), MAX_QUEUED); + let first = take_batch().unwrap(); + assert_eq!(first.events.len(), MAX_BATCH); + assert_eq!(queued(), MAX_QUEUED - MAX_BATCH); + // Turning it off drops what waits. + disable(); + assert_eq!(queued(), 0); + enable(identity(), true); + closing(); + assert!(matches!(take_all()[..], [Event::AppStarted, Event::AppClosed { .. }])); + disable(); + } + + #[test] + fn the_body_is_a_posthog_batch() { + let b = Batch { + identity: identity(), + events: vec![Event::UnavailableClicked { command: "solve.run", missing: vec![Requirement::Step, Requirement::Material] }], + }; + let v: Value = serde_json::from_str(&b.body("phc_test")).unwrap(); + assert_eq!(v["api_key"], "phc_test"); + let e = &v["batch"][0]; + assert_eq!((e["event"].as_str(), e["distinct_id"].as_str()), (Some("unavailable_clicked"), Some(identity().install_id.as_str()))); + assert_eq!(e["properties"]["missing"], json!(["step", "material"])); + assert_eq!(e["properties"]["$process_person_profile"], false); + assert_eq!(e["properties"]["$geoip_disable"], true); + assert_clean(&b.body("KEY")); + } + + #[test] + fn telemetry_is_off_without_a_key_in_automation_and_ci_and_when_turned_off() { + assert!(allowed(true, None, false, false)); + assert!(!allowed(false, None, false, false)); + assert!(!allowed(false, Some("1"), false, false)); + assert!(!allowed(true, Some("0"), false, false)); + assert!(!allowed(true, Some(" off "), false, false)); + assert!(!allowed(true, None, true, false)); + assert!(!allowed(true, None, false, true)); + assert!(!allowed(true, Some(""), true, false)); + assert!(allowed(true, Some("1"), true, true)); + } +}