master
John Lauer 1.0.58: Linux build in the same zip (wiki #127/#128: Flatpak KiCad, kicad-cli verbs, IPC live verbs; window verbs not y… 14b00c3 25d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
//! kicad_common.json: the IPC API server switch (port of handlers/ipc_api.py and the
//! read side in capabilities.py `_api_server_enabled`), plus the first-run settings
//! seeding from kicad_detect.ensure_win_user_config (the JSON half only; the lib-table
//! half lives with the library group).
//!
//! KiCad reads kicad_common.json at start, so flipping `api.enable_server` needs KiCad
//! closed and relaunched. Nothing here restarts KiCad; `needsRestart` says so.

use std::path::{Path, PathBuf};

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

/// Every `<root>/<version>/kicad_common.json` under a settings root, newest KiCad
/// version first (Python `_common_files`). A directory name that is not dotted
/// integers sorts last, like the Python `(0,)` key.
pub fn common_files(root: &Path) -> Vec<PathBuf> {
    let Ok(rd) = std::fs::read_dir(root) else {
        return Vec::new();
    };
    let mut found: Vec<PathBuf> = rd
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .map(|p| p.join("kicad_common.json"))
        .filter(|p| p.is_file())
        .collect();
    found.sort_by(|a, b| version_key(b).cmp(&version_key(a)).then_with(|| a.cmp(b)));
    found
}

fn version_key(p: &Path) -> Vec<u32> {
    let name = p.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    let parts: Option<Vec<u32>> = name.split('.').map(|x| x.parse::<u32>().ok()).collect();
    parts.unwrap_or_else(|| vec![0])
}

/// Where the per-version settings dirs live (`%APPDATA%/kicad` on Windows,
/// `~/.config/kicad` on Linux, `~/Library/Preferences/kicad` on macOS): the parent of
/// whatever the platform reports as the config dir of any version.
pub fn settings_root() -> Option<PathBuf> {
    kicad_platform::native().config_dir("0.0").and_then(|p| p.parent().map(Path::to_path_buf))
}

/// Read side (capabilities.py `_api_server_enabled`): `api.enable_server` from
/// `<config_dir>/kicad_common.json`. None when the file or the field is missing or
/// the file does not parse (older KiCad, or never launched).
pub fn api_server_enabled(config_dir: Option<&Path>) -> Option<bool> {
    let common = config_dir?.join("kicad_common.json");
    let text = std::fs::read_to_string(common).ok()?;
    let data: Value = serde_json::from_str(&text).ok()?;
    let api = data.get("api")?.as_object()?;
    let v = api.get("enable_server")?;
    Some(truthy(v))
}

/// Python `bool(x)` on a JSON value.
fn truthy(v: &Value) -> bool {
    match v {
        Value::Null => false,
        Value::Bool(b) => *b,
        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
        Value::String(s) => !s.is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(o) => !o.is_empty(),
    }
}

/// kicad_ipc_api {enable?: bool} against a settings root (Python `handle_ipc_api`).
/// Same keys as the Python: file, kicadSettingsVersion, enabled, changed, needsRestart,
/// and on a write backup, changedAt. `now_utc` is the ISO timestamp (injected so tests
/// are deterministic).
pub fn ipc_api(root: Option<&Path>, args: &Value, now_utc: &str) -> Value {
    let files = root.map(common_files).unwrap_or_default();
    let Some(target) = files.first() else {
        return json!({
            "success": false, "errorCode": "settings_not_found",
            "error": "No kicad_common.json found; start KiCad once so it writes its settings",
            "_hint": "KiCad creates <settings>/<version>/kicad_common.json on first launch.",
        });
    };
    let text = match std::fs::read_to_string(target) {
        Ok(t) => t,
        Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: {e}", target.display())}),
    };
    let mut settings: Value = match serde_json::from_str(&text) {
        Ok(v) => v,
        Err(e) => return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: {e}", target.display())}),
    };
    if !settings.is_object() {
        return json!({"success": false, "errorCode": "settings_unreadable", "error": format!("{}: top level is not a JSON object", target.display())});
    }
    let before = settings.get("api").and_then(Value::as_object).and_then(|a| a.get("enable_server")).map(truthy).unwrap_or(false);
    let version = target.parent().and_then(|d| d.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    let enable = match args.get("enable") {
        None | Some(Value::Null) => None,
        Some(Value::Bool(b)) => Some(*b),
        Some(_) => return json!({"success": false, "errorCode": "invalid_arg", "error": "enable must be true or false"}),
    };
    let mut result = json!({
        "success": true,
        "file": target.to_string_lossy().replace('\\', "/"),
        "kicadSettingsVersion": version,
        "enabled": before,
        "changed": false,
        "needsRestart": false,
    });
    match enable {
        Some(e) if e != before => {
            let backup = target.with_file_name(format!("{}.adom-bak", target.file_name().unwrap_or_default().to_string_lossy()));
            if let Err(e) = std::fs::copy(target, &backup) {
                return json!({"success": false, "errorCode": "backup_failed", "error": format!("cannot back up {}: {e}", target.display()),
                    "_hint": "The settings file was not touched. Check the directory is writable."});
            }
            let obj = settings.as_object_mut().unwrap();
            if !obj.get("api").map(Value::is_object).unwrap_or(false) {
                obj.insert("api".into(), Value::Object(Map::new()));
            }
            obj["api"]["enable_server"] = json!(e);
            let tmp = target.with_file_name(format!("{}.adom-tmp", target.file_name().unwrap_or_default().to_string_lossy()));
            let body = serde_json::to_string_pretty(&settings).unwrap_or_default() + "\n";
            if let Err(err) = std::fs::write(&tmp, body).and_then(|_| std::fs::rename(&tmp, target)) {
                let _ = std::fs::remove_file(&tmp);
                return json!({"success": false, "errorCode": "settings_write_failed", "error": format!("cannot write {}: {err}", target.display()),
                    "backup": backup.to_string_lossy().replace('\\', "/"),
                    "_hint": "The original is intact (atomic replace). Check the directory is writable and KiCad is not holding the file."});
            }
            result["enabled"] = json!(e);
            result["changed"] = json!(true);
            result["needsRestart"] = json!(true);
            result["backup"] = json!(backup.to_string_lossy().replace('\\', "/"));
            result["changedAt"] = json!(now_utc);
            let (running, _, why_unknown, pids) = crate::install_kicad::running_report();
            result["kicadRunning"] = running.clone();
            if let Some(why) = &why_unknown {
                result["kicadRunningUnknownBecause"] = json!(why);
            }
            if !pids.is_empty() {
                result["kicadPids"] = json!(pids);
            }
            let danger = match running.as_bool() {
                // A running KiCad rewrites kicad_common.json from memory when it exits, which
                // silently undoes this edit: say so as a fact, not as a caveat (wiki #63).
                Some(true) => format!("KiCad IS running right now (pid(s) {pids:?}), and it rewrites kicad_common.json from memory when it exits, which would undo this edit: close KiCad (kicad_close), then launch it again."),
                Some(false) => "No KiCad is running, so nothing will overwrite this edit: the next launch picks it up.".to_string(),
                None => "This build cannot tell whether KiCad is running (kicadRunning is null). A running KiCad that saves its preferences on exit overwrites this change, so close KiCad first when in doubt.".to_string(),
            };
            result["_hint"] = json!(format!("Written to kicad_common.json. KiCad reads it at start: kicad_close, then launch again, then kicad_routing_state will connect. {danger}"));
        }
        _ => {
            result["_hint"] = json!(format!(
                "IPC API server is {}. Pass enable:true|false to change it (KiCad must then be restarted).",
                if before { "enabled" } else { "disabled" }
            ));
        }
    }
    result
}

/// UTC timestamp in the Python format `%Y-%m-%dT%H:%M:%SZ`, no chrono needed.
pub fn now_utc_iso() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    let days = secs / 86_400;
    let rem = secs % 86_400;
    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    // Civil-from-days (Howard Hinnant), proleptic Gregorian.
    let z = days as i64 + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if mo <= 2 { y + 1 } else { y };
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

/// What first-run seeding did: which files it wrote and which it left alone.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Seeded {
    /// Files that existed but were still KiCad's untouched defaults, so the seed was merged in.
    pub merged: Vec<String>,
    pub written: Vec<String>,
    pub skipped: Vec<String>,
    pub errors: Vec<String>,
}

/// The three documents kicad_detect.ensure_win_user_config seeds. KiCad merges its
/// own defaults over whatever is written, so a partial document is safe.
pub fn first_run_documents() -> Vec<(&'static str, Value)> {
    vec![
        (
            "kicad_common.json",
            json!({
                "meta": {"filename": "kicad_common", "version": 3},
                "graphics": {"canvas_type": 1},
                "api": {"enable_server": true},
                "do_not_show_again": {
                    "zone_fill_warning": true,
                    "env_var_overwrite_warning": true,
                    "scaled_3d_models_warning": true,
                    "data_collection_prompt": true,
                    "update_check_prompt": true,
                    "migrate_wrl_prompt": true,
                },
            }),
        ),
        (
            "kicad.json",
            json!({
                "meta": {"filename": "kicad", "version": 0},
                "system": {"check_for_kicad_updates": false, "first_run_shown": true},
                "pcm": {"check_for_updates": false},
            }),
        ),
        (
            "eeschema.json",
            json!({
                "meta": {"filename": "eeschema", "version": 0},
                "system": {"never_show_rescue_dialog": true},
                "appearance": {
                    "show_illegal_symbol_lib_dialog": false,
                    "show_sexpr_file_convert_warning": false,
                    "show_sheet_filename_case_sensitivity_dialog": false,
                },
            }),
        ),
    ]
}

/// True when the file is KiCad's own untouched default: no first-run prompt answered.
pub fn is_pristine_common(doc: &Value) -> bool {
    let d = &doc["do_not_show_again"];
    d.is_object() && !truthy(&d["data_collection_prompt"]) && !truthy(&d["update_check_prompt"])
}

fn merge_value(into: &mut Value, from: &Value) {
    match (into, from) {
        (Value::Object(a), Value::Object(b)) => {
            for (k, v) in b {
                if k == "meta" { continue; } // KiCad's own schema version stays
                merge_value(a.entry(k.clone()).or_insert(Value::Null), v);
            }
        }
        (slot, v) => *slot = v.clone(),
    }
}

/// Merge the seed into an existing pristine kicad_common.json. Ok(false) = not pristine, untouched.
fn merge_into_pristine(path: &Path, seed: &Value) -> Result<bool, String> {
    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
    let mut doc: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
    if !is_pristine_common(&doc) {
        return Ok(false);
    }
    let backup = path.with_file_name(format!("{}.adom-bak", path.file_name().unwrap_or_default().to_string_lossy()));
    if !backup.exists() {
        std::fs::write(&backup, &text).map_err(|e| e.to_string())?;
    }
    merge_value(&mut doc, seed);
    std::fs::write(path, serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n").map_err(|e| e.to_string())?;
    Ok(true)
}

/// KiCad 10 shows its "Welcome to KiCad" setup wizard in EVERY editor start until
/// `kicad.json` has `system.first_run_shown: true`. Cancelling the wizard does not set it, and
/// a force-closed KiCad never writes its settings, so on a box the bridge drives the flag stayed
/// false for good: every open paid ~30 s for the bridge to find and cancel the wizard, and the
/// user saw it pop up (AdomLapper, 2026-09-21). Set it, keeping every other key. Call ONLY while
/// no KiCad runs, or KiCad writes its in-memory false back on exit. Ok(true) = changed.
pub fn mark_first_run_done(config_dir: &Path) -> Result<bool, String> {
    let path = config_dir.join("kicad.json");
    let Ok(text) = std::fs::read_to_string(&path) else { return Ok(false) }; // no file: seed_first_run's job
    let mut doc: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
    if doc.pointer("/system/first_run_shown").and_then(Value::as_bool) == Some(true) {
        return Ok(false);
    }
    if !doc.get("system").is_some_and(Value::is_object) {
        doc["system"] = json!({});
    }
    doc["system"]["first_run_shown"] = json!(true);
    let tmp = path.with_extension("json.adom-tmp");
    std::fs::write(&tmp, serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n").map_err(|e| e.to_string())?;
    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
    Ok(true)
}

/// Seed the first-run settings files into a per-version config dir, each written ONLY
/// if absent (never on a settings file the user already has). Creates the dir when
/// missing. Never panics; failures land in `Seeded.errors`.
pub fn seed_first_run(config_dir: &Path) -> Seeded {
    let mut out = Seeded::default();
    if let Err(e) = std::fs::create_dir_all(config_dir) {
        out.errors.push(format!("{}: {e}", config_dir.display()));
        return out;
    }
    for (name, doc) in first_run_documents() {
        let path = config_dir.join(name);
        if path.exists() {
            // kicad-cli writes kicad_common.json with defaults the first time it runs, and the
            // installer's own verify step runs kicad-cli. Skipping an existing file therefore
            // skipped EVERY fresh box and the startup wizard showed anyway (winvm, 10.0.6).
            // A file whose first-run prompts are all unanswered has never been through the
            // wizard: nobody's choices live in it, so the seed is merged in (backup kept).
            if name == "kicad_common.json" {
                match merge_into_pristine(&path, &doc) {
                    Ok(true) => { out.merged.push(name.to_string()); continue; }
                    Ok(false) => {}
                    Err(e) => out.errors.push(format!("{name}: {e}")),
                }
            }
            out.skipped.push(name.to_string());
            continue;
        }
        let body = serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n";
        match std::fs::write(&path, body) {
            Ok(()) => out.written.push(name.to_string()),
            Err(e) => out.errors.push(format!("{name}: {e}")),
        }
    }
    out
}

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

    struct Tmp(PathBuf);
    impl Drop for Tmp {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }
    fn tmp(name: &str) -> Tmp {
        let p = std::env::temp_dir().join(format!("kicad-core-common-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        std::fs::create_dir_all(&p).unwrap();
        Tmp(p)
    }
    fn read_json(p: &Path) -> Value {
        serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
    }

    #[test]
    fn the_setup_wizard_flag_is_set_and_nothing_else_changes() {
        let t = tmp("wizard");
        let f = t.0.join("kicad.json");
        std::fs::write(&f, r#"{"meta":{"version":0},"system":{"first_run_shown":false,"check_for_kicad_updates":true},"pcm":{"x":1}}"#).unwrap();
        assert_eq!(mark_first_run_done(&t.0), Ok(true));
        let d = read_json(&f);
        assert_eq!(d["system"]["first_run_shown"], json!(true));
        assert_eq!(d["system"]["check_for_kicad_updates"], json!(true));
        assert_eq!(d["pcm"]["x"], json!(1));
        assert_eq!(mark_first_run_done(&t.0), Ok(false)); // already done: untouched
        let empty = tmp("wizard-none");
        assert_eq!(mark_first_run_done(&empty.0), Ok(false)); // no file: nothing to do
    }

    #[test]
    fn a_pristine_file_kicad_cli_wrote_is_merged_and_a_users_file_is_left_alone() {
        let t = tmp("pristine");
        let f = t.0.join("kicad_common.json");
        // what kicad-cli leaves behind on a blank box
        std::fs::write(&f, r#"{"meta":{"version":6},"api":{"enable_server":false,"interpreter_path":"x"},"graphics":{"canvas_type":0},"do_not_show_again":{"data_collection_prompt":false,"update_check_prompt":false,"zone_fill_warning":false}}"#).unwrap();
        let s = seed_first_run(&t.0);
        assert_eq!(s.merged, vec!["kicad_common.json".to_string()]);
        let d = read_json(&f);
        assert_eq!(d["do_not_show_again"]["data_collection_prompt"], json!(true));
        assert_eq!(d["api"]["enable_server"], json!(true));
        assert_eq!(d["api"]["interpreter_path"], json!("x"));
        assert_eq!(d["meta"]["version"], json!(6));
        assert!(t.0.join("kicad_common.json.adom-bak").exists());
        // the user has been through the wizard: their file is theirs
        std::fs::write(&f, r#"{"api":{"enable_server":false},"do_not_show_again":{"data_collection_prompt":true,"update_check_prompt":true}}"#).unwrap();
        let s = seed_first_run(&t.0);
        assert!(s.merged.is_empty() && s.skipped.contains(&"kicad_common.json".to_string()));
        assert_eq!(read_json(&f)["api"]["enable_server"], json!(false));
    }

    #[test]
    fn seeds_only_absent_files() {
        let t = tmp("seed");
        let cfg = t.0.join("kicad").join("10.0");
        // Pre-existing user file must never be touched.
        std::fs::create_dir_all(&cfg).unwrap();
        std::fs::write(cfg.join("kicad.json"), "{\"system\": {\"check_for_kicad_updates\": true}}\n").unwrap();

        let s = seed_first_run(&cfg);
        assert_eq!(s.written, vec!["kicad_common.json".to_string(), "eeschema.json".to_string()]);
        assert_eq!(s.skipped, vec!["kicad.json".to_string()]);
        assert!(s.errors.is_empty());

        let common = read_json(&cfg.join("kicad_common.json"));
        assert_eq!(common["api"]["enable_server"], json!(true));
        assert_eq!(common["graphics"]["canvas_type"], json!(1));
        assert_eq!(common["meta"]["filename"], json!("kicad_common"));
        let nags = common["do_not_show_again"].as_object().unwrap();
        for k in ["zone_fill_warning", "env_var_overwrite_warning", "scaled_3d_models_warning", "data_collection_prompt", "update_check_prompt", "migrate_wrl_prompt"] {
            assert_eq!(nags.get(k), Some(&json!(true)), "{k}");
        }
        let ee = read_json(&cfg.join("eeschema.json"));
        assert_eq!(ee["system"]["never_show_rescue_dialog"], json!(true));
        assert_eq!(ee["appearance"]["show_illegal_symbol_lib_dialog"], json!(false));
        assert_eq!(ee["appearance"]["show_sexpr_file_convert_warning"], json!(false));
        assert_eq!(ee["appearance"]["show_sheet_filename_case_sensitivity_dialog"], json!(false));
        // The user's kicad.json is exactly what it was.
        let k = read_json(&cfg.join("kicad.json"));
        assert_eq!(k["system"]["check_for_kicad_updates"], json!(true));
        assert!(k.get("pcm").is_none());

        // Second run: everything skipped, nothing rewritten.
        let s2 = seed_first_run(&cfg);
        assert!(s2.written.is_empty());
        assert_eq!(s2.skipped.len(), 3);
        // Read side sees the seeded switch.
        assert_eq!(api_server_enabled(Some(&cfg)), Some(true));
    }

    #[test]
    fn seed_creates_missing_config_dir() {
        let t = tmp("seed-mkdir");
        let cfg = t.0.join("kicad").join("9.0");
        let s = seed_first_run(&cfg);
        assert_eq!(s.written.len(), 3);
        let k = read_json(&cfg.join("kicad.json"));
        assert_eq!(k["system"]["check_for_kicad_updates"], json!(false));
        assert_eq!(k["pcm"]["check_for_updates"], json!(false));
    }

    #[test]
    fn read_side_matches_python() {
        let t = tmp("read");
        assert_eq!(api_server_enabled(None), None);
        assert_eq!(api_server_enabled(Some(&t.0)), None);
        let f = t.0.join("kicad_common.json");
        std::fs::write(&f, "{\"api\": {}}").unwrap();
        assert_eq!(api_server_enabled(Some(&t.0)), None);
        std::fs::write(&f, "{\"api\": {\"enable_server\": false}}").unwrap();
        assert_eq!(api_server_enabled(Some(&t.0)), Some(false));
        std::fs::write(&f, "{\"api\": {\"enable_server\": 1}}").unwrap();
        assert_eq!(api_server_enabled(Some(&t.0)), Some(true));
        std::fs::write(&f, "not json").unwrap();
        assert_eq!(api_server_enabled(Some(&t.0)), None);
    }

    #[test]
    fn common_files_newest_version_first() {
        let t = tmp("versions");
        for v in ["8.0", "10.0", "9.0", "notes"] {
            let d = t.0.join(v);
            std::fs::create_dir_all(&d).unwrap();
            std::fs::write(d.join("kicad_common.json"), "{}").unwrap();
        }
        std::fs::create_dir_all(t.0.join("11.0")).unwrap(); // no file: not listed
        let names: Vec<String> = common_files(&t.0).iter().map(|p| p.parent().unwrap().file_name().unwrap().to_string_lossy().to_string()).collect();
        assert_eq!(names, vec!["10.0", "9.0", "8.0", "notes"]);
        assert!(common_files(&t.0.join("missing")).is_empty());
    }

    #[test]
    fn ipc_api_status_and_flip() {
        let t = tmp("ipc");
        let d = t.0.join("10.0");
        std::fs::create_dir_all(&d).unwrap();
        let f = d.join("kicad_common.json");
        std::fs::write(&f, "{\n  \"meta\": {\"version\": 3},\n  \"api\": {\"enable_server\": false},\n  \"graphics\": {\"canvas_type\": 1}\n}\n").unwrap();

        // Read-only status.
        let r = ipc_api(Some(&t.0), &json!({}), "2026-09-11T00:00:00Z");
        assert_eq!(r["success"], json!(true));
        assert_eq!(r["enabled"], json!(false));
        assert_eq!(r["changed"], json!(false));
        assert_eq!(r["needsRestart"], json!(false));
        assert_eq!(r["kicadSettingsVersion"], json!("10.0"));
        assert!(r.get("backup").is_none());
        assert!(r["_hint"].as_str().unwrap().contains("disabled"));

        // Same value: no write, no backup.
        let r = ipc_api(Some(&t.0), &json!({"enable": false}), "2026-09-11T00:00:00Z");
        assert_eq!(r["changed"], json!(false));
        assert!(!d.join("kicad_common.json.adom-bak").exists());

        // Bad type.
        let r = ipc_api(Some(&t.0), &json!({"enable": "yes"}), "2026-09-11T00:00:00Z");
        assert_eq!(r["errorCode"], json!("invalid_arg"));

        // Flip on: backup, atomic replace, other keys preserved, tmp gone.
        let r = ipc_api(Some(&t.0), &json!({"enable": true}), "2026-09-11T00:00:00Z");
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(r["enabled"], json!(true));
        assert_eq!(r["changed"], json!(true));
        assert_eq!(r["needsRestart"], json!(true));
        assert_eq!(r["changedAt"], json!("2026-09-11T00:00:00Z"));
        assert!(matches!(r["kicadRunning"], Value::Null | Value::Bool(false)), "{r}");
        let bak = d.join("kicad_common.json.adom-bak");
        assert!(bak.exists());
        assert_eq!(r["backup"].as_str().unwrap(), bak.to_string_lossy().replace('\\', "/"));
        assert_eq!(read_json(&bak)["api"]["enable_server"], json!(false));
        let after = read_json(&f);
        assert_eq!(after["api"]["enable_server"], json!(true));
        assert_eq!(after["graphics"]["canvas_type"], json!(1));
        assert_eq!(after["meta"]["version"], json!(3));
        assert!(!d.join("kicad_common.json.adom-tmp").exists());
        assert!(std::fs::read_to_string(&f).unwrap().ends_with('\n'));

        // Status now reports enabled; api_server_enabled agrees.
        let r = ipc_api(Some(&t.0), &json!({}), "x");
        assert_eq!(r["enabled"], json!(true));
        assert_eq!(api_server_enabled(Some(&d)), Some(true));
    }

    #[test]
    fn ipc_api_adds_api_block_when_missing() {
        let t = tmp("ipc-noapi");
        let d = t.0.join("9.0");
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("kicad_common.json"), "{\"api\": 5}").unwrap(); // not a dict: replaced, like setdefault would not; Python would crash here
        let r = ipc_api(Some(&t.0), &json!({"enable": true}), "x");
        assert_eq!(r["success"], json!(true), "{r}");
        assert_eq!(read_json(&d.join("kicad_common.json"))["api"]["enable_server"], json!(true));
    }

    #[test]
    fn ipc_api_errors() {
        let t = tmp("ipc-err");
        let r = ipc_api(Some(&t.0), &json!({}), "x");
        assert_eq!(r["errorCode"], json!("settings_not_found"));
        assert_eq!(ipc_api(None, &json!({}), "x")["errorCode"], json!("settings_not_found"));
        let d = t.0.join("10.0");
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("kicad_common.json"), "{ not json").unwrap();
        let r = ipc_api(Some(&t.0), &json!({}), "x");
        assert_eq!(r["errorCode"], json!("settings_unreadable"));
    }

    #[test]
    fn utc_timestamp_format() {
        let s = now_utc_iso();
        assert_eq!(s.len(), 20, "{s}");
        assert!(s.ends_with('Z'));
        assert_eq!(&s[4..5], "-");
        assert_eq!(&s[10..11], "T");
        assert!(s.starts_with("20"));
    }
}