npnp 0.1.11

Normalize Pin Net Pad (npnp): pure Rust LCEDA/EasyEDA downloader and Altium library exporter
Documentation
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use npnp::footprint::build_pcblib_from_payload;
use npnp::pcblib::write_pcblib;
use npnp::schlib::{
    SchlibMetadata, SchlibParameter, build_component_from_payload_with_metadata,
    write_schlib_library,
};
use serde_json::Value;

const STEP_FIXTURE: &[u8] = b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n";

#[test]
fn fixture_payloads_write_openable_altium_stream_layouts() {
    let output_dir = temp_output_dir("fixture_payloads_write_openable_altium_stream_layouts");
    fs::create_dir_all(&output_dir).expect("create temp output dir");

    let symbol_payload: Value =
        serde_json::from_str(include_str!("fixtures/easyeda_symbol.json")).expect("symbol fixture");
    let footprint_payload: Value =
        serde_json::from_str(include_str!("fixtures/easyeda_footprint.json"))
            .expect("footprint fixture");

    let metadata = SchlibMetadata {
        description: Some("Regression component".to_string()),
        designator: Some("U?".to_string()),
        comment: Some("REGRESSION".to_string()),
        parameters: vec![SchlibParameter {
            name: "Footprint".to_string(),
            value: "REGRESSION_QFN".to_string(),
        }],
        footprint_model_name: Some("REGRESSION_QFN".to_string()),
        footprint_library_file: Some("Regression.PcbLib".to_string()),
    };

    let component =
        build_component_from_payload_with_metadata(&symbol_payload, "REGRESSION_QFN", &metadata)
            .expect("build schematic component from fixture");
    let schlib_path = output_dir.join("Regression.SchLib");
    write_schlib_library(&[component], &schlib_path).expect("write SchLib fixture output");
    assert_cfb_stream_exists(&schlib_path, "/FileHeader");
    assert_cfb_stream_exists(&schlib_path, "/Storage");
    let schlib_data = read_cfb_stream_text(&schlib_path, "/REGRESSION_QFN/Data");
    assert!(schlib_data.contains("|LIBREFERENCE=REGRESSION_QFN|"));
    assert!(schlib_data.contains("|MODELTYPE=PCBLIB|"));
    assert!(schlib_data.contains("|MODELDATAFILEENTITY1=Regression.PcbLib|"));

    let pcblib =
        build_pcblib_from_payload(&footprint_payload, "REGRESSION_QFN", Some(STEP_FIXTURE))
            .expect("build PCB library from fixture");
    let pcb_component = pcblib
        .components
        .first()
        .expect("PCB fixture should produce a component");
    assert_eq!(pcb_component.pads.len(), 2);
    assert_eq!(pcb_component.tracks.len(), 1);
    assert_eq!(pcb_component.arcs.len(), 1);
    assert_eq!(pcb_component.regions.len(), 3);
    assert_eq!(pcb_component.bodies.len(), 1);
    assert_eq!(pcblib.models.len(), 1);

    let pcblib_path = output_dir.join("Regression.PcbLib");
    write_pcblib(&pcblib, &pcblib_path).expect("write PcbLib fixture output");
    assert_cfb_stream_exists(&pcblib_path, "/FileHeader");
    assert_cfb_stream_exists(&pcblib_path, "/Library/Data");
    assert_cfb_stream_exists(&pcblib_path, "/REGRESSION_QFN/Data");
    assert_cfb_stream_exists(&pcblib_path, "/Library/Models/0");

    fs::remove_dir_all(output_dir).ok();
}

fn temp_output_dir(test_name: &str) -> std::path::PathBuf {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock should be after Unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("npnp_{test_name}_{timestamp}"))
}

fn assert_cfb_stream_exists(path: &Path, stream_path: &str) {
    let file = File::open(path).expect("open compound file");
    let mut compound = cfb::CompoundFile::open(file).expect("open compound file structure");
    compound
        .open_stream(stream_path)
        .unwrap_or_else(|err| panic!("expected stream {stream_path}: {err}"));
}

fn read_cfb_stream_text(path: &Path, stream_path: &str) -> String {
    let file = File::open(path).expect("open compound file");
    let mut compound = cfb::CompoundFile::open(file).expect("open compound file structure");
    let mut stream = compound.open_stream(stream_path).expect("open stream");
    let mut data = Vec::new();
    stream.read_to_end(&mut data).expect("read stream");
    String::from_utf8_lossy(&data).into_owned()
}