crates/gunnar-ui-web/src/site_switches.rs
//! **The administrator's switches for the public site and the MCP connector** (owner decision
//! 2026-10-26, item 5), on `/admin`.
//!
//! Each switch exists only in a console BUILT with its feature (`public-site`, `mcp`), and the
//! console says which ones its build carries (`/auth/status` `switches`, to an administrator
//! only). **GUI-PUBSW (2026-10-29):** the panel used to be drawn only where the bundle was the
//! metered one AND the switch's own `GET` answered a position, so an administrator of a
//! `public-site` build could find no switch at all (gunnar.rs: the owner flipped it from the
//! browser's console). Now every switch the console names is drawn, first on `/admin`, in every
//! bundle; where its position cannot be read the panel says why and offers both positions
//! ([`position`]). The page still decides nothing: the console refuses a flip by anyone but an
//! administrator and stores each position as gunnar's generic setting.
use serde::Deserialize;
/// The public site's switch.
pub const PUBLIC_SITE_API: &str = "/api/public-site";
/// The MCP connector's switch.
pub const MCP_API: &str = "/api/mcp";
/// One switch, as the console answers it.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Position {
pub on: bool,
}
/// One switch the page may draw: the console's name for it, its route, its title and what it does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Switch {
/// The name `/auth/status` lists it by (`switches`).
pub name: &'static str,
pub api: &'static str,
pub title: &'static str,
pub what: &'static str,
}
/// The switches a console may carry, in the page's order.
pub const SWITCHES: &[Switch] = &[
Switch { name: "public-site", api: PUBLIC_SITE_API, title: "Public site", what: "The public home, llms.txt, the sitemap, the public repository pages and their issues, for visitors and search engines." },
Switch { name: "mcp", api: MCP_API, title: "MCP connector", what: "Lets a person connect an AI assistant to Gunnar with their own passkey and grants." },
];
/// Pure: **the switches an administrator's page draws** (GUI-PUBSW): every one the console says
/// its build carries, in the page's order; a name this page does not know is left out.
pub fn carried(said: &[String]) -> Vec<Switch> {
SWITCHES.iter().filter(|s| said.iter().any(|n| n == s.name)).copied().collect()
}
/// What a drawn switch's panel says of its position.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shown {
On,
Off,
/// The console did not answer a position: the panel says why, and offers both.
Unread(String),
}
/// Pure: **the panel's position** from the switch's own `GET` (its answer, or the refusal's
/// words): a position where the console answered one, else why it could not be read.
pub fn position(answer: Result<serde_json::Value, String>) -> Shown {
match answer {
Ok(v) => match v.get("on").and_then(serde_json::Value::as_bool) {
Some(true) => Shown::On,
Some(false) => Shown::Off,
None => Shown::Unread("the console answered no position".to_owned()),
},
Err(why) if why.trim().is_empty() => Shown::Unread("the console did not answer".to_owned()),
Err(why) => Shown::Unread(why),
}
}
/// Pure: **is a switch drawn, and in which position**: only where the console answered its
/// route with a position (a build with the feature); a refusal, a path never routed or an
/// answer with no position draws nothing.
pub fn shown(answer: Result<serde_json::Value, u16>) -> Option<bool> {
answer.ok().and_then(|v| v.get("on").and_then(serde_json::Value::as_bool))
}
/// The body that asks for `on`.
pub fn flip_body(on: bool) -> serde_json::Value {
serde_json::json!({ "on": on })
}
/// The button's words for a switch now `on`.
pub fn button(on: bool) -> &'static str {
if on { "Switch off" } else { "Switch on" }
}
/// IndexNow's key on the public site (A32-16): set or cleared by an administrator.
pub const INDEXNOW_API: &str = "/api/public-site/indexnow";
/// Pure: **is the IndexNow key drawn, and is one set**: only where the console's public site
/// answer says (`indexnow`); anything else draws nothing.
pub fn indexnow_shown(answer: Result<serde_json::Value, u16>) -> Option<bool> {
answer.ok().and_then(|v| v.get("indexnow").and_then(serde_json::Value::as_bool))
}
/// The body that sets the key as typed (empty clears it); the console trims and judges it.
pub fn key_body(key: &str) -> serde_json::Value {
serde_json::json!({ "key": key })
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ A default build answers neither route with a position: no switch is drawn.
#[test]
fn a_switch_is_drawn_only_where_the_console_answers_its_position() {
assert_eq!(shown(Ok(serde_json::json!({"on": true, "ai_crawlers": true}))), Some(true));
assert_eq!(shown(Ok(serde_json::json!({"on": false}))), Some(false));
assert_eq!(shown(Err(404)), None, "a default build: the route was never routed");
assert_eq!(shown(Err(401)), None);
assert_eq!(shown(Ok(serde_json::json!("<!doctype html>"))), None, "the console's page for a path it does not route");
assert_eq!(shown(Ok(serde_json::json!({"on": "yes"}))), None);
}
/// ★ A32-16: IndexNow's key is drawn only where the console answers whether it holds one
/// (a `public-site` build), and the page sends the key as typed (empty clears it).
#[test]
fn the_indexnow_key_is_drawn_where_the_console_says_and_sent_as_typed() {
assert_eq!(indexnow_shown(Ok(serde_json::json!({"on": true, "indexnow": false}))), Some(false));
assert_eq!(indexnow_shown(Ok(serde_json::json!({"on": true, "indexnow": true}))), Some(true));
assert_eq!(indexnow_shown(Ok(serde_json::json!({"on": true}))), None, "a console that has no IndexNow setting");
assert_eq!(indexnow_shown(Err(404)), None);
assert_eq!(key_body(" abc-123 "), serde_json::json!({"key": " abc-123 "}), "the console trims and judges it");
assert_eq!(key_body(""), serde_json::json!({"key": ""}));
assert_eq!(INDEXNOW_API, "/api/public-site/indexnow");
}
/// ★ GUI-PUBSW: the page draws every switch the console says its build carries, whatever
/// the bundle, and nothing for a console that names none.
#[test]
fn every_switch_the_console_names_is_drawn_and_no_other() {
let names = |v: Vec<Switch>| v.iter().map(|s| s.title).collect::<Vec<_>>();
assert_eq!(names(carried(&["public-site".into()])), ["Public site"]);
assert_eq!(names(carried(&["mcp".into(), "public-site".into()])), ["Public site", "MCP connector"], "in the page's order");
assert!(carried(&[]).is_empty(), "the default (airgap) console names none");
assert!(carried(&["a-switch-from-tomorrow".into()]).is_empty());
}
/// ★ GUI-PUBSW: a drawn switch whose read fails still shows, and says why.
#[test]
fn a_switch_whose_position_cannot_be_read_says_why() {
assert_eq!(position(Ok(serde_json::json!({"on": true}))), Shown::On);
assert_eq!(position(Ok(serde_json::json!({"on": false, "ai_crawlers": true}))), Shown::Off);
assert_eq!(position(Ok(serde_json::json!("<!doctype html>"))), Shown::Unread("the console answered no position".into()));
assert_eq!(position(Err("this needs the admin role".into())), Shown::Unread("this needs the admin role".into()));
assert_eq!(position(Err(" ".into())), Shown::Unread("the console did not answer".into()));
}
#[test]
fn the_flip_asks_for_the_other_position() {
assert_eq!(flip_body(true), serde_json::json!({"on": true}));
assert_eq!((button(true), button(false)), ("Switch off", "Switch on"));
assert_eq!(SWITCHES.iter().map(|s| s.api).collect::<Vec<_>>(), [PUBLIC_SITE_API, MCP_API]);
}
}