master
John Lauer Product name out of hints, examples and docs (wiki #118); skills package 1.0.152 4c212c6 1mo ago
"""Design rules by fab (wiki adom/kicad-bridge#73).

The canonical per-vendor rules live on the wiki page adom/pcb-design-rules,
which renders one KiCad custom-rules file per (vendor, process, layer count):

    https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/<slug>[-<process>]-<layers>L.kicad_dru

and a machine-readable index at .../render/rules/index.json. A rule change is
therefore a wiki publish, not a bridge release: this module never carries rule
values, it only resolves (fab, layers) to the page's file, stages it next to
the board as <board stem>.kicad_dru (the name KiCad loads automatically), and
optionally runs DRC so the caller sees the fab's limits applied.

The 3rd party fab is the baseline and is listed first.
"""

from __future__ import annotations

import json
import shutil
import time
import urllib.request
from pathlib import Path

PAGE = "https://wiki.adom.inc/adom/pcb-design-rules/render/"
INDEX_URL = PAGE + "rules/index.json"
HOUSE_SLUG = "adom-instapcb"
_TIMEOUT = 20


def _fetch(url: str) -> bytes:
    req = urllib.request.Request(url, headers={"User-Agent": "adom-bridge-kicad design_rules"})
    with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:  # nosec - fixed wiki host
        return r.read()


def _index() -> list[dict]:
    data = json.loads(_fetch(INDEX_URL).decode("utf-8"))
    vendors = data.get("vendors") or []
    # Baseline fab first, then the page's order.
    vendors.sort(key=lambda v: 0 if v.get("vendorSlug") == HOUSE_SLUG else 1)
    return vendors


def _norm(s: str) -> str:
    return "".join(ch for ch in (s or "").lower() if ch.isalnum())


def _match_fab(vendors: list[dict], fab: str) -> list[dict]:
    """Accept the slug, the display name, or a distinctive substring of either."""
    want = _norm(fab)
    if not want:
        return []
    exact = [v for v in vendors if _norm(v.get("vendorSlug")) == want or _norm(v.get("vendor")) == want]
    if exact:
        return exact
    return [v for v in vendors if want in _norm(v.get("vendorSlug")) or want in _norm(v.get("vendor"))]


def handle_list_design_rules(kicad_info: dict, args: dict) -> dict:
    """List the fabs, processes and layer counts the wiki page carries."""
    try:
        vendors = _index()
    except Exception as e:  # pylint: disable=broad-except
        return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",
                "_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}
    fab = args.get("fab")
    if fab:
        vendors = _match_fab(vendors, fab)
    rows = []
    for v in vendors:
        f = v.get("files") or {}
        rows.append({
            "fab": v.get("vendor"), "slug": v.get("vendorSlug"), "process": v.get("process"),
            "layers": v.get("layerCount"),
            "kicadDruUrl": PAGE + f["kicadDru"] if f.get("kicadDru") else None,
            "boardSetupUrl": PAGE + f["kicadBoardSetup"] if f.get("kicadBoardSetup") else None,
            "house": v.get("vendorSlug") == HOUSE_SLUG,
        })
    return {"success": True, "count": len(rows), "rules": rows, "indexUrl": INDEX_URL,
            "_hint": ("Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. "
                      "Omit fab for the baseline fab (the 3rd party fab). Values come from the wiki page at call time, so do not copy them into code.")}


def handle_set_design_rules(kicad_info: dict, args: dict) -> dict:
    """Stage a fab's KiCad rules file next to a board and (by default) run DRC.

    Args: fab (slug or name; default: the baseline fab), layers (int; default from
    the board's copper layer count when readable, else 2), process (optional),
    boardPath (.kicad_pcb, required), druPath (local override instead of the
    wiki file), runDrc (default true), keepExisting (default false: an existing
    <stem>.kicad_dru is backed up as .kicad_dru.bak-<timestamp> and replaced).
    """
    board = (args.get("boardPath") or args.get("filePath") or "").strip()
    if not board:
        return {"success": False, "error": "boardPath is required",
                "_hint": "Pass {\"boardPath\": \"<absolute path to the .kicad_pcb>\"}. The rules file is staged next to it as <stem>.kicad_dru, which KiCad loads automatically."}
    board_path = Path(board)
    if not board_path.exists() or board_path.suffix.lower() != ".kicad_pcb":
        return {"success": False, "error": f"Board not found or not a .kicad_pcb: {board}",
                "_hint": "The path must exist on the Windows host. Use push_file first if the board lives in the container."}

    dru_target = board_path.with_suffix(".kicad_dru")
    source: dict = {}
    override = (args.get("druPath") or "").strip()
    if override:
        src = Path(override)
        if not src.exists():
            return {"success": False, "error": f"druPath not found: {override}"}
        body = src.read_bytes()
        source = {"kind": "local", "path": str(src)}
    else:
        try:
            vendors = _index()
        except Exception as e:  # pylint: disable=broad-except
            return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",
                    "_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}
        fab = args.get("fab") or HOUSE_SLUG
        cands = _match_fab(vendors, fab)
        if not cands:
            return {"success": False, "error": f"No fab matches {fab!r}", "errorCode": "fab_not_found",
                    "knownFabs": sorted({v.get("vendorSlug") for v in vendors}),
                    "_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}
        layers = args.get("layers")
        if layers is None:
            layers = _board_copper_layers(board_path) or 2
        try:
            layers = int(layers)
        except (TypeError, ValueError):
            return {"success": False, "error": f"layers must be an integer, got {layers!r}"}
        process = args.get("process")
        pick = [v for v in cands if int(v.get("layerCount") or 0) == layers
                and (not process or _norm(v.get("process")) == _norm(process))]
        if not pick:
            return {"success": False, "errorCode": "no_rules_for_layers",
                    "error": f"{cands[0].get('vendor')} has no {layers}-layer{(' ' + process) if process else ''} rules on the page",
                    "available": [{"process": v.get("process"), "layers": v.get("layerCount")} for v in cands],
                    "_hint": "Pick one of `available`, or pass druPath with your own file."}
        v = pick[0]
        rel = (v.get("files") or {}).get("kicadDru")
        if not rel:
            return {"success": False, "error": f"The page lists no KiCad file for {v.get('vendor')} {layers}L"}
        url = PAGE + rel
        try:
            body = _fetch(url)
        except Exception as e:  # pylint: disable=broad-except
            return {"success": False, "error": f"Could not download {url}: {e}"}
        source = {"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),
                  "process": v.get("process"), "layers": layers}

    text = body.decode("utf-8", errors="replace")
    if "(version" not in text[:200] or "(rule" not in text:
        return {"success": False, "errorCode": "not_a_kicad_dru",
                "error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",
                "source": source, "head": text[:160]}

    backup = None
    if dru_target.exists():
        if args.get("keepExisting"):
            return {"success": False, "errorCode": "dru_exists",
                    "error": f"{dru_target.name} already exists and keepExisting is set",
                    "existing": str(dru_target), "source": source,
                    "_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}
        backup = dru_target.with_name(dru_target.name + f".bak-{time.strftime('%Y%m%d-%H%M%S')}")
        shutil.copy2(dru_target, backup)
    dru_target.write_bytes(body)

    rule_names = [ln.split('"')[1] for ln in text.splitlines() if ln.strip().startswith("(rule ") and '"' in ln]
    out = {"success": True, "druPath": str(dru_target), "backup": str(backup) if backup else None,
           "source": source, "ruleCount": len(rule_names), "rules": rule_names[:40]}

    if args.get("runDrc", True):
        try:
            from handlers.kicad_cli_lint import handle_lint_board
            drc = handle_lint_board(kicad_info, {"filePath": str(board_path), "schematicParity": bool(args.get("schematicParity", False))})
        except Exception as e:  # pylint: disable=broad-except
            drc = {"success": False, "error": f"DRC did not run: {e}"}
        out["drc"] = drc
        data = (drc.get("data") or {}) if isinstance(drc, dict) else {}
        summary = data.get("summary") or {}
        viols = data.get("violations") or []
        # The violations that cite one of the fab's rules by name are the ones
        # this verb exists for; list them separately from the board's own errors
        # (a missing outline, dangling tracks) so the caller sees the fab's
        # limits applied, measured (2026-09-03 on ConfRoomROG: "Track width
        # (rule 'min-track-width' min width 0.1000 mm; actual 0.0500 mm)").
        from_rules = [v for v in viols if "(rule '" in str(v.get("description") or "")]
        out["drcSummary"] = summary
        out["fabRuleViolations"] = [{"type": v.get("type"), "severity": v.get("severity"),
                                     "description": v.get("description")} for v in from_rules[:40]]
        out["otherViolations"] = len(viols) - len(from_rules)
        if drc.get("success") is False and not viols:
            out["_hint"] = f"Rules staged as {dru_target.name}, but DRC did not run: {drc.get('error') or drc.get('_hint')}"
        else:
            out["_hint"] = (f"Rules staged as {dru_target.name} (KiCad loads <board stem>.kicad_dru automatically; an open PCB editor applies it on its next DRC run). "
                            f"DRC with these rules: {len(from_rules)} violation(s) cite the fab's rules, {out['otherViolations']} other; "
                            f"totals {json.dumps(summary) if summary else 'n/a'}.")
    else:
        out["_hint"] = f"Rules staged as {dru_target.name}. Run kicad_lint_board or kicad_run_drc on the board to apply them."
    return out


def _board_copper_layers(board_path: Path) -> int | None:
    """Count copper layers from the board's (layers ...) block, cheaply."""
    try:
        head = board_path.read_text(encoding="utf-8", errors="replace")[:20000]
        i = head.find("(layers")
        if i < 0:
            return None
        depth = 0
        j = i
        while j < len(head):
            if head[j] == "(":
                depth += 1
            elif head[j] == ")":
                depth -= 1
                if depth == 0:
                    break
            j += 1
        block = head[i:j]
        import re
        n = len(re.findall(r'\(\d+\s+"[^"]+\.Cu"\s+(?:signal|power|mixed|jumper)', block))
        return n or None
    except Exception:  # pylint: disable=broad-except
        return None