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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
// Validate a chip directory by running ds2sf + concur in sequence and
// walking their hints[] arrays to drive autonomous self-repair.
//
// Both tools are agent-to-agent: each emits a stable
// <MPN>-{extraction,concur}.result.json with `status`, `exit_code`,
// `hints[]`, and (for concur) `variants[]`. We mirror those types via
// serde tagged-union deserialization and `match` on the action / kind.
//
// Read both contracts before touching this file:
// ~/.claude/skills/adom-ds2sf/SKILL.md
// ~/.claude/skills/adom-concur/SKILL.md
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
// ---------- ds2sf result.json types ----------
#[derive(Debug, Deserialize)]
pub struct Ds2sfResult {
pub schema_version: String,
pub status: String, // "ok" | "needs_better_pdf" | "unrecoverable"
pub exit_code: i32,
pub mpn: String,
#[serde(default)]
pub manufacturer: Option<String>,
pub datasheet_used: PathBuf,
#[serde(default)]
pub datasheet_sha256: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub review_required: Vec<serde_json::Value>,
#[serde(default)]
pub hints: Vec<Ds2sfHint>,
#[serde(default)]
pub outputs: Option<Ds2sfOutputs>,
}
#[derive(Debug, Deserialize)]
pub struct Ds2sfOutputs {
#[serde(default)]
pub symbol_json: Option<PathBuf>,
#[serde(default)]
pub footprint_json: Option<PathBuf>,
#[serde(default)]
pub provenance_json: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Ds2sfHint {
TryAlternatePdf {
#[serde(default)]
reason: Option<String>,
#[serde(default)]
candidates: Vec<PathBuf>,
},
UpdateUpstreamMetadata {
file: PathBuf,
key: String,
#[serde(default)]
current: Option<serde_json::Value>,
extracted: serde_json::Value,
},
UseDatasheetExtractedFootprint {
#[serde(default)]
package: Option<String>,
},
HumanReview {
#[serde(default)]
reason: Option<String>,
#[serde(default)]
notes: Option<String>,
},
// Catch-all so a future hint kind doesn't make deserialize fail.
#[serde(other)]
Unknown,
}
// ---------- concur result.json types ----------
#[derive(Debug, Deserialize)]
pub struct ConcurResult {
pub schema_version: String,
pub status: String, // "golden" | "golden_with_variants" | "divergent" | "unrecoverable"
pub exit_code: i32,
pub mpn: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub sources_compared: Vec<String>,
#[serde(default)]
pub sources_missing: Vec<String>,
#[serde(default)]
pub axes: Vec<serde_json::Value>,
#[serde(default)]
pub hints: Vec<ConcurHint>,
#[serde(default)]
pub variants: Vec<ConcurVariant>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum ConcurHint {
RefetchLibrarySource {
format: String, // "kicad_sym" | "kicad_mod" | "eagle"
#[serde(default)]
suspect: Option<String>,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
suggested_sources: Vec<String>,
#[serde(default)]
expected_resolution: Option<String>,
},
UlSymbolHasStubNames {
source: String,
#[serde(default)]
pin_count: Option<u32>,
#[serde(default)]
suggestion: Option<String>,
#[serde(default)]
chip_fetcher_action: Option<String>,
},
RerunDs2sfWithStrongerModel {
#[serde(default)]
reason: Option<String>,
#[serde(default)]
suspect_field: Option<String>,
#[serde(default)]
suggested_model: Option<String>,
},
AcceptWithCaveats {
#[serde(default)]
caveats: Vec<String>,
},
HumanReview {
#[serde(default)]
reason: Option<String>,
#[serde(default)]
notes: Option<String>,
},
SourceParseFailed {
format: String,
#[serde(default)]
path: Option<PathBuf>,
#[serde(default)]
error: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ConcurVariant {
pub kind: String, // tagged-string per contract
#[serde(flatten)]
pub extra: std::collections::BTreeMap<String, serde_json::Value>,
}
// ---------- public outcome ----------
#[derive(Debug)]
pub enum ValidationOutcome {
/// concur reported `golden` — every source agrees exactly.
Golden { mpn: String },
/// concur reported `golden_with_variants` — disagreements all explained
/// by variant kinds. Variants persisted to `<MPN>-validation-status.json`.
GoldenWithVariants {
mpn: String,
variants: Vec<ConcurVariant>,
used_ds2sf_preferred_symbol: bool,
},
/// We exhausted automated repairs (or hit a step that requires human
/// judgment). Final state of result.json saved alongside the chip.
NeedsHuman {
mpn: String,
reason: String,
last_concur_status: Option<String>,
last_ds2sf_status: Option<String>,
},
/// ds2sf came back with `unrecoverable` exit code 3.
Ds2sfUnrecoverable { mpn: String, summary: Option<String> },
}
// ---------- driver ----------
const MAX_DS2SF_PDF_TRIES: usize = 6;
const MAX_CONCUR_REPAIRS: usize = 5;
pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> {
let mpn = dir.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("chip dir has no basename: {}", dir.display()))?
.to_string();
println!("OK: validate {} — running ds2sf …", mpn);
let mut tried_pdfs: HashSet<PathBuf> = HashSet::new();
let mut ds2sf = run_ds2sf(dir, None)?;
tried_pdfs.insert(ds2sf.datasheet_used.clone());
// Phase 1 — needs_better_pdf loop
let mut tries = 0usize;
while ds2sf.status == "needs_better_pdf" {
tries += 1;
if tries > MAX_DS2SF_PDF_TRIES {
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: format!("ds2sf needs_better_pdf after {tries} attempts; out of candidates"),
last_concur_status: None,
last_ds2sf_status: Some("needs_better_pdf".into()),
});
}
let next = ds2sf.hints.iter().find_map(|h| match h {
Ds2sfHint::TryAlternatePdf { candidates, .. } => candidates.iter().find(|c| !tried_pdfs.contains(*c)).cloned(),
_ => None,
});
let Some(next) = next else {
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: "ds2sf needs_better_pdf — no untried candidate PDFs in hints[]".into(),
last_concur_status: None,
last_ds2sf_status: Some("needs_better_pdf".into()),
});
};
println!("OK: ds2sf wants alternate PDF → trying {}", next.display());
tried_pdfs.insert(next.clone());
ds2sf = run_ds2sf(dir, Some(&next))?;
}
if ds2sf.status == "unrecoverable" {
return Ok(ValidationOutcome::Ds2sfUnrecoverable {
mpn,
summary: ds2sf.summary,
});
}
if ds2sf.status != "ok" {
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: format!("ds2sf returned unexpected status {:?}", ds2sf.status),
last_concur_status: None,
last_ds2sf_status: Some(ds2sf.status),
});
}
// Apply update_upstream_metadata hints from ds2sf — these are
// info.json corrections (e.g. wrong package family detected).
apply_ds2sf_metadata_patches(&ds2sf);
// Phase 2 — concur loop
println!("OK: ds2sf complete; running concur …");
let mut tried_repairs: HashSet<String> = HashSet::new();
let mut used_ds2sf_preferred_symbol = false;
let mut last_concur_status = String::new();
let mut last_concur: Option<ConcurResult> = None;
for round in 1..=MAX_CONCUR_REPAIRS {
let r = run_concur(dir)?;
last_concur_status = r.status.clone();
match r.status.as_str() {
"golden" | "golden_with_variants" => {
// ul_symbol_has_stub_names is a "do not refetch" hint —
// mark the symbol source as ds2sf-preferred so downstream
// sym_create authors a fresh symbol from ds2sf JSON.
for h in &r.hints {
if let ConcurHint::UlSymbolHasStubNames { .. } = h {
if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {
eprintln!("WARN: could not mark {mpn} symbol source ds2sf-preferred: {e}");
} else {
used_ds2sf_preferred_symbol = true;
}
}
}
let _ = persist_validation_status(dir, &r);
if r.status == "golden" {
return Ok(ValidationOutcome::Golden { mpn });
} else {
return Ok(ValidationOutcome::GoldenWithVariants {
mpn,
variants: r.variants.clone(),
used_ds2sf_preferred_symbol,
});
}
}
"divergent" => {
// Walk hints in order; first action that's not been tried
// wins. Stop iterating once we acted (or once we determined
// no autonomous repair is available).
let mut acted = false;
for h in &r.hints {
match h {
ConcurHint::RerunDs2sfWithStrongerModel { suggested_model, reason, .. } => {
let key = "ds2sf_rerun".to_string();
if tried_repairs.insert(key) {
let model = suggested_model.clone().unwrap_or_else(|| "claude-opus-4-7".into());
println!("OK: concur round {round} — re-running ds2sf with model {model} (reason: {})",
reason.as_deref().unwrap_or("?"));
ds2sf = run_ds2sf_with_model(dir, &model)?;
acted = true;
break;
}
}
ConcurHint::RefetchLibrarySource { format, suggested_sources, notes, suspect, .. } => {
// Try the first untried (format, source) pair.
// If the refetch surface returns "not implemented"
// (current state — chip-fetcher's per-source refetch
// CLI isn't wired yet), record ALL suggested_sources
// in info.json.fetched_via_chain so the human has
// the priority-ordered list, then surface gracefully.
let key = format!("refetch_attempted|{format}");
if !tried_repairs.contains(&key) {
let pick = suggested_sources.iter().next().cloned();
if let Some(ref src) = pick {
println!("OK: concur round {round} — refetch_library_source {format} from {src} (suspect: {}, {})",
suspect.as_deref().unwrap_or("?"),
notes.as_deref().unwrap_or("no notes"));
match attempt_refetch_library(dir, format, src) {
Ok(()) => { acted = true; tried_repairs.insert(key); break; }
Err(e) => {
eprintln!("WARN: refetch {format}/{src} failed: {e}");
// Record the full priority-ordered list to the chain
// for later manual follow-up, and stop trying refetches
// for this format — every hint here is variant of "fix
// the kicad_sym," and they all fail the same way.
for s in suggested_sources {
let _ = attempt_refetch_library(dir, format, s); // appends to chain, returns Err
}
tried_repairs.insert(key);
// Don't break — let the OUTER for loop pick a different
// hint kind (e.g. UlSymbolHasStubNames) if present.
}
}
}
}
}
ConcurHint::UlSymbolHasStubNames { .. } => {
// Even on divergent, this hint means "don't
// refetch the .kicad_sym, prefer ds2sf." Mark
// and treat as soft repair (not a refetch).
let key = "ds2sf_preferred_symbol".to_string();
if tried_repairs.insert(key) {
if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {
eprintln!("WARN: could not mark ds2sf-preferred: {e}");
} else {
println!("OK: concur round {round} — marked symbol source ds2sf-preferred (UL stub names)");
used_ds2sf_preferred_symbol = true;
acted = true;
}
break;
}
}
_ => { /* AcceptWithCaveats / HumanReview / SourceParseFailed / Unknown — no autonomous action */ }
}
}
last_concur = Some(r);
if !acted {
let lc = last_concur.unwrap();
let _ = persist_validation_status(dir, &lc);
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: human_reason_from_hints(&lc),
last_concur_status: Some(last_concur_status),
last_ds2sf_status: Some("ok".into()),
});
}
}
"unrecoverable" => {
let _ = persist_validation_status(dir, &r);
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: r.summary.clone().unwrap_or_else(|| "concur unrecoverable".into()),
last_concur_status: Some(last_concur_status),
last_ds2sf_status: Some("ok".into()),
});
}
other => {
return Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: format!("concur returned unexpected status {other:?}"),
last_concur_status: Some(last_concur_status),
last_ds2sf_status: Some("ok".into()),
});
}
}
}
Ok(ValidationOutcome::NeedsHuman {
mpn,
reason: format!("concur still divergent after {MAX_CONCUR_REPAIRS} repair rounds"),
last_concur_status: Some(last_concur_status),
last_ds2sf_status: Some("ok".into()),
})
}
// ---------- subprocess helpers ----------
fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> Result<Ds2sfResult> {
let mut cmd = Command::new("adom-ds2sf");
cmd.arg("extract").arg(chip_dir).arg("--force").arg("--json-only");
if let Some(p) = pdf_override {
cmd.arg("--pdf").arg(p);
}
let _ = cmd.status().with_context(|| "running ds2sf extract")?;
read_ds2sf_result(chip_dir)
}
fn run_ds2sf_with_model(chip_dir: &Path, model: &str) -> Result<Ds2sfResult> {
let mut cmd = Command::new("adom-ds2sf");
cmd.arg("extract").arg(chip_dir)
.arg("--force").arg("--json-only")
.arg("--model").arg(model);
let _ = cmd.status().with_context(|| "running ds2sf extract --model")?;
read_ds2sf_result(chip_dir)
}
fn read_ds2sf_result(chip_dir: &Path) -> Result<Ds2sfResult> {
let mpn = chip_dir.file_name().and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("bad chip dir"))?;
let path = chip_dir.join(format!("{mpn}-extraction.result.json"));
let s = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let r: Ds2sfResult = serde_json::from_str(&s)
.with_context(|| format!("parsing ds2sf result.json: {}", path.display()))?;
Ok(r)
}
fn run_concur(chip_dir: &Path) -> Result<ConcurResult> {
let mut cmd = Command::new("adom-concur");
cmd.arg("check").arg(chip_dir).arg("--json-only");
let _ = cmd.status().with_context(|| "running concur check")?;
read_concur_result(chip_dir)
}
fn read_concur_result(chip_dir: &Path) -> Result<ConcurResult> {
let mpn = chip_dir.file_name().and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("bad chip dir"))?;
let path = chip_dir.join(format!("{mpn}-concur.result.json"));
let s = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let r: ConcurResult = serde_json::from_str(&s)
.with_context(|| format!("parsing concur result.json: {}", path.display()))?;
Ok(r)
}
// ---------- side-effects ----------
/// Apply ds2sf's update_upstream_metadata hints — patch info.json fields
/// where ds2sf detected the chip-fetcher hint was wrong (e.g. wrong
/// package family). Best-effort; won't unwind a partial patch.
fn apply_ds2sf_metadata_patches(r: &Ds2sfResult) {
for h in &r.hints {
if let Ds2sfHint::UpdateUpstreamMetadata { file, key, extracted, .. } = h {
if let Err(e) = patch_json_key(file, key, extracted) {
eprintln!("WARN: could not apply update_upstream_metadata to {} (key={key}): {e}", file.display());
} else {
println!("OK: patched {} → {key} := {}", file.display(), extracted);
}
}
}
}
fn patch_json_key(path: &Path, key: &str, value: &serde_json::Value) -> Result<()> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut v: serde_json::Value = serde_json::from_str(&s)
.with_context(|| format!("parsing {}", path.display()))?;
if let Some(obj) = v.as_object_mut() {
obj.insert(key.to_string(), value.clone());
} else {
return Err(anyhow!("{} is not a JSON object", path.display()));
}
std::fs::write(path, serde_json::to_string_pretty(&v)?)?;
Ok(())
}
/// Mark the chip dir's symbol source as `ds2sf-preferred` so downstream
/// `sym_create` authors a fresh symbol from ds2sf's extracted JSON
/// rather than the UL stub-named .kicad_sym.
fn mark_symbol_source_ds2sf_preferred(chip_dir: &Path) -> Result<()> {
let info = chip_dir.join("info.json");
let s = std::fs::read_to_string(&info)
.with_context(|| format!("reading {}", info.display()))?;
let mut v: serde_json::Value = serde_json::from_str(&s)?;
if let Some(obj) = v.as_object_mut() {
obj.insert("symbol_source".to_string(), serde_json::Value::String("ds2sf-preferred".into()));
obj.insert("symbol_source_reason".to_string(), serde_json::Value::String(
"concur reported ul_symbol_has_stub_names; .kicad_sym pin names are stubs (the UL bundle did not carry real labels). Downstream sym_create should author from <MPN>-symbol.extracted.json instead.".into()
));
}
std::fs::write(&info, serde_json::to_string_pretty(&v)?)?;
Ok(())
}
/// Write `<MPN>-validation-status.json` so downstream tools can read
/// the final state without re-running the whole pipeline.
fn persist_validation_status(chip_dir: &Path, r: &ConcurResult) -> Result<()> {
let mpn = chip_dir.file_name().and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("bad chip dir"))?;
let path = chip_dir.join(format!("{mpn}-validation-status.json"));
let body = serde_json::json!({
"schema_version": "1",
"validated_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()).unwrap_or(0),
"concur_status": r.status,
"sources_compared": r.sources_compared,
"sources_missing": r.sources_missing,
"variants": r.variants.iter().map(|v| {
let mut m = serde_json::Map::new();
m.insert("kind".into(), serde_json::Value::String(v.kind.clone()));
for (k, val) in &v.extra { m.insert(k.clone(), val.clone()); }
serde_json::Value::Object(m)
}).collect::<Vec<_>>(),
"summary": r.summary,
});
std::fs::write(&path, serde_json::to_string_pretty(&body)?)?;
println!("OK: validation status persisted to {}", path.display());
Ok(())
}
/// Refetch a specific library file from a specific source by shelling to
/// `chip-fetcher refetch`. `concur`'s `suggested_sources` is the
/// priority-ordered list; the validate loop walks it and calls this
/// function for the first untried entry. Returns Ok(()) when refetch
/// landed a new file, Err otherwise — the loop then re-runs concur and
/// expects the suspect axis to flip verdict.
///
/// Source slug normalization: maps third-party variants ("snapeda",
/// "Manufacturer site: foo (longer note)") to canonical Source enum
/// before invoking the subprocess. concur's suggested_sources entries
/// often carry verbose context after a colon ("Manufacturer site: …
/// search the part number on the manufacturer's website…"), so we
/// extract just the leading slug for routing.
fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Result<()> {
let mpn = chip_dir.file_name().and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("bad chip dir"))?;
// concur emits sources as either bare slugs ("Manufacturer", "Ultra
// Librarian", "SnapMagic / SnapEDA") or as longer descriptive strings
// ("Manufacturer site: foo (search the part number…)"). Extract just
// the routing slug so we can call refetch with a known source.
let lower = source.to_ascii_lowercase();
let source_slug = if lower.contains("manufacturer") || lower.contains("mfr") || lower.contains("vendor") {
"manufacturer"
} else if lower.contains("snapmagic") || lower.contains("snapeda") {
"snapmagic"
} else if lower.contains("mouser") {
"mouser"
} else if lower.contains("digikey") {
"digikey"
} else if lower.contains("arrow") {
"arrow"
} else if lower.contains("ultra librarian") || lower.contains("ultralibrarian") || lower.contains("componentsearchengine") || lower.contains("cse") {
"cse_ul"
} else if lower.contains("lcsc") {
"lcsc"
} else {
let _ = crate::sourcing::append_chain(mpn, crate::sourcing::Source::Manufacturer,
"refetch_unknown_source",
Some(&format!("concur suggested {source:?} which doesn't map to a known ladder slug")));
return Err(anyhow!("could not normalize source slug from {source:?}"));
};
println!("OK: invoking `adom-chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");
// Self-invoke by absolute path — name-independent across renames.
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());
let status = std::process::Command::new(exe)
.args(["refetch", mpn,
"--format", format,
"--source", source_slug])
.status()
.map_err(|e| anyhow!("could not exec adom-chip-fetcher refetch: {e}"))?;
if status.success() {
// refetch_cmd already appended an OK chain entry on success.
Ok(())
} else {
// refetch_cmd appended a failure entry; we just propagate.
Err(anyhow!("chip-fetcher refetch exited non-zero (timeout, user didn't click, or import failed) — see info.json.fetched_via_chain"))
}
}
fn human_reason_from_hints(r: &ConcurResult) -> String {
// Concise + deduped summary. Many divergent hints repeat the same
// refetch suggestion (e.g. one per pin disagreement). Collapse to
// unique action lines + a count of pin-level conflicts.
let mut refetch_targets: std::collections::BTreeSet<(String, String)> =
std::collections::BTreeSet::new(); // (format, top-source)
let mut human_reasons: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut parse_failures: Vec<(String, String)> = Vec::new();
let mut refetch_count = 0usize;
for h in &r.hints {
match h {
ConcurHint::RefetchLibrarySource { format, suggested_sources, .. } => {
refetch_count += 1;
if let Some(top) = suggested_sources.iter().next() {
// Use just the leading slug, not the long verbose note.
let trimmed = top.split(':').next().unwrap_or(top).trim().to_string();
refetch_targets.insert((format.clone(), trimmed));
}
}
ConcurHint::HumanReview { reason, .. } => {
human_reasons.insert(reason.clone().unwrap_or_else(|| "?".into()));
}
ConcurHint::SourceParseFailed { format, error, .. } => {
parse_failures.push((format.clone(), error.clone().unwrap_or_else(|| "?".into())));
}
_ => {}
}
}
let mut parts: Vec<String> = Vec::new();
if refetch_count > 0 {
parts.push(format!("{refetch_count} divergent axis hint(s) → refetch needed: {}",
refetch_targets.iter()
.map(|(f, s)| format!("{f}@{s}"))
.collect::<Vec<_>>().join(", ")));
}
for r in human_reasons { parts.push(format!("human_review:{r}")); }
for (f, e) in parse_failures { parts.push(format!("parse_failed:{f}({e})")); }
if parts.is_empty() {
r.summary.clone().unwrap_or_else(|| "concur divergent — no actionable hints".into())
} else {
parts.join("; ")
}
}
/// Pretty-print a ValidationOutcome for the CLI surface.
pub fn print_outcome(out: &ValidationOutcome) {
match out {
ValidationOutcome::Golden { mpn } => {
println!("OK: {mpn} → GOLDEN (every source agrees)");
}
ValidationOutcome::GoldenWithVariants { mpn, variants, used_ds2sf_preferred_symbol } => {
println!("OK: {mpn} → GOLDEN_WITH_VARIANTS ({} variant{})",
variants.len(), if variants.len() == 1 { "" } else { "s" });
for v in variants {
println!(" variant: {}", v.kind);
}
if *used_ds2sf_preferred_symbol {
println!(" note: symbol_source = ds2sf-preferred (UL bundle had stub pin names)");
}
}
ValidationOutcome::NeedsHuman { mpn, reason, last_concur_status, last_ds2sf_status } => {
println!("WARN: {mpn} → NEEDS_HUMAN");
println!(" reason: {reason}");
if let Some(s) = last_ds2sf_status { println!(" last ds2sf status: {s}"); }
if let Some(s) = last_concur_status { println!(" last concur status: {s}"); }
}
ValidationOutcome::Ds2sfUnrecoverable { mpn, summary } => {
println!("ERROR: {mpn} → DS2SF_UNRECOVERABLE");
if let Some(s) = summary { println!(" {s}"); }
}
}
}