main
Colby Knox 0.2.50 source (not yet released, needs signing): build_schematic and export_manufacturing find Altium's sheet and OutJo… ee90f38 5d ago
//! A project's manufacturing set (Gerbers, NC drill, pick and place, BOM) from ONE OutJob the bridge
//! writes for that project, composed from Altium's own OutJob templates on this machine (so the output
//! settings are the installed Altium's), every output on one GeneratedFiles medium that opens nothing.

use serde_json::{json, Value};

use crate::outputs;


/// INI as Altium writes it: [sections] of key=value lines, in order.
fn parse_ini(text: &str) -> Vec<(String, Vec<(String, String)>)> {
    let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();
    for line in text.lines() {
        let l = line.trim_end_matches('\r');
        if l.starts_with('[') && l.ends_with(']') {
            out.push((l[1..l.len() - 1].to_string(), Vec::new()));
        } else if let (Some(sec), Some((k, v))) = (out.last_mut(), l.split_once('=')) {
            sec.1.push((k.to_string(), v.to_string()));
        }
    }
    out
}

/// One output of a template's OutputGroup1 (by its OutputType), its keys renumbered to `to`.
fn take_output(ini: &[(String, Vec<(String, String)>)], kind: &str, to: usize) -> Option<Vec<(String, String)>> {
    let g = &ini.iter().find(|(n, _)| n == "OutputGroup1")?.1;
    let n = g.iter().find(|(k, v)| k.starts_with("OutputType") && v == kind)?.0["OutputType".len()..].to_string();
    let mut out = Vec::new();
    for (k, v) in g {
        // OutputXxxN, OutputXxxN_..., ConfigurationN_..., PageOptionsN, PcbPrintPreferencesN
        let renamed = ["OutputType", "OutputName", "OutputCategory", "OutputDocumentPath", "OutputVariantName", "OutputEnabled", "OutputDefault", "PageOptions", "PcbPrintPreferences", "Configuration"]
            .iter().find_map(|p| {
                let rest = k.strip_prefix(p)?;
                let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
                let tail = &rest[num.len()..];
                // OutputEnabledN_OutputMediumM: only the medium flag is rewritten below
                (num == n && (tail.is_empty() || tail.starts_with('_'))).then(|| format!("{p}{to}{tail}"))
            });
        if let Some(r) = renamed {
            if r.starts_with(&format!("OutputEnabled{to}_OutputMedium")) { continue; }
            out.push((r, v.clone()));
        }
    }
    Some(out)
}

fn set(kv: &mut Vec<(String, String)>, k: &str, v: &str) {
    match kv.iter_mut().find(|(a, _)| a == k) { Some(e) => e.1 = v.to_string(), None => kv.push((k.to_string(), v.to_string())) }
}

/// a `|`-separated record with one field replaced or added
fn field(rec: &str, k: &str, v: &str) -> String {
    let mut parts: Vec<String> = rec.split('|').map(String::from).collect();
    match parts.iter_mut().find(|p| p.split_once('=').map(|(a, _)| a == k).unwrap_or(false)) {
        Some(p) => *p = format!("{k}={v}"),
        None => parts.push(format!("{k}={v}")),
    }
    parts.join("|")
}

/// the project's manufacturing OutJob path (beside the project, named after it)
pub fn outjob_path(prj: &str) -> String {
    let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
    let stem = prj.rsplit(['\\', '/']).next().unwrap_or(prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
    format!(r"{pdir}\{stem}.Manufacturing.OutJob")
}

/// Write the project's manufacturing OutJob (only when its text changes: an open document rewritten on
/// disk makes Altium ask to reload it). Answers its path.
pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> {
    let (prj, pcb) = (prj.to_string(), pcb.to_string());
    // Altium's own OutJob templates, from the running install's Public Documents folder
    let read = |f: &str| crate::support::altium_public_file(&format!(r"OutputJobs\{f}"))
        .and_then(|p| std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"))).map(|t| parse_ini(&t));
    let (fab, asm) = match (read("Fabrication.OutJob"), read("Assembly.OutJob")) {
        (Ok(f), Ok(a)) => (f, a),
        (f, a) => return Err(json!({"success": false, "errorCode": "no_templates", "error": format!("Altium's OutJob templates were not found: {:?} {:?}", f.err(), a.err())})),
    };
    let pcb_name = pcb.rsplit(['\\', '/']).next().unwrap_or(&pcb).to_string();
    let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
    let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
    let mut outputs: Vec<Vec<(String, String)>> = Vec::new();
    let mut missing = Vec::new();
    for (src, kind) in [(&fab, "Gerber"), (&fab, "NC Drill"), (&asm, "Pick Place"), (&asm, "BOM_PartType")] {
        match take_output(src, kind, outputs.len() + 1) { Some(o) => outputs.push(o), None => missing.push(kind) }
    }
    if !missing.is_empty() { return Err(json!({"success": false, "errorCode": "template_output_missing", "error": format!("the templates have no {missing:?} output")})); }
    for (i, o) in outputs.iter_mut().enumerate() {
        let n = i + 1;
        let kind = o.iter().find(|(k, _)| k == &format!("OutputType{n}")).map(|(_, v)| v.clone()).unwrap_or_default();
        // the board for the PCB outputs; the BOM is the compiled project's
        set(o, &format!("OutputDocumentPath{n}"), if kind == "BOM_PartType" { "" } else { &pcb_name });
        set(o, &format!("OutputEnabled{n}"), "1");
        set(o, &format!("OutputEnabled{n}_OutputMedium1"), &n.to_string());
        if kind == "Pick Place" {
            // a CSV in millimetres, what an assembly house loads
            if let Some(e) = o.iter_mut().find(|(k, v)| k.starts_with(&format!("Configuration{n}_Item")) && v.contains("Record=PickPlaceView")) {
                e.1 = field(&field(&field(&e.1, "GenerateCSVFormat", "True"), "GenerateTextFormat", "False"), "Units", "Metric");
            }
        }
        if kind == "BOM_PartType" {
            // BatchMode is the BOM's file format: 0 csv, 1 txt, 2 xlsx, 3 html, 4 xml, 6 pdf; 5 (the
            // examples' value) wrote no file here (measured AD 26.11.1, 2026-10-15)
            let cols = bom_columns.map(|a| a.iter().filter_map(|x| x.as_str()).map(|c| format!("{c}=100")).collect::<Vec<_>>().join("|"))
                .unwrap_or_else(|| "Comment=100|Description=100|Designator=100|Footprint=100|Quantity=100|MPN=100|Manufacturer=100|Mouser Part #=100|DigiKey Part #=100|LCSC Part #=100".into());
            o.retain(|(k, _)| !k.starts_with(&format!("Configuration{n}_")));
            for (j, (name, item)) in [("General", "OpenExported=False|AddToProject=False|ForceFit=False|NotFitted=False|Database=False|IncludePCBData=False|ShowExportOptions=False|TemplateFilename=|BatchMode=0|FormWidth=1185|FormHeight=665|SupplierProdQty=1|SupplierAutoQty=False|SupplierUseCachedPricing=False|SupplierCurrency=<none>".to_string()),
                                        ("GroupOrder", "Comment=True|Footprint=True".to_string()),
                                        ("SortOrder", "Designator=Up|Comment=Up|Footprint=Up".to_string()),
                                        ("VisibleOrder", cols)].into_iter().enumerate() {
                o.push((format!("Configuration{n}_Name{}", j + 1), name.to_string()));
                o.push((format!("Configuration{n}_Item{}", j + 1), item));
            }
        }
    }
    let base = format!("Project Outputs for {stem}");
    let mut t = String::from("[OutputJobFile]\r\nVersion=1.0\r\nCaption=Manufacturing\r\nDescription=Gerbers, NC drill, pick and place and BOM (written by the Altium bridge)\r\n\r\n[OutputGroup1]\r\nName=Manufacturing\r\nDescription=\r\nTargetOutputMedium=Manufacturing\r\nVariantName=[No Variations]\r\nVariantScope=0\r\nCurrentConfigurationName=\r\nOutputMedium1=Manufacturing\r\nOutputMedium1_Type=GeneratedFiles\r\n");
    for o in &outputs { for (k, v) in o { t.push_str(&format!("{k}={v}\r\n")); } }
    t.push_str(&format!("\r\n[PublishSettings]\r\nOutputFilePath1={pdir}\\{base}\\\r\nReleaseManaged1=1\r\nOutputBasePath1={base}\r\nOutputPathMedia1=\r\nOutputPathMediaValue1=\r\nOutputPathOutputer1=[Output Type]\r\nOutputPathOutputerPrefix1=\r\nOutputPathOutputerValue1=\r\nOutputFileName1=\r\nOutputFileNameMulti1=\r\nUseOutputNameForMulti1=1\r\nOutputFileNameSpecial1=\r\nOpenOutput1=0\r\n"));
    t.push_str(&format!("\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1={pdir}\\{base}\\\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nOpenODBOutput1=0\r\nOpenGerberOutput1=0\r\nOpenNCDrillOutput1=0\r\nOpenIPCOutput1=0\r\nEnableReload1=0\r\n"));
    let outjob = outjob_path(&prj);
    if std::fs::read_to_string(&outjob).ok().as_deref() != Some(t.as_str()) {
        if let Err(e) = std::fs::write(&outjob, &t) { return Err(json!({"success": false, "errorCode": "write_failed", "error": format!("could not write {outjob}: {e}")})); }
    }
    Ok(outjob)
}

/// altium_export_manufacturing {projectPath, pcbPath, bomColumns?, timeout?}
pub fn export_manufacturing(args: &Value) -> Value {
    let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
    let (prj, pcb) = (st("projectPath"), st("pcbPath"));
    if prj.is_empty() || pcb.is_empty() {
        return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) and pcbPath (.PcbDoc) are required",
            "_hint": "altium_build_schematic writes the project; the board must be one of its documents."});
    }
    let outjob = match write_outjob(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array())) { Ok(p) => p, Err(e) => return e };
    // an OutJob generates for the project it belongs to: one that is not in the project runs for Free
    // Documents and writes nothing (measured 2026-10-15)
    let oj_name = outjob.rsplit(['\\', '/']).next().unwrap_or(&outjob).to_lowercase();
    let listed = std::fs::read_to_string(&prj).map(|t| t.lines().any(|l| l.to_lowercase().starts_with("documentpath=") && l.to_lowercase().ends_with(&oj_name))).unwrap_or(false);
    if !listed {
        return json!({"success": false, "errorCode": "outjob_not_in_project", "outjob": outjob,
            "error": format!("{outjob} is not a document of {prj}: an OutJob outside the project writes nothing"),
            "_hint": "Projects from altium_build_schematic list it. Otherwise add it in Altium (Project > Add Existing to Project) and run this again."});
    }
    // an OutJob already open as a FREE document stays one (its outputs went to 'Project Outputs for Free
    // Documents', and a free OutJob cannot build the project's BOM, measured 2026-10-15): close it, so
    // run_outjob opens it again inside its project
    crate::agent::call("close_document", &[("path", outjob.clone())], None, std::time::Duration::from_secs(60));
    // a fresh set: files from an earlier run (another BOM format, a removed layer) never pass as this one's
    if args.get("clean").and_then(|v| v.as_bool()) != Some(false) {
        let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
        let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
        let _ = std::fs::remove_dir_all(format!(r"{pdir}\Project Outputs for {stem}"));
    }
    let mut a = json!({"outjobPath": outjob, "projectPath": prj, "medium": "Manufacturing"});
    if let Some(v) = args.get("timeout") { a["timeout"] = v.clone(); }
    let mut r = outputs::run_outjob(&a);
    r["outjob"] = json!(outjob);
    r["_hint"] = json!("files[] are the manufacturing set under outputDir (Gerber, NC Drill, Pick Place, Bill of Materials folders); pull them with ab's pull_file and check them against the board before handing them over.");
    r
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renumbers_one_output() {
        let ini = parse_ini("[OutputGroup1]\nOutputType1=Gerber\nOutputName1=Gerber\nOutputEnabled1=1\nOutputEnabled1_OutputMedium3=1\nConfiguration1_Item1=a=b\nOutputType2=NC Drill\nOutputName2=NC\nConfiguration2_Item1=Record=DrillView\n");
        let o = take_output(&ini, "NC Drill", 1).unwrap();
        assert!(o.contains(&("OutputType1".into(), "NC Drill".into())));
        assert!(o.contains(&("Configuration1_Item1".into(), "Record=DrillView".into())));
        assert!(!o.iter().any(|(k, _)| k.contains("Medium")));
        assert_eq!(field("A=1|Units=Imperial", "Units", "Metric"), "A=1|Units=Metric");
        assert_eq!(field("A=1", "B", "2"), "A=1|B=2");
    }
}