app
Adom Parts Search
Public Made by Adomby adom
One search box across Mouser + DigiKey + JLCPCB. Parallel queries, side-by-side product photos, and a Mouser-preferred recommendation (40-min drone delivery to Fort Worth) that reasons around stock and lead time.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
//! Parallel HTTP aggregator across the three Adom parts services
//! (adom-mouser, adom-digikey, adom-jlcpcb). This module has no API
//! credentials of its own — each vendor's backend already holds them.
//!
//! Output shape: a unified Vec<VendorHit> with the minimum fields the
//! webview needs to render a comparison grid (MPN, image URL, price,
//! stock, link). Normalization across the three vendors' native schemas
//! happens here.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
pub const DEFAULT_MOUSER: &str = "https://mouser-xr8t4f5d01bt.adom.cloud";
pub const DEFAULT_DIGIKEY: &str = "https://digikey-gjuu8l5t69uh.adom.cloud";
pub const DEFAULT_JLCPCB: &str = "https://jlcpcb-wela51osctvp.adom.cloud";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Vendor { Mouser, DigiKey, JLCPCB }
impl Vendor {
pub fn as_str(&self) -> &'static str {
match self { Vendor::Mouser => "mouser", Vendor::DigiKey => "digikey", Vendor::JLCPCB => "jlcpcb" }
}
pub fn display(&self) -> &'static str {
match self { Vendor::Mouser => "Mouser", Vendor::DigiKey => "DigiKey", Vendor::JLCPCB => "JLCPCB" }
}
}
// VendorHit.cad_3d_* is populated opportunistically per-vendor:
// DigiKey — from MediaLinks[3D Model] in its v4 response
// Mouser — not exposed in their API (field stays None)
// JLCPCB — not exposed by CDFER scrape (stays None), BUT the
// aggregator below does a cross-vendor fallback: if a JLCPCB hit
// shares an MPN with a DigiKey hit that has a cad_3d_url, we
// copy it onto the JLCPCB card so the user gets the link
// regardless of which column they clicked.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendorHit {
pub vendor: Vendor,
pub vendor_pn: String, // mouser_pn, digikey_pn, or LCSC part number
pub mpn: String, // manufacturer PN — the cross-vendor match key
pub manufacturer: String,
pub description: String,
pub stock: u64,
pub unit_price: Option<String>,
pub price_100: Option<String>,
pub lead_time: String,
pub image_url: Option<String>,
pub datasheet_url: Option<String>,
pub product_url: Option<String>,
pub flags: Vec<String>, // "RoHS", "basic", "preferred", etc.
/// URL to a STEP/IGES/GLB/ZIP CAD model, when the source vendor
/// exposes one. Click-through usually redirects through SamacSys /
/// SnapEDA / Ultra Librarian auth.
#[serde(skip_serializing_if = "Option::is_none")]
pub cad_3d_url: Option<String>,
/// Best-guess CAD file format ("step", "iges", "stl", "zip").
#[serde(skip_serializing_if = "Option::is_none")]
pub cad_3d_format: Option<String>,
/// Origin of the CAD URL — "digikey" (native), "digikey-xref"
/// (cross-vendor fallback onto JLCPCB/Mouser card), etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub cad_3d_source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AggregatedResults {
pub query: String,
pub mouser: VendorBlock,
pub digikey: VendorBlock,
pub jlcpcb: VendorBlock,
pub recommendation: Option<Recommendation>,
/// Populated when no vendor produced a usable hit. Tells the user
/// why (no matches vs. all unreachable) and suggests a next step.
#[serde(skip_serializing_if = "Option::is_none")]
pub empty_state: Option<EmptyState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmptyState {
pub title: String,
pub explanation: String,
pub hint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VendorBlock {
pub ok: bool,
/// Short, human-friendly one-liner. Never contains API keys or URLs.
pub error: Option<String>,
/// Longer explanation of what happened + what the user can expect.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_explanation: Option<String>,
/// Actionable hint: what to try, when to retry, who to ping.
#[serde(skip_serializing_if = "Option::is_none")]
pub error_hint: Option<String>,
pub total: u64,
pub hits: Vec<VendorHit>,
pub backend_url: String,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub winner: Vendor,
pub reason: String,
pub mpn: String,
pub unit_price: Option<String>,
pub stock: u64,
}
/// Keep hits that contain the primary query token (>=3 char alphanumeric
/// run) in either MPN or description. Vendor backends that correctly
/// pass our MPN-or-keyword into FTS will be untouched; backends that
/// default-respond with "popular parts" on no-match (JLCPCB today) will
/// be filtered down to zero hits.
fn filter_relevant(hits: &[VendorHit], query: &str) -> Vec<VendorHit> {
let q_lower = query.to_lowercase();
// Primary token: longest alphanumeric run in the query.
let primary: String = q_lower
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| s.len() >= 3)
.max_by_key(|s| s.len())
.unwrap_or("")
.to_string();
if primary.is_empty() {
return hits.to_vec();
}
hits.iter().filter(|h| {
let mpn = h.mpn.to_lowercase();
let desc = h.description.to_lowercase();
mpn.contains(&primary) || desc.contains(&primary)
}).cloned().collect()
}
/// Translate a raw backend error into a short title + longer explanation
/// + actionable hint, written for a non-expert Adom user. Redacts API
/// keys, URLs, and stack traces before they reach stdout or the UI.
fn classify_error(vendor: Vendor, raw: &str) -> (String, String, String) {
let vendor_name = vendor.display();
let lower = raw.to_lowercase();
// Known: Mouser free-tier daily quota exhausted (HTTP 403 with
// "TooManyRequests" / "Maximum calls per day exceeded").
if lower.contains("maximum calls per day") || lower.contains("toomanyrequests") {
return (
format!("{vendor_name}'s daily search quota is used up."),
format!(
"Our {vendor_name} API key is on a free-tier plan that allows a fixed number of \
searches per day, and we've already used them all. The counter resets at \
midnight Central Time."
),
"DigiKey and JLCPCB results below are still live. If you need Mouser pricing \
before midnight CT, rotate the key or bump the plan — ask an Adom admin.".into(),
);
}
// Auth failures (401/403 without the quota marker).
if lower.contains("status code 401") || lower.contains("status code 403")
|| lower.contains("unauthorized") || lower.contains("forbidden") {
return (
format!("{vendor_name} rejected our API key."),
format!(
"The {vendor_name} backend returned a permission error for the stored API \
key. The key may have been rotated, revoked, or is missing from the \
service container."
),
format!("Ask an Adom admin to refresh the {vendor_name} key on the service container."),
);
}
// Network/container-up but upstream unreachable / timeout.
if lower.contains("timed out") || lower.contains("timeout")
|| lower.contains("connection refused") || lower.contains("connection reset") {
return (
format!("{vendor_name} backend is slow or unreachable right now."),
format!(
"We couldn't reach the {vendor_name} search service in time. This usually \
means the service container is restarting or the upstream vendor API is \
temporarily slow."
),
"Try the search again in a minute. Other vendors' results are unaffected.".into(),
);
}
// Backend-level errors (5xx, internal errors, panics).
if lower.contains("status code 5") || lower.contains("internal server error")
|| lower.contains("500") || lower.contains("502") || lower.contains("503") || lower.contains("504") {
return (
format!("{vendor_name} backend hit an internal error."),
format!(
"The {vendor_name} search service replied with an unexpected server error. \
This is usually transient."
),
"Try the search again in a minute. If this keeps happening, ask an Adom admin \
to check the service container's logs.".into(),
);
}
// Rate limiting that isn't quota-per-day (429 etc).
if lower.contains("status code 429") || lower.contains("rate limit") || lower.contains("too many requests") {
return (
format!("{vendor_name} temporarily rate-limited us."),
format!(
"The {vendor_name} API asked us to slow down. This is short-term throttling, \
not a daily cap."
),
"Wait 10-30 seconds and try again. If it persists, stagger your searches.".into(),
);
}
// Fallback: generic, with the vendor named but never the raw URL/key.
(
format!("{vendor_name} search failed."),
format!(
"The {vendor_name} backend returned an error we don't have a canned explanation \
for. DigiKey and JLCPCB results below are still valid."
),
"Try the search again. If it keeps failing, show this page to an Adom admin.".into(),
)
}
/// Remove anything that looks like a secret or internal URL from a raw
/// error message before it appears in logs. Called on the STDERR path
/// only; UI errors already come from `classify_error` and never include
/// the raw string.
#[allow(dead_code)]
fn redact(raw: &str) -> String {
let mut s = raw.to_string();
// Strip `apiKey=xxx...` query parameters
if let Some(start) = s.find("apiKey=") {
let end = s[start..].find(|c: char| c == '&' || c == ' ' || c == '"').map(|i| start + i).unwrap_or(s.len());
s.replace_range(start..end, "apiKey=***redacted***");
}
s
}
fn post_action(url: &str, body: Value) -> Result<Value, String> {
let url = if url.ends_with('/') { url.to_string() } else { format!("{}/", url) };
let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build();
let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => return Err(format!("{}", e)),
};
res.into_json().map_err(|e| format!("JSON: {}", e))
}
fn price_at_qty(tiers: Option<&Vec<Value>>, qty: u32) -> Option<String> {
let tiers = tiers?;
// Find the tier with the largest qty ≤ requested
let mut best: Option<(u32, String)> = None;
for t in tiers {
let tqty = t.get("qty").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let tprice = t.get("price").and_then(|v| v.as_str()).unwrap_or("");
if tqty <= qty && !tprice.is_empty() {
let keep = best.as_ref().map(|(q, _)| tqty >= *q).unwrap_or(true);
if keep {
best = Some((tqty, tprice.to_string()));
}
}
}
best.map(|(_, p)| p)
}
fn normalize_mouser(c: &Value) -> VendorHit {
let tiers = c.get("price_tiers").and_then(|v| v.as_array());
VendorHit {
vendor: Vendor::Mouser,
vendor_pn: c.get("mouser_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),
price_100: price_at_qty(tiers, 100),
lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),
image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),
datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),
product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),
flags: {
let mut f = vec![];
if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
f
},
cad_3d_url: None, // Mouser API doesn't expose CAD links
cad_3d_format: None,
cad_3d_source: None,
}
}
fn normalize_digikey(c: &Value) -> VendorHit {
let tiers = c.get("price_tiers").and_then(|v| v.as_array());
let cad_url = c.get("cad_3d_url").and_then(|v| v.as_str()).map(String::from);
let cad_fmt = c.get("cad_3d_format").and_then(|v| v.as_str()).map(String::from);
let has_cad = cad_url.is_some();
VendorHit {
vendor: Vendor::DigiKey,
vendor_pn: c.get("digikey_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),
price_100: price_at_qty(tiers, 100),
lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),
image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),
datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),
product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),
flags: {
let mut f = vec![];
if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
f
},
cad_3d_url: cad_url,
cad_3d_format: cad_fmt,
cad_3d_source: if has_cad { Some("digikey".into()) } else { None },
}
}
fn normalize_jlcpcb(c: &Value) -> VendorHit {
let tiers = c.get("price_tiers").and_then(|v| v.as_array());
let is_basic = c.get("is_basic").and_then(|v| v.as_bool()).unwrap_or(false);
let is_preferred = c.get("is_preferred").and_then(|v| v.as_bool()).unwrap_or(false);
let lcsc = c.get("lcsc").and_then(|v| v.as_str()).unwrap_or("").to_string();
let images = c.get("images").and_then(|v| v.as_array());
let image_url = c.get("image_url").and_then(|v| v.as_str()).map(String::from)
.or_else(|| images.and_then(|a| a.first()).and_then(|v| v.as_str()).map(String::from));
VendorHit {
vendor: Vendor::JLCPCB,
vendor_pn: lcsc.clone(),
mpn: c.get("mfr").or_else(|| c.get("mpn")).and_then(|v| v.as_str()).unwrap_or("").to_string(),
manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
unit_price: tiers.and_then(|t| t.first()).and_then(|t| t.get("price")).and_then(|v| v.as_str()).map(String::from),
price_100: price_at_qty(tiers, 100),
lead_time: String::new(),
image_url,
datasheet_url: c.get("datasheet").and_then(|v| v.as_str()).map(String::from),
product_url: c.get("jlcpcb_url").and_then(|v| v.as_str()).map(String::from),
flags: {
let mut f = vec![];
if is_basic { f.push("basic".into()); }
if is_preferred { f.push("preferred".into()); }
if !is_basic && !is_preferred { f.push("extended".into()); }
f
},
cad_3d_url: None, // filled in post-aggregation by cross-vendor MPN fallback
cad_3d_format: None,
cad_3d_source: None,
}
}
/// Fire three parallel HTTP POSTs to the vendor backends. Returns a
/// single unified AggregatedResults.
pub fn search_all(
query: &str,
limit: u32,
in_stock_only: bool,
mouser_api: Option<&str>,
digikey_api: Option<&str>,
jlcpcb_api: Option<&str>,
) -> AggregatedResults {
let m_url = mouser_api.unwrap_or(DEFAULT_MOUSER).to_string();
let d_url = digikey_api.unwrap_or(DEFAULT_DIGIKEY).to_string();
let j_url = jlcpcb_api.unwrap_or(DEFAULT_JLCPCB).to_string();
let q = query.to_string();
let (tx, rx) = mpsc::channel::<(Vendor, VendorBlock)>();
let body_common = json!({"keyword": q, "limit": limit, "inStockOnly": in_stock_only});
// Spawn three threads — one per vendor.
for (vendor, url) in [(Vendor::Mouser, m_url.clone()), (Vendor::DigiKey, d_url.clone()), (Vendor::JLCPCB, j_url.clone())] {
let tx = tx.clone();
let body = {
let mut b = body_common.clone();
b["action"] = Value::String("search".into());
b
};
let url_for_block = url.clone();
let q_for_thread = q.clone();
thread::spawn(move || {
let t0 = std::time::Instant::now();
let result = post_action(&url, body);
let elapsed = t0.elapsed().as_millis() as u64;
let mut block = VendorBlock {
ok: false, error: None, error_explanation: None, error_hint: None,
total: 0, hits: vec![],
backend_url: url_for_block, elapsed_ms: elapsed,
};
match result {
Ok(v) => {
if let Some(e) = v.get("error").and_then(|v| v.as_str()).or(
v.get("api_error").and_then(|v| v.as_str())
) {
let (t, expl, hint) = classify_error(vendor, e);
block.error = Some(t);
block.error_explanation = Some(expl);
block.error_hint = Some(hint);
} else {
block.ok = true;
block.total = v.get("count").and_then(|n| n.as_u64()).unwrap_or(0);
let components = v.get("components").and_then(|a| a.as_array()).cloned().unwrap_or_default();
let raw: Vec<VendorHit> = components.iter().map(|c| match vendor {
Vendor::Mouser => normalize_mouser(c),
Vendor::DigiKey => normalize_digikey(c),
Vendor::JLCPCB => normalize_jlcpcb(c),
}).collect();
// Drop hits that don't contain the primary query
// token in the MPN or description. JLCPCB currently
// returns its default popular-parts list on no-match,
// so filter vendor-side. Safe for all vendors; vendor
// FTS that does match will keep its hits.
block.hits = filter_relevant(&raw, &q_for_thread);
// Sort by stock DESC so the most-useful variant is
// the first card a user sees. Mouser + DigiKey often
// return a stock=0 "parent" MPN before the stocked
// reel/cut-tape children; rendering that stock=0
// card first makes the whole column look empty.
block.hits.sort_by(|a, b| b.stock.cmp(&a.stock));
// If everything got filtered as irrelevant, reset
// `total` so the UI shows "0 matches".
if block.hits.is_empty() { block.total = 0; }
}
}
Err(e) => {
let (t, expl, hint) = classify_error(vendor, &e);
block.error = Some(t);
block.error_explanation = Some(expl);
block.error_hint = Some(hint);
}
}
let _ = tx.send((vendor, block));
});
}
drop(tx);
let mut agg = AggregatedResults { query: query.into(), ..Default::default() };
for _ in 0..3 {
match rx.recv() {
Ok((Vendor::Mouser, b)) => agg.mouser = b,
Ok((Vendor::DigiKey, b)) => agg.digikey = b,
Ok((Vendor::JLCPCB, b)) => agg.jlcpcb = b,
Err(_) => break,
}
}
// Cross-vendor CAD fallback: DigiKey is the only backend that
// exposes CAD links today. For any Mouser or JLCPCB hit whose MPN
// matches a DigiKey hit with a cad_3d_url, copy that URL onto
// the sibling card so clicking "3D" from ANY column works.
// Tagged cad_3d_source = "digikey-xref" so the UI + downstream
// can tell xref-sourced URLs from native ones.
let dk_cad: std::collections::HashMap<String, (String, Option<String>)> = agg.digikey.hits.iter()
.filter(|h| h.cad_3d_url.is_some())
.map(|h| (h.mpn.to_lowercase(), (h.cad_3d_url.clone().unwrap(), h.cad_3d_format.clone())))
.collect();
if !dk_cad.is_empty() {
for h in agg.mouser.hits.iter_mut().chain(agg.jlcpcb.hits.iter_mut()) {
if h.cad_3d_url.is_some() { continue; }
if let Some((url, fmt)) = dk_cad.get(&h.mpn.to_lowercase()) {
h.cad_3d_url = Some(url.clone());
h.cad_3d_format = fmt.clone();
h.cad_3d_source = Some("digikey-xref".into());
}
}
}
agg.recommendation = pick_winner(&agg);
if agg.recommendation.is_none() {
agg.empty_state = Some(build_empty_state(&agg));
}
agg
}
fn build_empty_state(agg: &AggregatedResults) -> EmptyState {
let q = &agg.query;
let m_ok = agg.mouser.ok; let d_ok = agg.digikey.ok; let j_ok = agg.jlcpcb.ok;
let m_hits = agg.mouser.hits.len(); let d_hits = agg.digikey.hits.len(); let j_hits = agg.jlcpcb.hits.len();
// All three errored — service degradation, not a search-miss.
if !m_ok && !d_ok && !j_ok {
return EmptyState {
title: "All three vendor backends are currently unreachable.".into(),
explanation: format!(
"Mouser, DigiKey, and JLCPCB all returned errors for \"{q}\". This is a \
service-level issue, not a bad part number."
),
hint: "Check https://status.adom.cloud or ping an admin. Individual vendor errors \
are shown in each column.".into(),
};
}
// All three OK but zero hits — the part name didn't match anything.
if m_ok && d_ok && j_ok && m_hits == 0 && d_hits == 0 && j_hits == 0 {
return EmptyState {
title: format!("No matches for \"{q}\" on any vendor."),
explanation: "All three distributors returned zero hits. Vendor APIs match on MPN \
and description substrings, not on natural-language intent — so a \
typo, a too-broad phrase, or an out-of-family name will produce this \
empty result.".into(),
hint: "Check the MPN spelling, try a cleaner keyword (e.g. LM555 instead of \
\"555 timer chip\"), or pick a known family member. Common substitutes \
are documented in the adom-parts-search skill's family table.".into(),
};
}
// Mixed: some vendors OK with 0 hits, others errored — partial visibility.
let live_vendors: Vec<&str> = [
(m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),
].iter().filter(|(ok, _)| *ok).map(|(_, n)| *n).collect();
let down_vendors: Vec<&str> = [
(m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),
].iter().filter(|(ok, _)| !*ok).map(|(_, n)| *n).collect();
EmptyState {
title: format!("No in-stock matches for \"{q}\" right now."),
explanation: format!(
"{} returned zero hits. {} {} currently erroring (see the column \
card for details), so coverage is partial.",
live_vendors.join(" + "),
down_vendors.join(" + "),
if down_vendors.len() == 1 { "is" } else { "are" }
),
hint: "Try a canonical family MPN (LM555 / NE555 / STM32F103C8T6 / ESP32-S3-WROOM-1U-N4R2 \
/ iCE40HX4K-TQ144) or retry in a minute once the erroring backends recover.".into(),
}
}
/// Apply the Mouser-preferred policy from adom-parts-search SKILL:
/// near-zero shipping + 40-min drone means Mouser wins unless it's
/// out of stock / high-lead / DigiKey is dramatically cheaper.
///
/// We scan ALL hits per vendor, not just the first one — it's common
/// for Mouser to return a handful of stock=0 entries before the real
/// in-stock variant. Pick the FIRST in-stock hit per vendor, then
/// apply the vendor-preference policy on top.
fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> {
let long_lead = |lt: &str| lt.contains("280");
let first_in_stock = |hits: &[VendorHit]| {
hits.iter().find(|h| h.stock > 0 && !long_lead(&h.lead_time)).cloned()
};
let m = first_in_stock(&agg.mouser.hits);
let d = first_in_stock(&agg.digikey.hits);
if let Some(m) = &m {
return Some(Recommendation {
winner: Vendor::Mouser,
reason: "Mouser stocked — 40-min drone delivery from Mansfield, near-zero shipping.".into(),
mpn: m.mpn.clone(),
unit_price: m.unit_price.clone(),
stock: m.stock,
});
}
if let Some(d) = &d {
return Some(Recommendation {
winner: Vendor::DigiKey,
reason: "Mouser is out of stock or long-lead; DigiKey has immediate stock.".into(),
mpn: d.mpn.clone(),
unit_price: d.unit_price.clone(),
stock: d.stock,
});
}
None
}