app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::library;
/// Per-file upstream provenance — which ladder source + URL each file
/// came from. Passed into import_path and written to
/// `<MPN>-file-provenance.json` so downstream tools (chipsmith tooltips,
/// wiki chip pages, concur cross-validation notes) can attribute every
/// artifact to its origin without guessing.
#[derive(Clone, Default)]
pub struct FileProvenance {
pub discovery_source: String, // where did I FIND the link? ("digikey", "mouser", etc.)
pub content_origin: String, // who actually MADE the file? ("manufacturer", "ultralibrarian", etc.)
pub url: String, // the download URL the bytes came from
}
pub struct ImportReport {
pub dest_dir: PathBuf,
pub files_added: Vec<String>,
}
/// Import with provenance tracking. If `provenance` is Some, every file
/// that lands gets an entry in `<MPN>-file-provenance.json` mapping
/// `"<MPN>.kicad_sym" → { source, url, fetched_at }`. Chipsmith reads
/// this to show "source: SnapMagic" in the footprint-hover tooltip.
pub fn import_path_with_provenance(
src: &Path,
mpn_override: Option<&str>,
provenance: Option<&FileProvenance>,
) -> Result<ImportReport> {
let report = import_path(src, mpn_override)?;
if let Some(prov) = provenance {
for filename in &report.files_added {
let _ = write_file_provenance(&report.dest_dir, filename, prov);
}
// Keep a per-source VARIANT of the symbol/footprint so multiple sources
// coexist (the provenance carat lists each). Variant filename:
// <mpn>.<method>.kicad_sym, where method = content_origin (who made it).
let method = slugify(if !prov.content_origin.is_empty() { &prov.content_origin } else { &prov.discovery_source });
if !method.is_empty() && method != "ds2sf" {
if let Some(mpn) = report.dest_dir.file_name().and_then(|n| n.to_str()) {
for ext in ["kicad_sym", "kicad_mod"] {
let canon = format!("{mpn}.{ext}");
if report.files_added.iter().any(|f| f == &canon) {
let variant = report.dest_dir.join(format!("{mpn}.{method}.{ext}"));
let _ = std::fs::copy(report.dest_dir.join(&canon), &variant);
}
}
}
}
}
Ok(report)
}
/// Lowercase a source slug to safe filename chars (e.g. "Ultra Librarian" →
/// "ultralibrarian"), so variant filenames are predictable.
fn slugify(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
}
pub fn import_path(src: &Path, mpn_override: Option<&str>) -> Result<ImportReport> {
if !src.exists() {
return Err(anyhow!("path does not exist: {}", src.display()));
}
let mpn = match mpn_override {
Some(m) => m.to_string(),
None => infer_mpn(src)
.ok_or_else(|| anyhow!("could not infer MPN from path '{}' — pass --mpn", src.display()))?,
};
let dest = library::ensure_dir(&mpn)?;
let mut added = Vec::new();
if src.is_file() {
ingest_file(src, &mpn, &dest, &mut added)?;
} else if src.is_dir() {
ingest_dir(src, &mpn, &dest, &mut added)?;
}
Ok(ImportReport { dest_dir: dest, files_added: added })
}
fn ingest_file(file: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"zip" => unzip_and_ingest(file, mpn, dest, added),
"step" | "stp" | "pdf" | "kicad_mod" | "kicad_sym" | "wrl" => {
// Magic-byte validation. Catches the "naked curl returned a
// 174 KB HTML denial page that looks like 200 OK" footgun —
// PDFs are common, STEPs are common, and both have unambiguous
// magic strings. Refuse the import if the bytes don't match.
validate_magic_bytes(file, &ext)
.with_context(|| format!("magic-byte check failed for {}", file.display()))?;
let target_ext = if ext == "stp" { "step" } else { ext.as_str() };
let target_name = format!("{mpn}.{target_ext}");
let target = dest.join(&target_name);
std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;
added.push(target_name.clone());
// Per-MPN fetch-log: append a structured entry every time a
// file lands. Audit trail without trusting AI memory.
let _ = append_fetch_log(dest, mpn, &target_name, file);
Ok(())
}
// Fusion 360 + Altium. Inventory checks exact-case filenames
// (<MPN>.lbr, <MPN>.SchLib, <MPN>.PcbLib, <MPN>.IntLib), so we
// preserve the canonical case on the way in.
"lbr" | "schlib" | "pcblib" | "intlib" | "libpkg" => {
let target_ext = match ext.as_str() {
"lbr" => "lbr",
"schlib" => "SchLib",
"pcblib" => "PcbLib",
"intlib" => "IntLib",
"libpkg" => "LibPkg",
_ => ext.as_str(),
};
let target_name = format!("{mpn}.{target_ext}");
let target = dest.join(&target_name);
std::fs::copy(file, &target).with_context(|| format!("copying {} → {}", file.display(), target.display()))?;
added.push(target_name.clone());
let _ = append_fetch_log(dest, mpn, &target_name, file);
Ok(())
}
_ => Ok(()),
}
}
fn ingest_dir(dir: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
for entry in walk_recursive(dir)? {
ingest_file(&entry, mpn, dest, added)?;
}
Ok(())
}
fn unzip_and_ingest(zip: &Path, mpn: &str, dest: &Path, added: &mut Vec<String>) -> Result<()> {
let tmp = tempfile_dir()?;
let status = Command::new("unzip")
.arg("-q").arg("-o")
.arg(zip)
.arg("-d").arg(&tmp)
.status()
.context("running unzip — install `unzip` if missing")?;
if !status.success() {
return Err(anyhow!("unzip failed for {}", zip.display()));
}
ingest_dir(&tmp, mpn, dest, added)?;
let _ = std::fs::remove_dir_all(&tmp);
Ok(())
}
fn tempfile_dir() -> Result<PathBuf> {
let base = std::env::temp_dir().join(format!("adom-chip-fetcher-{}", std::process::id()));
std::fs::create_dir_all(&base)?;
Ok(base)
}
fn walk_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d)? {
let entry = entry?;
let p = entry.path();
if p.is_dir() { stack.push(p); }
else { out.push(p); }
}
}
Ok(out)
}
/// Infer an MPN from the path. Looks at the filename (and parent zip name)
/// for a token that looks like a part number — letters mixed with digits.
fn infer_mpn(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
for token in stem.split(|c: char| c == '-' || c == '_' || c == ' ' || c == '.') {
if looks_like_mpn(token) { return Some(token.to_string()); }
}
None
}
/// Public wrapper so other part-sources (e.g. the Adom Wiki puller) can record
/// provenance for files they copy in directly — the GLB and 3D-outline SVGs that
/// don't pass through `import_path` (which only ingests core CAD + datasheet).
pub fn record_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {
write_file_provenance(dest, filename, prov)
}
/// Append a per-file provenance entry to `<MPN>-file-provenance.json`.
/// Chipsmith reads this to show "source: SnapMagic" in the footprint
/// tooltip. Format:
/// ```json
/// { "ADS1115IDGSR.kicad_sym": { "source": "snapmagic", "url": "...", "fetched_at": 1778... },
/// "ADS1115IDGSR.step": { "source": "manufacturer", "url": "...", "fetched_at": 1778... } }
/// ```
fn write_file_provenance(dest: &Path, filename: &str, prov: &FileProvenance) -> Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let mpn = dest.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let path = dest.join(format!("{mpn}-file-provenance.json"));
let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
let s = std::fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str(&s).unwrap_or_default()
} else {
serde_json::Map::new()
};
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
map.insert(filename.to_string(), serde_json::json!({
"discovery_source": prov.discovery_source,
"content_origin": prov.content_origin,
"url": prov.url,
"fetched_at": now,
}));
std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;
Ok(())
}
/// The core artifacts whose origin we insist on knowing. (Present-on-disk files
/// without a recorded source are the "lazy fetch" failure.)
fn core_files(mpn: &str) -> Vec<String> {
vec![
format!("{mpn}.step"), format!("{mpn}.kicad_sym"), format!("{mpn}.kicad_mod"),
format!("{mpn}.lbr"), format!("{mpn}.SchLib"), format!("{mpn}.PcbLib"),
format!("{mpn}.IntLib"), format!("{mpn}.pdf"),
]
}
/// Files present in `dir` that have NO recorded provenance entry. Empty = good.
pub fn unprovenanced_files(dir: &Path, mpn: &str) -> Vec<String> {
let prov: serde_json::Map<String, serde_json::Value> =
std::fs::read_to_string(dir.join(format!("{mpn}-file-provenance.json")))
.ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
core_files(mpn).into_iter()
.filter(|f| dir.join(f).is_file())
.filter(|f| {
// present, but no entry OR an entry with no real source recorded
match prov.get(f) {
None => true,
Some(e) => {
let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");
let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");
(d.is_empty() || d == "unknown") && (o.is_empty() || o == "unknown")
}
}
})
.collect()
}
/// Backfill provenance for files that landed without going through import (the
/// B2B-XH-A "heartbeat stub" case). Derives each present-but-un-provenanced
/// file's source from the strongest signal on info.json — `step_source` for the
/// STEP, else the `fetched_via_chain` entry that succeeded — and records it
/// (tagged `derived: true`). When nothing is recoverable it writes
/// `discovery_source: "unknown"` so the gate keeps flagging it for a refetch
/// instead of silently passing. Returns (recovered, still_unknown) filenames.
pub fn backfill_dir(dir: &Path, mpn: &str) -> Result<(Vec<String>, Vec<String>)> {
use std::time::{SystemTime, UNIX_EPOCH};
let info: serde_json::Value = std::fs::read_to_string(dir.join("info.json"))
.ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(serde_json::json!({}));
// The chain entry that actually succeeded = where files were found.
let chain_src = info.get("fetched_via_chain").and_then(|c| c.as_array()).and_then(|arr| {
arr.iter().rev().find_map(|e| {
let o = e.get("outcome").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
(o.contains("ok") || o.contains("success") || o.contains("got"))
.then(|| e.get("source").and_then(|v| v.as_str()).unwrap_or("").to_string())
.filter(|s| !s.is_empty())
})
});
let step_src = info.get("step_source").and_then(|v| v.as_str())
.filter(|s| !s.is_empty() && *s != "—" && s.to_lowercase() != "none").map(String::from);
let path = dir.join(format!("{mpn}-file-provenance.json"));
let mut map: serde_json::Map<String, serde_json::Value> = if path.exists() {
serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default()
} else { serde_json::Map::new() };
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (mut recovered, mut unknown) = (Vec::new(), Vec::new());
// SELF-HEAL the datasheet source for the ONE provably-impossible case: the
// KiCad standard library ships symbols / footprints / 3D models and ZERO
// datasheets, so a `.pdf` tagged `kicad*` is a guaranteed wholesale mis-record
// (the whole JST/connector bundle got stamped with the KiCad-library source,
// datasheet included). The datasheet actually came from the manufacturer in
// that fetch, so re-attribute it to a manufacturer-tagged sibling (the STEP).
// John: "how could a pdf source come from std kicad library? i ONLY want the
// pdf from the manufacturer website." We do NOT touch aggregator-tagged PDFs
// (CSE / SnapEDA / Mouser) — those genuinely host datasheets, so relabeling
// them "manufacturer" would be a lie; the UI honestly flags them instead.
let impossible_for_datasheet = |s: &str|
matches!(s.to_lowercase().as_str(), "kicad" | "kicad-library" | "kicad_community");
let mfr_sibling = map.values().find(|e| {
let o = e.get("content_origin").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
let d = e.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
// a sibling downloaded from the maker's OWN site (not an aggregator/KiCad)
matches!(o.as_str(), "manufacturer" | "mfr" | "vendor")
&& !impossible_for_datasheet(&d)
&& !matches!(d.as_str(), "cse" | "componentsearch" | "cse_ul" | "samacsys"
| "snapeda" | "snapmagic" | "ultralibrarian" | "ul" | "mouser"
| "digikey" | "arrow" | "lcsc")
}).cloned();
let pdf_name = format!("{mpn}.pdf");
if let Some(entry) = map.get(&pdf_name).cloned() {
let disc = entry.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("");
let orig = entry.get("content_origin").and_then(|v| v.as_str()).unwrap_or("");
// Only act on the impossible case, and only when we have a genuine
// manufacturer-direct sibling to copy — otherwise leave it truthful.
if (impossible_for_datasheet(disc) || impossible_for_datasheet(orig)) && mfr_sibling.is_some() {
let m = mfr_sibling.as_ref().unwrap();
map.insert(pdf_name.clone(), serde_json::json!({
"discovery_source": m.get("discovery_source").and_then(|v| v.as_str()).unwrap_or("manufacturer"),
"content_origin": m.get("content_origin").and_then(|v| v.as_str()).unwrap_or("manufacturer"),
"url": m.get("url").and_then(|v| v.as_str()).unwrap_or(""),
"fetched_at": now, "derived": true, "recovered": true,
"corrected": "datasheet re-sourced to the manufacturer (was mis-tagged to the KiCad library, which ships no PDFs)",
}));
recovered.push(pdf_name);
}
}
// Only fill files that have NO entry at all — so this is idempotent and safe
// to run on every scan. (A file already recorded "unknown" keeps failing the
// gate but is not re-written each time.)
let to_fill: Vec<String> = core_files(mpn).into_iter()
.filter(|f| dir.join(f).is_file() && !map.contains_key(f.as_str()))
.collect();
for f in to_fill {
// STEP prefers step_source; everything else uses the chain source.
let src = if f.ends_with(".step") { step_src.clone().or_else(|| chain_src.clone()) }
else { chain_src.clone().or_else(|| step_src.clone()) };
let disc = src.clone().unwrap_or_else(|| "unknown".into());
if src.is_some() { recovered.push(f.clone()); } else { unknown.push(f.clone()); }
map.insert(f, serde_json::json!({
"discovery_source": disc, "content_origin": "", "url": "",
"fetched_at": now, "derived": true, "recovered": src.is_some(),
}));
}
if !recovered.is_empty() || !unknown.is_empty() {
std::fs::write(&path, serde_json::to_string_pretty(&serde_json::Value::Object(map))?)?;
}
Ok((recovered, unknown))
}
fn looks_like_mpn(s: &str) -> bool {
let has_alpha = s.chars().any(|c| c.is_ascii_alphabetic());
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let len_ok = s.len() >= 4 && s.len() <= 24;
has_alpha && has_digit && len_ok
}
/// Datasheet PDF quality — the programmatic guarantee that we have a REAL
/// datasheet, not a 1-page denial/cover page. Shells to pdfinfo (page count) and
/// pdftotext (pinout text). Both ship in the container.
pub struct PdfQuality {
pub pages: u32,
pub has_pinout: bool,
}
pub fn pdf_quality(file: &Path) -> PdfQuality {
let pages = std::process::Command::new("pdfinfo").arg(file).output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.lines().find_map(|l| l.strip_prefix("Pages:").map(|n| n.trim().parse::<u32>().ok())))
.flatten()
.unwrap_or(0);
// Pinout signal: pin-table / pin-configuration / common analog pin names.
let text = std::process::Command::new("pdftotext").arg(file).arg("-").output().ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default()
.to_lowercase();
let has_pinout = ["pin functions", "pin configuration", "terminal functions",
"pinout", "pin assignments", "pin descriptions"]
.iter().any(|k| text.contains(k))
|| text.matches("pin ").count() >= 8;
PdfQuality { pages, has_pinout }
}
/// Is this chip's datasheet GOOD ENOUGH for ds2sf to extract from? (multi-page +
/// a pinout). Used by the gate + the fetch flow to guarantee a usable datasheet
/// before — and after — running ds2sf. Returns Err(reason) when it isn't.
pub fn datasheet_ready(pdf: &Path) -> std::result::Result<PdfQuality, String> {
if !pdf.is_file() {
return Err("no datasheet PDF present".into());
}
let q = pdf_quality(pdf);
if q.pages < 3 {
return Err(format!("datasheet is only {} page(s) — a CDN cover/denial page, not the real datasheet", q.pages));
}
if !q.has_pinout {
return Err(format!("datasheet ({} pages) has no detectable pinout/pin-table — ds2sf can't extract a symbol from it", q.pages));
}
Ok(q)
}
/// Magic-byte validation. PDFs and STEPs both have unambiguous magic
/// strings — checking the first few bytes is the cheapest defense
/// against "naked curl returned a 174 KB Cloudflare denial page that
/// looks like 200 OK." Returns Err if the bytes don't match the claimed
/// extension. KiCad sym/mod and WRL are text-format with looser headers,
/// so we sanity-check just enough to reject HTML.
fn validate_magic_bytes(file: &Path, ext: &str) -> Result<()> {
use std::io::Read;
let mut f = std::fs::File::open(file)?;
let mut buf = [0u8; 64];
let n = f.read(&mut buf)?;
let head = &buf[..n];
let head_lc: String = head.iter().take(32).map(|b| b.to_ascii_lowercase() as char).collect();
// HTML masquerading as anything is the #1 footgun. Always reject.
if head_lc.starts_with("<!doctype html") || head_lc.starts_with("<html") || head_lc.contains("<head>") {
return Err(anyhow!("file looks like HTML (probably a CDN denial / 404 page), not a {ext}: {}", file.display()));
}
match ext {
"pdf" => {
if !head.starts_with(b"%PDF") {
return Err(anyhow!("not a real PDF (missing %PDF magic): {}", file.display()));
}
// QUALITY GATE (learned the hard way 2026-06-23): %PDF magic alone is
// NOT enough. A 1-page %PDF is almost always a CDN cover/denial page or
// a selection guide — NOT the datasheet — and it silently clobbered the
// real multi-page datasheets, breaking ds2sf. A genuine chip datasheet
// is many pages. Reject anything < 3 pages outright.
let q = pdf_quality(file);
if q.pages > 0 && q.pages < 3 {
return Err(anyhow!(
"PDF has only {} page(s) — not a real datasheet (likely a CDN cover/denial/selection page). \
A genuine chip datasheet is many pages with a pinout. Refusing the import: {}",
q.pages, file.display()
));
}
}
"step" | "stp" => {
// STEP files start with "ISO-10303-21;" (sometimes preceded by BOM)
let s: String = head.iter().take(64).map(|b| *b as char).collect();
if !s.contains("ISO-10303-21") {
return Err(anyhow!("not a real STEP (missing ISO-10303-21 header): {}", file.display()));
}
}
"kicad_sym" => {
// KiCad sym is s-expression starting with "(kicad_symbol_lib"
let s: String = head.iter().take(64).map(|b| *b as char).collect();
if !s.contains("kicad_symbol_lib") {
return Err(anyhow!("not a real KiCad symbol library: {}", file.display()));
}
}
"kicad_mod" => {
let s: String = head.iter().take(64).map(|b| *b as char).collect();
if !s.contains("footprint") && !s.contains("module") {
return Err(anyhow!("not a real KiCad footprint: {}", file.display()));
}
}
// VRML and EDA formats — too many vendor variants to magic-check
// tightly; HTML check above is enough.
_ => {}
}
Ok(())
}
/// Append a structured entry to `<MPN>-fetch-log.json` whenever a real
/// file lands. Audit trail: timestamp, source filename, target name,
/// size, magic-byte check pass. Survives across sessions; the AI doesn't
/// have to remember to write provenance — the binary writes it.
fn append_fetch_log(dest: &Path, mpn: &str, target_name: &str, src: &Path) -> Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let log_path = dest.join(format!("{mpn}-fetch-log.json"));
let mut entries: Vec<serde_json::Value> = if log_path.exists() {
let s = std::fs::read_to_string(&log_path).unwrap_or_default();
serde_json::from_str(&s).unwrap_or_else(|_| vec![])
} else {
vec![]
};
let size = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0);
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
entries.push(serde_json::json!({
"at": now,
"target": target_name,
"src_path": src.display().to_string(),
"size_bytes": size,
"magic_ok": true, // we wouldn't reach here if validate_magic_bytes failed
}));
let _ = std::fs::write(&log_path, serde_json::to_string_pretty(&entries)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_mpn_from_typical_ul_filename() {
let p = Path::new("2099328-Texas-Instruments-TPS62840.zip");
assert_eq!(infer_mpn(p).as_deref(), Some("TPS62840"));
}
#[test]
fn infers_mpn_from_plain_filename() {
let p = Path::new("LM358DR.step");
assert_eq!(infer_mpn(p).as_deref(), Some("LM358DR"));
}
#[test]
fn rejects_non_mpn_tokens() {
assert!(!looks_like_mpn("Texas"));
assert!(!looks_like_mpn("2099328"));
assert!(looks_like_mpn("TPS62840"));
assert!(looks_like_mpn("LM358"));
}
}