Skip to main content

pedalkernel/
bom.rs

1//! Bill-of-materials (BOM) generation from the parsed `.pedal` AST.
2//!
3//! Walks the (already `use(...)`-expanded) [`PedalDef`] component list and
4//! produces one row per refdes plus an aggregated purchase view grouped by
5//! `(category, value, model)` with summed quantities.
6//!
7//! Semantics match `tools/mouser_bom.py`: a `diode_pair` counts as **2**
8//! physical diodes (and aggregates with single diodes of the same type).
9//! Vendor part-number mapping intentionally stays in the Python tool.
10//!
11//! Rendering: [`to_table`] is the human-readable aligned view (per-refdes
12//! rows followed by the aggregated view); [`to_csv`] is a machine-readable
13//! CSV of the per-refdes rows; [`to_csv_aggregated`] is a CSV of the
14//! aggregated lines.
15
16use crate::compiler::component::Component;
17use crate::compiler::components::DiodePair;
18use crate::dsl::{PedalDef, PotTaper};
19use crate::kicad::{format_eng, value_str};
20use std::fmt::Write;
21
22/// One BOM row for a single component instance (refdes).
23#[derive(Debug, Clone, PartialEq)]
24pub struct BomRow {
25    /// Reference designator (the component id from the `.pedal` file).
26    pub refdes: String,
27    /// Component category (e.g. `resistor`, `potentiometer`, `PNP transistor`).
28    pub category: String,
29    /// Human-readable value / description (e.g. `4.7kΩ`, `Silicon`).
30    pub value: String,
31    /// Device model name when the component carries one (e.g. `AC128`).
32    pub model: String,
33    /// Physical part count for this refdes (`diode_pair` = 2 diodes).
34    pub qty: usize,
35}
36
37/// One aggregated BOM line, grouped by `(category, value, model)`.
38#[derive(Debug, Clone, PartialEq)]
39pub struct BomLine {
40    /// Component category shared by all refs on this line.
41    pub category: String,
42    /// Value / description shared by all refs on this line.
43    pub value: String,
44    /// Model name shared by all refs on this line.
45    pub model: String,
46    /// Total physical part count across all refs.
47    pub qty: usize,
48    /// Reference designators folded into this line, in netlist order.
49    pub refs: Vec<String>,
50}
51
52/// A complete BOM: per-refdes rows plus the aggregated purchase view.
53#[derive(Debug, Clone, PartialEq)]
54pub struct Bom {
55    /// Pedal name from the `.pedal` header.
56    pub pedal_name: String,
57    /// One row per component instance, in declaration order.
58    pub rows: Vec<BomRow>,
59    /// Aggregated lines grouped by `(category, value, model)`, first-seen order.
60    pub lines: Vec<BomLine>,
61}
62
63impl Bom {
64    /// Total physical part count (sum of row quantities).
65    pub fn total_parts(&self) -> usize {
66        self.rows.iter().map(|r| r.qty).sum()
67    }
68}
69
70/// Human-readable taper name for the BOM description column.
71fn taper_name(taper: PotTaper) -> &'static str {
72    match taper {
73        PotTaper::A => "audio",
74        PotTaper::B => "linear",
75        PotTaper::C => "reverse-log",
76    }
77}
78
79/// Build one BOM row for a component.
80fn row_for(refdes: &str, kind: &dyn Component) -> BomRow {
81    let model = kind.model_name().unwrap_or("").to_string();
82
83    // diode_pair = 2 physical diodes of the underlying type (mouser_bom.py
84    // semantics). Normalize category/value so pairs aggregate with singles.
85    if kind.as_any().downcast_ref::<DiodePair>().is_some() {
86        let value = kind
87            .diode_type()
88            .map(|dt| format!("{dt:?}"))
89            .unwrap_or_else(|| value_str(kind));
90        return BomRow {
91            refdes: refdes.to_string(),
92            category: "diode".to_string(),
93            value,
94            model,
95            qty: 2,
96        };
97    }
98
99    // Pots: surface the taper in the description (it is a physically
100    // different part — an A500k is not a B500k).
101    if kind.is_pot() {
102        if let (Some(r), Some(taper)) = (kind.resistance(), kind.pot_taper()) {
103            return BomRow {
104                refdes: refdes.to_string(),
105                category: kind.type_tag().to_string(),
106                value: format!("{} ({} taper)", format_eng(r, "\u{3a9}"), taper_name(taper)),
107                model,
108                qty: 1,
109            };
110        }
111    }
112
113    BomRow {
114        refdes: refdes.to_string(),
115        category: kind.type_tag().to_string(),
116        value: value_str(kind),
117        model,
118        qty: 1,
119    }
120}
121
122/// Build a [`Bom`] from an expanded [`PedalDef`].
123///
124/// Callers should pass a pedal loaded via
125/// [`crate::dsl_expand::parse_and_expand_pedal_file`] so subcircuit
126/// components are included.
127pub fn build_bom(pedal: &PedalDef) -> Bom {
128    let rows: Vec<BomRow> = pedal
129        .components
130        .iter()
131        .map(|c| row_for(&c.id, c.kind.as_ref()))
132        .collect();
133
134    let mut lines: Vec<BomLine> = Vec::new();
135    for row in &rows {
136        if let Some(line) = lines
137            .iter_mut()
138            .find(|l| l.category == row.category && l.value == row.value && l.model == row.model)
139        {
140            line.qty += row.qty;
141            line.refs.push(row.refdes.clone());
142        } else {
143            lines.push(BomLine {
144                category: row.category.clone(),
145                value: row.value.clone(),
146                model: row.model.clone(),
147                qty: row.qty,
148                refs: vec![row.refdes.clone()],
149            });
150        }
151    }
152
153    Bom {
154        pedal_name: pedal.name.clone(),
155        rows,
156        lines,
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Rendering
162// ---------------------------------------------------------------------------
163
164/// Quote a CSV field when needed (comma, quote, or newline present).
165fn csv_field(s: &str) -> String {
166    if s.contains(',') || s.contains('"') || s.contains('\n') {
167        format!("\"{}\"", s.replace('"', "\"\""))
168    } else {
169        s.to_string()
170    }
171}
172
173/// CSV of the per-refdes rows: `refdes,category,value,model,qty`.
174pub fn to_csv(bom: &Bom) -> String {
175    let mut out = String::with_capacity(1024);
176    out.push_str("refdes,category,value,model,qty\n");
177    for row in &bom.rows {
178        let _ = writeln!(
179            out,
180            "{},{},{},{},{}",
181            csv_field(&row.refdes),
182            csv_field(&row.category),
183            csv_field(&row.value),
184            csv_field(&row.model),
185            row.qty
186        );
187    }
188    out
189}
190
191/// CSV of the aggregated lines: `qty,category,value,model,refs`
192/// (refs are space-separated).
193pub fn to_csv_aggregated(bom: &Bom) -> String {
194    let mut out = String::with_capacity(1024);
195    out.push_str("qty,category,value,model,refs\n");
196    for line in &bom.lines {
197        let _ = writeln!(
198            out,
199            "{},{},{},{},{}",
200            line.qty,
201            csv_field(&line.category),
202            csv_field(&line.value),
203            csv_field(&line.model),
204            csv_field(&line.refs.join(" "))
205        );
206    }
207    out
208}
209
210/// Character count (not byte count) so `Ω`/`µ` don't break column alignment.
211fn width(s: &str) -> usize {
212    s.chars().count()
213}
214
215/// Pad `s` to `w` display characters.
216fn pad(s: &str, w: usize) -> String {
217    let mut out = s.to_string();
218    for _ in width(s)..w {
219        out.push(' ');
220    }
221    out
222}
223
224/// Render an aligned text table from `(header, rows)`.
225fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
226    let mut widths: Vec<usize> = headers.iter().map(|h| width(h)).collect();
227    for row in rows {
228        for (i, cell) in row.iter().enumerate() {
229            widths[i] = widths[i].max(width(cell));
230        }
231    }
232    let mut out = String::new();
233    let header_line: Vec<String> = headers
234        .iter()
235        .enumerate()
236        .map(|(i, h)| pad(h, widths[i]))
237        .collect();
238    let _ = writeln!(out, "  {}", header_line.join("  ").trim_end());
239    let rule: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
240    let _ = writeln!(out, "  {}", rule.join("  "));
241    for row in rows {
242        let cells: Vec<String> = row
243            .iter()
244            .enumerate()
245            .map(|(i, c)| pad(c, widths[i]))
246            .collect();
247        let _ = writeln!(out, "  {}", cells.join("  ").trim_end());
248    }
249    out
250}
251
252/// Display placeholder for empty model cells.
253fn dash(s: &str) -> String {
254    if s.is_empty() {
255        "-".to_string()
256    } else {
257        s.to_string()
258    }
259}
260
261/// Human-readable aligned table: per-refdes rows followed by the
262/// aggregated `(category, value, model)` view.
263pub fn to_table(bom: &Bom) -> String {
264    let mut out = String::with_capacity(2048);
265    let _ = writeln!(out, "BOM: {}", bom.pedal_name);
266    let _ = writeln!(
267        out,
268        "{} components, {} physical parts",
269        bom.rows.len(),
270        bom.total_parts()
271    );
272    let _ = writeln!(out);
273
274    let rows: Vec<Vec<String>> = bom
275        .rows
276        .iter()
277        .map(|r| {
278            vec![
279                r.refdes.clone(),
280                r.category.clone(),
281                r.value.clone(),
282                dash(&r.model),
283                r.qty.to_string(),
284            ]
285        })
286        .collect();
287    out.push_str(&render_table(
288        &["Ref", "Category", "Value", "Model", "Qty"],
289        &rows,
290    ));
291
292    let _ = writeln!(out);
293    let _ = writeln!(out, "Aggregated (category / value / model):");
294    let _ = writeln!(out);
295    let lines: Vec<Vec<String>> = bom
296        .lines
297        .iter()
298        .map(|l| {
299            vec![
300                l.qty.to_string(),
301                l.category.clone(),
302                l.value.clone(),
303                dash(&l.model),
304                l.refs.join(" "),
305            ]
306        })
307        .collect();
308    out.push_str(&render_table(
309        &["Qty", "Category", "Value", "Model", "Refs"],
310        &lines,
311    ));
312    out
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::dsl_expand::parse_and_expand_pedal_file;
323    use std::path::{Path, PathBuf};
324
325    /// Search examples/ subdirectories for a file by name.
326    fn find_example_file(filename: &str) -> PathBuf {
327        fn walk(dir: &Path, target: &str) -> Option<PathBuf> {
328            for entry in std::fs::read_dir(dir).ok()?.flatten() {
329                let path = entry.path();
330                if path.is_dir() {
331                    if let Some(found) = walk(&path, target) {
332                        return Some(found);
333                    }
334                } else if path.file_name().and_then(|n| n.to_str()) == Some(target) {
335                    return Some(path);
336                }
337            }
338            None
339        }
340        walk(Path::new("examples"), filename)
341            .unwrap_or_else(|| panic!("example file not found: {filename}"))
342    }
343
344    /// Collect every `.pedal` file under examples/.
345    fn all_example_pedals() -> Vec<PathBuf> {
346        fn walk(dir: &Path, acc: &mut Vec<PathBuf>) {
347            let Ok(rd) = std::fs::read_dir(dir) else {
348                return;
349            };
350            for entry in rd.flatten() {
351                let path = entry.path();
352                if path.is_dir() {
353                    walk(&path, acc);
354                } else if path.extension().and_then(|e| e.to_str()) == Some("pedal") {
355                    acc.push(path);
356                }
357            }
358        }
359        let mut acc = Vec::new();
360        walk(Path::new("examples"), &mut acc);
361        acc.sort();
362        acc
363    }
364
365    #[test]
366    fn fuzz_face_bom_rows() {
367        let path = find_example_file("fuzz_face.pedal");
368        let pedal = parse_and_expand_pedal_file(&path)
369            .unwrap_or_else(|e| panic!("failed to load {}: {e}", path.display()));
370        let bom = build_bom(&pedal);
371
372        // Real Fuzz Face BOM: 4 resistors, 3 caps, 2x AC128 PNP, 2 pots.
373        assert_eq!(bom.rows.len(), 11, "11 components total");
374        let count = |cat: &str| bom.rows.iter().filter(|r| r.category == cat).count();
375        assert_eq!(count("resistor"), 4, "4 resistors (R1-R4)");
376        assert_eq!(count("capacitor"), 3, "3 caps (C1-C3)");
377        assert_eq!(count("PNP transistor"), 2, "2 PNP transistors");
378        assert_eq!(count("potentiometer"), 2, "2 pots (Fuzz, Volume)");
379
380        // Both PNPs are AC128 and aggregate into a single qty-2 line.
381        let pnps: Vec<&BomRow> = bom
382            .rows
383            .iter()
384            .filter(|r| r.category == "PNP transistor")
385            .collect();
386        assert!(pnps.iter().all(|r| r.model == "AC128"), "PNPs are AC128");
387        let pnp_line = bom
388            .lines
389            .iter()
390            .find(|l| l.category == "PNP transistor" && l.model == "AC128")
391            .expect("aggregated AC128 line");
392        assert_eq!(pnp_line.qty, 2);
393        assert_eq!(pnp_line.refs, vec!["Q1", "Q2"]);
394
395        // Pot descriptions carry the taper.
396        let fuzz = bom.rows.iter().find(|r| r.refdes == "Fuzz").unwrap();
397        assert!(
398            fuzz.value.contains("linear taper"),
399            "Fuzz pot description shows taper: {}",
400            fuzz.value
401        );
402        let vol = bom.rows.iter().find(|r| r.refdes == "Volume").unwrap();
403        assert!(
404            vol.value.contains("audio taper"),
405            "Volume pot description shows taper: {}",
406            vol.value
407        );
408        assert!(
409            vol.value.contains("500.0k\u{3a9}"),
410            "Volume pot value shows resistance: {}",
411            vol.value
412        );
413    }
414
415    #[test]
416    fn diode_pair_counts_as_two_diodes() {
417        // mouser_bom.py semantics: diode_pair = 2 physical diodes, and it
418        // aggregates with single diodes of the same type.
419        let src = r#"
420pedal "Pair Test" {
421  components {
422    D1: diode_pair(silicon)
423    D2: diode(silicon)
424    R1: resistor(10k)
425  }
426  nets {
427    in -> D1.a
428    D1.b -> D2.a
429    D2.b -> R1.a
430    R1.b -> out
431  }
432}
433"#;
434        let pedal = crate::dsl::parse_pedal_file(src).expect("parse");
435        let bom = build_bom(&pedal);
436
437        let d1 = bom.rows.iter().find(|r| r.refdes == "D1").unwrap();
438        assert_eq!(d1.qty, 2, "diode_pair is 2 physical diodes");
439        assert_eq!(d1.category, "diode");
440        assert_eq!(d1.value, "Silicon");
441
442        let line = bom
443            .lines
444            .iter()
445            .find(|l| l.category == "diode" && l.value == "Silicon")
446            .expect("aggregated silicon diode line");
447        assert_eq!(line.qty, 3, "pair (2) + single (1) merge to one line");
448        assert_eq!(line.refs, vec!["D1", "D2"]);
449        assert_eq!(bom.total_parts(), 4);
450    }
451
452    /// Mirror of `all_pedal_files_export_kicad`: BOM generation must render
453    /// sensibly over every example pedal — tubes, transformers, BBDs,
454    /// photocouplers, tape heads, LFO/envelope followers, switched
455    /// components — without panicking.
456    #[test]
457    fn all_example_pedals_build_bom() {
458        let files = all_example_pedals();
459        assert!(
460            files.len() >= 25,
461            "expected the full examples corpus, found {}",
462            files.len()
463        );
464        for path in files {
465            let pedal = parse_and_expand_pedal_file(&path)
466                .unwrap_or_else(|e| panic!("failed to load {}: {e}", path.display()));
467            let bom = build_bom(&pedal);
468            assert_eq!(
469                bom.rows.len(),
470                pedal.components.len(),
471                "{}: one row per component",
472                path.display()
473            );
474
475            let table = to_table(&bom);
476            let csv = to_csv(&bom);
477            let agg = to_csv_aggregated(&bom);
478            assert!(
479                table.contains(&pedal.name),
480                "{}: table header",
481                path.display()
482            );
483            for comp in &pedal.components {
484                assert!(
485                    csv.contains(&comp.id),
486                    "{}: refdes {} missing from CSV",
487                    path.display(),
488                    comp.id
489                );
490            }
491            // Every row folds into exactly one aggregated line.
492            let line_qty: usize = bom.lines.iter().map(|l| l.qty).sum();
493            assert_eq!(line_qty, bom.total_parts(), "{}: qty sums", path.display());
494            assert!(!agg.is_empty());
495
496            // No row renders as an empty/placeholder value.
497            for row in &bom.rows {
498                assert!(
499                    !row.value.trim().is_empty(),
500                    "{}: {} has empty value",
501                    path.display(),
502                    row.refdes
503                );
504                assert!(
505                    !row.category.trim().is_empty(),
506                    "{}: {} has empty category",
507                    path.display(),
508                    row.refdes
509                );
510            }
511        }
512    }
513}