app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
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
}
/// 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(())
}
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"));
}
}