app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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"));
}
}