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
Colby Knox
Restore 25 files to their state before PR #61: the wiki's pr revert left them truncated to the diff's context
4f3a0f4
8d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852
//! Installing libraries and parts, ported from the Python bridge's
//! `handlers/install_library.py`, `handlers/install_symbol.py`,
//! `handlers/install_footprint.py::handle_install_footprint`,
//! `handlers/install_library_bundle.py`, `adom_library.py::add_symbol` and
//! `heal_split`, `handlers/kicad_parse_check.py` (the verdict-on-text helpers),
//! `kicad_detect.ensure_win_user_config` (the lib-table self-heal),
//! `handlers/make_part_project.py` and `handlers/export_part.py`.
//!
//! Everything here is file work. The lib-table parser and writer, the KiCad path
//! variable expansion, the row-first Adom paths (wiki #71), the quote-aware symbol
//! extraction and KiCad's own verdict on a library come from `crate::libraries`;
//! the zip reader comes from `crate::pcm`. Only what those modules keep private
//! is copied here, each copy named after its source.
//!
//! Window-phase steps the Python did through the KiCad window (opening the Symbol
//! or Footprint Editor after an install, the stale-editor check, the bundle's
//! proof frame, the running-KiCad check, registering a generated project with the
//! focus guardian) are NOT done here and are reported as such, never faked.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde_json::{json, Map, Value};
use crate::libraries::{
self, expand_kicad_uri, extract_symbols_tolerant, extract_top_level_symbols, kicad_parser_verdict, list_symbols,
norm_path, write_atomic, LibCtx, LibEntry, LibTable, ADOM_FP_DIR_NAME, ADOM_FP_LIB_NAME, ADOM_LIB_FILE,
ADOM_LIB_NAME,
};
pub const ADOM_FP_LIB_DESCRIPTION: &str = "Adom Bridge - custom footprints";
const DEFAULT_DESCRIPTION: &str = "Installed by Adom Bridge";
const NOT_INSTALLED_HINT: &str =
"Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.";
const PARSE_TIMEOUT: Duration = Duration::from_secs(60);
pub const MAX_ZIP_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_UNPACKED_BYTES: u64 = 512 * 1024 * 1024;
// ---------------------------------------------------------------------------
// Small helpers (std only)
// ---------------------------------------------------------------------------
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
/// A fresh, unique directory under the OS temp dir. Unique across threads of one
/// process (tests run in parallel) and across processes.
pub fn unique_temp_dir(prefix: &str) -> PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}{}-{nanos}-{n}", std::process::id()))
}
/// Standard-alphabet base64, the way `base64.b64decode` reads it: whitespace is
/// ignored, `=` padding is optional, any other non-alphabet byte is an error.
pub fn b64_decode(s: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
let mut padded = false;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => {
padded = true;
continue;
}
b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return Err(format!("invalid base64 character {:?}", c as char)),
};
if padded {
return Err("base64 data after padding".into());
}
buf = (buf << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
buf &= (1u32 << bits) - 1;
}
}
Ok(out)
}
/// `shutil.copytree` after an `rmtree`: the destination is replaced wholesale.
pub fn copy_tree(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?;
for entry in std::fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))? {
let entry = entry.map_err(|e| e.to_string())?;
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_tree(&from, &to)?;
} else {
std::fs::copy(&from, &to).map_err(|e| format!("cannot copy {} to {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
fn file_name(p: &Path) -> String {
p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
}
fn file_stem(p: &Path) -> String {
p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
}
fn has_ext(p: &Path, ext: &str) -> bool {
p.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(ext)).unwrap_or(false)
}
/// Python's `repr(float)` for the values these generators produce: an integral
/// value prints with `.0`, anything else with Rust's shortest round-trip form,
/// which matches Python's for doubles.
fn py_float(x: f64) -> String {
if x.fract() == 0.0 && x.abs() < 1e16 {
format!("{x:.1}")
} else {
format!("{x}")
}
}
/// Python's round(): correctly rounded on the float's exact decimal value, ties to
/// even (108.9125 -> 108.912). Rust's `{:.N}` formatting has the same semantics, so
/// go through it rather than the half-away-from-zero `f64::round`.
fn round_to(x: f64, places: i32) -> f64 {
format!("{:.*}", places.max(0) as usize, x).parse().unwrap_or(x)
}
/// Sorted `*.kicad_mod` paths in a directory.
fn kicad_mods(dir: &Path) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_file() && has_ext(p, "kicad_mod")).collect())
.unwrap_or_default();
v.sort();
v
}
/// `(footprint "NAME"` -> NAME, the first occurrence anywhere in the text.
pub fn footprint_name_in(text: &str) -> Option<String> {
let mut pos = 0;
while let Some(i) = text[pos..].find("(footprint") {
let start = pos + i + "(footprint".len();
let rest = &text[start..];
let trimmed = rest.trim_start();
if trimmed.len() != rest.len() {
if let Some(q) = trimmed.strip_prefix('"') {
if let Some(end) = q.find('"') {
if end > 0 {
return Some(q[..end].to_string());
}
}
}
}
pos = start;
}
None
}
/// Offset of the paren that closes the list opening at `open` (quote-aware), or
/// None when it never closes.
fn matching_close(text: &str, open: usize) -> Option<usize> {
let mut depth: i32 = 0;
let mut in_str = false;
let mut esc = false;
for (i, ch) in text[open..].char_indices() {
if in_str {
if esc {
esc = false;
} else if ch == '\\' {
esc = true;
} else if ch == '"' {
in_str = false;
}
continue;
}
match ch {
'"' => in_str = true,
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(open + i);
}
}
_ => {}
}
}
None
}
/// Every `(model "PATH"` occurrence as (offset of the opening quote, PATH).
fn model_refs(text: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(i) = text[pos..].find("(model") {
let after = pos + i + "(model".len();
let rest = &text[after..];
let trimmed = rest.trim_start();
if trimmed.len() != rest.len() {
if let Some(q) = trimmed.strip_prefix('"') {
let qpos = after + (rest.len() - trimmed.len());
if let Some(end) = q.find('"') {
out.push((qpos, q[..end].to_string()));
}
}
}
pos = after;
}
out
}
/// Python `_rewrite_model_refs`: every `(model "...")` whose BASENAME matches an
/// installed model file is pointed at the installed absolute path. Other refs
/// (stock `${KICAD*_3DMODEL_DIR}` ones) are left alone. Returns the count.
pub fn rewrite_model_refs(pretty_dir: &Path, installed_by_name: &BTreeMap<String, String>) -> usize {
let mut rewritten = 0;
for m in kicad_mods(pretty_dir) {
let Ok(text) = std::fs::read_to_string(&m) else { continue };
let mut new_text = String::with_capacity(text.len());
let mut last = 0;
for (qpos, reference) in model_refs(&text) {
let base = reference.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string();
let Some(dest) = installed_by_name.get(&base) else { continue };
rewritten += 1;
new_text.push_str(&text[last..qpos]);
new_text.push('"');
new_text.push_str(dest);
new_text.push('"');
last = qpos + 1 + reference.len() + 1;
}
new_text.push_str(&text[last..]);
if new_text != text {
let _ = std::fs::write(&m, new_text);
}
}
rewritten
}
/// Python `_add_model_to_footprint`: drop any existing `(model ...)` block and
/// insert a fresh reference before the footprint's closing paren.
pub fn add_model_to_footprint(footprint_path: &Path, model_path: &str) -> Result<(), String> {
let mut text = std::fs::read_to_string(footprint_path).map_err(|e| format!("cannot read {}: {e}", footprint_path.display()))?;
// Remove existing model blocks (quote-aware, back to front so offsets hold).
let mut opens: Vec<usize> = Vec::new();
let mut pos = 0;
while let Some(i) = text[pos..].find("(model") {
let at = pos + i;
let after = &text[at + "(model".len()..];
if after.starts_with(|c: char| c.is_ascii_whitespace()) {
opens.push(at);
}
pos = at + "(model".len();
}
for open in opens.into_iter().rev() {
if let Some(close) = matching_close(&text, open) {
let mut start = open;
while start > 0 && text.as_bytes()[start - 1].is_ascii_whitespace() {
start -= 1;
}
text.replace_range(start..close + 1, "");
}
}
let block = format!(
"\n (model \"{model_path}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )"
);
if let Some(idx) = text.rfind(')') {
text = format!("{}{}\n{}", &text[..idx], block, &text[idx..]);
}
std::fs::write(footprint_path, text).map_err(|e| format!("cannot write {}: {e}", footprint_path.display()))
}
// ---------------------------------------------------------------------------
// Bootstrap of a never-launched KiCad's per-user config (lib-table self-heal)
// ---------------------------------------------------------------------------
/// Python `kicad_detect.ensure_win_user_config`, the lib-table half. For each of
/// sym-lib-table and fp-lib-table under `config_dir`: a missing table is seeded
/// from KiCad's own template (`<base>/share/kicad/template/<name>`), else written
/// empty; an EXISTING table gets every template row it lacks merged in (v0.9.84
/// self-heal: earlier bootstraps could leave a user with no stock libraries).
/// User rows are never touched. Returns `{"sym-lib-table": {seeded, merged},
/// "fp-lib-table": {...}}`; a table that cannot be read or written carries `error`.
pub fn heal_lib_tables(config_dir: &Path, base_dir: Option<&str>) -> Value {
let mut out = Map::new();
let _ = std::fs::create_dir_all(config_dir);
let template = base_dir.map(|b| Path::new(b).join("share").join("kicad").join("template"));
for (fname, root) in [("sym-lib-table", "sym_lib_table"), ("fp-lib-table", "fp_lib_table")] {
let dest = config_dir.join(fname);
let mut rep = json!({"seeded": false, "merged": 0});
if !dest.exists() {
match libraries::ensure_lib_table(&dest, root, base_dir) {
Ok(()) => rep["seeded"] = json!(true),
Err(e) => rep["error"] = json!(e),
}
out.insert(fname.into(), rep);
continue;
}
let Some(src) = template.as_ref().map(|t| t.join(fname)).filter(|p| p.is_file()) else {
out.insert(fname.into(), rep);
continue;
};
let (mut user_t, tmpl_t) = match (LibTable::parse_file(&dest), LibTable::parse_file(&src)) {
(Ok(u), Ok(t)) => (u, t),
(Err(e), _) | (_, Err(e)) => {
rep["error"] = json!(e);
out.insert(fname.into(), rep);
continue;
}
};
let mut added = 0;
for e in tmpl_t.entries {
if !user_t.has_library(&e.name) {
user_t.entries.push(e);
added += 1;
}
}
if added > 0 {
match user_t.write_file(&dest) {
Ok(()) => rep["merged"] = json!(added),
Err(e) => rep["error"] = json!(e),
}
}
out.insert(fname.into(), rep);
}
Value::Object(out)
}
/// The dirs KiCad creates on its first launch, created ahead of it so an install
/// works on a never-launched KiCad: `<config>/`, `<user>/symbols`,
/// `<user>/footprints`, then the lib tables seeded and healed. Only runs when the
/// config or user dir is missing, exactly like the Python's `if not config_dir or
/// not user_dir` bootstrap; returns the two dirs.
pub fn ensure_user_config(ctx: &LibCtx) -> Result<(PathBuf, PathBuf), String> {
let (Some(config), Some(user)) = (ctx.config_dir.clone(), ctx.user_dir.clone()) else {
return Err("KiCad config/user directory not found".into());
};
let fresh = !(config.is_dir() && user.is_dir());
// The two library folders are always ensured (idempotent, like the Python's mkdir
// exist_ok); the lib-table seed and heal run only on a fresh box.
std::fs::create_dir_all(&config).map_err(|e| format!("cannot create {}: {e}", config.display()))?;
std::fs::create_dir_all(user.join("symbols")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;
std::fs::create_dir_all(user.join("footprints")).map_err(|e| format!("cannot create {}: {e}", user.display()))?;
if fresh {
heal_lib_tables(&config, ctx.base_dir.as_deref());
}
Ok((config, user))
}
// ---------------------------------------------------------------------------
// KiCad's verdict on library TEXT (handlers/kicad_parse_check.py)
// ---------------------------------------------------------------------------
/// Python `wrap_symbol_blocks`: symbol blocks as a minimal library.
pub fn wrap_symbol_blocks(blocks: &[&str]) -> String {
format!("(kicad_symbol_lib (version 20231120) (generator \"adom-parse-check\")\n{}\n)\n", blocks.join("\n"))
}
/// Python `verdict_text`: the verdict for library text that has not been written
/// anywhere yet (a temp file that is deleted afterwards).
pub fn verdict_text(cli: Option<&Path>, lib_text: &str) -> Value {
let Some(cli) = cli.filter(|c| c.is_file()) else {
return json!({"available": false, "ok": null, "symbols": [], "units": 0,
"error": "kicad-cli not found; KiCad's parser could not be asked"});
};
let dir = unique_temp_dir("adom-candidate-");
let _ = std::fs::create_dir_all(&dir);
let tmp = dir.join("candidate.kicad_sym");
if let Err(e) = std::fs::write(&tmp, lib_text) {
let _ = std::fs::remove_dir_all(&dir);
return json!({"available": true, "ok": false, "symbols": [], "units": 0, "error": format!("cannot stage candidate: {e}")});
}
let v = kicad_parser_verdict(Some(cli), &tmp, PARSE_TIMEOUT);
let _ = std::fs::remove_dir_all(&dir);
v
}
/// Python `refused_symbols`: which of these (name, block) symbols KiCad refuses
/// on their own. `{available, accepted[], refused{name: error}}`.
pub fn refused_symbols(cli: Option<&Path>, blocks: &[(String, String)]) -> Value {
if cli.filter(|c| c.is_file()).is_none() {
return json!({"available": false, "accepted": [], "refused": {}});
}
let mut accepted = Vec::new();
let mut refused = Map::new();
for (name, blk) in blocks {
let v = verdict_text(cli, &wrap_symbol_blocks(&[blk.as_str()]));
if v["ok"].as_bool().unwrap_or(false) {
accepted.push(name.clone());
} else {
let err = v["error"].as_str().filter(|s| !s.is_empty()).unwrap_or("refused");
refused.insert(name.clone(), json!(err));
}
}
json!({"available": true, "accepted": accepted, "refused": refused})
}
// ---------------------------------------------------------------------------
// The library's direct children in order (adom_library._parse_library_nodes)
// ---------------------------------------------------------------------------
/// One direct child of `(kicad_symbol_lib ...)`: a symbol (name set) or an
/// attribute such as `(version ...)` or `(embedded_fonts no)`.
#[derive(Clone, Debug)]
struct LibNode {
name: Option<String>,
block: String,
}
impl LibNode {
fn is_symbol(&self) -> bool {
self.name.is_some()
}
}
/// Copied from `crate::libraries::quote_aware_children` (private there): the
/// spans of the root's direct child lists. Errors on any imbalance.
fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {
let mut in_str = false;
let mut esc = false;
let mut depth: i32 = 0;
let mut root_open = None;
let mut root_close = None;
let mut child_start = None;
let mut children = Vec::new();
for (i, ch) in text.char_indices() {
if in_str {
if esc {
esc = false;
} else if ch == '\\' {
esc = true;
} else if ch == '"' {
in_str = false;
}
continue;
}
match ch {
'"' => in_str = true,
'(' => {
depth += 1;
if depth == 1 {
root_open = Some(i);
} else if depth == 2 && child_start.is_none() {
child_start = Some(i);
}
}
')' => {
depth -= 1;
if depth == 1 {
if let Some(s) = child_start.take() {
children.push((s, i + 1));
}
} else if depth == 0 {
root_close = Some(i);
break;
} else if depth < 0 {
return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));
}
}
_ => {}
}
}
if in_str {
return Err("unterminated quoted string".into());
}
let open = root_open.ok_or_else(|| "no s-expression found".to_string())?;
let close = root_close.ok_or_else(|| "unbalanced s-expression: the top-level list never closes".to_string())?;
Ok((open, close, children))
}
/// Copied from `crate::libraries::symbol_block_name` (private there).
fn symbol_block_name(block: &str) -> Option<String> {
let t = block.trim_start().strip_prefix('(')?.trim_start();
let t = t.strip_prefix("symbol")?;
let t2 = t.trim_start();
if t2.len() == t.len() {
return None;
}
let t = t2.strip_prefix('"')?;
let mut out = String::new();
let mut chars = t.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
if let Some(n) = chars.next() {
out.push(n);
}
}
'"' => return Some(out),
_ => out.push(c),
}
}
None
}
/// Python `_parse_library_nodes`: the library's direct children in order.
/// A non-library root or trailing text after the closer is an error.
fn parse_library_nodes(text: &str) -> Result<Vec<LibNode>, String> {
let (open, close, children) = quote_aware_children(text)?;
let head = text[open + 1..].split_whitespace().next().unwrap_or("").trim_end_matches(')');
if head != "kicad_symbol_lib" {
return Err(format!("not a symbol library (top-level list is '{head}')"));
}
if !text[close + 1..].trim().is_empty() {
let tail: String = text[close + 1..].trim().chars().take(40).collect();
return Err(format!("trailing content after the library's closing paren: {tail:?}"));
}
Ok(children.into_iter().map(|(a, b)| LibNode { name: symbol_block_name(&text[a..b]), block: text[a..b].to_string() }).collect())
}
fn attr_head(block: &str) -> &str {
block.trim_start_matches(['(', ' ', '\t']).split_whitespace().next().unwrap_or("")
}
// ---------------------------------------------------------------------------
// add_symbol (adom_library.add_symbol)
// ---------------------------------------------------------------------------
/// Merge the symbol(s) of a whole `.kicad_sym` text into the Adom library that
/// KiCad reads (row first). Existing symbols of the same name are replaced in
/// place; new ones go after the last symbol (or after the header attrs of an
/// empty library). Two gates before any write: the rebuilt text must parse back
/// with at least as many nodes, and KiCad's own parser must accept first the
/// incoming symbols alone and then the merged library (no kicad-cli: no verdict,
/// said out loud). One-generation `Adom.kicad_sym.bak` beside the library.
///
/// Returns `{success, added[], lib_path, kicadParser}` or `{success:false, error,
/// errorCode?, kicadParser?, _hint?}`.
pub fn add_symbol(ctx: &LibCtx, symbol_text: &str) -> Value {
// Same bootstrap the Python does through kicad_detect.ensure_win_user_config: a
// never-launched KiCad has no config or user dir yet (issue #20, ADOMBASELINE 2026-07).
if let Err(e) = ensure_user_config(ctx) {
return json!({"success": false, "error": e});
}
let ensure = libraries::ensure_adom_library(ctx, true);
if let Some(err) = ensure.get("error").and_then(Value::as_str) {
return json!({"success": false, "error": err});
}
let Some((_table, lib_path)) = libraries::adom_paths(ctx) else {
return json!({"success": false, "error": "KiCad paths not found"});
};
let incoming = match extract_top_level_symbols(symbol_text) {
Ok(v) => v,
Err(e) => return json!({"success": false, "error": format!("The provided .kicad_sym is not a valid symbol library: {e}")}),
};
if incoming.is_empty() {
return json!({"success": false, "error": "No symbols found in the provided file content"});
}
let existing_text = match std::fs::read_to_string(&lib_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("cannot read {}: {e}", norm_path(&lib_path))}),
};
let nodes = match parse_library_nodes(&existing_text) {
Ok(n) => n,
Err(e) => {
return json!({"success": false,
"error": format!("REFUSING to merge: the existing library at {} is corrupt ({e}). Repair or restore it first - merging into a broken file can only spread the damage.", norm_path(&lib_path)),
"_hint": "kicad_lint_library {libraryPath} now reports structural balance; a .bak written by a previous merge sits beside the library if one exists."})
}
};
let incoming_by_name: BTreeMap<&str, &str> = incoming.iter().map(|(n, b)| (n.as_str(), b.as_str())).collect();
let added_names: Vec<String> = incoming.iter().map(|(n, _)| n.clone()).collect();
let mut out_nodes: Vec<LibNode> = Vec::with_capacity(nodes.len() + incoming.len());
let mut replaced: Vec<&str> = Vec::new();
let mut last_symbol_idx: Option<usize> = None;
for node in &nodes {
match node.name.as_deref().and_then(|n| incoming_by_name.get(n).map(|b| (n, *b))) {
Some((n, blk)) => {
out_nodes.push(LibNode { name: Some(n.to_string()), block: blk.to_string() });
replaced.push(n);
}
None => out_nodes.push(node.clone()),
}
if node.is_symbol() {
last_symbol_idx = Some(out_nodes.len() - 1);
}
}
let new_syms: Vec<LibNode> = incoming
.iter()
.filter(|(n, _)| !replaced.iter().any(|r| r == n))
.map(|(n, b)| LibNode { name: Some(n.clone()), block: b.clone() })
.collect();
let insert_at = match last_symbol_idx {
Some(i) => i + 1,
None => {
let mut at = 0;
for n in &out_nodes {
if !n.is_symbol() && matches!(attr_head(&n.block), "version" | "generator" | "generator_version") {
at += 1;
} else {
break;
}
}
at
}
};
let tail = out_nodes.split_off(insert_at);
out_nodes.extend(new_syms);
out_nodes.extend(tail);
let mut new_text = String::from("(kicad_symbol_lib\n");
for n in &out_nodes {
new_text.push('\t');
new_text.push_str(n.block.trim());
new_text.push('\n');
}
new_text.push_str(")\n");
// THE GATE (#54): never write a library that does not parse back clean.
match parse_library_nodes(&new_text) {
Err(e) => {
return json!({"success": false,
"error": format!("internal merge error: the rebuilt library failed its own balance check ({e}); the library on disk was NOT touched. This is a bridge bug - report it with the input file.")})
}
Ok(rebuilt) if rebuilt.len() < nodes.len() => {
return json!({"success": false,
"error": format!("internal merge error: the rebuilt library lost nodes ({} < {}); the library on disk was NOT touched. This is a bridge bug - report it.", rebuilt.len(), nodes.len())})
}
Ok(_) => {}
}
// THE KICAD GATE (wiki #71): the incoming symbols alone, then the merge.
let cli = ctx.kicad_cli.as_deref();
let incoming_blocks: Vec<&str> = incoming.iter().map(|(_, b)| b.as_str()).collect();
let incoming_v = verdict_text(cli, &wrap_symbol_blocks(&incoming_blocks));
if incoming_v["available"].as_bool().unwrap_or(false) && !incoming_v["ok"].as_bool().unwrap_or(false) {
return json!({"success": false, "errorCode": "kicad_refuses_symbol", "kicadParser": incoming_v,
"error": format!("KiCad's own parser refuses the uploaded symbol(s) {:?}: {}. The library on disk was NOT touched, because one refused symbol makes KiCad drop the whole library.", added_names, incoming_v["error"].as_str().unwrap_or("")),
"_hint": "Fix the .kicad_sym so `kicad-cli sym export svg` accepts it (common: bare `hide yes` inside (effects ...) instead of `(hide yes)`, or a pre-20231120 shape). kicad_lint_library reports KiCad's verdict as kicadParser."});
}
let merged_v = verdict_text(cli, &new_text);
if merged_v["available"].as_bool().unwrap_or(false) && !merged_v["ok"].as_bool().unwrap_or(false) {
return json!({"success": false, "errorCode": "kicad_refuses_merged_library", "kicadParser": merged_v,
"error": format!("KiCad's parser accepts the upload alone but refuses the merged library: {}. Nothing was written. The existing library is probably already refused by KiCad; run kicad_adom_library_status (kicadParser) and kicad_adom_library_heal mode:prune to drop the culprit.", merged_v["error"].as_str().unwrap_or(""))});
}
// One-generation backup beside the library, then the replace.
let _ = std::fs::write(lib_path.with_extension("kicad_sym.bak"), &existing_text);
if let Err(e) = write_atomic(&lib_path, new_text.as_bytes()) {
return json!({"success": false, "error": e});
}
let parser = if merged_v["available"].as_bool().unwrap_or(false) {
json!({"available": true, "ok": true, "symbols": merged_v["symbols"], "units": merged_v["units"]})
} else {
json!({"available": false, "ok": null, "note": "kicad-cli not found; KiCad's verdict was not obtained"})
};
json!({"success": true, "added": added_names, "lib_path": norm_path(&lib_path), "kicadParser": parser})
}
// ---------------------------------------------------------------------------
// kicad_install_library (handlers/install_library.py)
// ---------------------------------------------------------------------------
fn s_arg(args: &Value, key: &str) -> String {
args.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}
/// Python `_single_part_hint`: a one-part (or file-named) `.kicad_sym` handed to
/// install_library usually meant install_symbol (wiki #72). Say so, do not refuse.
pub fn single_part_hint(source: &Path, lib_name: &str) -> Option<Value> {
if source.is_dir() || !has_ext(source, "kicad_sym") {
return None;
}
let names = list_symbols(source);
if names.is_empty() || names.len() > 3 {
return None;
}
if names.len() == 1 || names.iter().any(|n| n == lib_name) {
let shown = names.join(", ");
return Some(json!({
"singlePartFile": true,
"symbolNames": names,
"_hint": format!(
"NOTE: this file holds {} symbol(s) ({shown}), so KiCad now has a whole library named '{lib_name}' containing only that part, and the shared Adom library was NOT touched. If you meant 'put this part into the Adom library', the verb is kicad_install_symbol '{{\"filePath\":\"{}\"}}' (then kicad_show_library Adom). Both are valid; only one matches what a shared-library caller usually means.",
names.len(), norm_path(source)),
}));
}
None
}
fn merge_into(target: &mut Value, extra: &Value) {
if let (Some(dst), Some(src)) = (target.as_object_mut(), extra.as_object()) {
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
}
}
/// `kicad_install_library {libraryPath, libraryType?: symbol|footprint, libraryName?,
/// description?, model3dPath?, modelsPath?}`.
pub fn install_library(ctx: &LibCtx, installed: bool, args: &Value) -> Value {
if !installed {
return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});
}
let lib_path = s_arg(args, "libraryPath");
let lib_type = args.get("libraryType").and_then(Value::as_str).unwrap_or("symbol").to_string();
if lib_path.is_empty() {
return json!({"success": false, "error": "No libraryPath specified"});
}
if lib_type == "footprint" {
return install_footprint_library(ctx, args);
}
let mut lib_name = s_arg(args, "libraryName");
let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();
let source = PathBuf::from(&lib_path);
if !source.exists() {
return json!({"success": false, "error": format!("Library source not found: {lib_path}"),
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});
}
if lib_name.is_empty() {
lib_name = file_stem(&source);
}
let wrong_tool = single_part_hint(&source, &lib_name);
// wiki #71: ask KiCad's own parser before registering a .kicad_sym.
if source.is_file() && has_ext(&source, "kicad_sym") {
let v = kicad_parser_verdict(ctx.kicad_cli.as_deref(), &source, PARSE_TIMEOUT);
if v["available"].as_bool().unwrap_or(false) && !v["ok"].as_bool().unwrap_or(false) {
return json!({"success": false, "errorCode": "kicad_refuses_library", "kicadParser": v.clone(),
"error": format!("KiCad's own parser refuses {}: {}. Not copied, not registered: KiCad would list this library empty.", file_name(&source), v["error"].as_str().unwrap_or("")),
"_hint": "Fix the file until `kicad-cli sym export svg` accepts it; kicad_lint_library shows the same verdict as kicadParser."});
}
}
if lib_type != "symbol" {
return json!({"success": false, "error": format!("Unknown library type: {lib_type}. Use 'symbol' or 'footprint'")});
}
let (config_dir, user_dir) = match ensure_user_config(ctx) {
Ok(d) => d,
Err(_) => {
return json!({"success": false, "error": "KiCad config directory not found and could not be bootstrapped",
"_hint": "Run kicad_list_versions to confirm KiCad is installed."})
}
};
let dest_dir = user_dir.join("symbols");
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&dest_dir))});
}
let dest = dest_dir.join(file_name(&source));
let copied = if source.is_dir() {
if dest.exists() {
let _ = std::fs::remove_dir_all(&dest);
}
copy_tree(&source, &dest)
} else {
std::fs::copy(&source, &dest).map(|_| ()).map_err(|e| format!("cannot copy to {}: {e}", norm_path(&dest)))
};
if let Err(e) = copied {
return json!({"success": false, "error": e});
}
let table_path = config_dir.join("sym-lib-table");
let mut table = match LibTable::parse_file(&table_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),
};
let uri = norm_path(&dest);
if table.has_library(&lib_name) {
// Idempotent: an already-registered library is SUCCESS (the file was refreshed above).
let mut out = json!({"success": true, "alreadyRegistered": true,
"output": format!("Library '{lib_name}' already registered in sym-lib-table (file refreshed at {uri})."),
"_hint": format!("Library was already present: nothing to change. This is a successful no-op.{}",
wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default())});
if let Some(w) = &wrong_tool {
out["singlePartFile"] = w["singlePartFile"].clone();
out["symbolNames"] = w["symbolNames"].clone();
}
return out;
}
table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));
if let Err(e) = table.write_file(&table_path) {
return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});
}
let mut out = json!({
"success": true,
"output": format!("Library '{lib_name}' installed to {uri} and registered in sym-lib-table"),
"data": {"name": lib_name, "path": uri, "table": "sym-lib-table"},
"_hint": format!("Library '{lib_name}' is now registered. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path>\"}}'` BEFORE install to catch malformed files, outdated formats, or wrong-filetype-renamed-with-.kicad_sym surprises in ~100ms, much faster than discovering a broken library inside the GUI later. Any KiCad windows already open need to be closed + reopened to pick up the new library entry (sym-lib-table is read at startup).{}",
wrong_tool.as_ref().and_then(|w| w["_hint"].as_str()).map(|h| format!(" {h}")).unwrap_or_default()),
});
if let Some(w) = &wrong_tool {
out["singlePartFile"] = w["singlePartFile"].clone();
out["symbolNames"] = w["symbolNames"].clone();
}
out
}
/// Python `_install_footprint`: a `.kicad_mod` (wrapped in `<name>.pretty`) or a
/// whole `.pretty` dir into `<user>/footprints`, optional 3D models (one file via
/// `model3dPath`, or a directory via `modelsPath` / the bundle's `3dmodels/`
/// sibling, with the COPIED footprints' matching refs rewritten), registered in
/// fp-lib-table.
pub fn install_footprint_library(ctx: &LibCtx, args: &Value) -> Value {
let lib_path = s_arg(args, "libraryPath");
let mut lib_name = s_arg(args, "libraryName");
let description = args.get("description").and_then(Value::as_str).unwrap_or(DEFAULT_DESCRIPTION).to_string();
let model_3d_path = s_arg(args, "model3dPath");
let source = PathBuf::from(&lib_path);
if !source.exists() {
return json!({"success": false, "error": format!("Footprint source not found: {lib_path}"),
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path."});
}
let (config_dir, user_dir) = match ensure_user_config(ctx) {
Ok(d) => d,
Err(_) => {
return json!({"success": false, "error": "KiCad config/user directory not found and could not be bootstrapped",
"_hint": "Is KiCad installed? Run kicad_list_versions. If it's freshly installed and never launched, this bootstrap should have created %APPDATA%/kicad/<ver>/."})
}
};
let footprints_dir = user_dir.join("footprints");
if let Err(e) = std::fs::create_dir_all(&footprints_dir) {
return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&footprints_dir))});
}
let pretty_dir: PathBuf;
let mut dest_file: Option<PathBuf> = None;
if source.is_dir() && has_ext(&source, "pretty") {
if lib_name.is_empty() {
lib_name = file_stem(&source);
}
pretty_dir = footprints_dir.join(file_name(&source));
if pretty_dir.exists() {
let _ = std::fs::remove_dir_all(&pretty_dir);
}
if let Err(e) = copy_tree(&source, &pretty_dir) {
return json!({"success": false, "error": e});
}
} else if source.is_file() && has_ext(&source, "kicad_mod") {
if lib_name.is_empty() {
lib_name = file_stem(&source);
}
pretty_dir = footprints_dir.join(format!("{lib_name}.pretty"));
if let Err(e) = std::fs::create_dir_all(&pretty_dir) {
return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});
}
// KiCad requires the file name to match the footprint name inside it.
let fp_text = std::fs::read_to_string(&source).unwrap_or_default();
let correct_name = footprint_name_in(&fp_text).map(|n| format!("{n}.kicad_mod")).unwrap_or_else(|| file_name(&source));
let dest = pretty_dir.join(&correct_name);
if let Err(e) = std::fs::copy(&source, &dest) {
return json!({"success": false, "error": format!("cannot copy to {}: {e}", norm_path(&dest))});
}
dest_file = Some(dest);
} else {
return json!({"success": false,
"error": format!("Invalid footprint source: expected .kicad_mod file or .pretty directory, got {}", file_name(&source))});
}
// Single 3D model (model3dPath).
let mut model_installed_path = String::new();
if !model_3d_path.is_empty() {
let model_source = PathBuf::from(&model_3d_path);
if model_source.exists() {
let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));
let _ = std::fs::create_dir_all(&models_dir);
let model_dest = models_dir.join(file_name(&model_source));
if std::fs::copy(&model_source, &model_dest).is_ok() {
model_installed_path = norm_path(&model_dest);
if let Some(dest) = &dest_file {
let _ = add_model_to_footprint(dest, &model_installed_path);
}
}
}
}
// Bundle models: modelsPath, or the 3dmodels/ sibling of a source .pretty.
let mut models_installed = 0usize;
let mut refs_rewritten = 0usize;
let mut models_src = s_arg(args, "modelsPath");
if models_src.is_empty() && source.is_dir() && has_ext(&source, "pretty") {
if let Some(sibling) = source.parent().map(|p| p.join("3dmodels")).filter(|p| p.is_dir()) {
models_src = norm_path(&sibling);
}
}
if !models_src.is_empty() {
let models_src_dir = PathBuf::from(&models_src);
if models_src_dir.is_dir() {
let models_dir = user_dir.join("3dmodels").join(format!("{lib_name}.3dshapes"));
let _ = std::fs::create_dir_all(&models_dir);
let mut files: Vec<PathBuf> =
std::fs::read_dir(&models_src_dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();
files.sort();
let mut installed_by_name = BTreeMap::new();
for mf in files {
if !mf.is_file() {
continue;
}
let dest_model = models_dir.join(file_name(&mf));
if std::fs::copy(&mf, &dest_model).is_ok() {
installed_by_name.insert(file_name(&mf), norm_path(&dest_model));
models_installed += 1;
}
}
if !installed_by_name.is_empty() {
refs_rewritten = rewrite_model_refs(&pretty_dir, &installed_by_name);
}
}
}
// Register in fp-lib-table.
let table_path = config_dir.join("fp-lib-table");
let mut table = match LibTable::parse_file(&table_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),
};
let uri = norm_path(&pretty_dir);
let registered = if table.has_library(&lib_name) {
false
} else {
table.add_library(LibEntry::new(&lib_name, "KiCad", &uri, "", &description));
if let Err(e) = table.write_file(&table_path) {
return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});
}
true
};
let mut output = format!(
"Footprint library '{lib_name}' installed to {uri}{}",
if registered { " and registered in fp-lib-table" } else { " (already registered)" }
);
let mut data = json!({"name": lib_name, "path": uri, "table": "fp-lib-table"});
if !model_installed_path.is_empty() {
data["model3dPath"] = json!(model_installed_path);
output.push_str(&format!(". 3D model installed to {model_installed_path}"));
}
if models_installed > 0 {
data["modelsInstalled"] = json!(models_installed);
data["modelRefsRewritten"] = json!(refs_rewritten);
output.push_str(&format!(
". {models_installed} 3D model(s) installed to 3dmodels/{lib_name}.3dshapes; {refs_rewritten} footprint model ref(s) rewritten to the installed paths"
));
}
json!({
"success": true,
"output": output,
"data": data,
"_hint": format!("Footprint library '{lib_name}' is now in fp-lib-table. For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path-to-.pretty-dir>\"}}'` BEFORE install to validate each .kicad_mod inside in ~100ms (catches malformed files, outdated formats). Any open KiCad windows need to be closed + reopened to pick up the new fp-lib-table entry."),
})
}
// ---------------------------------------------------------------------------
// kicad_install_symbol (handlers/install_symbol.py)
// ---------------------------------------------------------------------------
/// The text of an upload: `filePath` on this machine, else base64 `fileContent`.
fn read_upload(args: &Value, what: &str) -> Result<(String, String), Value> {
let file_name_arg = s_arg(args, "fileName");
let file_content = s_arg(args, "fileContent");
let file_path = s_arg(args, "filePath").trim().to_string();
if file_path.is_empty() && file_name_arg.is_empty() {
return Err(json!({"success": false, "error": "No fileName specified"}));
}
if file_path.is_empty() && file_content.is_empty() {
return Err(json!({"success": false, "error": "No fileContent specified (or pass filePath)"}));
}
if !file_path.is_empty() {
let p = PathBuf::from(&file_path);
if !p.exists() {
return Err(json!({"success": false, "error": format!("filePath not found: {file_path}"),
"_hint": "Absolute path on THIS machine: push it first (ab send_files) if it lives elsewhere."}));
}
let text = std::fs::read_to_string(&p).map_err(|e| json!({"success": false, "error": format!("Failed to read filePath: {e}")}))?;
let name = if file_name_arg.is_empty() { file_name(&p) } else { file_name_arg };
return Ok((text, name));
}
let bytes = b64_decode(&file_content).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {e}")}))?;
let text = String::from_utf8(bytes).map_err(|e| json!({"success": false, "error": format!("Failed to decode fileContent: {what} is not UTF-8: {e}")}))?;
Ok((text, file_name_arg))
}
/// `kicad_install_symbol {fileName, fileContent | filePath, symbolName?, openEditor?,
/// quietInstall?, libraryName?}`: merge into the Adom library KiCad reads.
/// The editor step is window-phase and not attempted; `editorOpened` is false.
pub fn install_symbol(ctx: &LibCtx, installed: bool, args: &Value) -> Value {
if !installed {
return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});
}
let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);
let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);
let (symbol_text, _name) = match read_upload(args, "symbol") {
Ok(t) => t,
Err(e) => return e,
};
let result = add_symbol(ctx, &symbol_text);
if !result["success"].as_bool().unwrap_or(false) {
let mut out = json!({"success": false, "error": result["error"].as_str().unwrap_or("Unknown error")});
for k in ["errorCode", "kicadParser", "_hint"] {
if let Some(v) = result.get(k).filter(|v| !v.is_null()) {
out[k] = v.clone();
}
}
return out;
}
let added: Vec<String> = result["added"].as_array().map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();
let lib_path = result["lib_path"].as_str().unwrap_or("").to_string();
let mut output_parts = vec![format!("Symbol(s) {} added to '{ADOM_LIB_NAME}' library", added.join(", "))];
if open_editor {
output_parts.push("Symbol Editor: not opened by this build (window control arrives in phase 3; call kicad_show_symbol)".into());
}
let requested_lib = s_arg(args, "libraryName").trim().to_string();
let ignored_lib = !requested_lib.is_empty() && requested_lib != ADOM_LIB_NAME;
if ignored_lib {
output_parts.push(format!(
"NOTE: libraryName '{requested_lib}' was ignored - every Adom symbol installs into the single global '{ADOM_LIB_NAME}' library"
));
}
let mut out = json!({
"success": true,
"output": output_parts.join(" | "),
"kicadParser": result["kicadParser"],
"installedPath": lib_path,
"symbolNames": added,
"libraryName": ADOM_LIB_NAME,
"editorOpened": false,
});
if ignored_lib {
out["ignoredArgs"] = json!({"libraryName": requested_lib});
out["_hint"] = json!(format!(
"libraryName is not honoured by this verb: all Adom symbols live in '{ADOM_LIB_NAME}'. Drop the argument, or use kicad_install_library if you genuinely need a separate library file."
));
}
out
}
// ---------------------------------------------------------------------------
// kicad_install_footprint (handlers/install_footprint.py)
// ---------------------------------------------------------------------------
/// Replace the first `(model "...")` path, or append a fresh model block before
/// the footprint's closing paren (Python's inline logic, byte for byte).
fn wire_model_into_footprint(footprint_text: &str, model_uri: &str) -> String {
if footprint_text.contains("(model ") {
if let Some((qpos, old)) = model_refs(footprint_text).into_iter().next() {
let mut t = String::with_capacity(footprint_text.len() + model_uri.len());
// Rewrite the head `(model "old"` as `(model "new"`.
let head_start = footprint_text[..qpos].rfind("(model").unwrap_or(qpos);
t.push_str(&footprint_text[..head_start]);
t.push_str(&format!("(model \"{model_uri}\""));
t.push_str(&footprint_text[qpos + 1 + old.len() + 1..]);
return t;
}
}
let body = footprint_text.trim_end();
let close = body.rfind(')').unwrap_or(body.len());
format!(
"{} (model \"{model_uri}\"\n (offset (xyz 0 0 0))\n (scale (xyz 1 1 1))\n (rotate (xyz 0 0 0))\n )\n)\n",
&body[..close]
)
}
/// The Adom.pretty KiCad reads: ROW FIRST (wiki #71). If fp-lib-table already
/// registers an Adom row with a literal uri, that directory; else
/// `<user>/footprints/Adom.pretty`. A uri with `${...}` is not expanded here
/// (the Python did not either).
pub fn adom_pretty_dir(config_dir: &Path, user_dir: &Path) -> PathBuf {
let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);
if let Ok(t) = LibTable::parse_file(&config_dir.join("fp-lib-table")) {
if let Some(row) = t.get(ADOM_FP_LIB_NAME) {
if !row.uri.is_empty() && !row.uri.contains("${") {
return PathBuf::from(row.uri.replace('\\', "/"));
}
}
}
default
}
/// `kicad_install_footprint {fileName, fileContent | filePath, footprintName?,
/// openEditor?, quietInstall?, model?: {fileName, fileContent} | {filePath}}`.
/// The editor step and the stale-editor check are window-phase and not attempted.
pub fn install_footprint(ctx: &LibCtx, installed: bool, args: &Value) -> Value {
if !installed {
return json!({"success": false, "error": "KiCad not installed", "_hint": NOT_INSTALLED_HINT});
}
let quiet = args.get("quietInstall").and_then(Value::as_bool).unwrap_or(false);
let open_editor = !quiet && args.get("openEditor").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(true);
let (mut footprint_text, file_name_arg) = match read_upload(args, "footprint") {
Ok(t) => t,
Err(e) => return e,
};
let mut footprint_name = s_arg(args, "footprintName");
if footprint_name.is_empty() {
footprint_name = footprint_name_in(&footprint_text).unwrap_or_else(|| file_stem(Path::new(&file_name_arg)));
}
let (config_dir, user_dir) = match ensure_user_config(ctx) {
Ok(d) => d,
Err(_) => return json!({"success": false, "error": "KiCad user/config directory not found (bootstrap failed)"}),
};
let fp_dir = user_dir.join("footprints");
let _ = std::fs::create_dir_all(&fp_dir);
let pretty_dir = adom_pretty_dir(&config_dir, &user_dir);
if let Err(e) = std::fs::create_dir_all(&pretty_dir) {
return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&pretty_dir))});
}
// Optional 3D model: install + wire into the footprint.
let model = args.get("model").cloned().unwrap_or(Value::Null);
let mut model_installed = String::new();
let model_src_path = s_arg(&model, "filePath").trim().to_string();
let has_inline = !s_arg(&model, "fileName").is_empty() && !s_arg(&model, "fileContent").is_empty();
if !model_src_path.is_empty() || has_inline {
let (model_bytes, model_file_name) = if !model_src_path.is_empty() {
let mp = PathBuf::from(&model_src_path);
if !mp.exists() {
return json!({"success": false, "error": format!("model.filePath not found: {model_src_path}"),
"_hint": "Absolute path on THIS machine: push it first (ab send_files)."});
}
match std::fs::read(&mp) {
Ok(b) => (b, file_name(&mp)),
Err(e) => return json!({"success": false, "error": format!("Failed to read model.filePath: {e}")}),
}
} else {
match b64_decode(&s_arg(&model, "fileContent")) {
Ok(b) => (b, s_arg(&model, "fileName")),
Err(e) => return json!({"success": false, "error": format!("Failed to decode model.fileContent: {e}")}),
}
};
let models_dir = user_dir.join("3dmodels").join("Adom");
let _ = std::fs::create_dir_all(&models_dir);
let model_path = models_dir.join(file_name(Path::new(&model_file_name)));
if let Err(e) = std::fs::write(&model_path, &model_bytes) {
return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&model_path))});
}
model_installed = norm_path(&model_path);
footprint_text = wire_model_into_footprint(&footprint_text, &model_installed);
}
// The file must be named after the footprint inside it.
let dest_file = pretty_dir.join(format!("{footprint_name}.kicad_mod"));
let existed_before = dest_file.exists();
// Identical content: leave the file alone (an mtime bump past KiCad's cache
// load makes the opener report a stale cache on every tour after the first).
let unchanged = existed_before && std::fs::read_to_string(&dest_file).map(|t| t == footprint_text).unwrap_or(false);
if !unchanged {
if let Err(e) = std::fs::write(&dest_file, &footprint_text) {
return json!({"success": false, "error": format!("cannot write {}: {e}", norm_path(&dest_file))});
}
}
// Register Adom in fp-lib-table.
let table_path = config_dir.join("fp-lib-table");
let mut table = match LibTable::parse_file(&table_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("Failed to parse fp-lib-table: {e}")}),
};
let mut registered = false;
if !table.has_library(ADOM_FP_LIB_NAME) {
table.add_library(LibEntry::new(ADOM_FP_LIB_NAME, "KiCad", &norm_path(&pretty_dir), "", ADOM_FP_LIB_DESCRIPTION));
if let Err(e) = table.write_file(&table_path) {
return json!({"success": false, "error": format!("cannot write fp-lib-table: {e}")});
}
registered = true;
}
let mut output_parts = vec![format!("Footprint '{footprint_name}' installed to '{ADOM_FP_LIB_NAME}' library")];
if registered {
output_parts.push("registered in fp-lib-table".into());
}
if !model_installed.is_empty() {
output_parts.push(format!("3D model installed + referenced: {model_installed}"));
}
if open_editor {
output_parts.push("Footprint Editor: not opened by this build (window control arrives in phase 3; call kicad_show_footprint)".into());
}
json!({
"success": true,
"output": output_parts.join(" | "),
"installedPath": norm_path(&dest_file),
"existedBefore": existed_before,
"unchanged": unchanged,
"footprintName": footprint_name,
"libraryName": ADOM_FP_LIB_NAME,
"editorOpened": false,
"model3d": if model_installed.is_empty() { Value::Null } else { json!(model_installed) },
})
}
// ---------------------------------------------------------------------------
// kicad_install_library_bundle (handlers/install_library_bundle.py)
// ---------------------------------------------------------------------------
fn name_ok(name: &str) -> bool {
// ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$
let mut chars = name.chars();
let Some(first) = chars.next() else { return false };
if !first.is_ascii_alphanumeric() {
return false;
}
let rest: Vec<char> = chars.collect();
rest.len() <= 63 && rest.iter().all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-'))
}
/// Python `_safe_extract`: unpack with a zip-slip guard and an unpacked-size cap.
/// Returns the number of files extracted.
pub fn safe_extract(zip_bytes: &[u8], dest: &Path) -> Result<usize, String> {
let zf = crate::pcm::ZipFile::parse(zip_bytes)?;
let mut total: u64 = 0;
let mut count = 0usize;
for e in &zf.entries {
let name = e.name.as_str();
if name.is_empty() || name.ends_with('/') {
continue;
}
let norm = name.replace('\\', "/");
let drive = norm.len() >= 2 && norm.as_bytes()[0].is_ascii_alphabetic() && norm.as_bytes()[1] == b':';
if norm.starts_with('/') || drive || norm.split('/').any(|seg| seg == "..") {
return Err(format!("unsafe zip entry: {name}"));
}
total += e.size;
if total > MAX_UNPACKED_BYTES {
return Err("bundle unpacks past the size cap".into());
}
let target = dest.join(&norm);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let data = zf.read(e)?;
std::fs::write(&target, data).map_err(|e| format!("cannot write {}: {e}", target.display()))?;
count += 1;
}
Ok(count)
}
fn walk_files(root: &Path, out: &mut Vec<PathBuf>) {
if let Ok(rd) = std::fs::read_dir(root) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk_files(&p, out);
} else {
out.push(p);
}
}
}
}
/// Python `_find_bundle_parts`: the shallowest `*.kicad_sym`, its sibling
/// `*.pretty` dir (first, sorted) and a sibling `3dmodels/` dir.
pub fn find_bundle_parts(root: &Path) -> Option<(PathBuf, Option<PathBuf>, Option<PathBuf>)> {
let mut all = Vec::new();
walk_files(root, &mut all);
let mut syms: Vec<PathBuf> = all.into_iter().filter(|p| has_ext(p, "kicad_sym")).collect();
syms.sort_by_key(|p| (p.components().count(), norm_path(p)));
let sym = syms.into_iter().next()?;
let base = sym.parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());
let mut dirs: Vec<PathBuf> =
std::fs::read_dir(&base).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect()).unwrap_or_default();
dirs.sort();
let pretty = dirs.into_iter().find(|d| has_ext(d, "pretty"));
let models = base.join("3dmodels");
Some((sym, pretty, if models.is_dir() { Some(models) } else { None }))
}
/// Python `_top_symbol_names`: every `(symbol "NAME"` anywhere in the file, minus
/// `_N_N` sub-units (a whole-text scan, unlike the line-anchored list_symbols).
pub fn top_symbol_names(sym_path: &Path) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(sym_path) else { return Vec::new() };
let mut names = Vec::new();
let mut pos = 0;
while let Some(i) = text[pos..].find("(symbol") {
let after = pos + i + "(symbol".len();
let rest = &text[after..];
let trimmed = rest.trim_start();
if trimmed.len() != rest.len() {
if let Some(q) = trimmed.strip_prefix('"') {
if let Some(end) = q.find('"') {
let name = &q[..end];
if !libraries::is_subunit_name(name) {
names.push(name.to_string());
}
}
}
}
pos = after;
}
names
}
/// `kicad_install_library_bundle {zipPath, libraryName?, capture?}`.
/// The proof frame (`capture:true`) and the running-KiCad check are window-phase:
/// `kicadRunning` is null, `restartNeeded` true, `proofSkipped` says why.
pub fn install_library_bundle(ctx: &LibCtx, installed: bool, args: &Value) -> Value {
if !installed {
return json!({"success": false, "error": "KiCad not installed", "_hint": "Install KiCad first (kicad_upgrade can do it), then retry."});
}
let zip_path = s_arg(args, "zipPath");
if zip_path.is_empty() {
return json!({"success": false, "error": "No zipPath specified",
"_hint": "Stage the bundle zip with write_file / write_file_chunk first, then pass its path."});
}
let source = PathBuf::from(&zip_path);
if !source.is_file() {
return json!({"success": false, "error": format!("Bundle zip not found: {zip_path}"),
"_hint": "The path must exist on THIS machine. Stage it with write_file / write_file_chunk first."});
}
if std::fs::metadata(&source).map(|m| m.len()).unwrap_or(0) > MAX_ZIP_BYTES {
return json!({"success": false, "error": "Bundle zip is larger than the 256MB cap"});
}
let lib_name = {
let n = s_arg(args, "libraryName");
if n.is_empty() { file_stem(&source) } else { n }.trim().to_string()
};
if !name_ok(&lib_name) {
return json!({"success": false, "error": format!("Library name not usable as a KiCad library id: {lib_name:?}"),
"_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."});
}
let extract_dir = unique_temp_dir("adom-lib-bundle-");
let _ = std::fs::create_dir_all(&extract_dir);
let result = bundle_inner(ctx, args, &source, &lib_name, &extract_dir);
let _ = std::fs::remove_dir_all(&extract_dir);
result
}
fn bundle_inner(ctx: &LibCtx, args: &Value, source: &Path, lib_name: &str, extract_dir: &Path) -> Value {
let bytes = match std::fs::read(source) {
Ok(b) => b,
Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),
};
let extracted = match safe_extract(&bytes, extract_dir) {
Ok(n) => n,
Err(e) => return json!({"success": false, "error": format!("Could not unpack the bundle: {e}")}),
};
if extracted == 0 {
return json!({"success": false, "error": "The bundle zip is empty"});
}
let Some((sym, pretty, models)) = find_bundle_parts(extract_dir) else {
return json!({"success": false, "error": "No .kicad_sym found in the bundle",
"_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."});
};
let capture = args.get("capture").map(|v| !matches!(v, Value::Bool(false) | Value::Null)).unwrap_or(false);
if args.get("planOnly") == Some(&json!(true)) {
// wiki #106: the listing only, nothing installed, no lib-table writes
let mut plan = bundle_plan(&sym, pretty.as_deref(), models.as_deref(), capture);
plan["libraryName"] = json!(lib_name);
return json!({"success": true, "planOnly": true, "plan": plan, "mutated": false,
"_hint": "Nothing installed. Lay out the progress from plan.steps (the real run answers steps[] with the same ids) and the evidence slots from plan.expectedEvidence; then call again without planOnly."});
}
let started = std::time::Instant::now();
let mut done: Vec<Value> = vec![json!({"id": "unpack", "done": true, "elapsedMs": started.elapsed().as_millis() as u64})];
let top_names = top_symbol_names(&sym);
let mut report = json!({"libraryName": lib_name, "symbols": top_names.len(),
"footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0});
let sym_res = install_library(ctx, true, &json!({
"libraryPath": norm_path(&sym), "libraryType": "symbol", "libraryName": lib_name,
"description": "Adom wiki Cloud Library",
}));
if !sym_res["success"].as_bool().unwrap_or(false) {
return json!({"success": false, "error": format!("Symbol install failed: {}", sym_res["error"].as_str().unwrap_or("")),
"step": "symbols", "detail": sym_res, "steps": done});
}
done.push(json!({"id": "symbols", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));
if let Some(pretty) = &pretty {
let fp_res = install_footprint_library(ctx, &json!({
"libraryPath": norm_path(pretty), "libraryName": lib_name,
"modelsPath": models.as_ref().map(|m| norm_path(m)).unwrap_or_default(),
"description": "Adom wiki Cloud Library",
}));
if !fp_res["success"].as_bool().unwrap_or(false) {
return json!({"success": false, "error": format!("Footprint install failed: {}", fp_res["error"].as_str().unwrap_or("")),
"step": "footprints", "detail": fp_res, "symbolsInstalled": true});
}
report["footprints"] = json!(kicad_mods(pretty).len());
report["modelsInstalled"] = fp_res["data"].get("modelsInstalled").cloned().unwrap_or(json!(0));
report["modelRefsRewritten"] = fp_res["data"].get("modelRefsRewritten").cloned().unwrap_or(json!(0));
done.push(json!({"id": "footprints", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));
if models.is_some() {
done.push(json!({"id": "models", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));
}
}
done.push(json!({"id": "register", "done": true, "elapsedMs": started.elapsed().as_millis() as u64}));
report["steps"] = json!(done);
// Whether the user has to restart KiCad is a fact about their machine, not a guess:
// the process scan answers it where the OS layer has one (wiki #63).
let (running, restart, why_unknown, pids) = crate::install_kicad::running_report();
report["kicadRunning"] = running.clone();
report["restartNeeded"] = json!(restart);
if let Some(why) = &why_unknown {
report["kicadRunningUnknownBecause"] = json!(why);
}
if !pids.is_empty() {
report["kicadPids"] = json!(pids);
}
let mut parts = vec![format!("Library '{lib_name}' installed: {} symbols", report["symbols"])];
if report["footprints"].as_u64().unwrap_or(0) > 0 {
parts.push(format!("{} footprints", report["footprints"]));
}
if report["modelsInstalled"].as_u64().unwrap_or(0) > 0 {
parts.push(format!("{} 3D models", report["modelsInstalled"]));
}
let restart_line = crate::install_kicad::restart_sentence(&running, &pids, "the library");
let summary = parts.join(", ") + ". " + &restart_line;
let mut result = json!({
"success": true,
"output": summary,
"data": report,
"_hint": format!("The bundle is installed and registered in both lib tables. {restart_line}"),
});
if capture {
result["proofSkipped"] = json!("capture is window-phase: this build opens no Symbol Editor and takes no frame; call kicad_show_library once the window verbs land");
}
result
}
/// The plan for a bundle install (wiki #106): the steps the real run reports with the same ids, a
/// rough time for each, and per part the evidence the install produces: its symbol always, its
/// footprint when the bundle's .pretty has the symbol's footprint, 3d when that footprint's model is
/// in the bundle. One library frame at the end (the Symbol Editor), only with capture: never one
/// image per part.
pub fn bundle_plan(sym: &Path, pretty: Option<&Path>, models: Option<&Path>, capture: bool) -> Value {
let text = std::fs::read_to_string(sym).unwrap_or_default();
let fps: Vec<PathBuf> = pretty.map(kicad_mods).unwrap_or_default();
let model_files: Vec<String> = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_lowercase())).collect()).unwrap_or_default();
let model_bytes: u64 = models.and_then(|m| std::fs::read_dir(m).ok()).map(|rd| rd.flatten().filter_map(|e| e.metadata().ok().map(|m| m.len())).sum()).unwrap_or(0);
let fp_text = |name: &str| fps.iter().find(|p| p.file_stem().map(|s| s.to_string_lossy() == name).unwrap_or(false)).and_then(|p| std::fs::read_to_string(p).ok());
let mut evidence = Vec::new();
if let Ok(root) = crate::sxedit::parse(&text) {
for sy in root.kids_tagged("symbol") {
let Some(name) = sy.atom(1) else { continue };
let fp_prop = sy.kids_tagged("property").find(|p| p.atom(1) == Some("Footprint")).and_then(|p| p.atom(2)).unwrap_or("");
let fp_name = fp_prop.rsplit(':').next().unwrap_or("");
let mut slots = vec!["symbol"];
if let Some(ft) = (!fp_name.is_empty()).then(|| fp_text(fp_name)).flatten() {
slots.push("footprint");
let has_model = ft.match_indices("(model ").any(|(i, _)| {
let rest = &ft[i + 7..];
let path = rest.trim_start().trim_start_matches('"').split('"').next().unwrap_or("");
let stem = path.rsplit(['/', '\\']).next().unwrap_or("").rsplit_once('.').map(|x| x.0).unwrap_or("").to_lowercase();
!stem.is_empty() && model_files.contains(&stem)
});
if has_model {
slots.push("3d");
}
}
evidence.push(json!({"part": name, "footprint": fp_prop, "slots": slots}));
}
}
let n = evidence.len() as f64;
let mut steps = vec![json!({"id": "unpack", "label": "Unpack the bundle", "etaSec": 1})];
steps.push(json!({"id": "symbols", "label": format!("Install {} symbol(s)", evidence.len()), "etaSec": (1.0 + n * 0.05).ceil()}));
if !fps.is_empty() {
steps.push(json!({"id": "footprints", "label": format!("Install {} footprint(s)", fps.len()), "etaSec": (1.0 + fps.len() as f64 * 0.05).ceil()}));
}
if !model_files.is_empty() {
steps.push(json!({"id": "models", "label": format!("Carry {} 3D model(s)", model_files.len()), "etaSec": (1.0 + model_bytes as f64 / 50e6).ceil()}));
}
steps.push(json!({"id": "register", "label": "Register both library tables", "etaSec": 1}));
if capture {
steps.push(json!({"id": "frame", "label": "One frame of the library in the Symbol Editor", "etaSec": 15}));
}
let total: f64 = steps.iter().filter_map(|s| s["etaSec"].as_f64()).sum();
json!({"steps": steps, "totalEtaSec": total, "expectedEvidence": evidence, "libraryFrame": if capture { 1 } else { 0 },
"counts": {"symbols": evidence.len(), "footprints": fps.len(), "models": model_files.len()}})
}
// ---------------------------------------------------------------------------
// kicad_adom_library_heal (adom_library.heal_split)
// ---------------------------------------------------------------------------
/// Reconcile a split Adom library (wiki #71). `merge` (default) copies every
/// top-level symbol the orphan has and the registered file lacks into the
/// registered file through `add_symbol` (balance gate, .bak); `repoint` rewrites
/// the row's uri to the orphan; `prune` asks KiCad about each symbol alone and
/// rebuilds the registered file from the accepted ones (`.pruned.bak` kept).
/// `dry_run` previews. Human-chosen: the bridge cannot know which file is real.
pub fn heal_split(ctx: &LibCtx, mode: &str, dry_run: bool) -> Value {
let rep = libraries::library_split_report(ctx);
if mode == "prune" {
let reg = rep["registeredPath"].as_str().unwrap_or("").to_string();
if reg.is_empty() || !Path::new(®).exists() {
return json!({"success": false, "error": "no registered Adom library file to prune", "report": rep});
}
let text = match std::fs::read_to_string(®) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("cannot read {reg}: {e}")}),
};
let cli = ctx.kicad_cli.as_deref();
let whole = kicad_parser_verdict(cli, Path::new(®), PARSE_TIMEOUT);
if !whole["available"].as_bool().unwrap_or(false) {
return json!({"success": false, "error": whole["error"], "kicadParser": whole});
}
let (blocks, notes) = extract_symbols_tolerant(&text);
let names: Vec<&str> = blocks.iter().map(|(n, _)| n.as_str()).collect();
if whole["ok"].as_bool().unwrap_or(false) {
return json!({"success": true, "healed": false, "mode": "prune", "dryRun": dry_run,
"kicadParser": whole, "dropped": [], "kept": names,
"_hint": "KiCad already loads this library; nothing to prune."});
}
let per = refused_symbols(cli, &blocks);
let accepted: Vec<&str> = per["accepted"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let kept: Vec<&(String, String)> = blocks.iter().filter(|(n, _)| accepted.contains(&n.as_str())).collect();
let kept_names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect();
let refused_count = per["refused"].as_object().map(|o| o.len()).unwrap_or(0);
let mut out = json!({"success": true, "mode": "prune", "dryRun": dry_run, "registeredPath": reg,
"kicadParserBefore": whole, "dropped": per["refused"], "kept": kept_names, "sourceNotes": notes});
if dry_run {
out["healed"] = json!(false);
out["_hint"] = json!(format!("Would rebuild {reg} with {} symbol(s) KiCad accepts and drop {refused_count} it refuses (names in dropped). Nothing written.", kept.len()));
return out;
}
let kept_blocks: Vec<&str> = kept.iter().map(|(_, b)| b.as_str()).collect();
let new_text = wrap_symbol_blocks(&kept_blocks).replace("\"adom-parse-check\"", "\"adom-desktop\"");
let after = verdict_text(cli, &new_text);
if !after["ok"].as_bool().unwrap_or(false) {
out["success"] = json!(false);
out["healed"] = json!(false);
out["kicadParserAfter"] = after;
out["error"] = json!("the pruned library is STILL refused by KiCad; nothing written (report this with the file)");
return out;
}
let backup = format!("{reg}.pruned.bak");
if let Err(e) = std::fs::write(&backup, &text) {
return json!({"success": false, "error": format!("cannot write backup {backup}: {e}")});
}
if let Err(e) = write_atomic(Path::new(®), new_text.as_bytes()) {
return json!({"success": false, "error": e});
}
out["healed"] = json!(true);
out["kicadParserAfter"] = after;
out["backup"] = json!(backup);
out["_hint"] = json!(format!("Rebuilt with {} symbol(s) KiCad accepts; dropped {refused_count} (see dropped, with KiCad's reason each). Original saved beside it. KiCad sees the change after its next library load.", kept.len()));
return out;
}
if !rep["split"].as_bool().unwrap_or(false) {
let mut out = json!({"success": true, "healed": false, "reason": "no split"});
merge_into(&mut out, &rep);
return out;
}
let registered = PathBuf::from(rep["registeredPath"].as_str().unwrap_or(""));
let orphan = PathBuf::from(rep["bridgeDefaultPath"].as_str().unwrap_or(""));
if mode == "repoint" {
let Some((table_path, _)) = libraries::adom_paths(ctx) else {
return json!({"success": false, "error": "no Adom row to repoint"});
};
let mut table = match LibTable::parse_file(&table_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("Failed to parse sym-lib-table: {e}")}),
};
let Some(row) = table.entries.iter_mut().find(|e| e.name == ADOM_LIB_NAME) else {
return json!({"success": false, "error": "no Adom row to repoint"});
};
if !dry_run {
row.uri = norm_path(&orphan);
if let Err(e) = table.write_file(&table_path) {
return json!({"success": false, "error": format!("cannot write sym-lib-table: {e}")});
}
}
return json!({"success": true, "healed": !dry_run, "mode": "repoint", "dryRun": dry_run,
"rowNowPointsAt": norm_path(&orphan),
"_hint": "KiCad reads the repointed file after its next library load (restart)."});
}
if mode != "merge" {
return json!({"success": false, "error": format!("unknown mode {mode:?}"), "allowed": ["merge", "repoint", "prune"]});
}
let orphan_text = match std::fs::read_to_string(&orphan) {
Ok(t) => t,
Err(e) => return json!({"success": false, "error": format!("cannot read orphan: {e}")}),
};
// TOLERANT SOURCE READ: symbols after the library's closer (the old appender
// bug) are still offered, each balance-checked on its own.
let (src_symbols, unparseable) = extract_symbols_tolerant(&orphan_text);
let have: Vec<String> = list_symbols(®istered).into_iter().map(|n| n.to_lowercase()).collect();
let to_add: Vec<&(String, String)> = src_symbols.iter().filter(|(n, _)| !have.contains(&n.to_lowercase())).collect();
let skipped: Vec<&str> = src_symbols.iter().filter(|(n, _)| have.contains(&n.to_lowercase())).map(|(n, _)| n.as_str()).collect();
if dry_run {
return json!({"success": true, "healed": false, "mode": "merge", "dryRun": true,
"wouldAdd": to_add.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), "alreadyPresent": skipped,
"unparseableInSource": unparseable,
"registeredPath": norm_path(®istered), "orphanPath": norm_path(&orphan)});
}
let mut added: Vec<String> = Vec::new();
let mut failed: Vec<Value> = Vec::new();
for (name, text) in to_add {
// add_symbol takes a WHOLE .kicad_sym; row-first paths land it in the registered file.
let wrapped = format!("(kicad_symbol_lib (version 20231120) (generator \"adom-heal\")\n{text}\n)\n");
let r = add_symbol(ctx, &wrapped);
if r["success"].as_bool().unwrap_or(false) {
added.push(name.clone());
} else {
failed.push(json!({"symbol": name, "error": r["error"]}));
}
}
let after = libraries::library_split_report(ctx);
json!({"success": failed.is_empty(), "healed": true, "mode": "merge", "dryRun": false,
"added": added, "alreadyPresent": skipped, "failed": failed,
"unparseableInSource": unparseable,
"registeredSymbolsAfter": after["registeredSymbols"],
"_hint": "Merged into the file KiCad reads. A running KiCad sees them after its next library load (kicad_rescan_libraries force:true, or restart). The orphan file was not modified or deleted."})
}
// ---------------------------------------------------------------------------
// Part lookups shared by make_part_project and export_part
// ---------------------------------------------------------------------------
/// The Adom.kicad_sym KiCad reads (row first, wiki #71), falling back to the
/// bridge default `<user>/symbols/Adom.kicad_sym` when no row resolves.
fn adom_sym_lib(ctx: &LibCtx, user_dir: &Path) -> PathBuf {
libraries::adom_paths(ctx).map(|(_, p)| p).unwrap_or_else(|| user_dir.join("symbols").join(ADOM_LIB_FILE))
}
/// The Adom.pretty KiCad reads (row first), with `${...}` uris expanded.
fn adom_pretty(ctx: &LibCtx, user_dir: &Path) -> PathBuf {
let default = user_dir.join("footprints").join(ADOM_FP_DIR_NAME);
let Some(cfg) = &ctx.config_dir else { return default };
if let Ok(t) = LibTable::parse_file(&cfg.join("fp-lib-table")) {
if let Some(row) = t.get(ADOM_FP_LIB_NAME) {
if let Some(p) = expand_kicad_uri(&row.uri, ctx) {
return PathBuf::from(p);
}
}
}
default
}
/// Documents/ for this user: the grandparent of Documents/KiCad/<ver>, which is
/// what every platform's user_dir is.
fn documents_dir(user_dir: &Path) -> PathBuf {
user_dir.parent().and_then(Path::parent).map(Path::to_path_buf).unwrap_or_else(|| user_dir.to_path_buf())
}
fn find_symbol_block(lib_text: &str, name: &str) -> Result<Option<String>, String> {
Ok(extract_top_level_symbols(lib_text)?.into_iter().find(|(n, _)| n == name).map(|(_, b)| b))
}
/// `[-\d.]+` at the start of `s`, parsed. Returns (value, consumed).
fn num_token(s: &str) -> Option<(f64, usize)> {
let n = s.bytes().take_while(|b| b.is_ascii_digit() || *b == b'-' || *b == b'.').count();
if n == 0 {
return None;
}
s[..n].parse::<f64>().ok().map(|v| (v, n))
}
fn skip_ws(s: &str) -> &str {
s.trim_start()
}
/// Python `_pins`: `(pin TYPE SHAPE (at X Y ANGLE))` connection points.
fn pins(sym_block: &str) -> Vec<(f64, f64, f64)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(i) = sym_block[pos..].find("(pin") {
let after = pos + i + "(pin".len();
pos = after;
let s = &sym_block[after..];
if !s.starts_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let s = skip_ws(s);
let t1 = s.split_whitespace().next().unwrap_or("");
if t1.is_empty() {
continue;
}
let s = skip_ws(&s[t1.len()..]);
let t2 = s.split_whitespace().next().unwrap_or("");
if t2.is_empty() {
continue;
}
let s = skip_ws(&s[t2.len()..]);
let Some(s) = s.strip_prefix("(at") else { continue };
if !s.starts_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let s = skip_ws(s);
let Some((x, n)) = num_token(s) else { continue };
let s = skip_ws(&s[n..]);
let Some((y, n)) = num_token(s) else { continue };
let s = skip_ws(&s[n..]);
let Some((a, n)) = num_token(s) else { continue };
if s[n..].starts_with(')') {
out.push((x, y, a));
}
}
out
}
/// Python `_pads`: `(pad "N" ... (at X Y` with no parens in between.
fn pads(fp_text: &str) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(i) = fp_text[pos..].find("(pad") {
let after = pos + i + "(pad".len();
pos = after;
let s = &fp_text[after..];
if !s.starts_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let s = skip_ws(s);
let Some(q) = s.strip_prefix('"') else { continue };
let Some(end) = q.find('"') else { continue };
let s = &q[end + 1..];
let stop = s.find(['(', ')']).unwrap_or(s.len());
let s = &s[stop..];
let Some(s) = s.strip_prefix("(at") else { continue };
if !s.starts_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let s = skip_ws(s);
let Some((x, n)) = num_token(s) else { continue };
let s = skip_ws(&s[n..]);
let Some((y, _)) = num_token(s) else { continue };
out.push((x, y));
}
out
}
/// Every `(start|end|at|center X Y` pair in a footprint.
fn coords(fp_text: &str) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut pos = 0;
while let Some(i) = fp_text[pos..].find('(') {
let after = pos + i + 1;
pos = after;
let s = &fp_text[after..];
let Some(rest) = ["start", "end", "at", "center"].iter().find_map(|w| s.strip_prefix(w)) else { continue };
if !rest.starts_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let s = skip_ws(rest);
let Some((x, n)) = num_token(s) else { continue };
let s = skip_ws(&s[n..]);
let Some((y, _)) = num_token(s) else { continue };
out.push((x, y));
}
out
}
fn reference_property(block: &str) -> Option<String> {
let mut pos = 0;
while let Some(i) = block[pos..].find("(property") {
let after = pos + i + "(property".len();
pos = after;
let s = skip_ws(&block[after..]);
let Some(s) = s.strip_prefix("\"Reference\"") else { continue };
let s = skip_ws(s);
let Some(q) = s.strip_prefix('"') else { continue };
let Some(end) = q.find('"') else { continue };
return Some(q[..end].to_string());
}
None
}
fn replace_first(text: &str, from: &str, to: &str) -> String {
match text.find(from) {
Some(i) => format!("{}{}{}", &text[..i], to, &text[i + from.len()..]),
None => text.to_string(),
}
}
fn uuid(n: u64) -> String {
format!("00000000-0000-4000-8000-{n:012}")
}
// ---------------------------------------------------------------------------
// kicad_make_part_project (handlers/make_part_project.py)
// ---------------------------------------------------------------------------
/// Generate `<proj>.kicad_pro/.kicad_sch/.kicad_pcb` around one installed Adom
/// part: the symbol alone with two stub wires, the footprint alone inside tight
/// edge cuts with two copper stubs, the footprint's `(model ...)` kept.
/// Registering the project with the focus guardian is window-phase and skipped.
pub fn make_part_project(ctx: &LibCtx, args: &Value) -> Value {
let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();
if part.is_empty() {
return json!({"success": false, "error": "partName required",
"_hint": "The symbol's name in the Adom library: kicad_install_symbol first."});
}
let fp_name = {
let f = s_arg(args, "footprintName");
if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }
};
let proj = {
let p = s_arg(args, "projectName").trim().to_string();
if !p.is_empty() {
p
} else {
// re.sub(r"[^a-z0-9-]+", "-", part.lower()).strip("-") + "-demo"
let mut s = String::new();
let mut in_run = false;
for c in part.to_lowercase().chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
s.push(c);
in_run = false;
} else if !in_run {
s.push('-');
in_run = true;
}
}
format!("{}-demo", s.trim_matches('-'))
}
};
let Some(user_dir) = ctx.user_dir.clone() else {
return json!({"success": false, "error": "KiCad user directory not found",
"_hint": "kicad_status reports install state; run once with KiCad installed."});
};
let sym_lib = adom_sym_lib(ctx, &user_dir);
if !sym_lib.exists() {
return json!({"success": false, "error": format!("Adom library not found at {}", norm_path(&sym_lib)),
"_hint": "kicad_install_symbol installs the part into the Adom library first."});
}
let lib_text = std::fs::read_to_string(&sym_lib).unwrap_or_default();
let block = match find_symbol_block(&lib_text, &part) {
Ok(Some(b)) => b,
Ok(None) => {
return json!({"success": false, "error": format!("Symbol '{part}' not in the Adom library"),
"_hint": "kicad_install_symbol with the part's .kicad_sym first (wiki part pages ship it)."})
}
Err(e) => return json!({"success": false, "error": format!("Adom library at {} does not parse: {e}", norm_path(&sym_lib))}),
};
let fp_path = adom_pretty(ctx, &user_dir).join(format!("{fp_name}.kicad_mod"));
if !fp_path.exists() {
return json!({"success": false, "error": format!("Footprint '{fp_name}' not in Adom.pretty"),
"_hint": "kicad_install_footprint (with model:{...} for the 3D step) first."});
}
let fp_text = std::fs::read_to_string(&fp_path).unwrap_or_default();
let out_dir = match s_arg(args, "outputDir").trim() {
"" => documents_dir(&user_dir).join(&proj),
d => PathBuf::from(d),
};
// called as the standard's create_part_project: never overwrite without overwrite:true
if args.get("_refuseExisting") == Some(&json!(true)) && out_dir.join(format!("{proj}.kicad_pro")).exists() {
return json!({"success": false, "errorCode": "project_exists", "error": format!("A project named {proj} already exists in {}", norm_path(&out_dir)),
"_hint": "Pass overwrite:true to regenerate it in place, or another projectName."});
}
if let Err(e) = std::fs::create_dir_all(&out_dir) {
return json!({"success": false, "error": format!("Failed to write project files: cannot create {}: {e}", norm_path(&out_dir))});
}
// The part names its own reference prefix.
let refdes = format!("{}1", reference_property(&block).map(|r| r.trim().to_string()).filter(|r| !r.is_empty()).unwrap_or_else(|| "U".into()));
// ---------------- schematic: JUST the part ----------------
let sheet = uuid(1);
let (ax, ay) = (127.0f64, 80.0f64);
let lib_entry = format!(" {}", replace_first(&block, &format!("(symbol \"{part}\""), &format!("(symbol \"Adom:{part}\"")));
let mut wires = String::new();
let mut wire_n = 300u64;
for (px, py, pa) in pins(&block).into_iter().take(2) {
let (cx, cy) = (ax + px, ay - py);
let dx = -pa.to_radians().cos();
let dy = pa.to_radians().sin();
let (ex, ey) = (round_to(cx + 6.0 * dx, 3), round_to(cy + 6.0 * dy, 3));
wires.push_str(&format!(
" (wire (pts (xy {} {}) (xy {} {})) (stroke (width 0) (type default)) (uuid \"{}\"))\n",
py_float(round_to(cx, 3)), py_float(round_to(cy, 3)), py_float(ex), py_float(ey), uuid(wire_n)
));
wire_n += 1;
}
let schematic = format!(
"(kicad_sch (version 20231120) (generator \"eeschema\") (generator_version \"8.0\")\n (uuid \"{sheet}\") (paper \"A4\")\n (title_block (title \"{part} demo - generated by the Adom KiCad Bridge\") (company \"Adom\"))\n (lib_symbols\n{lib_entry}\n )\n{wires} (symbol (lib_id \"Adom:{part}\") (at {ax} {ay} 0) (unit 1)\n (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid \"{u201}\")\n (property \"Reference\" \"{refdes}\" (at {ax} {ay_ref} 0) (effects (font (size 1.27 1.27))))\n (property \"Value\" \"{part}\" (at {ax} {ay_val} 0) (effects (font (size 1.27 1.27))))\n (property \"Footprint\" \"Adom:{fp_name}\" (at {ax} {ay} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n (instances (project \"{proj}\" (path \"/{sheet}\" (reference \"{refdes}\") (unit 1))))\n )\n (sheet_instances (path \"/\" (page \"1\")))\n)\n",
ax = py_float(ax), ay = py_float(ay), ay_ref = py_float(ay - 7.5), ay_val = py_float(ay + 7.0), u201 = uuid(201)
);
// ---------------- board: JUST the part, centered ----------------
let part_at = (108.0f64, 68.0f64);
let cs = coords(&fp_text);
let half_w = cs.iter().map(|(x, _)| x.abs()).fold(1.0f64, f64::max);
let half_h = cs.iter().map(|(_, y)| y.abs()).fold(1.0f64, f64::max);
let margin = (2.0f64).max(half_h * 0.8);
let x1 = round_to(part_at.0 - half_w - margin, 2);
let y1 = round_to(part_at.1 - half_h.max(1.0) - margin, 2);
let x2 = round_to(part_at.0 + half_w + margin, 2);
let y2 = round_to(part_at.1 + half_h.max(1.0) + margin, 2);
let fp_placed = replace_first(&fp_text, &format!("(footprint \"{fp_name}\""), &format!("(footprint \"Adom:{fp_name}\""));
let fp_placed = replace_first(&fp_placed, "(layer \"F.Cu\")", &format!("(layer \"F.Cu\")\n (at {} {})", py_float(part_at.0), py_float(part_at.1)));
let fp_placed = replace_first(&fp_placed, "(fp_text reference \"REF**\"", &format!("(fp_text reference \"{refdes}\""));
let fp_placed = replace_first(&fp_placed, "(property \"Reference\" \"REF**\"", &format!("(property \"Reference\" \"{refdes}\""));
let mut edge = String::new();
let pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)];
for i in 0..4 {
let ((ex1, ey1), (ex2, ey2)) = (pts[i], pts[i + 1]);
edge.push_str(&format!(
" (gr_line (start {} {}) (end {} {}) (stroke (width 0.15) (type solid)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n",
py_float(ex1), py_float(ey1), py_float(ex2), py_float(ey2), uuid(400 + i as u64)
));
}
// Two short copper stubs off the pads keep the board looking real.
let mut segments = String::new();
let pd = pads(&fp_text);
if pd.len() >= 2 && (pd[0].1 - pd[1].1).abs() < 0.01 {
let mut two = vec![pd[0], pd[1]];
two.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let (left, right) = (two[0], two[1]);
let py_abs = part_at.1 + left.1;
segments.push_str(&format!(
" (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n (segment (start {} {}) (end {} {}) (width 0.3) (layer \"F.Cu\"))\n",
py_float(round_to(part_at.0 + left.0, 3)), py_float(round_to(py_abs, 3)),
py_float(round_to(x1 + 0.8, 3)), py_float(round_to(py_abs, 3)),
py_float(round_to(part_at.0 + right.0, 3)), py_float(round_to(py_abs, 3)),
py_float(round_to(x2 - 0.8, 3)), py_float(round_to(py_abs, 3)),
));
}
let fps = format!(" {}", fp_placed.trim_end().replace('\n', "\n "));
let board = format!(
"(kicad_pcb (version 20240108) (generator \"pcbnew\") (generator_version \"8.0\")\n (general (thickness 1.6)) (paper \"A4\")\n (layers\n (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)\n (36 \"B.SilkS\" user \"B.Silkscreen\") (37 \"F.SilkS\" user \"F.Silkscreen\")\n (38 \"B.Mask\" user) (39 \"F.Mask\" user) (40 \"B.Paste\" user) (41 \"F.Paste\" user)\n (44 \"Edge.Cuts\" user) (45 \"Margin\" user) (49 \"F.Fab\" user) (50 \"B.Fab\" user)\n (46 \"B.CrtYd\" user \"B.Courtyard\") (47 \"F.CrtYd\" user \"F.Courtyard\")\n )\n (setup (pad_to_mask_clearance 0))\n{edge}{fps}\n{segments})\n"
);
let pro = format!("{{\"board\":{{}},\"meta\":{{\"filename\":\"{proj}.kicad_pro\",\"version\":1}},\"schematic\":{{}},\"sheets\":[]}}");
let mut files = Map::new();
for (name, content) in [(format!("{proj}.kicad_pro"), &pro), (format!("{proj}.kicad_sch"), &schematic), (format!("{proj}.kicad_pcb"), &board)] {
let p = out_dir.join(&name);
if let Err(e) = std::fs::write(&p, content) {
return json!({"success": false, "error": format!("Failed to write project files: {e}")});
}
let key = name.rsplit('.').next().unwrap_or("").to_string();
files.insert(key, json!(norm_path(&p)));
}
// Stale single-instance locks from a previous open of the same project.
if let Ok(rd) = std::fs::read_dir(&out_dir) {
for e in rd.flatten() {
if has_ext(&e.path(), "lck") {
let _ = std::fs::remove_file(e.path());
}
}
}
for (name, content) in [("kicad_sch", &schematic), ("kicad_pcb", &board)] {
if content.matches('(').count() != content.matches(')').count() {
return json!({"success": false, "error": format!("internal: unbalanced s-expression in {proj}.{name}")});
}
}
json!({
"success": true,
"projectName": proj,
"projectFile": files["kicad_pro"],
"schematicFile": files["kicad_sch"],
"boardFile": files["kicad_pcb"],
"partName": part,
"footprintName": fp_name,
"reference": refdes,
"output": format!("Generated {proj}: the part alone as {refdes}, single-component by design (schematic + part-dominant board with edge cuts, copper stubs and the part's 3D model)."),
"_hint": "Project surfaces now have something real to show: kicad_show_project {filePath: projectFile}, kicad_show_schematic {filePath: schematicFile}, kicad_show_2d_board / kicad_show_3d_board {filePath: boardFile}. The footprint keeps its (model ...) reference, so the 3D board renders the actual part. Regenerating overwrites in place.",
"_next": ["kicad_show_project", "kicad_show_schematic", "kicad_show_2d_board", "kicad_show_3d_board"],
})
}
// ---------------------------------------------------------------------------
// kicad_export_part (handlers/export_part.py)
// ---------------------------------------------------------------------------
/// One-off single-part library exports: a `.kicad_sym` with ONLY that symbol, a
/// `.pretty` with ONLY that footprint, the bare 3D model file.
pub fn export_part(ctx: &LibCtx, args: &Value) -> Value {
let part = args.get("partName").or_else(|| args.get("symbolName")).and_then(Value::as_str).unwrap_or("").trim().to_string();
if part.is_empty() {
return json!({"success": false, "error": "partName required"});
}
let fp_name = {
let f = s_arg(args, "footprintName");
if f.trim().is_empty() { part.clone() } else { f.trim().to_string() }
};
let what: Vec<String> = match args.get("what") {
Some(Value::String(s)) => vec![s.to_lowercase()],
Some(Value::Array(a)) if !a.is_empty() => a.iter().filter_map(Value::as_str).map(|s| s.to_lowercase()).collect(),
_ => vec!["symbol".into(), "footprint".into(), "model".into()],
};
let Some(user_dir) = ctx.user_dir.clone() else {
return json!({"success": false, "error": "KiCad user directory not found"});
};
let out_dir = match s_arg(args, "outputDir").trim() {
"" => documents_dir(&user_dir).join("adom-part-exports").join(&part),
d => PathBuf::from(d),
};
if let Err(e) = std::fs::create_dir_all(&out_dir) {
return json!({"success": false, "error": format!("cannot create {}: {e}", norm_path(&out_dir))});
}
let mut exports = Map::new();
let mut errors: Vec<String> = Vec::new();
let pretty_src = adom_pretty(ctx, &user_dir);
if what.iter().any(|w| w == "symbol") {
let sym_lib = adom_sym_lib(ctx, &user_dir);
match std::fs::read_to_string(&sym_lib).map_err(|e| e.to_string()).and_then(|t| extract_top_level_symbols(&t)) {
Err(e) => errors.push(format!("symbol export: {e}")),
Ok(blocks) => match blocks.into_iter().find(|(n, _)| *n == part) {
None => errors.push(format!("symbol '{part}' not in {}", norm_path(&sym_lib))),
Some((_, blk)) => {
let one = format!("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n{}\n)\n", blk.trim_end());
let p = out_dir.join(format!("{part}-symbol-only.kicad_sym"));
match std::fs::write(&p, &one) {
Err(e) => errors.push(format!("symbol export: {e}")),
Ok(()) => {
let names: Vec<String> = extract_top_level_symbols(&one).map(|v| v.into_iter().map(|(n, _)| n).collect()).unwrap_or_default();
let single = names.len() == 1 && names[0] == part;
exports.insert("symbol".into(), json!({"path": norm_path(&p), "symbolsInLibrary": names, "single": single}));
}
}
}
},
}
}
if what.iter().any(|w| w == "footprint") {
let src = pretty_src.join(format!("{fp_name}.kicad_mod"));
if !src.exists() {
errors.push(format!("footprint '{fp_name}' not in Adom.pretty"));
} else {
let pretty = out_dir.join(format!("{part}-footprint-only.pretty"));
let dst = pretty.join(format!("{fp_name}.kicad_mod"));
match std::fs::create_dir_all(&pretty).map_err(|e| e.to_string()).and_then(|_| std::fs::copy(&src, &dst).map(|_| ()).map_err(|e| e.to_string())) {
Err(e) => errors.push(format!("footprint export: {e}")),
Ok(()) => {
let mods: Vec<String> = kicad_mods(&pretty).iter().map(|p| file_name(p)).collect();
let single = mods.len() == 1 && mods[0] == format!("{fp_name}.kicad_mod");
exports.insert("footprint".into(), json!({"path": norm_path(&dst), "library": norm_path(&pretty), "footprintsInLibrary": mods, "single": single}));
}
}
}
}
if what.iter().any(|w| w == "model") {
let models_dir = user_dir.join("3dmodels").join("Adom");
let mut src: Option<PathBuf> = None;
for ext in [".step", ".stp", ".wrl"] {
let c = models_dir.join(format!("{fp_name}{ext}"));
if c.exists() {
src = Some(c);
break;
}
}
if src.is_none() {
// Fall back to the model the footprint references.
let fp = pretty_src.join(format!("{fp_name}.kicad_mod"));
if let Ok(t) = std::fs::read_to_string(&fp) {
if let Some((_, reference)) = model_refs(&t).into_iter().next() {
let p = PathBuf::from(&reference);
if p.exists() {
src = Some(p);
}
}
}
}
match src {
None => errors.push(format!("no 3D model for '{fp_name}' in 3dmodels/Adom")),
Some(src) => {
let ext = src.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
let dst = out_dir.join(format!("{part}-model-only{ext}"));
match std::fs::copy(&src, &dst) {
Err(e) => errors.push(format!("model export: {e}")),
Ok(_) => {
let bytes = std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0);
exports.insert("model".into(), json!({"path": norm_path(&dst), "bytes": bytes}));
}
}
}
}
}
let ok = exports.len() == what.len() && errors.is_empty();
let mut kinds: Vec<&String> = exports.keys().collect();
kinds.sort();
let output = format!(
"one-off exports for {part}: {}{}",
kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>().join(", "),
if errors.is_empty() { String::new() } else { format!(" | {} error(s)", errors.len()) }
);
json!({
"success": ok,
"partName": part,
"outputDir": norm_path(&out_dir),
"exports": exports,
"errors": errors,
"output": output,
"_hint": "Each file is a standalone single-entry library: the .kicad_sym holds ONLY this symbol (open with kicad_show_symbol after installing, or any KiCad), the .pretty holds ONLY this footprint, the model file is the bare 3D body. The reverse of install: proves per-asset round-trip. Pull them with ab pull_file.",
})
}
// ---- uninstall_library ---------------------------------------------------------------------
/// The exact inverse of `install_library`: remove a library's row from the user's
/// sym-lib-table and/or fp-lib-table and, only when asked, delete the files the install copied.
///
/// Safety rules (each refusal is a named errorCode with the next call in the hint):
/// - the shared `Adom` library is never removed here (every Adom part lives in it);
/// - a row whose uri is a KiCad stock path (`${KICAD*_..._DIR}`) is never removed: that is
/// KiCad's own library set, not something the bridge installed;
/// - `deleteFiles` only ever deletes inside `Documents/KiCad/<ver>/{symbols,footprints,3dmodels}`,
/// the folders the install verbs write to. A library registered in place from anywhere else
/// is unregistered and its files are left alone (`filesKept` says why).
pub fn uninstall_library(ctx: &LibCtx, kicad_running: bool, args: &Value) -> Value {
let name = s_arg(args, "libraryName");
if name.is_empty() {
return json!({"success": false, "errorCode": "library_name_required",
"error": "libraryName is required",
"Hint": "kicad_uninstall_library {\"libraryName\":\"MyParts\"}; the name is the row name in Preferences > Manage Symbol/Footprint Libraries (kicad_install_library returned it as data.name)."});
}
let which = s_arg(args, "libraryType").to_lowercase();
if !matches!(which.as_str(), "" | "both" | "symbol" | "footprint") {
return json!({"success": false, "errorCode": "bad_library_type",
"error": format!("libraryType must be symbol, footprint or both (default), got '{which}'"),
"Hint": "omit libraryType to remove the name from both tables"});
}
if name.eq_ignore_ascii_case("Adom") {
return json!({"success": false, "errorCode": "adom_library_protected",
"error": "the shared 'Adom' library is not removed by this verb: every part installed with kicad_install_symbol / kicad_install_footprint lives in it",
"Hint": "remove one part with the Symbol/Footprint Editor, or repair the library with kicad_adom_library_heal"});
}
let delete_files = args.get("deleteFiles").and_then(Value::as_bool).unwrap_or(false);
let user_dir = ctx.user_dir.clone();
let mut removed: Vec<Value> = Vec::new();
let mut files_deleted: Vec<String> = Vec::new();
let mut files_kept: Vec<Value> = Vec::new();
let mut stock: Vec<String> = Vec::new();
let tables: Vec<(&str, Option<PathBuf>)> = vec![("symbol", ctx.sym_lib_table()), ("footprint", ctx.fp_lib_table())];
for (kind, table_path) in tables {
if !(which.is_empty() || which == "both" || which == kind) {
continue;
}
let Some(table_path) = table_path else { continue };
if !table_path.is_file() {
continue;
}
let mut table = match LibTable::parse_file(&table_path) {
Ok(t) => t,
Err(e) => return json!({"success": false, "errorCode": "lib_table_unreadable",
"error": format!("cannot parse {}: {e}", table_path.display()),
"Hint": "kicad_adom_library_heal repairs a damaged lib table"}),
};
let Some(entry) = table.get(&name).cloned() else { continue };
// A fresh KiCad 10 install holds ALL its stock libraries in one row of type "Table" (named
// "KiCad", uri = the template table, an absolute path with no ${KICAD..} in it). Removing that
// row would take every stock library away, so a nested-table row is stock by definition.
if entry.lib_type.eq_ignore_ascii_case("Table") {
stock.push(format!("{kind}: nested table {}", entry.uri));
continue;
}
if entry.uri.contains("${KICAD") && entry.uri.contains("_DIR}") && !entry.uri.contains("3RD_PARTY") {
stock.push(format!("{kind}: {}", entry.uri));
continue;
}
table.remove_library(&name);
if let Err(e) = table.write_file(&table_path) {
return json!({"success": false, "errorCode": "lib_table_write_failed",
"error": format!("cannot write {}: {e}", table_path.display()),
"Hint": "close KiCad's Manage Libraries dialog if it is open, then retry"});
}
removed.push(json!({"table": if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" }, "uri": entry.uri}));
let resolved = crate::libraries::expand_kicad_uri(&entry.uri, ctx).unwrap_or_else(|| entry.uri.clone());
let target = PathBuf::from(&resolved);
if !delete_files {
continue;
}
let managed = user_dir.as_ref().map(|u| {
let sub = u.join(if kind == "symbol" { "symbols" } else { "footprints" });
// a path component boundary, not a bare string prefix: "footprints-old/x" is NOT under "footprints"
let (t, base) = (crate::libraries::norm_path(&target), format!("{}/", crate::libraries::norm_path(&sub).trim_end_matches('/')));
t.starts_with(&base) && !t.split('/').any(|c| c == "..")
}).unwrap_or(false);
if !managed {
files_kept.push(json!({"path": resolved, "why": "outside Documents/KiCad/<ver>/symbols|footprints: registered in place, not a copy the bridge made"}));
continue;
}
let gone = if target.is_dir() { std::fs::remove_dir_all(&target).is_ok() } else if target.is_file() { std::fs::remove_file(&target).is_ok() } else { false };
if gone {
files_deleted.push(resolved.clone());
}
if kind == "footprint" {
if let Some(u) = &user_dir {
let models = u.join("3dmodels").join(format!("{name}.3dshapes"));
if models.is_dir() && std::fs::remove_dir_all(&models).is_ok() {
files_deleted.push(models.to_string_lossy().replace('\\', "/"));
}
}
}
}
if removed.is_empty() {
if !stock.is_empty() {
return json!({"success": false, "errorCode": "stock_library_protected", "libraryName": name, "stock": stock,
"error": format!("'{name}' is one of KiCad's own stock libraries; this verb only removes libraries that were added to the user tables"),
"Hint": "disable a stock library in Preferences > Manage Libraries instead (the Active checkbox)"});
}
return json!({"success": false, "errorCode": "library_not_registered", "libraryName": name,
"error": format!("no library named '{name}' in the {} lib table(s) of KiCad {}", if which.is_empty() || which == "both" { "symbol or footprint" } else { which.as_str() }, ctx.version),
"Hint": "names are case-sensitive and per KiCad version: kicad_list_versions, then pass kicadVersion; kicad_show_library lists what is registered"});
}
let mut out = json!({"success": true, "libraryName": name, "kicadVersion": ctx.version,
"removed": removed, "filesDeleted": files_deleted, "filesKept": files_kept,
"deleteFiles": delete_files, "kicadRunning": kicad_running, "tableReloadNeeded": kicad_running});
if !stock.is_empty() {
out["stockRowsLeft"] = json!(stock);
}
out["Hint"] = json!(if kicad_running {
"Removed from the table on disk. KiCad is RUNNING and holds its lib tables in memory: the library stays listed (and usable) in this session until KiCad restarts or the user presses OK in Preferences > Manage Libraries. If KiCad then saves its tables it can write the row back: prefer uninstalling with KiCad closed."
} else if delete_files {
"Removed from the lib table(s) and the installed copy deleted. kicad_install_library puts it back."
} else {
"Removed from the lib table(s); the library files were left in place. Pass deleteFiles:true to also delete the copy under Documents/KiCad/<ver>/ (never files outside it)."
});
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const SYM_R: &str = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n\t(symbol \"R_Adom\"\n\t\t(property \"Reference\" \"R\" (at 0 0 0))\n\t\t(property \"Value\" \"R\" (at 0 0 0))\n\t\t(symbol \"R_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n\t\t(symbol \"R_Adom_1_1\"\n\t\t\t(pin passive line\n\t\t\t\t(at 0 3.81 270) (length 1.27) (name \"~\") (number \"1\"))\n\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name \"~\") (number \"2\"))\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n";
const SYM_C: &str = "(kicad_symbol_lib (version 20231120) (generator \"x\")\n (symbol \"C_Adom\"\n (property \"Reference\" \"C\" (at 0 0 0))\n (property \"Value\" \"PA13(JTMS/SWDIO)\" (at 0 0 0))\n (symbol \"C_Adom_0_1\" (rectangle (start -1 -2) (end 1 2)))\n )\n)\n";
const MOD_R: &str = "(footprint \"R_0805_Adom\"\n\t(version 20240108)\n\t(generator \"pcbnew\")\n\t(layer \"F.Cu\")\n\t(property \"Reference\" \"REF**\"\n\t\t(at 0 -1.65 0)\n\t\t(layer \"F.SilkS\")\n\t)\n\t(attr smd)\n\t(fp_rect\n\t\t(start -1.68 -0.95)\n\t\t(end 1.68 0.95)\n\t\t(layer \"F.CrtYd\")\n\t)\n\t(pad \"1\" smd roundrect\n\t\t(at -0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(pad \"2\" smd roundrect\n\t\t(at 0.9125 0)\n\t\t(size 1.025 1.4)\n\t\t(layers \"F.Cu\" \"F.Paste\" \"F.Mask\")\n\t)\n\t(model \"3dmodels/R_0805.step\"\n\t\t(offset (xyz 0 0 0))\n\t\t(scale (xyz 1 1 1))\n\t\t(rotate (xyz 0 0 0))\n\t)\n)\n";
struct Box_ {
root: PathBuf,
ctx: LibCtx,
}
impl Drop for Box_ {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
/// A fake box: <root>/kicad (install with a template), <root>/cfg, <root>/Documents/KiCad/10.0.
fn fake_box(name: &str) -> Box_ {
let root = unique_temp_dir(&format!("adom-install-{name}-"));
let _ = std::fs::remove_dir_all(&root);
let base = root.join("kicad");
let tmpl = base.join("share/kicad/template");
std::fs::create_dir_all(&tmpl).unwrap();
std::fs::write(tmpl.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Device\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Device.kicad_sym\")(options \"\")(descr \"Generic devices\"))\n (lib (name \"Connector\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Connector.kicad_sym\")(options \"\")(descr \"\"))\n)\n").unwrap();
std::fs::write(tmpl.join("fp-lib-table"), "(fp_lib_table\n (version 7)\n (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n").unwrap();
let cfg = root.join("cfg");
let user = root.join("Documents/KiCad/10.0");
let ctx = LibCtx {
version: "10.0".into(),
base_dir: Some(norm_path(&base)),
config_dir: Some(cfg),
user_dir: Some(user),
kicad_cli: None,
env_overrides: BTreeMap::new(),
};
Box_ { root, ctx }
}
fn b64(s: &str) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = s.as_bytes();
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.len();
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let v = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(T[(v >> 18) as usize & 63] as char);
out.push(T[(v >> 12) as usize & 63] as char);
out.push(if n > 1 { T[(v >> 6) as usize & 63] as char } else { '=' });
out.push(if n > 2 { T[v as usize & 63] as char } else { '=' });
}
out
}
#[test]
fn base64_round_trip_and_errors() {
let text = "(kicad_symbol_lib (symbol \"A\"))\n";
assert_eq!(b64_decode(&b64(text)).unwrap(), text.as_bytes());
assert_eq!(b64_decode("aGk=").unwrap(), b"hi");
assert_eq!(b64_decode("aGk").unwrap(), b"hi");
assert_eq!(b64_decode("aG\nk=").unwrap(), b"hi");
assert!(b64_decode("a*k=").is_err());
}
#[test]
fn heal_lib_tables_seeds_and_merges() {
let bx = fake_box("heal");
let cfg = bx.ctx.config_dir.clone().unwrap();
let base = bx.ctx.base_dir.clone().unwrap();
// Missing tables: seeded from the template.
let r = heal_lib_tables(&cfg, Some(&base));
assert_eq!(r["sym-lib-table"]["seeded"], json!(true));
assert_eq!(r["fp-lib-table"]["seeded"], json!(true));
let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();
assert!(t.has_library("Device") && t.has_library("Connector"));
// An existing table missing stock rows: merged, user rows untouched.
std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n (lib (name \"Mine\")(type \"KiCad\")(uri \"C:/x/Mine.kicad_sym\")(options \"\")(descr \"mine\"))\n (lib (name \"Device\")(type \"KiCad\")(uri \"D:/custom/Device.kicad_sym\")(options \"\")(descr \"override\"))\n)\n").unwrap();
let r = heal_lib_tables(&cfg, Some(&base));
assert_eq!(r["sym-lib-table"]["merged"], json!(1));
assert_eq!(r["fp-lib-table"]["merged"], json!(0));
let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();
assert_eq!(t.entries.len(), 3);
assert_eq!(t.entries[0].name, "Mine");
assert_eq!(t.get("Device").unwrap().uri, "D:/custom/Device.kicad_sym");
assert_eq!(t.entries[2].name, "Connector");
// No template at all: still seeds an empty table, never errors.
let cfg2 = bx.root.join("cfg2");
let r = heal_lib_tables(&cfg2, None);
assert_eq!(r["sym-lib-table"]["seeded"], json!(true));
assert!(std::fs::read_to_string(cfg2.join("fp-lib-table")).unwrap().starts_with("(fp_lib_table"));
}
#[test]
fn install_symbol_merges_into_adom_with_backup() {
let bx = fake_box("sym");
// Never-launched KiCad: nothing exists yet.
let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["symbolNames"], json!(["R_Adom"]));
assert_eq!(r["libraryName"], json!("Adom"));
assert_eq!(r["editorOpened"], json!(false));
assert_eq!(r["kicadParser"]["available"], json!(false));
let lib = PathBuf::from(r["installedPath"].as_str().unwrap());
assert!(lib.ends_with("Documents/KiCad/10.0/symbols/Adom.kicad_sym"), "{}", lib.display());
assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string()]);
// Bootstrap seeded the stock rows and registered Adom.
let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();
assert!(t.has_library("Device") && t.has_library("Adom"));
assert!(bx.ctx.user_dir.clone().unwrap().join("footprints").is_dir());
// Second symbol appends after the last symbol, before (embedded_fonts).
let c_path = bx.root.join("C.kicad_sym");
std::fs::write(&c_path, SYM_C).unwrap();
let r = install_symbol(&bx.ctx, true, &json!({"filePath": norm_path(&c_path), "libraryName": "AdomTest", "openEditor": false}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["ignoredArgs"], json!({"libraryName": "AdomTest"}));
assert!(r["_hint"].as_str().unwrap().contains("not honoured"));
assert!(!r["output"].as_str().unwrap().contains("Symbol Editor"));
let text = std::fs::read_to_string(&lib).unwrap();
assert_eq!(list_symbols(&lib), vec!["R_Adom".to_string(), "C_Adom".to_string()]);
// The bridge's empty library template carries no (embedded_fonts) node (same as
// the Python's _EMPTY_LIB), so the merged file ends with the closing paren and
// the new symbol sits after the last existing one.
assert!(text.trim_end().ends_with(')'), "{text}");
assert!(text.find("(symbol \"R_Adom\"").unwrap() < text.find("(symbol \"C_Adom\"").unwrap());
assert!(lib.with_extension("kicad_sym.bak").is_file());
// Re-install replaces in place, count unchanged.
let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "quietInstall": true}));
assert_eq!(r["success"], json!(true));
assert_eq!(list_symbols(&lib).len(), 2);
// Errors.
assert_eq!(install_symbol(&bx.ctx, false, &json!({}))["error"], json!("KiCad not installed"));
assert_eq!(install_symbol(&bx.ctx, true, &json!({}))["error"], json!("No fileName specified"));
assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "x"}))["error"], json!("No fileContent specified (or pass filePath)"));
assert!(install_symbol(&bx.ctx, true, &json!({"filePath": "/nope/x.kicad_sym"}))["error"].as_str().unwrap().starts_with("filePath not found"));
let bad = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_pcb (x))")}));
assert!(bad["error"].as_str().unwrap().contains("not a valid symbol library"));
let empty = install_symbol(&bx.ctx, true, &json!({"fileName": "x", "fileContent": b64("(kicad_symbol_lib (version 1))")}));
assert_eq!(empty["error"], json!("No symbols found in the provided file content"));
}
#[test]
fn install_symbol_honours_the_row_first_rule() {
let bx = fake_box("rowfirst");
let cfg = bx.ctx.config_dir.clone().unwrap();
let elsewhere = bx.root.join("OneDrive/Adom.kicad_sym");
std::fs::create_dir_all(elsewhere.parent().unwrap()).unwrap();
std::fs::write(&elsewhere, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n)\n").unwrap();
let mut t = LibTable::empty("sym_lib_table");
t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));
std::fs::create_dir_all(&cfg).unwrap();
t.write_file(&cfg.join("sym-lib-table")).unwrap();
let r = install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R)}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["installedPath"], json!(norm_path(&elsewhere)));
assert_eq!(list_symbols(&elsewhere), vec!["R_Adom".to_string()]);
assert!(!bx.ctx.user_dir.clone().unwrap().join("symbols/Adom.kicad_sym").exists());
}
#[test]
fn add_symbol_refuses_a_corrupt_library() {
let bx = fake_box("corrupt");
let user = bx.ctx.user_dir.clone().unwrap();
std::fs::create_dir_all(user.join("symbols")).unwrap();
std::fs::create_dir_all(bx.ctx.config_dir.clone().unwrap()).unwrap();
std::fs::write(user.join("symbols/Adom.kicad_sym"), "(kicad_symbol_lib (version 1)\n)\n(symbol \"Stray\" (x))\n").unwrap();
let r = add_symbol(&bx.ctx, SYM_R);
assert_eq!(r["success"], json!(false));
assert!(r["error"].as_str().unwrap().starts_with("REFUSING to merge"), "{r}");
}
#[test]
fn install_footprint_into_adom_pretty_with_model() {
let bx = fake_box("fp");
let step = bx.root.join("R_0805.step");
std::fs::write(&step, b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n").unwrap();
let r = install_footprint(&bx.ctx, true, &json!({
"fileName": "whatever.kicad_mod", "fileContent": b64(MOD_R),
"model": {"filePath": norm_path(&step)}
}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["footprintName"], json!("R_0805_Adom"));
assert_eq!(r["libraryName"], json!("Adom"));
assert_eq!(r["existedBefore"], json!(false));
assert_eq!(r["unchanged"], json!(false));
assert_eq!(r["editorOpened"], json!(false));
let installed = PathBuf::from(r["installedPath"].as_str().unwrap());
assert!(installed.ends_with("footprints/Adom.pretty/R_0805_Adom.kicad_mod"));
let model = r["model3d"].as_str().unwrap().to_string();
assert!(model.ends_with("3dmodels/Adom/R_0805.step"));
let text = std::fs::read_to_string(&installed).unwrap();
assert!(text.contains(&format!("(model \"{model}\"")), "{text}");
assert!(!text.contains("3dmodels/R_0805.step"));
let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();
assert!(t.has_library("Adom") && t.has_library("Resistor_SMD"));
assert!(r["output"].as_str().unwrap().contains("registered in fp-lib-table"));
// Same content again: unchanged, not rewritten.
let r2 = install_footprint(&bx.ctx, true, &json!({"filePath": norm_path(&installed), "openEditor": false}));
assert_eq!(r2["existedBefore"], json!(true));
assert_eq!(r2["unchanged"], json!(true));
assert_eq!(r2["model3d"], Value::Null);
// A footprint with no model gets one injected before the closing paren.
let bare = "(footprint \"Bare\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1))\n)\n";
let r3 = install_footprint(&bx.ctx, true, &json!({"fileName": "Bare.kicad_mod", "fileContent": b64(bare),
"model": {"fileName": "Bare.wrl", "fileContent": b64("#VRML V2.0 utf8\n")}}));
assert_eq!(r3["success"], json!(true), "{r3}");
let text = std::fs::read_to_string(r3["installedPath"].as_str().unwrap()).unwrap();
assert!(text.ends_with(" (rotate (xyz 0 0 0))\n )\n)\n"), "{text}");
assert!(text.contains("3dmodels/Adom/Bare.wrl"));
// Missing model path is an error before anything is written.
let r4 = install_footprint(&bx.ctx, true, &json!({"fileName": "X.kicad_mod", "fileContent": b64(bare), "model": {"filePath": "/nope.step"}}));
assert_eq!(r4["success"], json!(false));
assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty/X.kicad_mod").exists());
}
#[test]
fn install_footprint_row_first_directory() {
let bx = fake_box("fprow");
let cfg = bx.ctx.config_dir.clone().unwrap();
let elsewhere = bx.root.join("OneDrive/Adom.pretty");
let mut t = LibTable::empty("fp_lib_table");
t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&elsewhere), "", ""));
std::fs::create_dir_all(&cfg).unwrap();
t.write_file(&cfg.join("fp-lib-table")).unwrap();
let r = install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R)}));
assert_eq!(r["success"], json!(true), "{r}");
assert!(elsewhere.join("R_0805_Adom.kicad_mod").is_file());
assert!(!bx.ctx.user_dir.clone().unwrap().join("footprints/Adom.pretty").exists());
}
#[test]
fn install_library_symbol_and_single_part_hint() {
let bx = fake_box("lib");
let src = bx.root.join("MyParts.kicad_sym");
std::fs::write(&src, SYM_R).unwrap();
let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["data"]["name"], json!("MyParts"));
assert_eq!(r["data"]["table"], json!("sym-lib-table"));
assert!(r["data"]["path"].as_str().unwrap().ends_with("symbols/MyParts.kicad_sym"));
assert_eq!(r["singlePartFile"], json!(true));
assert_eq!(r["symbolNames"], json!(["R_Adom"]));
assert!(r["_hint"].as_str().unwrap().contains("kicad_install_symbol"));
let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("sym-lib-table")).unwrap();
assert_eq!(t.get("MyParts").unwrap().descr, "Installed by Adom Bridge");
// Idempotent.
let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src)}));
assert_eq!(r["success"], json!(true));
assert_eq!(r["alreadyRegistered"], json!(true));
// Errors.
assert_eq!(install_library(&bx.ctx, true, &json!({}))["error"], json!("No libraryPath specified"));
assert_eq!(install_library(&bx.ctx, false, &json!({"libraryPath": "x"}))["error"], json!("KiCad not installed"));
assert!(install_library(&bx.ctx, true, &json!({"libraryPath": "/nope.kicad_sym"}))["error"].as_str().unwrap().starts_with("Library source not found"));
assert!(install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&src), "libraryType": "weird"}))["error"].as_str().unwrap().starts_with("Unknown library type"));
}
#[test]
fn install_library_footprint_pretty_with_bundle_models() {
let bx = fake_box("libfp");
let bundle = bx.root.join("bundle");
let pretty = bundle.join("Cloud.pretty");
std::fs::create_dir_all(&pretty).unwrap();
std::fs::create_dir_all(bundle.join("3dmodels")).unwrap();
std::fs::write(pretty.join("R_0805_Adom.kicad_mod"), MOD_R).unwrap();
std::fs::write(pretty.join("Other.kicad_mod"), "(footprint \"Other\" (model \"${KICAD10_3DMODEL_DIR}/x.step\"))").unwrap();
std::fs::write(bundle.join("3dmodels/R_0805.step"), b"ISO-10303-21;").unwrap();
let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&pretty), "libraryType": "footprint", "description": "Adom wiki Cloud Library"}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["data"]["name"], json!("Cloud"));
assert_eq!(r["data"]["modelsInstalled"], json!(1));
assert_eq!(r["data"]["modelRefsRewritten"], json!(1));
let user = bx.ctx.user_dir.clone().unwrap();
let installed = user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod");
let text = std::fs::read_to_string(&installed).unwrap();
let model = norm_path(&user.join("3dmodels/Cloud.3dshapes/R_0805.step"));
assert!(text.contains(&format!("(model \"{model}\"")), "{text}");
// The stock ref and the SOURCE are untouched.
assert!(std::fs::read_to_string(user.join("footprints/Cloud.pretty/Other.kicad_mod")).unwrap().contains("${KICAD10_3DMODEL_DIR}"));
assert!(std::fs::read_to_string(pretty.join("R_0805_Adom.kicad_mod")).unwrap().contains("3dmodels/R_0805.step"));
let t = LibTable::parse_file(&bx.ctx.config_dir.clone().unwrap().join("fp-lib-table")).unwrap();
assert_eq!(t.get("Cloud").unwrap().descr, "Adom wiki Cloud Library");
// A single .kicad_mod is wrapped in <name>.pretty and renamed after its declared name.
let one = bx.root.join("misnamed.kicad_mod");
std::fs::write(&one, MOD_R).unwrap();
let step = bx.root.join("body.step");
std::fs::write(&step, b"ISO-10303-21;").unwrap();
let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&one), "libraryType": "footprint", "libraryName": "Solo", "model3dPath": norm_path(&step)}));
assert_eq!(r["success"], json!(true), "{r}");
let f = user.join("footprints/Solo.pretty/R_0805_Adom.kicad_mod");
assert!(f.is_file());
let text = std::fs::read_to_string(&f).unwrap();
assert!(text.contains("3dmodels/Solo.3dshapes/body.step"), "{text}");
assert!(!text.contains("3dmodels/R_0805.step"));
assert!(r["data"]["model3dPath"].as_str().unwrap().ends_with("Solo.3dshapes/body.step"));
let r = install_library(&bx.ctx, true, &json!({"libraryPath": norm_path(&bx.root.join("body.step")), "libraryType": "footprint"}));
assert!(r["error"].as_str().unwrap().starts_with("Invalid footprint source"));
}
/// A stored (method 0) zip built by hand: enough for the reader in pcm.rs.
fn stored_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
fn crc32(data: &[u8]) -> u32 {
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c ^= b as u32;
for _ in 0..8 {
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
}
}
!c
}
let mut out = Vec::new();
let mut cd = Vec::new();
for (name, data) in files {
let off = out.len() as u32;
let crc = crc32(data);
out.extend_from_slice(b"PK\x03\x04");
out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(data);
cd.extend_from_slice(b"PK\x01\x02");
cd.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
cd.extend_from_slice(&crc.to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(name.len() as u16).to_le_bytes());
cd.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
cd.extend_from_slice(&off.to_le_bytes());
cd.extend_from_slice(name.as_bytes());
}
let cd_off = out.len() as u32;
out.extend_from_slice(&cd);
out.extend_from_slice(b"PK\x05\x06");
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(cd.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&[0, 0]);
out
}
#[test]
fn bundle_installs_symbols_footprints_and_models() {
let bx = fake_box("bundle");
let zip = stored_zip(&[
("Cloud/Cloud.kicad_sym", SYM_R.as_bytes()),
("Cloud/Cloud.pretty/R_0805_Adom.kicad_mod", MOD_R.as_bytes()),
("Cloud/3dmodels/R_0805.step", b"ISO-10303-21;"),
("Cloud/3dmodels/", b""),
]);
let zp = bx.root.join("Cloud.zip");
std::fs::write(&zp, &zip).unwrap();
let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "capture": true}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["data"]["libraryName"], json!("Cloud"));
assert_eq!(r["data"]["symbols"], json!(1));
assert_eq!(r["data"]["footprints"], json!(1));
assert_eq!(r["data"]["modelsInstalled"], json!(1));
assert_eq!(r["data"]["modelRefsRewritten"], json!(1));
// Unknown (null) where the OS cannot list processes; false on a host with no KiCad running.
assert!(matches!(r["data"]["kicadRunning"], Value::Null | Value::Bool(false)), "{r}");
if r["data"]["kicadRunning"].is_null() {
assert_eq!(r["data"]["restartNeeded"], json!(true));
}
assert!(r["proofSkipped"].as_str().unwrap().contains("window-phase"));
let user = bx.ctx.user_dir.clone().unwrap();
assert!(user.join("symbols/Cloud.kicad_sym").is_file());
assert!(user.join("footprints/Cloud.pretty/R_0805_Adom.kicad_mod").is_file());
assert!(user.join("3dmodels/Cloud.3dshapes/R_0805.step").is_file());
let cfg = bx.ctx.config_dir.clone().unwrap();
assert!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().has_library("Cloud"));
assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("Cloud"));
// Guards.
assert_eq!(install_library_bundle(&bx.ctx, true, &json!({}))["error"], json!("No zipPath specified"));
assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&zp), "libraryName": "-bad"}))["error"].as_str().unwrap().contains("not usable"));
let evil = bx.root.join("evil.zip");
std::fs::write(&evil, stored_zip(&[("../escape.kicad_sym", b"x")])).unwrap();
let r = install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&evil)}));
assert!(r["error"].as_str().unwrap().contains("unsafe zip entry"), "{r}");
let nosym = bx.root.join("nosym.zip");
std::fs::write(&nosym, stored_zip(&[("readme.txt", b"x")])).unwrap();
assert_eq!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(&nosym)}))["error"], json!("No .kicad_sym found in the bundle"));
let notzip = bx.root.join("not.zip");
std::fs::write(¬zip, b"hello").unwrap();
assert!(install_library_bundle(&bx.ctx, true, &json!({"zipPath": norm_path(¬zip)}))["error"].as_str().unwrap().starts_with("Could not unpack the bundle"));
}
#[test]
fn heal_merge_repoint_and_no_split() {
let bx = fake_box("healsplit");
let cfg = bx.ctx.config_dir.clone().unwrap();
let user = bx.ctx.user_dir.clone().unwrap();
// No split yet: nothing to do.
std::fs::create_dir_all(&cfg).unwrap();
let r = heal_split(&bx.ctx, "merge", false);
assert_eq!(r["success"], json!(true));
assert_eq!(r["healed"], json!(false));
assert_eq!(r["reason"], json!("no split"));
assert_eq!(r["split"], json!(false));
// Now split: the row points elsewhere, the default file has an orphan symbol
// plus a trailing block after the closer (the old appender bug).
let reg = bx.root.join("elsewhere/Adom.kicad_sym");
std::fs::create_dir_all(reg.parent().unwrap()).unwrap();
std::fs::write(®, "(kicad_symbol_lib (version 20231120) (generator \"x\")\n\t(symbol \"R_Adom\" (property \"Reference\" \"R\" (at 0 0 0)))\n)\n").unwrap();
let mut t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();
t.remove_library("Adom");
t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(®), "", ""));
t.write_file(&cfg.join("sym-lib-table")).unwrap();
std::fs::create_dir_all(user.join("symbols")).unwrap();
let orphan = user.join("symbols/Adom.kicad_sym");
std::fs::write(&orphan, format!("{}(symbol \"Trail\" (property \"Reference\" \"T\" (at 0 0 0)))\n(symbol \"Broken\" (x\n", SYM_C)).unwrap();
let dry = heal_split(&bx.ctx, "merge", true);
assert_eq!(dry["success"], json!(true), "{dry}");
assert_eq!(dry["dryRun"], json!(true));
assert_eq!(dry["wouldAdd"], json!(["C_Adom", "Trail"]));
assert_eq!(dry["unparseableInSource"], json!(["Broken"]));
assert_eq!(list_symbols(®).len(), 1);
let r = heal_split(&bx.ctx, "merge", false);
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["healed"], json!(true));
assert_eq!(r["added"], json!(["C_Adom", "Trail"]));
assert_eq!(r["failed"], json!([]));
assert_eq!(r["registeredSymbolsAfter"], json!(3));
assert_eq!(list_symbols(®), vec!["R_Adom".to_string(), "C_Adom".to_string(), "Trail".to_string()]);
assert!(reg.with_extension("kicad_sym.bak").is_file());
// The orphan is untouched.
assert!(std::fs::read_to_string(&orphan).unwrap().contains("Broken"));
// Unknown mode.
let r = heal_split(&bx.ctx, "nuke", false);
assert_eq!(r["success"], json!(false));
assert_eq!(r["allowed"], json!(["merge", "repoint", "prune"]));
// Repoint: dry run leaves the row, the real run rewrites it.
let r = heal_split(&bx.ctx, "repoint", true);
assert_eq!(r["healed"], json!(false));
assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(®));
let r = heal_split(&bx.ctx, "repoint", false);
assert_eq!(r["healed"], json!(true));
assert_eq!(r["rowNowPointsAt"], json!(norm_path(&orphan)));
assert_eq!(LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap().get("Adom").unwrap().uri, norm_path(&orphan));
// Prune without kicad-cli: an honest refusal, nothing written.
let r = heal_split(&bx.ctx, "prune", false);
assert_eq!(r["success"], json!(false));
assert_eq!(r["kicadParser"]["available"], json!(false));
assert!(!orphan.with_extension("kicad_sym.pruned.bak").exists());
}
#[test]
fn make_part_project_and_export_part() {
let bx = fake_box("proj");
assert_eq!(install_symbol(&bx.ctx, true, &json!({"fileName": "R.kicad_sym", "fileContent": b64(SYM_R), "openEditor": false}))["success"], json!(true));
let step = bx.root.join("R_0805_Adom.step");
std::fs::write(&step, b"ISO-10303-21;").unwrap();
assert_eq!(install_footprint(&bx.ctx, true, &json!({"fileName": "r.kicad_mod", "fileContent": b64(MOD_R), "openEditor": false, "model": {"filePath": norm_path(&step)}}))["success"], json!(true));
let r = make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["projectName"], json!("r-adom-demo"));
assert_eq!(r["reference"], json!("R1"));
let proj = PathBuf::from(r["projectFile"].as_str().unwrap());
assert!(proj.ends_with("Documents/r-adom-demo/r-adom-demo.kicad_pro"), "{}", proj.display());
let sch = std::fs::read_to_string(r["schematicFile"].as_str().unwrap()).unwrap();
assert!(sch.contains("(symbol \"Adom:R_Adom\""));
assert!(sch.contains("(lib_id \"Adom:R_Adom\") (at 127.0 80.0 0)"));
assert!(sch.contains("(property \"Reference\" \"R1\" (at 127.0 72.5 0)"));
assert!(sch.contains("(property \"Footprint\" \"Adom:R_0805_Adom\""));
// Two pins (0, 3.81, 270) and (0, -3.81, 90): stub wires going away from the body.
assert!(sch.contains("(wire (pts (xy 127.0 76.19) (xy 127.0 70.19))"), "{sch}");
assert!(sch.contains("(wire (pts (xy 127.0 83.81) (xy 127.0 89.81))"), "{sch}");
assert_eq!(sch.matches('(').count(), sch.matches(')').count());
let pcb = std::fs::read_to_string(r["boardFile"].as_str().unwrap()).unwrap();
assert!(pcb.contains("(footprint \"Adom:R_0805_Adom\""));
assert!(pcb.contains("(layer \"F.Cu\")\n (at 108.0 68.0)"));
assert!(pcb.contains("(property \"Reference\" \"R1\""));
assert!(pcb.contains("(model \""));
assert!(pcb.contains("(gr_line (start 104.32 64.35) (end 111.68 64.35)"), "{pcb}");
assert!(pcb.contains("(segment (start 107.088 68.0) (end 105.12 68.0)"), "{pcb}");
assert!(pcb.contains("(segment (start 108.912 68.0) (end 110.88 68.0)"), "{pcb}");
assert_eq!(pcb.matches('(').count(), pcb.matches(')').count());
let pro = std::fs::read_to_string(&proj).unwrap();
assert_eq!(pro, "{\"board\":{},\"meta\":{\"filename\":\"r-adom-demo.kicad_pro\",\"version\":1},\"schematic\":{},\"sheets\":[]}");
// Errors.
assert_eq!(make_part_project(&bx.ctx, &json!({}))["error"], json!("partName required"));
assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "Nope"}))["error"], json!("Symbol 'Nope' not in the Adom library"));
assert_eq!(make_part_project(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "Nope"}))["error"], json!("Footprint 'Nope' not in Adom.pretty"));
let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "footprintName": "R_0805_Adom"}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["errors"], json!([]));
assert!(r["outputDir"].as_str().unwrap().ends_with("Documents/adom-part-exports/R_Adom"));
assert_eq!(r["exports"]["symbol"]["single"], json!(true));
assert_eq!(r["exports"]["symbol"]["symbolsInLibrary"], json!(["R_Adom"]));
assert_eq!(r["exports"]["footprint"]["single"], json!(true));
assert!(r["exports"]["footprint"]["path"].as_str().unwrap().ends_with("R_Adom-footprint-only.pretty/R_0805_Adom.kicad_mod"));
assert!(r["exports"]["model"]["path"].as_str().unwrap().ends_with("R_Adom-model-only.step"));
assert_eq!(r["exports"]["model"]["bytes"], json!(13));
assert_eq!(r["output"], json!("one-off exports for R_Adom: footprint, model, symbol"));
let one = std::fs::read_to_string(r["exports"]["symbol"]["path"].as_str().unwrap()).unwrap();
assert!(one.starts_with("(kicad_symbol_lib\n (version 20231120)\n (generator \"adom-export-part\")\n"));
// Partial: only the symbol, and a missing part reports errors with success:false.
let r = export_part(&bx.ctx, &json!({"partName": "R_Adom", "what": "symbol", "outputDir": norm_path(&bx.root.join("exp"))}));
assert_eq!(r["success"], json!(true));
assert_eq!(r["exports"].as_object().unwrap().len(), 1);
let r = export_part(&bx.ctx, &json!({"partName": "Nope"}));
assert_eq!(r["success"], json!(false));
assert_eq!(r["errors"].as_array().unwrap().len(), 3);
assert_eq!(export_part(&bx.ctx, &json!({}))["error"], json!("partName required"));
}
#[test]
fn a_bundle_plan_lists_steps_and_slots_without_installing() {
let d = std::env::temp_dir().join(format!("adom-bundle-plan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("Lib.pretty")).unwrap();
std::fs::create_dir_all(d.join("3dmodels")).unwrap();
std::fs::write(d.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20241209)\n (symbol \"AAA\" (property \"Footprint\" \"Lib:FP_A\" (at 0 0 0)))\n (symbol \"BBB\" (property \"Footprint\" \"Lib:FP_B\" (at 0 0 0)))\n (symbol \"CCC\" (property \"Footprint\" \"\" (at 0 0 0))))").unwrap();
std::fs::write(d.join("Lib.pretty/FP_A.kicad_mod"), "(footprint \"FP_A\" (model \"${ADOM}/3dmodels/fp_a.step\"))").unwrap();
std::fs::write(d.join("Lib.pretty/FP_B.kicad_mod"), "(footprint \"FP_B\")").unwrap();
std::fs::write(d.join("3dmodels/fp_a.step"), "ISO-10303-21;").unwrap();
let p = bundle_plan(&d.join("Lib.kicad_sym"), Some(&d.join("Lib.pretty")), Some(&d.join("3dmodels")), true);
let ev = p["expectedEvidence"].as_array().unwrap();
assert_eq!(ev[0]["slots"], json!(["symbol", "footprint", "3d"]), "{p}");
assert_eq!(ev[1]["slots"], json!(["symbol", "footprint"]));
assert_eq!(ev[2]["slots"], json!(["symbol"]));
let ids: Vec<&str> = p["steps"].as_array().unwrap().iter().filter_map(|s| s["id"].as_str()).collect();
assert_eq!(ids, vec!["unpack", "symbols", "footprints", "models", "register", "frame"]);
assert_eq!(p["libraryFrame"], 1);
assert!(p["totalEtaSec"].as_f64().unwrap() > 0.0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn footprint_text_helpers() {
assert_eq!(footprint_name_in(MOD_R).as_deref(), Some("R_0805_Adom"));
assert_eq!(footprint_name_in("(module \"X\")"), None);
assert_eq!(pads(MOD_R), vec![(-0.9125, 0.0), (0.9125, 0.0)]);
assert_eq!(pins(SYM_R), vec![(0.0, 3.81, 270.0), (0.0, -3.81, 90.0)]);
assert!(coords(MOD_R).contains(&(-1.68, -0.95)));
let refs = model_refs(MOD_R);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].1, "3dmodels/R_0805.step");
assert_eq!(py_float(127.0), "127.0");
assert_eq!(py_float(72.5), "72.5");
assert_eq!(py_float(-0.9125), "-0.9125");
assert!(name_ok("Adom Cloud_1.0-x") && !name_ok(" x") && !name_ok("") && !name_ok(&"a".repeat(65)));
let w = wire_model_into_footprint("(footprint \"X\"\n (model \"old.step\" (offset (xyz 0 0 0)))\n)\n", "C:/new.step");
assert!(w.contains("(model \"C:/new.step\" (offset"), "{w}");
assert!(!w.contains("old.step"));
}
// ---- uninstall_library ----
fn uninstall_ctx(root: &Path) -> LibCtx {
let cfg = root.join("cfg"); let user = root.join("user");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::create_dir_all(user.join("footprints").join("Mine.pretty")).unwrap();
std::fs::create_dir_all(user.join("3dmodels").join("Mine.3dshapes")).unwrap();
std::fs::write(user.join("footprints").join("Mine.pretty").join("a.kicad_mod"), "(footprint \"a\")").unwrap();
let fp_uri = user.join("footprints").join("Mine.pretty").to_string_lossy().replace('\\', "/");
std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n (version 7)\n (lib (name \"Mine\")(type \"KiCad\")(uri \"{fp_uri}\")(options \"\")(descr \"\"))\n (lib (name \"Elsewhere\")(type \"KiCad\")(uri \"{}/outside.pretty\")(options \"\")(descr \"\"))\n (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${{KICAD10_FOOTPRINT_DIR}}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n", root.to_string_lossy().replace('\\', "/"))).unwrap();
std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n (version 7)\n)\n").unwrap();
std::fs::create_dir_all(root.join("outside.pretty")).unwrap();
LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() }
}
#[test]
fn uninstall_removes_row_and_only_managed_files() {
let root = unique_temp_dir("kb-uninstall-a");
let ctx = uninstall_ctx(&root);
let r = uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "deleteFiles": true}));
assert_eq!(r["success"], json!(true), "{r}");
assert_eq!(r["removed"].as_array().unwrap().len(), 1);
assert!(!ctx.user_dir.as_ref().unwrap().join("footprints").join("Mine.pretty").exists());
assert!(!ctx.user_dir.as_ref().unwrap().join("3dmodels").join("Mine.3dshapes").exists());
let table = LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap();
assert!(!table.has_library("Mine") && table.has_library("Elsewhere") && table.has_library("Resistor_SMD"));
// registered in place, outside the managed folders: unregistered, files kept
let r = uninstall_library(&ctx, true, &json!({"libraryName": "Elsewhere", "deleteFiles": true}));
assert_eq!(r["success"], json!(true), "{r}");
assert!(root.join("outside.pretty").exists());
assert_eq!(r["filesKept"].as_array().unwrap().len(), 1);
assert_eq!(r["tableReloadNeeded"], json!(true));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn uninstall_never_removes_the_nested_stock_table_and_respects_the_folder_boundary() {
let root = unique_temp_dir("kb-uninstall-c");
let ctx = uninstall_ctx(&root);
let cfg = ctx.config_dir.clone().unwrap();
let user = ctx.user_dir.clone().unwrap();
// a fresh KiCad 10 box: ONE row of type Table carries every stock library
let sibling = user.join("footprints-old").join("Sib.pretty");
std::fs::create_dir_all(&sibling).unwrap();
let sib_uri = sibling.to_string_lossy().replace('\\', "/");
std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n (version 7)\n (lib (name \"KiCad\")(type \"Table\")(uri \"C:/Program Files/KiCad/10.0/share/kicad/template/fp-lib-table\")(options \"\")(descr \"KiCad Default Libraries\"))\n (lib (name \"Sib\")(type \"KiCad\")(uri \"{sib_uri}\")(options \"\")(descr \"\"))\n)\n")).unwrap();
let r = uninstall_library(&ctx, false, &json!({"libraryName": "KiCad", "deleteFiles": true}));
assert_eq!(r["errorCode"], json!("stock_library_protected"), "{r}");
assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("KiCad"));
// "footprints-old" shares a string prefix with "footprints" but is not inside it: files stay
let r = uninstall_library(&ctx, false, &json!({"libraryName": "Sib", "deleteFiles": true}));
assert_eq!(r["success"], json!(true), "{r}");
assert!(sibling.is_dir() && r["filesDeleted"].as_array().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn uninstall_refuses_adom_stock_and_unknown() {
let root = unique_temp_dir("kb-uninstall-b");
let ctx = uninstall_ctx(&root);
assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Adom"}))["errorCode"], json!("adom_library_protected"));
assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Resistor_SMD"}))["errorCode"], json!("stock_library_protected"));
assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Nope"}))["errorCode"], json!("library_not_registered"));
assert_eq!(uninstall_library(&ctx, false, &json!({}))["errorCode"], json!("library_name_required"));
assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "libraryType": "banana"}))["errorCode"], json!("bad_library_type"));
assert!(LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap().has_library("Resistor_SMD"));
let _ = std::fs::remove_dir_all(&root);
}
}