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);
    }
}