main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago
//! BOM assembly: the com host returns raw component rows; the SERVER turns them
//! into the deliverable.
//!
//! That split is deliberate. The host process is doing single-threaded COM work
//! against a live application — the less time it spends sorting strings and
//! building CSV, the shorter the window in which SOLIDWORKS is blocked. So the
//! host walks the tree and the server does the paperwork: sort by name, work out
//! the union of custom-property names (they differ per component — that union is
//! the column set), and write RFC 4180 CSV.

use serde_json::Value;

/// Rows sorted by name, case-insensitively — the order a human reads a BOM in.
pub fn sort_rows(rows: &mut [Value]) {
    rows.sort_by_key(|r| r.get("name").and_then(|v| v.as_str()).unwrap_or("").to_lowercase());
}

/// The union of every row's custom-property names, sorted: the CSV columns.
pub fn prop_columns(rows: &[Value]) -> Vec<String> {
    let mut names: Vec<String> = rows
        .iter()
        .filter_map(|r| r.get("properties").and_then(|p| p.as_object()))
        .flat_map(|p| p.keys().cloned())
        .collect();
    names.sort();
    names.dedup();
    names
}

/// A property's display value: `resolved` (what SOLIDWORKS evaluates the
/// expression to) beats the raw `value`; missing is empty.
fn prop_cell(row: &Value, name: &str) -> String {
    let p = row.get("properties").and_then(|p| p.get(name));
    for key in ["resolved", "value"] {
        if let Some(v) = p.and_then(|p| p.get(key)) {
            let s = cell(v);
            if !s.is_empty() {
                return s;
            }
        }
    }
    String::new()
}

fn cell(v: &Value) -> String {
    match v {
        Value::Null => String::new(),
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

fn escape(field: &str) -> String {
    if field.contains([',', '"', '\n', '\r']) {
        format!("\"{}\"", field.replace('"', "\"\""))
    } else {
        field.to_string()
    }
}

fn write_row(out: &mut String, fields: &[String]) {
    out.push_str(&fields.iter().map(|f| escape(f)).collect::<Vec<_>>().join(","));
    out.push_str("\r\n");
}

/// The whole CSV: fixed columns first, then one column per custom property.
pub fn to_csv(rows: &[Value], prop_names: &[String]) -> String {
    let mut out = String::new();
    let mut header: Vec<String> =
        ["item", "name", "configuration", "quantity", "file"].iter().map(|s| s.to_string()).collect();
    header.extend(prop_names.iter().cloned());
    write_row(&mut out, &header);

    for (i, row) in rows.iter().enumerate() {
        let mut fields = vec![
            (i + 1).to_string(),
            cell(row.get("name").unwrap_or(&Value::Null)),
            cell(row.get("configuration").unwrap_or(&Value::Null)),
            cell(row.get("quantity").unwrap_or(&Value::Null)),
            cell(row.get("file").unwrap_or(&Value::Null)),
        ];
        fields.extend(prop_names.iter().map(|p| prop_cell(row, p)));
        write_row(&mut out, &fields);
    }
    out
}

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

    fn rows() -> Vec<Value> {
        vec![
            json!({"name": "shaft", "configuration": "Long", "quantity": 1,
                   "file": "C:\\m\\shaft.sldprt",
                   "properties": {"PartNo": {"value": "SHF-220", "resolved": "SHF-220"}}}),
            json!({"name": "Bracket", "configuration": "Default", "quantity": 2,
                   "file": "C:\\m\\bracket.sldprt",
                   "properties": {"PartNo": {"value": "BRK-100", "resolved": "BRK-100"},
                                  "Material": {"value": "6061-T6", "resolved": "6061-T6"}}}),
        ]
    }

    #[test]
    fn sorts_case_insensitively_by_name() {
        let mut r = rows();
        sort_rows(&mut r);
        assert_eq!(r[0]["name"], "Bracket");
    }

    #[test]
    fn columns_are_the_sorted_union_of_property_names() {
        assert_eq!(prop_columns(&rows()), vec!["Material".to_string(), "PartNo".to_string()]);
    }

    #[test]
    fn csv_has_a_header_item_numbers_and_blank_cells_for_absent_props() {
        let mut r = rows();
        sort_rows(&mut r);
        let cols = prop_columns(&r);
        let csv = to_csv(&r, &cols);
        let lines: Vec<&str> = csv.lines().collect();
        assert_eq!(lines[0], "item,name,configuration,quantity,file,Material,PartNo");
        assert_eq!(lines[1], "1,Bracket,Default,2,C:\\m\\bracket.sldprt,6061-T6,BRK-100");
        // shaft has no Material -> empty cell, and keeps its item number
        assert_eq!(lines[2], "2,shaft,Long,1,C:\\m\\shaft.sldprt,,SHF-220");
        assert!(csv.ends_with("\r\n"), "RFC 4180 line endings");
    }

    #[test]
    fn fields_with_commas_and_quotes_are_escaped() {
        let r = vec![json!({
            "name": "odd, part", "configuration": "\"quoted\"", "quantity": 1, "file": "x",
            "properties": {"Note": {"resolved": "a,b"}}
        })];
        let csv = to_csv(&r, &["Note".to_string()]);
        assert!(csv.contains("\"odd, part\""), "{csv}");
        assert!(csv.contains("\"\"\"quoted\"\"\""), "{csv}");
        assert!(csv.contains("\"a,b\""), "{csv}");
    }

    #[test]
    fn resolved_wins_over_value_and_missing_is_blank() {
        let r = vec![json!({
            "name": "n", "quantity": 1, "file": "f", "configuration": "",
            "properties": {"A": {"value": "raw", "resolved": "evaluated"}, "B": {}}
        })];
        let csv = to_csv(&r, &["A".to_string(), "B".to_string()]);
        assert!(csv.contains("evaluated,"), "{csv}");
        assert!(csv.trim_end().ends_with("evaluated,"), "{csv}");
    }
}