1use 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#[derive(Debug, Clone, PartialEq)]
24pub struct BomRow {
25 pub refdes: String,
27 pub category: String,
29 pub value: String,
31 pub model: String,
33 pub qty: usize,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct BomLine {
40 pub category: String,
42 pub value: String,
44 pub model: String,
46 pub qty: usize,
48 pub refs: Vec<String>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct Bom {
55 pub pedal_name: String,
57 pub rows: Vec<BomRow>,
59 pub lines: Vec<BomLine>,
61}
62
63impl Bom {
64 pub fn total_parts(&self) -> usize {
66 self.rows.iter().map(|r| r.qty).sum()
67 }
68}
69
70fn 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
79fn row_for(refdes: &str, kind: &dyn Component) -> BomRow {
81 let model = kind.model_name().unwrap_or("").to_string();
82
83 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 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
122pub 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
160fn 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
173pub 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
191pub 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
210fn width(s: &str) -> usize {
212 s.chars().count()
213}
214
215fn 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
224fn 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
252fn dash(s: &str) -> String {
254 if s.is_empty() {
255 "-".to_string()
256 } else {
257 s.to_string()
258 }
259}
260
261pub 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#[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 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 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 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 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 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 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 #[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 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 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}