master
John Lauer 1.0.50: 1.0.50: the background guard (backgroundGuard) is OFF by default. John, after watching it live: 'you just are t… cad51ac 1mo ago
//! The bridge's own user-visible settings (`kicad_get_settings` / `kicad_set_settings`),
//! ported from `handlers/settings.py`. These are NOT KiCad's settings: they are the
//! preferences the dashboard renders (taskbar overlay badges, activity indicator),
//! persisted per box in `<home>/.adom/kicad-bridge-settings.json`.
//!
//! The Python writes the file in place with no backup and no KiCad-running check;
//! the port keeps that contract but writes through a sibling temp file and a
//! rename so a crash mid-write cannot leave a half-written JSON behind.

use std::path::PathBuf;

use serde_json::{json, Map, Value};

/// Every settable key with its default. Order is the order `_hint` lists them.
pub fn defaults() -> Vec<(&'static str, Value)> {
    vec![
        ("overlayBadges", json!(true)),
        // How the taskbar shows "Adom is driving KiCad": progress paints a bar from
        // the measured verb timings and suppresses the OS flash; flash leaves the
        // Windows attention flash alone; both; none.
        ("activityIndicator", json!("progress")),
        // A foreground the calling AI asked for (foreground:true with a reason) announces
        // itself with a toast, which ab mirrors to the user's other boxes. One toast per
        // this many minutes; later foregrounds inside the window keep only the 3 s
        // on-screen caption on the driven box. 0 turns the toast off (John, 2026-09-19:
        // one per verb "makes them more disruptive than you foregrounding kicad").
        ("foregroundToastMinutes", json!(10)),
        // The A/B switch John asked for (2026-09-21): true = a KiCad window the bridge is opening is
        // sent behind the user's window; false = the bridge never moves or pushes any KiCad window,
        // so whatever KiCad does with the foreground is what the user sees. DEFAULT OFF from 1.0.50
        // (John, 2026-09-21: "you just are too aggressive with it and kicad acts weird when you have
        // it on"); wiki #114 tracks the work before it may be on by default again.
        ("backgroundGuard", json!(false)),
    ]
}

pub const ACTIVITY_CHOICES: &[&str] = &["progress", "flash", "both", "none"];

pub fn defaults_map() -> Map<String, Value> {
    defaults().into_iter().map(|(k, v)| (k.to_string(), v)).collect()
}

/// `<USERPROFILE or HOME>/.adom/kicad-bridge-settings.json`. Creates `.adom/`.
pub fn settings_path() -> PathBuf {
    let base = std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty())
        .or_else(|| std::env::var("HOME").ok().filter(|s| !s.is_empty()))
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    let d = base.join(".adom");
    let _ = std::fs::create_dir_all(&d);
    d.join("kicad-bridge-settings.json")
}

/// Defaults overlaid with whatever the file holds (unknown keys are kept, like the
/// Python `dict.update`). An unreadable or invalid file yields the defaults.
pub fn get_settings() -> Map<String, Value> {
    let mut out = defaults_map();
    let file = read_file_map();
    let chosen_guard = file.get(GUARD_CHOSEN).and_then(Value::as_bool).unwrap_or(false);
    for (k, v) in file {
        if k == GUARD_CHOSEN || (k == "backgroundGuard" && !chosen_guard) {
            continue;
        }
        out.insert(k, v);
    }
    out
}

/// Marker written beside `backgroundGuard` when someone sets it on purpose. Before 1.0.50 every
/// save wrote EVERY default into the file, so a stored `backgroundGuard:true` may be nobody's
/// choice; without this marker it is ignored and the (now off) default applies.
const GUARD_CHOSEN: &str = "backgroundGuardChosen";

fn read_file_map() -> Map<String, Value> {
    std::fs::read_to_string(settings_path()).ok()
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .and_then(|v| match v { Value::Object(m) => Some(m), _ => None })
        .unwrap_or_default()
}

/// Type a raw value by its default's type. The first Python cut bool()'d everything,
/// so `activityIndicator:"flash"` persisted as true; this is the fix carried over.
pub fn coerce(key: &str, raw: &Value) -> Result<Value, String> {
    if key == "activityIndicator" {
        let v = value_text(raw).trim().to_lowercase();
        if !ACTIVITY_CHOICES.contains(&v.as_str()) {
            return Err(format!("{key} must be one of {}", ACTIVITY_CHOICES.join(", ")));
        }
        return Ok(json!(v));
    }
    let default = defaults().into_iter().find(|(k, _)| *k == key).map(|(_, v)| v).unwrap_or(Value::Null);
    if default.is_number() {
        let n = match raw {
            Value::Number(n) => n.as_f64(),
            Value::String(t) => t.trim().parse::<f64>().ok(),
            Value::Bool(false) | Value::Null => Some(0.0),
            _ => None,
        };
        return match n {
            Some(f) if f.is_finite() && f >= 0.0 => Ok(json!((f.min(1440.0)).round() as u64)),
            _ => Err(format!("{key} must be a number of minutes, 0 or more (0 turns it off)")),
        };
    }
    if default.is_boolean() {
        return Ok(json!(match raw {
            Value::String(s) => matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "on" | "yes"),
            Value::Bool(b) => *b,
            Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
            Value::Null => false,
            Value::Array(a) => !a.is_empty(),
            Value::Object(o) => !o.is_empty(),
        }));
    }
    Ok(raw.clone())
}

fn value_text(v: &Value) -> String {
    match v {
        Value::String(s) => s.clone(),
        Value::Bool(b) => if *b { "True".into() } else { "False".into() },
        other => other.to_string(),
    }
}

/// Python `handle_set_settings`: `restoreDefaults` first, then every known key
/// present in `args`. Returns the full verb response.
pub fn set_settings(args: &Value) -> Value {
    let mut s = get_settings();
    let mut changed = Map::new();
    if args.get("restoreDefaults").and_then(truthy).unwrap_or(false) {
        for (k, v) in defaults() {
            if s.get(k) != Some(&v) {
                changed.insert(k.to_string(), v.clone());
            }
            s.insert(k.to_string(), v);
        }
    }
    for (k, _) in defaults() {
        if let Some(raw) = args.get(k) {
            let v = match coerce(k, raw) {
                Ok(v) => v,
                Err(e) => return json!({"success": false, "error": e}),
            };
            if s.get(k) != Some(&v) {
                changed.insert(k.to_string(), v.clone());
            }
            s.insert(k.to_string(), v);
        }
    }
    let keys: Vec<&str> = defaults().into_iter().map(|(k, _)| k).collect();
    if changed.is_empty() {
        return json!({"success": true, "settings": s, "changed": {},
                      "output": "no settings changed",
                      "_hint": format!("Settable keys: {}.", keys.join(", "))});
    }
    // Persist only what someone CHOSE: the keys already in the file plus the ones set now.
    // Writing the whole map (as before 1.0.50) froze every default into the file, so changing a
    // default in a release reached nobody who had ever saved a setting.
    let mut file = read_file_map();
    if args.get("restoreDefaults").and_then(truthy).unwrap_or(false) {
        for (k, _) in defaults() {
            file.remove(k);
        }
        file.remove(GUARD_CHOSEN);
    }
    for (k, _) in defaults() {
        if args.get(k).is_some() {
            if let Some(v) = s.get(k) {
                file.insert(k.to_string(), v.clone());
            }
            if k == "backgroundGuard" {
                file.insert(GUARD_CHOSEN.to_string(), json!(true));
            }
        }
    }
    let text = serde_json::to_string_pretty(&Value::Object(file)).unwrap_or_default();
    if let Err(e) = crate::libraries::write_atomic(&settings_path(), text.as_bytes()) {
        return json!({"success": false, "error": format!("could not persist settings: {e}")});
    }
    json!({"success": true, "settings": s, "changed": changed,
           "output": format!("settings updated: {}", python_repr(&changed))})
}

/// A Python-flavoured truthiness for the `restoreDefaults` flag.
fn truthy(v: &Value) -> Option<bool> {
    Some(match v {
        Value::Bool(b) => *b,
        Value::Null => false,
        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
        Value::String(s) => !s.is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(o) => !o.is_empty(),
    })
}

/// `{'overlayBadges': False}`: the Python `output` line prints the dict with repr.
fn python_repr(m: &Map<String, Value>) -> String {
    let parts: Vec<String> = m
        .iter()
        .map(|(k, v)| {
            let val = match v {
                Value::Bool(true) => "True".to_string(),
                Value::Bool(false) => "False".to_string(),
                Value::Null => "None".to_string(),
                Value::String(s) => format!("'{s}'"),
                other => other.to_string(),
            };
            format!("'{k}': {val}")
        })
        .collect();
    format!("{{{}}}", parts.join(", "))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn only_chosen_settings_are_saved_and_an_unchosen_guard_does_not_count() {
        let dir = std::env::temp_dir().join(format!("kb-settings-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".adom")).unwrap();
        let old = std::env::var_os("USERPROFILE");
        std::env::set_var("USERPROFILE", &dir);
        // a file the old save-everything code wrote: guard true, nobody chose it
        std::fs::write(settings_path(), r#"{"overlayBadges":true,"activityIndicator":"progress","foregroundToastMinutes":10,"backgroundGuard":true}"#).unwrap();
        assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(false)));
        // changing another setting does not freeze the guard into the file
        set_settings(&json!({"foregroundToastMinutes": 0}));
        let f: Value = serde_json::from_str(&std::fs::read_to_string(settings_path()).unwrap()).unwrap();
        assert_eq!(f["foregroundToastMinutes"], json!(0));
        assert!(f.get(GUARD_CHOSEN).is_none());
        // someone turns it on on purpose: it sticks
        set_settings(&json!({"backgroundGuard": true}));
        assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(true)));
        // restore defaults: back to off, marker gone
        set_settings(&json!({"restoreDefaults": true}));
        assert_eq!(get_settings().get("backgroundGuard"), Some(&json!(false)));
        match old { Some(v) => std::env::set_var("USERPROFILE", v), None => std::env::remove_var("USERPROFILE") }
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn coerce_types_by_default() {
        assert_eq!(coerce("overlayBadges", &json!("off")).unwrap(), json!(false));
        assert_eq!(coerce("overlayBadges", &json!("Yes")).unwrap(), json!(true));
        assert_eq!(coerce("overlayBadges", &json!(0)).unwrap(), json!(false));
        assert_eq!(coerce("activityIndicator", &json!("Flash")).unwrap(), json!("flash"));
        assert!(coerce("activityIndicator", &json!("blink")).is_err());
        assert!(coerce("activityIndicator", &json!(true)).is_err());
    }

    #[test]
    fn repr_matches_python() {
        let mut m = Map::new();
        m.insert("overlayBadges".into(), json!(false));
        m.insert("activityIndicator".into(), json!("flash"));
        assert_eq!(python_repr(&m), "{'activityIndicator': 'flash', 'overlayBadges': False}");
    }

    #[test]
    fn defaults_have_every_key() {
        let d = defaults_map();
        assert_eq!(d.get("overlayBadges"), Some(&json!(true)));
        assert_eq!(d.get("activityIndicator"), Some(&json!("progress")));
    }

    #[test]
    fn foreground_toast_minutes_is_a_clamped_count() {
        assert_eq!(coerce("foregroundToastMinutes", &json!("15")).unwrap(), json!(15));
        assert_eq!(coerce("foregroundToastMinutes", &json!(0)).unwrap(), json!(0));
        assert_eq!(coerce("foregroundToastMinutes", &json!(false)).unwrap(), json!(0));
        assert_eq!(coerce("foregroundToastMinutes", &json!(99999)).unwrap(), json!(1440));
        assert!(coerce("foregroundToastMinutes", &json!(-5)).is_err());
        assert!(coerce("foregroundToastMinutes", &json!("soon")).is_err());
    }
}