app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
//! First-class integration with the Adom parts-data tools.
//!
//! THE LESSON (learned the hard way 2026-06-23): the Adom parts tools
//! — `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` +
//! `adom-jlcpcb`) — keep their API keys baked into their OWN service containers.
//! They ALWAYS work with NO key needed. chip-fetcher must NEVER assume it needs
//! a `MOUSER_API_KEY` of its own; it just shells to `adom-parts-search`.
//!
//! What these tools give us, for free, on every chip:
//! - a human description ("what is this part")
//! - the manufacturer, image, datasheet URL, product URL
//! - stock + pricing + lead time + lifecycle → stock.json
//! - EVERY orderable package variant of a base MPN → the orderable-MPN that
//! CAD sites (CSE / UltraLibrarian) require. This is the permanent fix for
//! the "base ADS1263 → CSE Pick-One, no partID" trap.
//!
//! `adom-parts-search search <MPN>` prints unified JSON to stdout:
//! { query, mouser:{ok,total,hits:[{vendor,vendor_pn,mpn,manufacturer,
//! description,stock,unit_price,price_100,lead_time,image_url,
//! datasheet_url,product_url,flags}]}, digikey:{…}, jlcpcb:{…},
//! recommendation:{winner,reason,mpn,unit_price,stock} }
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::process::Command;
use crate::library;
const PARTS_SEARCH_BIN: &str = "adom-parts-search";
pub struct EnrichReport {
pub mpn: String,
pub description: Option<String>,
pub wrote_stock: bool,
pub variants: Vec<String>,
}
/// Run `adom-parts-search search <query>` and return the parsed JSON.
/// Tries the umbrella tool; bubbles up a clear error if it's missing.
pub fn parts_search(query: &str) -> Result<Value> {
let out = Command::new(PARTS_SEARCH_BIN)
.arg("search")
.arg(query)
.output()
.with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}` — is adom-parts-search installed?"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
// The CLI prints a one-line "OK: …" summary before the JSON body; find the
// first '{' and parse from there.
let start = stdout.find('{').ok_or_else(|| {
anyhow::anyhow!("adom-parts-search returned no JSON for {query}: {}", stdout.trim())
})?;
let v: Value = serde_json::from_str(&stdout[start..])
.with_context(|| format!("parsing adom-parts-search JSON for {query}"))?;
Ok(v)
}
/// All hits across Mouser + DigiKey (+ JLCPCB), Mouser first.
fn all_hits(data: &Value) -> Vec<Value> {
let mut hits = Vec::new();
for vendor in ["mouser", "digikey", "jlcpcb"] {
if let Some(h) = data.get(vendor).and_then(|b| b.get("hits")).and_then(|h| h.as_array()) {
hits.extend(h.iter().cloned());
}
}
hits
}
/// The orderable package variants for a (possibly base) MPN — the exact strings
/// CAD sites need. e.g. ADS1263 → ["ADS1263IPW", "ADS1263IPWR"]. Dedup,
/// preserving Mouser-first order. This is the permanent orderable-MPN resolver.
pub fn resolve_orderable(mpn: &str) -> Vec<String> {
let data = match parts_search(mpn) {
Ok(d) => d,
Err(_) => return vec![],
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for h in all_hits(&data) {
if let Some(m) = h.get("mpn").and_then(|x| x.as_str()) {
let m = m.trim().to_string();
if !m.is_empty() && seen.insert(m.clone()) {
out.push(m);
}
}
}
out
}
/// Pick the best hit for `mpn`: exact MPN match first (Mouser preferred via
/// all_hits order), else the first hit.
fn best_hit(data: &Value, mpn: &str) -> Option<Value> {
let hits = all_hits(data);
let want = mpn.to_uppercase();
hits.iter()
.find(|h| h.get("mpn").and_then(|x| x.as_str()).map(|s| s.to_uppercase() == want).unwrap_or(false))
.cloned()
.or_else(|| hits.into_iter().next())
}
/// Clean a distributor description into a human "what is this chip" line.
/// Mouser appends vendor-PN noise like " A 595-ADS1263IPW" and a category
/// prefix "Analog to Digital Converters - "; strip both, collapse whitespace.
fn clean_description(raw: &str) -> String {
let mut s = raw.trim().to_string();
// Strip a trailing " A <digits>-<rest>" Mouser vendor-PN tail.
if let Some(idx) = s.rfind(" A ") {
let tail = &s[idx + 3..];
if tail.chars().take(4).filter(|c| c.is_ascii_digit()).count() >= 2
|| tail.contains('-')
{
s.truncate(idx);
}
}
// Keep a leading "Category - " prefix as context but drop it if it's the
// generic ADC/converter family noise that just repeats the part type.
s = s.trim_end_matches(['-', ' ', '·']).trim().to_string();
// Collapse internal whitespace.
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Build a stock.json body from the best hit.
fn stock_body(hit: &Value) -> Value {
let get = |k: &str| hit.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let stock = hit.get("stock").and_then(|x| x.as_u64()).unwrap_or(0);
let flags: Vec<String> = hit.get("flags").and_then(|f| f.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default();
let lifecycle = if flags.iter().any(|f| {
let f = f.to_uppercase();
f.contains("NRND") || f.contains("EOL") || f.contains("OBSOLETE") || f.contains("LAST TIME")
}) { "nrnd_or_eol" } else { "active" };
let mut tiers = Vec::new();
let p1 = get("unit_price");
if !p1.is_empty() { tiers.push(json!({"price": p1, "qty": 1})); }
let p100 = get("price_100");
if !p100.is_empty() { tiers.push(json!({"price": p100, "qty": 100})); }
json!({
"checked_at": "via adom-parts-search",
"source": "adom-parts-search",
get_vendor_key(hit): {
"vendor_pn": get("vendor_pn"),
"mpn": get("mpn"),
"manufacturer": get("manufacturer"),
"stock": stock,
"stock_text": format!("{stock} in stock"),
"lead_time": get("lead_time"),
"lifecycle": lifecycle,
"unit_price": p1,
"price_tiers": tiers,
"product_url": get("product_url"),
"image_url": get("image_url"),
"flags": flags,
}
})
}
fn get_vendor_key(hit: &Value) -> String {
hit.get("vendor").and_then(|x| x.as_str()).unwrap_or("mouser").to_lowercase()
}
/// Enrich a chip from the Adom parts tools: write info.json.description (+
/// manufacturer / image / datasheet / product URLs) and stock.json. Best-effort
/// per field; never fails the sourcing run.
pub fn enrich(mpn: &str) -> Result<EnrichReport> {
let dir = library::root().join(mpn);
if !dir.exists() {
anyhow::bail!("library/{mpn}/ does not exist");
}
let data = parts_search(mpn)?;
let mut seen = std::collections::HashSet::new();
let variants: Vec<String> = all_hits(&data).iter()
.filter_map(|h| h.get("mpn").and_then(|x| x.as_str()).map(String::from))
.filter(|m| !m.is_empty() && seen.insert(m.clone()))
.collect();
let hit = best_hit(&data, mpn);
let mut description = None;
let mut wrote_stock = false;
if let Some(h) = hit.as_ref() {
// --- info.json: description + provenance-friendly metadata ---
let info_path = dir.join("info.json");
let mut info: Value = std::fs::read_to_string(&info_path).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| json!({}));
// Only set the description from the distributor if there isn't already a
// (likely hand-curated, nicer) one — don't clobber a better description.
let existing = info.get("description").and_then(|d| d.as_str()).unwrap_or("").trim().to_string();
if existing.len() < 10 {
let raw = h.get("description").and_then(|x| x.as_str()).unwrap_or("");
let cleaned = clean_description(raw);
if cleaned.len() >= 10 {
info["description"] = json!(cleaned);
description = Some(cleaned);
}
} else {
description = Some(existing);
}
for (k, src) in [("manufacturer", "manufacturer"), ("image_url", "image_url"),
("datasheet_url", "datasheet_url"), ("product_url", "product_url")] {
if let Some(v) = h.get(src).and_then(|x| x.as_str()) {
if !v.is_empty() && info.get(k).and_then(|x| x.as_str()).unwrap_or("").is_empty() {
info[k] = json!(v);
}
}
}
let _ = std::fs::write(&info_path, serde_json::to_string_pretty(&info).unwrap_or_default());
// --- stock.json: stock + pricing + lifecycle ---
let stock = stock_body(h);
if std::fs::write(dir.join("stock.json"), serde_json::to_string_pretty(&stock).unwrap_or_default()).is_ok() {
wrote_stock = true;
}
}
Ok(EnrichReport { mpn: mpn.to_string(), description, wrote_stock, variants })
}