app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
0009b41
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
//! KiCad projects as files: create (KiCad 10 .kicad_pro, blank .kicad_sch and .kicad_pcb),
//! describe, rename (the files and the references inside them) and snapshot. The verb layer
//! adds the PDFs through kicad-cli and the "is KiCad holding it" refusal.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{json, Map, Value};
use crate::libraries::write_atomic;
use crate::schematic::{new_uuid, quote, Document, Kind, Node};
/// KiCad 10 file format versions.
pub const SCH_VERSION: &str = "20260306";
pub const PCB_VERSION: &str = "20260206";
const BLANK_PCB: &str = include_str!("../../../../templates/blank-board.kicad_pcb");
fn n(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {
json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})
}
/// A project name KiCad and every OS accept as a file stem.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 120 && !name.starts_with('.') && !name.ends_with('.') && !name.ends_with(' ') && name.chars().all(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') && !c.is_control())
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
/// The .kicad_pro KiCad 10 writes for a new project, trimmed to the keys that matter;
/// KiCad fills every other setting with its defaults on first open.
pub fn project_json(name: &str, root_uuid: &str, text_vars: &BTreeMap<String, String>) -> String {
let vars: Map<String, Value> = text_vars.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
let doc = json!({
"board": {"design_settings": {"defaults": {}, "rules": {}, "track_widths": [], "via_dimensions": []}},
"meta": {"filename": format!("{name}.kicad_pro"), "version": 3},
"net_settings": {
"classes": [{
"bus_width": 12, "clearance": 0.2, "diff_pair_gap": 0.25, "diff_pair_via_gap": 0.25, "diff_pair_width": 0.2,
"line_style": 0, "microvia_diameter": 0.3, "microvia_drill": 0.1, "name": "Default", "pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 2147483647, "schematic_color": "rgba(0, 0, 0, 0.000)", "track_width": 0.2, "via_diameter": 0.6, "via_drill": 0.3, "wire_width": 6
}],
"meta": {"version": 5}, "net_colors": null, "netclass_assignments": null, "netclass_patterns": []
},
"pcbnew": {"last_paths": {"gencad": "", "idf": "", "netlist": "", "plot": "", "pos_files": "", "specctra_dsn": "", "step": "", "svg": "", "vrml": ""}, "page_layout_descr_file": ""},
"schematic": {"legacy_lib_dir": "", "legacy_lib_list": [], "top_level_sheets": [{"filename": format!("{name}.kicad_sch"), "name": name, "uuid": root_uuid}]},
"sheets": [[root_uuid, "Root"]],
"text_variables": vars,
});
serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n"
}
/// A blank KiCad 10 schematic with an optional title. Returns (text, root uuid).
pub fn blank_schematic(title: Option<&str>) -> (String, String) {
let uuid = new_uuid();
let tb = title.map(|t| format!("\t(title_block\n\t\t(title {})\n\t)\n", quote(t))).unwrap_or_default();
let text = format!(
"(kicad_sch\n\t(version {SCH_VERSION})\n\t(generator \"eeschema\")\n\t(generator_version \"10.0\")\n\t(uuid \"{uuid}\")\n\t(paper \"A4\")\n{tb}\t(lib_symbols)\n\t(sheet_instances\n\t\t(path \"/\"\n\t\t\t(page \"1\")\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n"
);
(text, uuid)
}
/// The blank KiCad 10 board (templates/blank-board.kicad_pcb) with `layers` copper layers
/// (2 to 32, even) and an optional title block.
pub fn blank_board(title: Option<&str>, layers: u32) -> Result<String, String> {
if !(2..=32).contains(&layers) || layers % 2 != 0 {
return Err(format!("layers must be an even number from 2 to 32 (got {layers})"));
}
let mut text = BLANK_PCB.replace("\r\n", "\n");
let anchor = "\t\t(0 \"F.Cu\" signal)\n";
if !text.contains(anchor) {
return Err("the blank board template has no F.Cu layer line".into());
}
let inner: String = (1..layers - 1).map(|k| format!("\t\t({} \"In{k}.Cu\" signal)\n", 2 + 2 * k)).collect();
text = text.replacen(anchor, &format!("{anchor}{inner}"), 1);
if let Some(t) = title {
let paper = "\t(paper \"A4\")\n";
text = text.replacen(paper, &format!("{paper}\t(title_block\n\t\t(title {})\n\t)\n", quote(t)), 1);
}
Ok(text)
}
pub struct CreateOpts<'a> {
pub title: Option<&'a str>,
pub layers: u32,
pub fab_rules: Option<&'a str>,
pub text_vars: BTreeMap<String, String>,
pub dry_run: bool,
}
/// Write `<parent>/<name>/{name}.kicad_pro|.kicad_sch|.kicad_pcb`. Refuses a folder that
/// already holds a .kicad_pro. Every file is parsed again before anything is written.
pub fn create(parent: &Path, name: &str, o: &CreateOpts) -> Result<Value, Value> {
if !valid_name(name) {
return Err(err("bad_arg", format!("not a usable project name: {name:?}"), "Use letters, digits, spaces, '-', '_' or '.'; no path separators."));
}
let dir = parent.join(name);
if dir.is_dir() {
let existing: Vec<String> = std::fs::read_dir(&dir).map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|f| f.ends_with(".kicad_pro")).collect()).unwrap_or_default();
if !existing.is_empty() {
return Err(err("project_exists", format!("{} already holds {}", n(&dir), existing.join(", ")), "Pick another name or folder; kicad_project_info reads the existing project."));
}
}
let mut vars = o.text_vars.clone();
if let Some(r) = o.fab_rules {
vars.insert("FAB_RULES".into(), r.to_string());
}
let (sch, root) = blank_schematic(o.title);
let pcb = blank_board(o.title, o.layers).map_err(|e| err("bad_arg", e, "Pass layers: 2, 4, 6, ..."))?;
let pro = project_json(name, &root, &vars);
// Round-trip with the project's own parsers before writing anything.
Document::parse(&sch).map_err(|e| err("internal", format!("generated schematic does not parse: {e}"), "File a bug."))?;
let b = crate::pcb::parse_pcb_text(&pcb, "new").map_err(|e| err("internal", format!("generated board does not parse: {e}"), "File a bug."))?;
if crate::pcb::copper_layers(&b).len() as u32 != o.layers {
return Err(err("internal", "generated board has the wrong copper count", "File a bug."));
}
serde_json::from_str::<Value>(&pro).map_err(|e| err("internal", format!("generated project is not JSON: {e}"), "File a bug."))?;
let files = [(format!("{name}.kicad_pro"), pro), (format!("{name}.kicad_sch"), sch), (format!("{name}.kicad_pcb"), pcb)];
let paths: Vec<String> = files.iter().map(|(f, _)| n(&dir.join(f))).collect();
if !o.dry_run {
std::fs::create_dir_all(&dir).map_err(|e| err("write_failed", format!("cannot create {}: {e}", n(&dir)), "Pass a parent folder the desktop user can write."))?;
for (f, text) in &files {
write_atomic(&dir.join(f), text.as_bytes()).map_err(|e| err("write_failed", e, "Check permissions on the folder."))?;
}
}
Ok(json!({"projectDir": n(&dir), "files": paths, "rootSheetUuid": root, "copperLayers": o.layers, "textVariables": vars, "dryRun": o.dry_run}))
}
// ---------------------------------------------------------------------------
// Locate and describe
// ---------------------------------------------------------------------------
/// The .kicad_pro for a path that is the project file, one of its documents, or its folder.
pub fn resolve_pro(path: &Path) -> Result<PathBuf, Value> {
if path.is_dir() {
let mut pros: Vec<PathBuf> = std::fs::read_dir(path).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_pro").unwrap_or(false)).collect()).unwrap_or_default();
pros.sort();
return match pros.len() {
1 => Ok(pros.remove(0)),
0 => Err(err("project_not_found", format!("no .kicad_pro in {}", n(path)), "Pass the .kicad_pro itself, or kicad_project_create to make one.")),
_ => Err(err("ambiguous_project", format!("{} projects in {}", pros.len(), n(path)), format!("Pass one of: {}", pros.iter().map(|p| n(p)).collect::<Vec<_>>().join(", ")))),
};
}
let ext = path.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default();
let pro = if ext == "kicad_pro" { path.to_path_buf() } else { path.with_extension("kicad_pro") };
if pro.is_file() {
Ok(pro)
} else {
Err(err("project_not_found", format!("project file not found: {}", n(&pro)), "Pass the absolute path of the .kicad_pro (or its folder)."))
}
}
fn iso(t: std::time::SystemTime) -> String {
let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + if mo <= 2 { 1 } else { 0 };
format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
}
/// The child sheets a schematic references: (sheet name, file name).
pub fn child_sheets(text: &str) -> Vec<(String, String)> {
let Ok(doc) = Document::parse(text) else { return Vec::new() };
doc.root
.children()
.iter()
.filter(|c| c.head() == Some("sheet"))
.filter_map(|s| {
let prop = |k: &str| s.children().iter().find(|p| p.head() == Some("property") && p.arg(0).as_deref() == Some(k)).and_then(|p| p.arg(1));
let file = prop("Sheetfile").or_else(|| prop("Sheet file"))?;
Some((prop("Sheetname").or_else(|| prop("Sheet name")).unwrap_or_default(), file))
})
.collect()
}
/// kicad_project_info: name, files, sheet tree, board summary, text variables, net classes.
pub fn info(pro: &Path) -> Result<Value, Value> {
let dir = pro.parent().unwrap_or(Path::new("."));
let name = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let text = std::fs::read_to_string(pro).map_err(|e| err("read_failed", format!("cannot read {}: {e}", n(pro)), "Check the path."))?;
let doc: Value = serde_json::from_str(&text).map_err(|e| err("bad_project", format!("{} is not JSON: {e}", n(pro)), "A KiCad 5 .pro is not supported; open it in KiCad 10 once to convert."))?;
let mut files: Vec<Value> = Vec::new();
let mut latest: Option<std::time::SystemTime> = None;
let mut entries: Vec<_> = std::fs::read_dir(dir).map(|rd| rd.flatten().collect()).unwrap_or_default();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let p = e.path();
let fname = e.file_name().to_string_lossy().to_string();
let is_kicad = fname.ends_with(".kicad_pro") || fname.ends_with(".kicad_sch") || fname.ends_with(".kicad_pcb") || fname.ends_with(".kicad_prl") || fname.ends_with(".kicad_dru") || fname.ends_with(".kicad_sym") || fname == "sym-lib-table" || fname == "fp-lib-table" || fname.ends_with(".pretty") || fname.ends_with(".kicad_jobset");
if !is_kicad {
continue;
}
let m = e.metadata().ok();
let modified = m.as_ref().and_then(|m| m.modified().ok());
if let Some(t) = modified {
if latest.map(|l| t > l).unwrap_or(true) {
latest = Some(t);
}
}
files.push(json!({"name": fname, "path": n(&p), "bytes": m.as_ref().filter(|m| m.is_file()).map(|m| m.len()), "dir": p.is_dir(), "modified": modified.map(iso)}));
}
// Sheet tree from the root schematic, depth-first, cycles cut.
let root_file = doc.pointer("/schematic/top_level_sheets/0/filename").and_then(Value::as_str).map(str::to_string).unwrap_or(format!("{name}.kicad_sch"));
let mut sheets: Vec<Value> = Vec::new();
let mut stack: Vec<(String, String, usize)> = vec![("Root".into(), root_file.clone(), 0)];
let mut seen = std::collections::BTreeSet::new();
while let Some((sname, file, depth)) = stack.pop() {
let path = dir.join(&file);
let exists = path.is_file();
sheets.push(json!({"name": sname, "file": file, "depth": depth, "exists": exists}));
if !exists || !seen.insert(file.clone()) || depth > 32 {
continue;
}
if let Ok(t) = std::fs::read_to_string(&path) {
for (cn, cf) in child_sheets(&t).into_iter().rev() {
stack.push((cn, cf, depth + 1));
}
}
}
let board_path = dir.join(format!("{name}.kicad_pcb"));
let board = if board_path.is_file() {
match crate::pcb::parse_pcb(&board_path) {
Ok(b) => json!({
"file": n(&board_path), "copperLayers": crate::pcb::copper_layers(&b), "footprints": b.footprints.len(),
"nets": b.nets.iter().filter(|x| !x.name.is_empty()).count(), "tracks": b.segments.len(), "vias": b.vias.len(), "zones": b.zones.len(),
"size": b.dimensions.as_ref().map(|d| json!({"widthMm": d.width, "heightMm": d.height})), "version": b.version,
}),
Err(e) => json!({"file": n(&board_path), "error": e}),
}
} else {
Value::Null
};
let classes: Vec<Value> = doc.pointer("/net_settings/classes").and_then(Value::as_array).map(|a| a.iter().map(|c| json!({"name": c.get("name"), "clearance": c.get("clearance"), "trackWidth": c.get("track_width"), "viaDiameter": c.get("via_diameter"), "viaDrill": c.get("via_drill")})).collect()).unwrap_or_default();
let assignments = doc.pointer("/net_settings/netclass_patterns").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0);
Ok(json!({
"name": name,
"projectFile": n(pro),
"projectDir": n(dir),
"files": files,
"rootSchematic": root_file,
"sheets": sheets,
"board": board,
"textVariables": doc.get("text_variables").cloned().unwrap_or(json!({})),
"netClasses": classes,
"netClassPatterns": assignments,
"lastModified": latest.map(iso),
"locks": open_locks(dir),
}))
}
/// KiCad's lock files in a project folder (`~<file>.lck`): any of them means an editor
/// holds a document of this project.
pub fn open_locks(dir: &Path) -> Vec<String> {
let mut out: Vec<String> = std::fs::read_dir(dir)
.map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|f| f.starts_with('~') && f.ends_with(".lck")).collect())
.unwrap_or_default();
out.sort();
out
}
// ---------------------------------------------------------------------------
// Rename
// ---------------------------------------------------------------------------
#[derive(Debug, Default)]
pub struct RenamePlan {
/// (new path, new content) written before the old files are removed.
pub writes: Vec<(PathBuf, String)>,
pub removes: Vec<PathBuf>,
pub moves: Vec<(PathBuf, PathBuf)>,
pub changes: Vec<String>,
}
impl RenamePlan {
pub fn public(&self) -> Value {
json!({
"writes": self.writes.iter().map(|(p, _)| n(p)).collect::<Vec<_>>(),
"removes": self.removes.iter().map(|p| n(p)).collect::<Vec<_>>(),
"moves": self.moves.iter().map(|(a, b)| json!({"from": n(a), "to": n(b)})).collect::<Vec<_>>(),
"changes": self.changes,
})
}
}
/// Replace, in a schematic, every `(project "old" ...)` instance header with the new name.
fn rename_in_schematic(text: &str, old: &str, new: &str) -> Result<(String, usize), String> {
let mut doc = Document::parse(text)?;
fn walk(node: &mut Node, old: &str, new: &str, count: &mut usize) {
if node.head() == Some("project") && node.arg(0).as_deref() == Some(old) {
node.set_arg(0, Node::quoted(new));
*count += 1;
}
if let Kind::List { children, .. } = &mut node.kind {
for c in children.iter_mut() {
walk(c, old, new, count);
}
}
}
let mut count = 0;
walk(&mut doc.root, old, new, &mut count);
Ok((doc.to_text(), count))
}
/// In a board: footprints' `(sheetfile "old.kicad_sch")` and a title equal to the old name.
fn rename_in_board(text: &str, old: &str, new: &str) -> Result<(String, usize), String> {
let mut doc = Document::parse(text)?;
let old_sch = format!("{old}.kicad_sch");
let new_sch = format!("{new}.kicad_sch");
let mut count = 0;
if let Some(children) = doc.root.children_mut() {
for c in children.iter_mut() {
match c.head() {
Some("footprint") => {
if let Some(sf) = c.child_mut("sheetfile") {
if sf.arg(0).as_deref() == Some(old_sch.as_str()) {
sf.set_arg(0, Node::quoted(&new_sch));
count += 1;
}
}
}
Some("title_block") => {
if let Some(t) = c.child_mut("title") {
if t.arg(0).as_deref() == Some(old) {
t.set_arg(0, Node::quoted(new));
count += 1;
}
}
}
_ => {}
}
}
}
let out = doc.to_text();
crate::pcb::parse_pcb_text(&out, "renamed").map_err(|e| format!("renamed board does not parse: {e}"))?;
Ok((out, count))
}
/// Plan renaming the project at `pro` to `new`: the four files (and .kicad_dru, the
/// backups folder) get the new stem; the project JSON, every schematic's instance headers
/// and the board's sheet references follow. Nothing is touched until `apply`.
pub fn rename_plan(pro: &Path, new: &str) -> Result<RenamePlan, Value> {
if !valid_name(new) {
return Err(err("bad_arg", format!("not a usable project name: {new:?}"), "Use letters, digits, spaces, '-', '_' or '.'."));
}
let dir = pro.parent().unwrap_or(Path::new("."));
let old = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
if old == new {
return Err(err("bad_arg", "the new name is the current name", "Pass a different newName."));
}
let mut plan = RenamePlan::default();
let target = |ext: &str| dir.join(format!("{new}.{ext}"));
for ext in ["kicad_pro", "kicad_sch", "kicad_pcb", "kicad_prl", "kicad_dru"] {
let src = dir.join(format!("{old}.{ext}"));
if src.is_file() && target(ext).exists() {
return Err(err("target_exists", format!("{} already exists", n(&target(ext))), "Pick a name no file in the folder uses."));
}
}
// Project JSON.
let pro_text = std::fs::read_to_string(pro).map_err(|e| err("read_failed", e.to_string(), "Check the path."))?;
let mut doc: Value = serde_json::from_str(&pro_text).map_err(|e| err("bad_project", format!("not JSON: {e}"), "Open the project in KiCad 10 once."))?;
doc["meta"]["filename"] = json!(format!("{new}.kicad_pro"));
if let Some(sheets) = doc.pointer_mut("/schematic/top_level_sheets").and_then(Value::as_array_mut) {
for s in sheets {
if s.get("filename").and_then(Value::as_str) == Some(format!("{old}.kicad_sch").as_str()) {
s["filename"] = json!(format!("{new}.kicad_sch"));
if s.get("name").and_then(Value::as_str) == Some(old.as_str()) {
s["name"] = json!(new);
}
}
}
}
plan.writes.push((target("kicad_pro"), serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n"));
plan.removes.push(pro.to_path_buf());
plan.changes.push("project meta.filename and the top-level sheet entry".into());
// Local settings.
let prl = dir.join(format!("{old}.kicad_prl"));
if prl.is_file() {
let t = std::fs::read_to_string(&prl).unwrap_or_default();
let new_text = match serde_json::from_str::<Value>(&t) {
Ok(mut v) => {
if v.get("meta").is_some() {
v["meta"]["filename"] = json!(format!("{new}.kicad_prl"));
}
serde_json::to_string_pretty(&v).unwrap_or(t) + "\n"
}
Err(_) => t,
};
plan.writes.push((target("kicad_prl"), new_text));
plan.removes.push(prl);
}
// Every schematic in the folder: instance headers name the project.
let mut schs: Vec<PathBuf> = std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_sch").unwrap_or(false)).collect()).unwrap_or_default();
schs.sort();
for sch in schs {
let t = std::fs::read_to_string(&sch).map_err(|e| err("read_failed", e.to_string(), "Check permissions."))?;
let (out, count) = rename_in_schematic(&t, &old, new).map_err(|e| err("bad_schematic", format!("{}: {e}", n(&sch)), "Fix or remove the broken schematic first."))?;
let is_root = sch.file_stem().map(|s| s.to_string_lossy() == old).unwrap_or(false);
if is_root {
plan.writes.push((target("kicad_sch"), out));
plan.removes.push(sch.clone());
plan.changes.push(format!("root schematic renamed, {count} instance header(s) updated"));
} else if count > 0 {
plan.writes.push((sch.clone(), out));
plan.changes.push(format!("{}: {count} instance header(s) updated", sch.file_name().unwrap().to_string_lossy()));
}
}
let pcb = dir.join(format!("{old}.kicad_pcb"));
if pcb.is_file() {
let t = std::fs::read_to_string(&pcb).map_err(|e| err("read_failed", e.to_string(), "Check permissions."))?;
let (out, count) = rename_in_board(&t, &old, new).map_err(|e| err("bad_board", e, "Fix the board first (kicad_lint_board)."))?;
plan.writes.push((target("kicad_pcb"), out));
plan.removes.push(pcb);
plan.changes.push(format!("board renamed, {count} sheet reference(s) updated"));
}
let dru = dir.join(format!("{old}.kicad_dru"));
if dru.is_file() {
plan.moves.push((dru, target("kicad_dru")));
}
let backups = dir.join(format!("{old}-backups"));
if backups.is_dir() && !dir.join(format!("{new}-backups")).exists() {
plan.moves.push((backups, dir.join(format!("{new}-backups"))));
}
// Every written file must parse again.
for (p, text) in &plan.writes {
let ext = p.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let ok = match ext.as_str() {
"kicad_pro" | "kicad_prl" => serde_json::from_str::<Value>(text).is_ok() || ext == "kicad_prl",
"kicad_sch" | "kicad_pcb" => Document::parse(text).is_ok(),
_ => true,
};
if !ok {
return Err(err("internal", format!("rewritten {} does not parse", n(p)), "File a bug; nothing was changed."));
}
}
Ok(plan)
}
/// Write the new files, then move and remove the old ones.
pub fn apply(plan: &RenamePlan) -> Result<(), String> {
for (p, text) in &plan.writes {
write_atomic(p, text.as_bytes())?;
}
for (a, b) in &plan.moves {
std::fs::rename(a, b).map_err(|e| format!("cannot move {} to {}: {e}", n(a), n(b)))?;
}
let written: Vec<&PathBuf> = plan.writes.iter().map(|(p, _)| p).collect();
for p in &plan.removes {
if !written.contains(&p) {
std::fs::remove_file(p).map_err(|e| format!("cannot remove {}: {e}", n(p)))?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Snapshot
// ---------------------------------------------------------------------------
/// The files a snapshot copies: the project's KiCad documents and local tables (top level
/// of the project folder only; libraries in sub-folders are referenced, not copied).
pub fn snapshot_files(dir: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
let f = p.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
!f.starts_with('~') && (["kicad_pro", "kicad_sch", "kicad_pcb", "kicad_prl", "kicad_dru", "kicad_sym", "kicad_jobset"].iter().any(|x| f.ends_with(&format!(".{x}"))) || f == "sym-lib-table" || f == "fp-lib-table")
})
.collect()
})
.unwrap_or_default();
out.sort();
out
}
/// A filesystem-safe label.
pub fn safe_label(s: &str) -> String {
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect::<String>().trim_matches('-').chars().take(40).collect()
}
/// Copy the project files into `<dir>/snapshots/<stamp>[-label]/`. Returns (folder, copied).
pub fn snapshot(pro: &Path, stamp: &str, label: Option<&str>) -> Result<(PathBuf, Vec<String>), String> {
let dir = pro.parent().unwrap_or(Path::new("."));
let name = match label.map(safe_label).filter(|l| !l.is_empty()) {
Some(l) => format!("{stamp}-{l}"),
None => stamp.to_string(),
};
let mut dest = dir.join("snapshots").join(&name);
let mut k = 2;
while dest.exists() {
dest = dir.join("snapshots").join(format!("{name}-{k}"));
k += 1;
}
std::fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", n(&dest)))?;
let mut copied = Vec::new();
for f in snapshot_files(dir) {
let to = dest.join(f.file_name().unwrap());
std::fs::copy(&f, &to).map_err(|e| format!("cannot copy {}: {e}", n(&f)))?;
copied.push(f.file_name().unwrap().to_string_lossy().to_string());
}
Ok((dest, copied))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("kc-project-{tag}-{}-{}", std::process::id(), new_uuid()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn create_writes_three_files_that_parse_again() {
let d = tmp("create");
let o = CreateOpts { title: Some("Widget \"A\""), layers: 4, fab_rules: Some("jlcpcb"), text_vars: BTreeMap::new(), dry_run: false };
let r = create(&d, "widget", &o).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 3);
let pdir = d.join("widget");
let pro: Value = serde_json::from_str(&std::fs::read_to_string(pdir.join("widget.kicad_pro")).unwrap()).unwrap();
assert_eq!(pro["meta"]["filename"], "widget.kicad_pro");
assert_eq!(pro["text_variables"]["FAB_RULES"], "jlcpcb");
assert_eq!(pro["schematic"]["top_level_sheets"][0]["uuid"], r["rootSheetUuid"]);
let sch = std::fs::read_to_string(pdir.join("widget.kicad_sch")).unwrap();
assert!(sch.contains("(version 20260306)") && sch.contains("(title \"Widget \\\"A\\\"\")"));
Document::parse(&sch).unwrap();
let b = crate::pcb::parse_pcb(&pdir.join("widget.kicad_pcb")).unwrap();
assert_eq!(crate::pcb::copper_layers(&b), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);
assert_eq!(b.title_block["title"], "Widget \"A\"");
let again = create(&d, "widget", &o).unwrap_err();
assert_eq!(again["errorCode"], "project_exists");
assert_eq!(create(&d, "a/b", &o).unwrap_err()["errorCode"], "bad_arg");
let dry = create(&d, "dry", &CreateOpts { dry_run: true, layers: 2, ..o }).unwrap();
assert_eq!(dry["dryRun"], true);
assert!(!d.join("dry").exists());
assert!(blank_board(None, 3).is_err());
let _ = std::fs::remove_dir_all(&d);
}
fn sample_project(d: &Path) -> PathBuf {
let o = CreateOpts { title: Some("old"), layers: 2, fab_rules: None, text_vars: [("REV".to_string(), "A".to_string())].into_iter().collect(), dry_run: false };
create(d, "old", &o).unwrap();
let p = d.join("old");
// a child sheet with an instance header, and the root referencing it
let child = "(kicad_sch\n\t(version 20260306)\n\t(generator \"eeschema\")\n\t(uuid \"c1\")\n\t(paper \"A4\")\n\t(lib_symbols)\n\t(symbol\n\t\t(lib_id \"Device:R\")\n\t\t(at 10 10 0)\n\t\t(instances\n\t\t\t(project \"old\"\n\t\t\t\t(path \"/r/s\"\n\t\t\t\t\t(reference \"R1\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n)\n";
std::fs::write(p.join("power.kicad_sch"), child).unwrap();
let root = std::fs::read_to_string(p.join("old.kicad_sch")).unwrap();
let root = root.replace("\t(lib_symbols)\n", "\t(lib_symbols)\n\t(sheet\n\t\t(at 20 20)\n\t\t(size 10 10)\n\t\t(property \"Sheetname\" \"Power\"\n\t\t\t(at 20 19 0)\n\t\t)\n\t\t(property \"Sheetfile\" \"power.kicad_sch\"\n\t\t\t(at 20 31 0)\n\t\t)\n\t\t(instances\n\t\t\t(project \"old\"\n\t\t\t\t(path \"/r\"\n\t\t\t\t\t(page \"2\")\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n");
std::fs::write(p.join("old.kicad_sch"), root).unwrap();
let pcb = std::fs::read_to_string(p.join("old.kicad_pcb")).unwrap();
let pcb = pcb.replace("\t(embedded_fonts no)\n)", "\t(footprint \"R:R\"\n\t\t(layer \"F.Cu\")\n\t\t(at 1 1)\n\t\t(property \"Reference\" \"R1\")\n\t\t(sheetname \"/\")\n\t\t(sheetfile \"old.kicad_sch\")\n\t)\n\t(embedded_fonts no)\n)");
std::fs::write(p.join("old.kicad_pcb"), pcb).unwrap();
std::fs::write(p.join("old.kicad_prl"), "{\n \"meta\": {\n \"filename\": \"old.kicad_prl\",\n \"version\": 5\n }\n}\n").unwrap();
std::fs::write(p.join("old.kicad_dru"), "(version 1)\n").unwrap();
p.join("old.kicad_pro")
}
#[test]
fn info_reports_files_sheets_board_and_variables() {
let d = tmp("info");
let pro = sample_project(&d);
let i = info(&pro).unwrap();
assert_eq!(i["name"], "old");
let sheets: Vec<&str> = i["sheets"].as_array().unwrap().iter().map(|s| s["file"].as_str().unwrap()).collect();
assert_eq!(sheets, vec!["old.kicad_sch", "power.kicad_sch"]);
assert_eq!(i["sheets"][1]["name"], "Power");
assert_eq!(i["board"]["footprints"], 1);
assert_eq!(i["textVariables"]["REV"], "A");
assert_eq!(i["netClasses"][0]["name"], "Default");
assert!(i["lastModified"].as_str().unwrap().ends_with('Z'));
assert_eq!(resolve_pro(&pro.with_extension("kicad_pcb")).unwrap(), pro);
assert_eq!(resolve_pro(pro.parent().unwrap()).unwrap(), pro);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn rename_moves_files_and_rewrites_references() {
let d = tmp("rename");
let pro = sample_project(&d);
let p = pro.parent().unwrap().to_path_buf();
let plan = rename_plan(&pro, "new").unwrap();
assert!(plan.public()["changes"].as_array().unwrap().len() >= 3);
apply(&plan).unwrap();
for f in ["new.kicad_pro", "new.kicad_sch", "new.kicad_pcb", "new.kicad_prl", "new.kicad_dru", "power.kicad_sch"] {
assert!(p.join(f).is_file(), "{f}");
}
for f in ["old.kicad_pro", "old.kicad_sch", "old.kicad_pcb", "old.kicad_prl", "old.kicad_dru"] {
assert!(!p.join(f).exists(), "{f} should be gone");
}
let pro2: Value = serde_json::from_str(&std::fs::read_to_string(p.join("new.kicad_pro")).unwrap()).unwrap();
assert_eq!(pro2["meta"]["filename"], "new.kicad_pro");
assert_eq!(pro2["schematic"]["top_level_sheets"][0]["filename"], "new.kicad_sch");
assert_eq!(pro2["schematic"]["top_level_sheets"][0]["name"], "new");
let child = std::fs::read_to_string(p.join("power.kicad_sch")).unwrap();
assert!(child.contains("(project \"new\"") && !child.contains("\"old\""));
let root = std::fs::read_to_string(p.join("new.kicad_sch")).unwrap();
assert!(root.contains("(project \"new\"") && root.contains("(property \"Sheetfile\" \"power.kicad_sch\""));
let pcb = std::fs::read_to_string(p.join("new.kicad_pcb")).unwrap();
assert!(pcb.contains("(sheetfile \"new.kicad_sch\")") && pcb.contains("(title \"new\")"));
let prl: Value = serde_json::from_str(&std::fs::read_to_string(p.join("new.kicad_prl")).unwrap()).unwrap();
assert_eq!(prl["meta"]["filename"], "new.kicad_prl");
assert_eq!(rename_plan(&p.join("new.kicad_pro"), "new").unwrap_err()["errorCode"], "bad_arg");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn rename_refuses_an_existing_target_and_locks_are_seen() {
let d = tmp("rename-refuse");
let pro = sample_project(&d);
let p = pro.parent().unwrap();
std::fs::write(p.join("taken.kicad_pcb"), "x").unwrap();
assert_eq!(rename_plan(&pro, "taken").unwrap_err()["errorCode"], "target_exists");
assert!(open_locks(p).is_empty());
std::fs::write(p.join("~old.kicad_sch.lck"), "{}").unwrap();
assert_eq!(open_locks(p), vec!["~old.kicad_sch.lck"]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn snapshot_copies_the_project_files() {
let d = tmp("snap");
let pro = sample_project(&d);
let (dest, copied) = snapshot(&pro, "20261027-120000", Some("before route!")).unwrap();
assert!(dest.ends_with("snapshots/20261027-120000-before-route"));
assert!(copied.contains(&"old.kicad_pcb".to_string()) && copied.contains(&"power.kicad_sch".to_string()) && copied.contains(&"old.kicad_dru".to_string()));
let (dest2, _) = snapshot(&pro, "20261027-120000", Some("before route!")).unwrap();
assert_ne!(dest, dest2, "a second snapshot in the same second gets its own folder");
let _ = std::fs::remove_dir_all(&d);
}
}