master
John Lauer Verify deletions after commit and separate zone refill from reconciliation 98495bb 1mo ago
//! Conservative native zone-plan preparation. Ownership is explicit; full definitions,
//! including holes, arcs and settings, decide equality. Equal area never does.
use std::collections::HashSet;
use serde_json::{json, Value};
use crate::{pcb::{Board, RoutingError}, schematic::{Document, Node}, zones::{self,ZoneSpec}};

pub struct Prepared {
    pub candidate: String,
    pub remove: Vec<String>,
    pub unchanged: Vec<String>,
    pub create: Vec<ZoneSpec>,
}
fn fail(message: impl Into<String>) -> RoutingError {
    RoutingError::new("zone_plan_refused",message).with("mutated",json!(false))
}
fn canonical(n: &Node) -> Value {
    if !n.is_list() {
        return json!(n.value());
    }
    let head = n.head().unwrap_or("");
    let mut children: Vec<Value> = n
        .children()
        .iter()
        .filter(|c| {
            // KiCad 10 omits this legacy no-thickness marker and zero priority.
            !matches!(
                (c.head(), c.arg(0).as_deref()),
                (Some("filled_areas_thickness"), Some("no"))
                    | (Some("priority"), Some("0"))
                    | (Some("locked"), Some("no"))
            )
        })
        .map(canonical)
        .collect();
    // Fill yes describes generated fill state, not the requested copper definition.
    if head == "fill" {
        children.retain(|v| v != "yes");
    }
    // Metadata order is insignificant. Polygon/point order and all unknown
    // properties remain significant; never collapse holes or arcs to area.
    if matches!(head, "zone" | "fill" | "connect_pads" | "keepout") {
        children.sort_by_key(Value::to_string);
    }
    json!(children)
}
fn definition_node(n: &Node) -> Node {
    let mut copy=n.clone();
    if let Some(cs)=copy.children_mut() {
        cs.retain(|c| !matches!(c.head(),Some("uuid"|"filled_polygon"|"fill_segments")));
    }
    copy
}
pub fn definition(n: &Node) -> Value { canonical(&definition_node(n)) }
pub fn readback(text: &str) -> Result<Value,String> {
    let doc=Document::parse(text)?;
    Ok(json!(doc.root.children().iter().filter(|n|n.head()==Some("zone")).map(|n|json!({"id":n.child_value("uuid"),"definition":definition(n),"definitionSexpr":definition_node(n).to_text()})).collect::<Vec<_>>()))
}
pub fn prepare(text:&str, board:&Board, args:&Value) -> Result<Prepared,RoutingError> {
    let ids=args["ownedItemIds"].as_array().ok_or_else(||fail("ownedItemIds must be an explicit array, empty for a first plan"))?;
    if ids.len()>256 || ids.iter().any(|v|v.as_str().map(|s|s.is_empty()).unwrap_or(true)) { return Err(fail("ownedItemIds must contain at most 256 nonempty strings")); }
    let owned:HashSet<String>=ids.iter().map(|v|v.as_str().unwrap().to_string()).collect();
    if owned.len()!=ids.len(){return Err(fail("duplicate ownedItemIds"));}
    let rows=args["zones"].as_array().ok_or_else(||fail("zones must be an explicit array, empty to remove only owned zones"))?;
    for row in rows {
        if row.get("holes").is_some() || row.get("arcs").is_some() { return Err(fail("desired holes/arcs are not supported by this polygon request schema; no edit performed")); }
    }
    let specs=if rows.is_empty(){Vec::new()}else{zones::parse_specs(board,args)?};
    let mut doc=Document::parse(text).map_err(fail)?;
    let existing:Vec<Node>=doc.root.children().iter().filter(|n|n.head()==Some("zone")).cloned().collect();
    for id in &owned {
        let n=existing.iter().find(|n|n.child_value("uuid").as_ref()==Some(id)).ok_or_else(||fail(format!("owned ID {id} is not a live zone")))?;
        if n.child("locked").map(|c|c.arg(0).as_deref()!=Some("no")).unwrap_or(false) || n.child("teardrop").is_some(){return Err(fail(format!("owned zone {id} is locked or a teardrop")));}
    }
    let mut names=HashSet::new();
    let mut unchanged=Vec::new();let mut create=Vec::new();let mut blocks=Vec::new();
    for spec in specs {
        if spec.name.is_empty() || !names.insert(spec.name.clone()){return Err(fail("every planned zone requires a unique nonempty name"));}
        let matching:Vec<&Node>=existing.iter().filter(|n|n.child_value("name").as_deref()==Some(&spec.name)).collect();
        if matching.len()>1 || matching.iter().any(|n|!owned.contains(&n.child_value("uuid").unwrap_or_default())){return Err(fail(format!("zone name {:?} is ambiguous or belongs to an unowned zone",spec.name)));}
        let block=zones::zone_sexpr(&spec);let node=crate::schematic::parse_node(&block).map_err(fail)?;
        if let Some(old)=matching.first().filter(|n|definition(n)==definition(&node)) {
            unchanged.push(old.child_value("uuid").unwrap());
        } else {blocks.push(node);create.push(spec);}
    }
    let remove:Vec<String>=owned.into_iter().filter(|id|!unchanged.contains(id)).collect();
    let cs=doc.root.children_mut().ok_or_else(||fail("invalid board root"))?;
    cs.retain(|n|n.head()!=Some("zone") || !remove.contains(&n.child_value("uuid").unwrap_or_default()));
    cs.extend(blocks);
    Ok(Prepared{candidate:doc.to_text(),remove,unchanged,create})
}
#[cfg(test)] mod tests {
    use super::*;
    fn fixture() -> (String,Board,Value) {
        let text="(kicad_pcb (version 20240108) (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\"))) (gr_rect (start 0 0) (end 50 40) (layer \"Edge.Cuts\")))";
        let board=crate::pcb::parse_pcb_text(text,"test.kicad_pcb").unwrap();
        let request=json!({"net":"GND","name":"power","layers":["F.Cu"],"polygon":[[2,2],[8,2],[8,8],[2,8]]});
        (text.into(),board,request)
    }
    #[test]fn preserves_foreign_and_refuses_ownership_conflicts() {
        let (text,board,request)=fixture();
        let first=prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).unwrap();
        let doc=Document::parse(&first.candidate).unwrap();
        let id=doc.root.children().iter().find(|n|n.head()==Some("zone")).unwrap().child_value("uuid").unwrap();
        let unrelated=prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[]})).unwrap();
        assert_eq!(unrelated.candidate,first.candidate);
        assert!(prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).is_err());
        let same=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[request.clone()]})).unwrap();
        assert_eq!(same.unchanged,vec![id.clone()]);assert!(same.remove.is_empty() && same.create.is_empty());
        let mut changed=request;changed["clearance"]=json!(0.5);
        let update=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[changed]})).unwrap();
        assert_eq!(update.remove,vec![id]);assert_eq!(update.create.len(),1);
        assert!(prepare(&text,&board,&json!({"ownedItemIds":["foreign"],"zones":[]})).is_err());
        assert!(prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[{"holes":[]}]})).is_err());
    }
    #[test]fn equality_keeps_shape_holes_and_rules() {
        let a=crate::schematic::parse_node("(zone (uuid a) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))").unwrap();
        let b=crate::schematic::parse_node("(zone (uuid b) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))) (filled_polygon (pts (xy 0 0))))").unwrap();
        assert_eq!(definition(&a),definition(&b));
        for changed in [a.to_text().replace("F.Cu","B.Cu"),a.to_text().replace("(xy 0 0)","(xy 10 10)"),a.to_text().replace("(name p)","(name p) (keepout (tracks not_allowed))")] {
            assert_ne!(definition(&a),definition(&crate::schematic::parse_node(&changed).unwrap()));
        }
    }

    #[test]
    fn native_saved_zone_defaults_are_idempotent() {
        let (text, board, request) = fixture();
        let first = prepare(&text, &board, &json!({"ownedItemIds": [], "zones": [request.clone()]})).unwrap();
        let doc = Document::parse(&first.candidate).unwrap();
        let zone = doc.root.children().iter().find(|n| n.head() == Some("zone")).unwrap();
        let id = zone.child_value("uuid").unwrap();
        // KiCad's saved copper zone omits the legacy filled_areas marker,
        // moves island mode after thermal settings, and carries generated fills.
        let mut saved = zone.clone();
        saved.children_mut().unwrap().retain(|n| n.head() != Some("filled_areas_thickness"));
        let fill = saved.children_mut().unwrap().iter_mut().find(|n| n.head() == Some("fill")).unwrap();
        let children = fill.children_mut().unwrap();
        let index = children.iter().position(|n| n.head() == Some("island_removal_mode")).unwrap();
        let mode = children.remove(index); children.push(mode);
        saved.children_mut().unwrap().push(crate::schematic::parse_node("(filled_polygon (layer F.Cu) (pts (xy 2 2) (xy 8 2) (xy 8 8)))").unwrap());
        assert_eq!(definition(zone), definition(&saved));
        let native_text = first.candidate.replace(&zone.to_text(), &saved.to_text());
        let again = prepare(&native_text, &board, &json!({"ownedItemIds": [id.clone()], "zones": [request]})).unwrap();
        assert_eq!(again.unchanged, vec![id]);
        assert!(again.remove.is_empty() && again.create.is_empty());
        for extra in ["(island_removal_mode 1)", "(island_removal_mode 2)"] {
            let other = saved.to_text().replace("(island_removal_mode 0)", extra);
            assert_ne!(definition(&saved), definition(&crate::schematic::parse_node(&other).unwrap()));
        }
    }
}