via-pcb-cli 0.1.2

Project-oriented CLI wrapper for via checks, snapshots, BOMs, and exports.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

const MANAGED_FOOTPRINTS_FILE: &str = ".via-managed-footprints";

pub(crate) struct KicadExportSummary {
    pub(crate) generated_footprints: usize,
    pub(crate) manual_footprints: usize,
}

pub(crate) fn write_artifacts(
    board: &via_core::Board,
    out: &Path,
    footprint_export: Option<via_project::KicadFootprintExport>,
    project_name: &str,
    footprint_cache_version: &str,
) -> via_core::Result<KicadExportSummary> {
    let stem = project_name;
    let footprint_summary = if let Some(footprint_export) = &footprint_export {
        write_embedded_footprints(board, &footprint_export.output_dir, footprint_cache_version)?
    } else {
        KicadExportSummary {
            generated_footprints: 0,
            manual_footprints: 0,
        }
    };
    via_kicad::write_netlist(board, out.join(format!("{stem}.net")))?;
    let mut options = via_kicad::SchematicProjectOptions::new(stem);
    if let Some(footprint_export) = footprint_export {
        options =
            options.footprint_library(footprint_export.library_name, footprint_export.library_path);
    }
    via_kicad::write_schematic_project(board, out, &options)?;

    Ok(footprint_summary)
}

pub(crate) fn load_required_official_footprint_texts(
    board: &via_core::Board,
    version: &str,
) -> via_core::Result<BTreeMap<String, String>> {
    let official_ids = required_official_footprint_map(board)?;
    if official_ids.is_empty() {
        return Ok(BTreeMap::new());
    }

    let cache = via_kicad_footprints::FootprintCache::open(version)
        .map_err(|err| via_core::Error::Io(err.to_string()))?;
    let mut texts = BTreeMap::new();
    for (local_name, id) in official_ids {
        let text = cache
            .footprint_text(&id)
            .map_err(|err| via_core::Error::Io(err.to_string()))?;
        texts.insert(local_name, text);
    }
    Ok(texts)
}

fn required_official_footprint_map(
    board: &via_core::Board,
) -> via_core::Result<BTreeMap<String, via_kicad_footprints::FootprintId>> {
    let mut official = BTreeMap::new();
    for footprint in board
        .footprints()
        .filter(|footprint| footprint.ir().is_none())
    {
        let Some(via_core::FootprintAsset::KicadLibrary { library, name }) = footprint.asset()
        else {
            continue;
        };
        if name != footprint.name() {
            return Err(via_core::Error::Io(format!(
                "KiCad footprint aliasing is not supported: local footprint {} points to {}:{}",
                footprint.name(),
                library,
                name
            )));
        }
        official.insert(
            footprint.name().to_owned(),
            via_kicad_footprints::FootprintId::new(library.clone(), name.clone()),
        );
    }
    Ok(official)
}

fn write_embedded_footprints(
    board: &via_core::Board,
    pretty_dir: &Path,
    footprint_cache_version: &str,
) -> via_core::Result<KicadExportSummary> {
    let exportable = validate_local_footprint_exports(board)?;
    std::fs::create_dir_all(pretty_dir).map_err(|err| via_core::Error::Io(err.to_string()))?;
    let official_ids = required_official_footprint_map(board)?;
    let official_cache = if official_ids.is_empty() {
        None
    } else {
        Some(
            via_kicad_footprints::FootprintCache::open(footprint_cache_version)
                .map_err(|err| via_core::Error::Io(err.to_string()))?,
        )
    };
    let mut generated_footprints = 0;
    let mut manual_footprints = 0;
    for footprint in board.footprints() {
        if let Some(ir) = footprint.ir() {
            let file_name = via_kicad_footprints::footprint_file_name(footprint.name())
                .map_err(|err| via_core::Error::Io(err.to_string()))?;
            ir.write_kicad_mod(pretty_dir.join(file_name))
                .map_err(|err| via_core::Error::Io(err.to_string()))?;
            generated_footprints += 1;
            continue;
        }

        if let Some(id) = official_ids.get(footprint.name())
            && let Some(cache) = &official_cache
        {
            cache
                .copy_footprint_to_pretty_dir(id, pretty_dir)
                .map_err(|err| via_core::Error::Io(err.to_string()))?;
            manual_footprints += 1;
        }
    }
    prune_stale_footprints(pretty_dir, &exportable)?;
    Ok(KicadExportSummary {
        generated_footprints,
        manual_footprints,
    })
}

fn validate_local_footprint_exports(board: &via_core::Board) -> via_core::Result<BTreeSet<String>> {
    let mut exportable = BTreeSet::new();
    for footprint in board.footprints() {
        let can_export = footprint.ir().is_some()
            || matches!(
                footprint.asset(),
                Some(via_core::FootprintAsset::KicadLibrary { .. })
            );
        if can_export {
            exportable.insert(footprint.name().to_owned());
        } else {
            return Err(via_core::Error::Io(format!(
                "footprint {} only has pad metadata; it cannot be written to the local KiCad library",
                footprint.name()
            )));
        }
    }

    for module in board.modules() {
        let Some(name) = module.footprint_name() else {
            continue;
        };
        if !exportable.contains(name) {
            return Err(via_core::Error::Io(format!(
                "{} references footprint {} but no exportable footprint asset or generated IR was loaded",
                module.refdes(),
                name
            )));
        }
    }
    Ok(exportable)
}

fn prune_stale_footprints(
    pretty_dir: &Path,
    expected: &BTreeSet<String>,
) -> via_core::Result<usize> {
    let expected_files = managed_footprint_file_names(expected)?;
    let manifest_path = pretty_dir.join(MANAGED_FOOTPRINTS_FILE);
    let previous_files = read_managed_footprints(&manifest_path)?;
    let mut removed = 0usize;

    for file_name in previous_files.difference(&expected_files) {
        let path = pretty_dir.join(file_name);
        if !path.is_file() {
            continue;
        }
        std::fs::remove_file(&path).map_err(|err| via_core::Error::Io(err.to_string()))?;
        removed += 1;
    }

    write_managed_footprints(&manifest_path, &expected_files)?;
    Ok(removed)
}

fn managed_footprint_file_names(expected: &BTreeSet<String>) -> via_core::Result<BTreeSet<String>> {
    expected
        .iter()
        .map(|name| {
            via_kicad_footprints::footprint_file_name(name)
                .map_err(|err| via_core::Error::Io(err.to_string()))
        })
        .collect()
}

fn read_managed_footprints(path: &Path) -> via_core::Result<BTreeSet<String>> {
    if !path.exists() {
        return Ok(BTreeSet::new());
    }
    let text = std::fs::read_to_string(path).map_err(|err| via_core::Error::Io(err.to_string()))?;
    Ok(text
        .lines()
        .map(str::trim)
        .filter(|line| is_safe_managed_footprint_file(line))
        .map(str::to_owned)
        .collect())
}

fn write_managed_footprints(path: &Path, files: &BTreeSet<String>) -> via_core::Result<()> {
    let mut text = String::new();
    for file in files {
        text.push_str(file);
        text.push('\n');
    }
    std::fs::write(path, text).map_err(|err| via_core::Error::Io(err.to_string()))
}

fn is_safe_managed_footprint_file(file: &str) -> bool {
    let path = Path::new(file);
    !file.is_empty()
        && path.components().count() == 1
        && path.file_name().and_then(|name| name.to_str()) == Some(file)
        && path.extension().and_then(|ext| ext.to_str()) == Some("kicad_mod")
}

#[cfg(test)]
mod tests {
    use super::*;
    use via_core::{Design, FootprintPads, part, pin};

    #[test]
    fn local_footprint_export_rejects_missing_footprint_data() {
        let mut design = Design::new("missing_export_footprint");
        let module = design
            .add(
                part("J1", "connector")
                    .footprint("Missing")
                    .pin(pin("1").passive())
                    .pin(pin("2").passive()),
            )
            .unwrap();
        design
            .net("N")
            .connect_all(&mut design, [module.pin("1"), module.pin("2")]);
        let board = design.build().unwrap();

        let err = validate_local_footprint_exports(&board).unwrap_err();
        assert!(format!("{err}").contains("no exportable footprint asset or generated IR"));
    }

    #[test]
    fn local_footprint_export_rejects_pad_metadata_only() {
        let mut design = Design::new("pad_metadata_only");
        design.add_footprint_pads(FootprintPads::new("PadOnly", ["1", "2"]));
        let module = design
            .add(
                part("J1", "connector")
                    .footprint("PadOnly")
                    .pin(pin("1").passive())
                    .pin(pin("2").passive()),
            )
            .unwrap();
        design
            .net("N")
            .connect_all(&mut design, [module.pin("1"), module.pin("2")]);
        let board = design.build().unwrap();

        let err = validate_local_footprint_exports(&board).unwrap_err();
        assert!(format!("{err}").contains("only has pad metadata"));
    }

    #[test]
    fn stale_local_footprint_files_are_pruned_only_when_previously_managed() {
        let root =
            std::env::temp_dir().join(format!("via_kicad_export_prune_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("Keep.kicad_mod"), "").unwrap();
        std::fs::write(root.join("Stale.kicad_mod"), "").unwrap();
        std::fs::write(root.join("Shared.kicad_mod"), "").unwrap();
        std::fs::write(root.join("notes.txt"), "").unwrap();
        std::fs::write(
            root.join(MANAGED_FOOTPRINTS_FILE),
            "Keep.kicad_mod\nStale.kicad_mod\n..\\Unsafe.kicad_mod\n",
        )
        .unwrap();
        let expected = BTreeSet::from(["Keep".to_owned()]);

        let removed = prune_stale_footprints(&root, &expected).unwrap();

        assert_eq!(removed, 1);
        assert!(root.join("Keep.kicad_mod").exists());
        assert!(!root.join("Stale.kicad_mod").exists());
        assert!(root.join("Shared.kicad_mod").exists());
        assert!(root.join("notes.txt").exists());
        assert_eq!(
            std::fs::read_to_string(root.join(MANAGED_FOOTPRINTS_FILE)).unwrap(),
            "Keep.kicad_mod\n"
        );
        let _ = std::fs::remove_dir_all(root);
    }

    #[test]
    fn first_footprint_export_records_manifest_without_deleting_shared_files() {
        let root = std::env::temp_dir().join(format!(
            "via_kicad_export_first_prune_{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("Keep.kicad_mod"), "").unwrap();
        std::fs::write(root.join("Shared.kicad_mod"), "").unwrap();
        let expected = BTreeSet::from(["Keep".to_owned()]);

        let removed = prune_stale_footprints(&root, &expected).unwrap();

        assert_eq!(removed, 0);
        assert!(root.join("Keep.kicad_mod").exists());
        assert!(root.join("Shared.kicad_mod").exists());
        assert_eq!(
            std::fs::read_to_string(root.join(MANAGED_FOOTPRINTS_FILE)).unwrap(),
            "Keep.kicad_mod\n"
        );
        let _ = std::fs::remove_dir_all(root);
    }
}