app
Adom DS2SF - Datasheet to Symbol and Footprint
Public Made by Adomby adom
Datasheet to symbol + footprint + provenance JSON — pins and pads, extracted from the PDF.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
use anyhow::Context as _;
#[allow(unused_imports)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Command;
use std::path::Path;
use crate::context::Context;
use crate::extract::SymbolOutcome;
use crate::io::{err, hint, note, ok, set_json_only};
use crate::result::{self, Hint, Outputs, ResultDoc, Status};
use crate::{emit, extract, normalize};
#[derive(Parser)]
#[command(name = "adom-ds2sf", version, about = "Datasheet → symbol + footprint + provenance JSON")]
pub struct Cli {
#[command(subcommand)]
pub command: Cmd,
}
#[derive(Subcommand)]
pub enum Cmd {
/// Read a chip's datasheet PDF and emit symbol + footprint + provenance JSONs.
Extract {
/// Path to a chip-fetcher chip directory (e.g. /home/adom/project/chip-fetcher/library/ADS1115IDGSR).
chip_dir: PathBuf,
/// Override auto-detected datasheet PDF.
#[arg(long)]
pdf: Option<PathBuf>,
/// Override the MPN (defaults to chip-dir basename or info.json).
#[arg(long)]
mpn: Option<String>,
/// Override the manufacturer name.
#[arg(long)]
manufacturer: Option<String>,
/// Override the datasheet URL.
#[arg(long)]
datasheet_url: Option<String>,
/// Override the datasheet source tier.
#[arg(long, value_parser = ["mfr","snapmagic","mouser","digikey","arrow","cse","lcsc","unknown"])]
source_tier: Option<String>,
/// Output directory (defaults to chip-dir).
#[arg(long)]
out_dir: Option<PathBuf>,
/// Claude model to use.
#[arg(long, default_value = "claude-opus-4-7")]
model: String,
/// Bypass the cache and re-run Claude even if outputs exist.
#[arg(long)]
force: bool,
/// Suppress progress text; only emit OK:/ERROR: lines.
#[arg(long)]
json_only: bool,
/// Skip Stage 1b (footprint pass) — useful for symbol-only sanity checks.
#[arg(long)]
symbol_only: bool,
/// Skip Stage 1a (symbol pass) — re-run only the footprint pass against existing cached symbol output.
#[arg(long)]
footprint_only: bool,
},
/// Resolve a package string against the service-kicad standard library.
Normalize {
/// Package string to resolve, e.g. "VSSOP-10" or "QFN-32 5x5 0.5mm".
package: String,
},
/// Print a summary of an existing extraction in a chip directory.
Inspect {
chip_dir: PathBuf,
},
/// Regenerate `<MPN>-footprint.authoritative.svg` from the cached
/// footprint JSON without re-running Claude. Useful for downstream
/// tooling that wants the visual reference without paying for an
/// extraction, and for iterating on the SVG generator itself.
Svg {
chip_dir: PathBuf,
},
/// Probe `claude` CLI, service-kicad reachability.
Health,
/// Drop SKILL.md to ~/.claude/skills/adom-ds2sf/.
Install,
}
pub fn run(cli: Cli) -> i32 {
match cli.command {
Cmd::Extract {
chip_dir,
pdf,
mpn,
manufacturer,
datasheet_url,
source_tier,
out_dir,
model,
force,
json_only,
symbol_only,
footprint_only,
} => {
set_json_only(json_only);
run_extract(
chip_dir, pdf, mpn, manufacturer, datasheet_url, source_tier,
out_dir, model, force, symbol_only, footprint_only,
)
}
Cmd::Normalize { package } => match normalize::resolve(&package, None) {
Ok(resolved) => {
match resolved.kicad_baseline {
Some(b) => ok(format!("{} → {}:{} (service-kicad stdlib)", package, b.library, b.name)),
None => {
crate::io::warn(format!(
"{} → no service-kicad stdlib match (tried {} libraries)",
package, resolved.search_attempts.len()
));
for a in &resolved.search_attempts {
note(format!(" tried {}:{} → {}", a.library, a.name, a.result));
}
}
}
0
}
Err(e) => { err(e); 1 }
},
Cmd::Inspect { chip_dir } => {
let prov_path = chip_dir.join(format!(
"{}-extraction.provenance.json",
chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?")
));
if !prov_path.exists() {
err(format!("no provenance at {}", prov_path.display()));
hint(format!(
"run `ds2sf extract {}` first",
chip_dir.display()
));
return 1;
}
match std::fs::read_to_string(&prov_path)
.context("reading provenance")
.and_then(|raw| serde_json::from_str::<Value>(&raw).context("parsing provenance"))
{
Ok(prov) => { print_inspect(&prov); 0 }
Err(e) => { err(e); 1 }
}
}
Cmd::Svg { chip_dir } => match run_svg_only(&chip_dir) {
Ok(r) => {
if let Some(p) = &r.ds2sf_svg {
ok(format!("wrote {} (ds2sf-datasheet-derived)", p.display()));
}
if let Some(p) = &r.pcbnew_svg {
ok(format!("wrote {} (pcbnew-stdlib)", p.display()));
}
if r.ds2sf_svg.is_none() {
err("SVG generation failed"); 1
} else { 0 }
}
Err(e) => { err(e); 1 }
},
Cmd::Health => {
let mut all_ok = true;
match Command::new("claude").arg("--version").output() {
Ok(o) if o.status.success() => {
let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
ok(format!("claude CLI {v}"));
}
_ => {
err("claude CLI not on PATH");
hint("install from claude.ai/code or ensure it is in $PATH");
all_ok = false;
}
}
// service-kicad probe — informational only; extraction runs without it
let url = "https://kicad-rk5ue5pcfemi.adom.cloud/health";
match ureq::get(url).timeout(std::time::Duration::from_secs(3)).call() {
Ok(r) if r.status() == 200 => ok(format!("service-kicad reachable ({url})")),
Ok(r) => {
crate::io::warn(format!("service-kicad returned status {} — stdlib match disabled", r.status()));
}
Err(e) => {
crate::io::warn(format!("service-kicad unreachable ({e}) — stdlib match disabled"));
}
}
if all_ok {
ok("ds2sf ready");
0
} else {
1
}
}
Cmd::Install => {
let home = match std::env::var("HOME") {
Ok(h) => h,
Err(_) => { err("HOME not set"); return 1; }
};
let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-ds2sf");
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
err(format!("creating skill dir: {e}")); return 1;
}
let dest = dest_dir.join("SKILL.md");
// SAFETY NET — do not rely on for release propagation.
const SKILL_MD: &str = include_str!("../SKILL.md");
if let Err(e) = std::fs::write(&dest, SKILL_MD) {
err(format!("writing SKILL.md: {e}")); return 1;
}
ok(format!("installed SKILL.md → {}", dest.display()));
0
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_extract(
chip_dir: PathBuf,
pdf: Option<PathBuf>,
mpn: Option<String>,
manufacturer: Option<String>,
datasheet_url: Option<String>,
source_tier: Option<String>,
out_dir: Option<PathBuf>,
model: String,
force: bool,
symbol_only: bool,
footprint_only: bool,
) -> i32 {
let ctx = match Context::ingest(
&chip_dir,
pdf.as_deref(),
mpn.as_deref(),
manufacturer.as_deref(),
datasheet_url.as_deref(),
source_tier.as_deref(),
out_dir.as_deref(),
) {
Ok(c) => c,
Err(e) => {
err(&e);
// Best-effort: emit a result.json describing the pre-flight failure.
// We can't write into out_dir without ctx, but we can still log.
return Status::Unrecoverable.exit_code();
}
};
if !force && match ctx.outputs_exist() { Ok(b) => b, Err(_) => false }
&& !symbol_only && !footprint_only
{
// Cache hit: the prior result.json (if any) stands. Return its exit code,
// or 0 if the legacy run pre-dates result.json.
let cached_status = std::fs::read_to_string(result::result_path(&ctx))
.ok()
.and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())
.map(|d| d.exit_code)
.unwrap_or(0);
ok(format!(
"{} — cached extraction at {} (use --force to re-run)",
ctx.mpn,
ctx.provenance_path().display()
));
return cached_status;
}
if let Err(e) = ctx.cache_dir_create() {
err(e);
return Status::Unrecoverable.exit_code();
}
let _ = ctx.write_pdf_sha_marker();
note(format!(
"ds2sf {}: {} ({} pages, sha256 {}…)",
ctx.mpn,
ctx.datasheet_path.display(),
ctx.page_count.unwrap_or(0),
&ctx.datasheet_sha256[..12],
));
// Stage 1a — symbol pass (or load cached for --footprint-only).
let symbol = if footprint_only {
match extract::load_cached_symbol(&ctx) {
Ok(s) => s,
Err(e) => {
err(format!("--footprint-only requires a cached symbol pass: {e}"));
return finalize_unrecoverable(&ctx, &model, "no_cached_symbol", &e.to_string());
}
}
} else {
match extract::run_symbol_pass(&ctx, &model) {
Ok(SymbolOutcome::Ok(s)) => {
let _ = extract::cache_symbol(&ctx, &s);
s
}
Ok(SymbolOutcome::Refused { review_required, raw_response }) => {
let _ = std::fs::write(
ctx.cache_dir().join("claude-symbol-refusal.txt"),
raw_response,
);
return finalize_refusal(&ctx, &model, review_required);
}
Err(e) => {
err(format!("symbol-pass failed: {e}"));
return finalize_unrecoverable(&ctx, &model, "symbol_pass_error", &e.to_string());
}
}
};
// Stage 1b — footprint pass (skipped if --symbol-only).
let footprint = if symbol_only {
None
} else {
match extract::run_footprint_pass(&ctx, &model, &symbol) {
Ok(fp) => {
let _ = extract::cache_footprint(&ctx, &fp);
Some(fp)
}
Err(e) => {
err(format!("footprint-pass failed: {e}"));
return finalize_unrecoverable(&ctx, &model, "footprint_pass_error", &e.to_string());
}
}
};
let normalized = footprint
.as_ref()
.map(|fp| normalize::resolve(&fp.package_name, ctx.package_hint.as_deref()).ok())
.unwrap_or(None);
if let Err(e) = emit::write_all(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &model) {
err(format!("emit failed: {e}"));
return finalize_unrecoverable(&ctx, &model, "emit_error", &e.to_string());
}
// Generate footprint SVGs — two independent renderings:
// 1. ds2sf SVG: synthesized from the datasheet mechanical drawing
// 2. pcbnew SVG: fetched from service-kicad (when stdlib match exists)
let svg_result = if let Some(fp) = footprint.as_ref() {
let r = crate::fpsvg::generate(&ctx, fp, normalized.as_ref());
if let Some(p) = &r.ds2sf_svg {
note(format!("[svg] datasheet-derived SVG → {}", p.display()));
}
if let Some(p) = &r.pcbnew_svg {
note(format!("[svg] pcbnew comparison SVG → {}", p.display()));
}
if r.ds2sf_svg.is_none() {
crate::io::warn(format!("[svg] ds2sf SVG generation failed for {}", ctx.mpn));
}
Some(r)
} else {
None
};
let summary = emit::summary_line(&ctx, &symbol, footprint.as_ref(), normalized.as_ref());
ok(&summary);
let merged_review = extract::merge_review_required(&symbol, footprint.as_ref());
for entry in &merged_review {
let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("unspecified");
let detail = serde_json::to_string(entry).unwrap_or_default();
crate::io::warn(format!("review needed — {reason}: {detail}"));
}
// Build hints[] from review_required + normalize attempts + package mismatch.
let mut hints = build_ok_hints(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &merged_review);
let result_doc = ResultDoc {
schema_version: "1".into(),
status: Status::Ok,
exit_code: Status::Ok.exit_code(),
mpn: ctx.mpn.clone(),
manufacturer: ctx.manufacturer.clone(),
datasheet_used: ctx.datasheet_path.clone(),
datasheet_sha256: ctx.datasheet_sha256.clone(),
summary,
review_required: merged_review,
hints: std::mem::take(&mut hints),
outputs: Outputs {
symbol_json: Some(ctx.symbol_out_path()),
footprint_json: footprint.as_ref().map(|_| ctx.footprint_out_path()),
provenance_json: Some(ctx.provenance_path()),
footprint_svg_ds2sf: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),
footprint_svg_pcbnew: svg_result.as_ref().and_then(|r| r.pcbnew_svg.clone()),
footprint_svg: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),
footprint_svg_source: Some("ds2sf-datasheet-derived".to_string()),
},
extractor_version: env!("CARGO_PKG_VERSION").into(),
claude_model: model.clone(),
};
if let Err(e) = result::write(&ctx, &result_doc) {
err(format!("writing result.json: {e}"));
}
Status::Ok.exit_code()
}
fn finalize_refusal(ctx: &Context, model: &str, review_required: Vec<Value>) -> i32 {
let alts = result::find_alternate_pdfs(ctx);
let mut hints: Vec<Hint> = Vec::new();
if !alts.is_empty() {
hints.push(Hint::TryAlternatePdf {
reason: result::reasons::MODULE_EXTERNAL_PINOUT_MISSING.into(),
candidates: alts,
});
}
let summary = format!(
"{} — extractor refused: supplied PDF doesn't contain the right pinout (likely a module's SoC datasheet was passed instead of the module datasheet, or the wrong variant). Re-call with a different --pdf.",
ctx.mpn
);
crate::io::warn(&summary);
let doc = ResultDoc {
schema_version: "1".into(),
status: Status::NeedsBetterPdf,
exit_code: Status::NeedsBetterPdf.exit_code(),
mpn: ctx.mpn.clone(),
manufacturer: ctx.manufacturer.clone(),
datasheet_used: ctx.datasheet_path.clone(),
datasheet_sha256: ctx.datasheet_sha256.clone(),
summary,
review_required,
hints,
outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },
extractor_version: env!("CARGO_PKG_VERSION").into(),
claude_model: model.into(),
};
let _ = result::write(ctx, &doc);
Status::NeedsBetterPdf.exit_code()
}
fn finalize_unrecoverable(ctx: &Context, model: &str, reason: &str, notes: &str) -> i32 {
let summary = format!("{} — unrecoverable: {reason}", ctx.mpn);
let review_required = vec![json!({
"reason": result::reasons::UNRECOVERABLE_EXTRACTION_ERROR,
"stage_reason": reason,
"notes": notes,
})];
let doc = ResultDoc {
schema_version: "1".into(),
status: Status::Unrecoverable,
exit_code: Status::Unrecoverable.exit_code(),
mpn: ctx.mpn.clone(),
manufacturer: ctx.manufacturer.clone(),
datasheet_used: ctx.datasheet_path.clone(),
datasheet_sha256: ctx.datasheet_sha256.clone(),
summary,
review_required,
hints: vec![Hint::HumanReview {
reason: reason.into(),
notes: notes.into(),
}],
outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },
extractor_version: env!("CARGO_PKG_VERSION").into(),
claude_model: model.into(),
};
let _ = result::write(ctx, &doc);
Status::Unrecoverable.exit_code()
}
fn build_ok_hints(
ctx: &Context,
_sym: &extract::SymbolPass,
fp: Option<&extract::FootprintPass>,
norm: Option<&normalize::Resolved>,
review: &[Value],
) -> Vec<Hint> {
let mut hints = Vec::new();
// Package mismatch between info.json and datasheet → upstream metadata fix.
for entry in review {
let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason == result::reasons::PACKAGE_MISMATCH_IN_HINT {
let info_json = ctx.chip_dir.join("info.json");
let extracted = fp
.map(|f| f.package_name.clone())
.unwrap_or_default();
hints.push(Hint::UpdateUpstreamMetadata {
file: info_json,
key: "package".into(),
current: ctx.package_hint.clone().unwrap_or_default(),
extracted,
});
} else {
// Generic human-review surfacing for everything else.
let notes = entry
.get("notes")
.or_else(|| entry.get("note"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
hints.push(Hint::HumanReview {
reason: reason.to_string(),
notes,
});
}
}
// Custom package → caller should use datasheet-extracted dimensions.
if let Some(fp) = fp {
let no_match = norm.map(|n| n.kicad_baseline.is_none()).unwrap_or(true);
if no_match {
hints.push(Hint::UseDatasheetExtractedFootprint {
package: fp.package_name.clone(),
});
}
}
hints
}
fn print_inspect(prov: &serde_json::Value) {
let mpn = prov.get("mpn").and_then(|v| v.as_str()).unwrap_or("?");
let manuf = prov.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("?");
let pkg = prov
.pointer("/footprint/package_name_provenance/value")
.and_then(|v| v.as_str())
.unwrap_or("?");
let baseline = prov
.pointer("/footprint/kicad_baseline")
.map(|v| {
if v.is_null() {
"no stdlib match".to_string()
} else {
let lib = v.get("library").and_then(|x| x.as_str()).unwrap_or("?");
let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?");
format!("{lib}:{name}")
}
})
.unwrap_or_else(|| "unknown".to_string());
let pin_count = prov
.pointer("/symbol/pin_provenance")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let review = prov
.get("review_required")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
note(format!("MPN: {mpn} (manufacturer: {manuf})"));
note(format!("Package: {pkg}"));
note(format!("Stdlib match: {baseline}"));
note(format!("Pins extracted: {pin_count}"));
note(format!("review_required entries: {review}"));
if let Some(arr) = prov.get("review_required").and_then(|v| v.as_array()) {
for r in arr {
note(format!(" - {}", serde_json::to_string(r).unwrap_or_default()));
}
}
}
/// `ds2sf svg <CHIP-DIR>` — regenerate the authoritative footprint SVG
/// from the cached `<MPN>-footprint.extracted.json`. Useful for iterating
/// on the SVG generator without re-running Claude, and for downstream
/// tooling that wants the visual reference.
fn run_svg_only(chip_dir: &Path) -> anyhow::Result<crate::fpsvg::SvgResult> {
let chip_dir = chip_dir.canonicalize()?;
let mpn = chip_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let fp_path = chip_dir.join(format!("{mpn}-footprint.extracted.json"));
let fp_raw = std::fs::read_to_string(&fp_path).map_err(|e| {
anyhow::anyhow!("reading {} — run `ds2sf extract` first: {e}", fp_path.display())
})?;
let fp_json: serde_json::Value = serde_json::from_str(&fp_raw)?;
// Reconstruct a minimal FootprintPass + Resolved from the cached JSON.
let pkg_name = fp_json
.get("package")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_string();
let body_x = fp_json.pointer("/bodyDimensions/x").and_then(|v| v.as_f64()).unwrap_or(2.0);
let body_y = fp_json.pointer("/bodyDimensions/y").and_then(|v| v.as_f64()).unwrap_or(2.0);
let body_t = fp_json.pointer("/bodyDimensions/thickness_max").and_then(|v| v.as_f64());
let pad_count = fp_json.get("padCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let lead_dimensions = fp_json
.get("leadDimensions").cloned()
.unwrap_or(serde_json::Value::Null);
let pad_descriptions = fp_json
.get("padDescriptions")
.and_then(|v| v.as_object()).cloned()
.unwrap_or_default();
let symbol_pin_map = fp_json
.get("symbolPinMap")
.and_then(|v| v.as_object()).cloned()
.unwrap_or_default();
let fp = crate::extract::FootprintPass {
package_name: pkg_name,
is_standard_jedec: fp_json.get("kicad_baseline").map(|v| !v.is_null()).unwrap_or(false),
package_name_source_page: 0,
pad_count,
body_dimensions: crate::extract::BodyDims {
x: body_x,
y: body_y,
thickness_max: body_t,
source_page: 0,
figure_label: String::new(),
},
lead_dimensions,
mechanical: fp_json.get("mechanical").cloned(),
pad_descriptions,
symbol_pin_map,
review_required: vec![],
};
let kicad_baseline = fp_json
.get("kicad_baseline")
.and_then(|v| if v.is_null() { None } else { Some(v) })
.and_then(|v| {
let lib = v.get("library").and_then(|x| x.as_str())?.to_string();
let name = v.get("name").and_then(|x| x.as_str())?.to_string();
Some(crate::normalize::KicadBaseline {
library: lib,
name,
source: "service-kicad-stdlib".into(),
})
});
let resolved = crate::normalize::Resolved {
input: fp_json.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string(),
kicad_baseline,
search_attempts: vec![],
};
// Build a minimal Context with just the fields fpsvg needs.
let mpn_buf = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
let ctx = crate::context::Context {
mpn: mpn_buf.clone(),
manufacturer: None,
package_hint: None,
description_hint: None,
family_hint: None,
datasheet_path: chip_dir.join(format!("{mpn_buf}.pdf")),
datasheet_url: None,
datasheet_source_tier: None,
datasheet_source_host: None,
datasheet_sha256: String::new(),
fetched_at: None,
extracted_at: String::new(),
chip_dir: chip_dir.clone(),
out_dir: chip_dir.clone(),
page_count: None,
};
let r = crate::fpsvg::generate(&ctx, &fp, Some(&resolved));
// Back-stamp SVG paths onto result.json if it exists.
let result_path = chip_dir.join(format!("{mpn_buf}-extraction.result.json"));
if result_path.exists() && r.ds2sf_svg.is_some() {
if let Ok(raw) = std::fs::read_to_string(&result_path) {
if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(outputs) = doc.get_mut("outputs").and_then(|v| v.as_object_mut()) {
if let Some(p) = &r.ds2sf_svg {
outputs.insert("footprint_svg_ds2sf".into(), serde_json::json!(p.display().to_string()));
outputs.insert("footprint_svg".into(), serde_json::json!(p.display().to_string()));
}
if let Some(p) = &r.pcbnew_svg {
outputs.insert("footprint_svg_pcbnew".into(), serde_json::json!(p.display().to_string()));
}
outputs.insert("footprint_svg_source".into(), serde_json::json!("ds2sf-datasheet-derived"));
let _ = std::fs::write(
&result_path,
serde_json::to_string_pretty(&doc).unwrap_or(raw) + "\n",
);
}
}
}
}
Ok(r)
}