Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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]);
    }
}