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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
//! `adom-parts-search search <keyword>` — unified JSON on stdout.
//! `adom-parts-search show <keyword>` — same, but also pushes results into
//! the running Hydrogen webview app so the user sees the 3-column grid.
use crate::vendors;
use crate::{ShowArgs, SearchArgs};
pub fn run_search(args: SearchArgs) -> Result<(), String> {
let agg = vendors::search_all(
&args.keyword,
args.limit,
args.in_stock_only,
args.mouser_api.as_deref(),
args.digikey_api.as_deref(),
args.jlcpcb_api.as_deref(),
);
// OK: line to stderr so stdout stays pure JSON for jq.
let ok_line = format!(
"Mouser: {} hit(s) | DigiKey: {} hit(s) | JLCPCB: {} hit(s) | recommendation: {}",
agg.mouser.hits.len(),
agg.digikey.hits.len(),
agg.jlcpcb.hits.len(),
agg.recommendation.as_ref()
.map(|r| format!("{} {}", r.winner.display(), r.mpn))
.unwrap_or_else(|| "none".into()),
);
if crate::stderr_is_tty() {
eprintln!("{}{}OK:{} {}", crate::GREEN, crate::BOLD, crate::RESET, ok_line);
} else {
eprintln!("OK: {}", ok_line);
}
println!("{}", serde_json::to_string_pretty(&agg).unwrap_or_default());
Ok(())
}
/// `show` — run the search *through* a running app server, so the same
/// results the AI prints to stdout are the ones the user sees in the
/// webview tab. Auto-starts the app in the background if it isn't up.
pub fn run_show(args: ShowArgs) -> Result<(), String> {
let base = format!("http://127.0.0.1:{}", args.port);
// 1. Ensure the app server is up. If not, spawn it detached and wait.
if !ping(&base) {
crate::note!("app server not running on :{} — starting it...", args.port);
spawn_app_detached(args.port, &args.tab_name)?;
wait_until_up(&base, std::time::Duration::from_secs(10))?;
crate::ok(format!("app server up on :{}", args.port));
} else {
crate::note!("app server already up on :{}", args.port);
}
// 2. Ensure the Hydrogen tab exists (if the app was already up without
// a tab we still want the user to see results).
ensure_tab(&args.tab_name, args.port);
// 3. POST the search through the app — server updates its state, UI
// poller renders the 3 columns, and we get the full JSON back to
// print to stdout.
let body = serde_json::json!({
"keyword": args.keyword,
"limit": args.limit,
"inStockOnly": args.in_stock_only,
});
let resp = post_json(&format!("{}/search", base), body)
.map_err(|e| format!("POST /search failed: {}", e))?;
// 4. Human-readable summary to stdout (AI-friendly).
render_summary_to_stdout(&args.keyword, &resp);
// 5. Full JSON so downstream jq / AI has structured access to the data.
println!("{}", serde_json::to_string_pretty(&resp).unwrap_or_default());
Ok(())
}
fn ping(base: &str) -> bool {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_millis(400))
.build();
agent.get(&format!("{}/state", base)).call().is_ok()
}
fn wait_until_up(base: &str, total: std::time::Duration) -> Result<(), String> {
let start = std::time::Instant::now();
while start.elapsed() < total {
if ping(base) { return Ok(()); }
std::thread::sleep(std::time::Duration::from_millis(150));
}
Err(format!("app server on {} did not come up within {:?}", base, total))
}
fn spawn_app_detached(port: u16, tab_name: &str) -> Result<(), String> {
use std::process::{Command, Stdio};
let exe = std::env::current_exe().map_err(|e| format!("current_exe: {}", e))?;
Command::new(exe)
.args([
"app",
"--port", &port.to_string(),
"--tab-name", tab_name,
"--no-tab",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| format!("spawn: {}", e))
}
fn ensure_tab(tab_name: &str, port: u16) {
use std::process::Command;
// `find-tab` takes the name as a positional arg. Exit-code isn't
// a reliable signal (the CLI returns 0 in both found and not-found
// cases), so parse stdout for an object with `panelId`.
let already = Command::new("adom-cli")
.args(["hydrogen", "workspace", "find-tab", tab_name])
.output()
.ok()
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
.map(|v| v.get("panelId").is_some())
.unwrap_or(false);
if already {
let _ = Command::new("adom-cli")
.args(["hydrogen", "workspace", "active-tab", "--name", tab_name])
.output();
return;
}
let url = crate::app::tab::proxy_url(port);
let icon = include_str!("../docs/icon.svg");
if let Err(e) = crate::app::tab::add_webview_tab(tab_name, &url, icon) {
crate::warn(format!("couldn't open tab: {}. Manually visit: {}", e, url));
}
}
fn post_json(url: &str, body: serde_json::Value) -> Result<serde_json::Value, String> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(60))
.build();
match agent.post(url).set("Content-Type", "application/json").send_json(body) {
Ok(r) => r.into_json().map_err(|e| format!("decode: {}", e)),
Err(ureq::Error::Status(code, r)) => {
let body: serde_json::Value = r.into_json().unwrap_or(serde_json::json!({}));
Err(format!("HTTP {}: {}", code, body))
}
Err(e) => Err(e.to_string()),
}
}
fn render_summary_to_stdout(query: &str, resp: &serde_json::Value) {
use crate::{BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, stdout_is_tty};
let color = |code: &str, s: &str| -> String {
if stdout_is_tty() { format!("{}{}{}", code, s, RESET) } else { s.to_string() }
};
println!();
println!("{} keyword: \"{}\"", color(BOLD, "adom-parts-search"), query);
for vendor in ["mouser", "digikey", "jlcpcb"] {
let v = &resp[vendor];
let color_code = match vendor {
"mouser" => "\x1b[38;5;39m", // Mouser blue
"digikey" => "\x1b[38;5;203m", // DigiKey red
_ => "\x1b[38;5;107m", // JLCPCB green
};
let header = color(color_code, &format!("{:<8}", vendor.to_uppercase()));
let ok_flag = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
let hits: Vec<&serde_json::Value> = v.get("hits").and_then(|h| h.as_array()).map(|a| a.iter().collect()).unwrap_or_default();
let total = v.get("total").and_then(|n| n.as_u64()).unwrap_or(0);
let elapsed = v.get("elapsed_ms").and_then(|n| n.as_u64()).unwrap_or(0);
let status = if ok_flag { color(GREEN, "ok") } else {
let e = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
color(RED, &format!("err: {}", truncate(e, 60)))
};
println!();
println!("{} total={:>3} hits={:>2} {}ms [{}]",
header, total, hits.len(), elapsed, status);
for h in hits.iter().take(5) {
let mpn = h.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");
let manuf = h.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("");
let price1 = h.get("price_1").and_then(|v| v.as_str()).unwrap_or("—");
let stock = h.get("stock").and_then(|v| v.as_u64()).unwrap_or(0);
let lead = h.get("lead_time").and_then(|v| v.as_str()).unwrap_or("");
println!(" {} {} {} stock {} {}",
color(BOLD, &format!("{:<28}", truncate(mpn, 28))),
color(DIM, &format!("{:<16}", truncate(manuf, 16))),
color(YELLOW, &format!("{:>9}", price1)),
format_stock(stock),
color(DIM, lead));
}
}
// Recommendation OR empty-state explainer
if let Some(rec) = resp.get("recommendation").filter(|v| !v.is_null()) {
let winner = rec.get("winner").and_then(|v| v.as_str()).unwrap_or("—");
let mpn = rec.get("mpn").and_then(|v| v.as_str()).unwrap_or("—");
let price = rec.get("unit_price").and_then(|v| v.as_str()).unwrap_or("—");
let stock = rec.get("stock").and_then(|v| v.as_u64()).unwrap_or(0);
let reason = rec.get("reason").and_then(|v| v.as_str()).unwrap_or("");
println!();
println!("{} {} {} {} stock {}",
color(BOLD, "RECOMMENDATION →"),
color(CYAN, winner),
mpn,
color(YELLOW, price),
stock);
println!(" {}", color(DIM, reason));
} else if let Some(es) = resp.get("empty_state").filter(|v| !v.is_null()) {
let title = es.get("title").and_then(|v| v.as_str()).unwrap_or("No results.");
let expl = es.get("explanation").and_then(|v| v.as_str()).unwrap_or("");
let hint = es.get("hint").and_then(|v| v.as_str()).unwrap_or("");
println!();
println!("{} {}", color(BOLD, "NO MATCH —"), color(YELLOW, title));
if !expl.is_empty() { println!(" {}", color(DIM, expl)); }
if !hint.is_empty() { println!(" {} {}", color(CYAN, "→"), hint); }
}
println!();
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else {
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('…'); out
}
}
fn format_stock(n: u64) -> String {
if n == 0 { "0".to_string() }
else if n < 10 { format!("{}", n) }
else if n < 1000 { format!("{}", n) }
else { format!("{}k", n / 1000) }
}