1use hashbrown::HashMap;
20use std::collections::HashSet;
21
22use crate::dsl::*;
23use crate::models;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Severity {
28 Info,
30 Warning,
32 Error,
34}
35
36#[derive(Debug, Clone)]
38pub struct PedalWarning {
39 pub severity: Severity,
40 pub code: &'static str,
42 pub message: String,
44}
45
46impl std::fmt::Display for PedalWarning {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 let tag = match self.severity {
49 Severity::Info => "info",
50 Severity::Warning => "warning",
51 Severity::Error => "error",
52 };
53 write!(f, "[{}] {}: {}", tag, self.code, self.message)
54 }
55}
56
57pub fn validate_pedal(pedal: &PedalDef) -> Vec<PedalWarning> {
62 let mut warnings = Vec::new();
63
64 check_duplicate_component_ids(pedal, &mut warnings);
65 check_component_values(pedal, &mut warnings);
66 check_net_references(pedal, &mut warnings);
67 check_pin_validity(pedal, &mut warnings);
68 check_transformer_pins(pedal, &mut warnings);
69 check_orphaned_components(pedal, &mut warnings);
70 check_signal_path(pedal, &mut warnings);
71 check_controls(pedal, &mut warnings);
72 check_modulation_targets(pedal, &mut warnings);
73 check_supply_config(pedal, &mut warnings);
74 check_monitor_references(pedal, &mut warnings);
75 check_model_names(pedal, &mut warnings);
76 check_mirrors(pedal, &mut warnings);
77
78 warnings
79}
80
81fn check_duplicate_component_ids(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
87 let mut seen: HashMap<&str, usize> = HashMap::new();
88 for (i, comp) in pedal.components.iter().enumerate() {
89 if let Some(&prev) = seen.get(comp.id.as_str()) {
90 w.push(PedalWarning {
91 severity: Severity::Error,
92 code: "duplicate-component-id",
93 message: format!(
94 "Component '{}' declared at index {} and again at index {}",
95 comp.id, prev, i
96 ),
97 });
98 } else {
99 seen.insert(&comp.id, i);
100 }
101 }
102}
103
104fn check_component_values(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
106 for comp in &pedal.components {
107 let issues = comp.kind.validate_values(&comp.id);
108 for (severity, message) in issues {
109 let code = match severity {
110 Severity::Error => "invalid-value",
111 Severity::Warning | Severity::Info => "suspicious-value",
112 };
113 w.push(PedalWarning {
114 severity,
115 code,
116 message,
117 });
118 }
119 }
120}
121
122fn check_net_references(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
124 let comp_ids: HashSet<&str> = pedal.components.iter().map(|c| c.id.as_str()).collect();
125 let reserved: HashSet<&str> = [
126 "in",
127 "out",
128 "gnd",
129 "vcc",
130 "fx_send",
131 "fx_return",
132 "gate",
134 "cv_pitch",
135 "cv_mod",
136 "cv_filter",
137 ]
138 .iter()
139 .copied()
140 .collect();
141 let supply_names: HashSet<&str> = pedal.supplies.iter().map(|s| s.name.as_str()).collect();
142 let supply_list: Vec<&str> = pedal.supply_names();
143
144 fn check_pin_ref(
145 pin: &Pin,
146 comp_ids: &HashSet<&str>,
147 reserved: &HashSet<&str>,
148 supply_names: &HashSet<&str>,
149 supply_list: &[&str],
150 w: &mut Vec<PedalWarning>,
151 ) {
152 match pin {
153 Pin::ComponentPin { component, .. } => {
154 if !comp_ids.contains(component.as_str()) {
155 w.push(PedalWarning {
156 severity: Severity::Error,
157 code: "unknown-component-ref",
158 message: format!(
159 "Net references component '{}' which is not declared in components",
160 component
161 ),
162 });
163 }
164 }
165 Pin::Reserved(name) => {
166 if !reserved.contains(name.as_str()) && !supply_names.contains(name.as_str()) {
167 w.push(PedalWarning {
168 severity: Severity::Warning,
169 code: "unknown-reserved-pin",
170 message: format!(
171 "Net uses reserved pin '{}' — expected one of: in, out, gnd, vcc{}",
172 name,
173 if supply_names.is_empty() {
174 String::new()
175 } else {
176 format!(", {}", supply_list.join(", "))
177 }
178 ),
179 });
180 }
181 }
182 Pin::Fork {
183 switch,
184 destinations,
185 } => {
186 if !comp_ids.contains(switch.as_str()) {
187 w.push(PedalWarning {
188 severity: Severity::Error,
189 code: "unknown-component-ref",
190 message: format!(
191 "Fork references switch component '{}' which is not declared",
192 switch
193 ),
194 });
195 }
196 for dest in destinations {
197 check_pin_ref(dest, comp_ids, reserved, supply_names, supply_list, w);
198 }
199 }
200 Pin::SubcircuitPort { .. } => {}
202 }
203 }
204
205 for net in &pedal.nets {
206 check_pin_ref(
207 &net.from,
208 &comp_ids,
209 &reserved,
210 &supply_names,
211 &supply_list,
212 w,
213 );
214 for to_pin in &net.to {
215 check_pin_ref(to_pin, &comp_ids, &reserved, &supply_names, &supply_list, w);
216 }
217 }
218}
219
220fn valid_pins_for(kind: &dyn super::component::Component) -> &'static [&'static str] {
222 kind.pin_config().valid_pins
223}
224
225fn check_pin_validity(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
227 use super::component::Component;
228
229 let comp_map: HashMap<&str, &Box<dyn Component>> = pedal
230 .components
231 .iter()
232 .map(|c| (c.id.as_str(), &c.kind))
233 .collect();
234
235 let is_valid_modulation_pin =
238 |kind: &dyn Component, pin: &str| -> bool { kind.modulation_pins().contains(&pin) };
239
240 let check_pin = |pin: &Pin, w: &mut Vec<PedalWarning>| {
241 if let Pin::ComponentPin { component, pin } = pin {
242 if let Some(kind) = comp_map.get(component.as_str()) {
243 let valid = valid_pins_for(kind.as_ref());
244 if !valid.is_empty()
247 && !valid.contains(&pin.as_str())
248 && !is_valid_modulation_pin(kind.as_ref(), pin)
249 {
250 w.push(PedalWarning {
251 severity: Severity::Error,
252 code: "unknown-pin",
253 message: format!(
254 "Pin '{}.{}' is not a recognized pin — expected one of: {}",
255 component,
256 pin,
257 valid.join(", ")
258 ),
259 });
260 }
261 }
262 }
264 };
265
266 for net in &pedal.nets {
267 check_pin(&net.from, w);
268 for to_pin in &net.to {
269 check_pin(to_pin, w);
270 }
271 }
272}
273
274fn check_transformer_pins(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
280 let transformers: HashMap<&str, &TransformerConfig> = pedal
282 .components
283 .iter()
284 .filter_map(|c| c.kind.transformer_config().map(|cfg| (c.id.as_str(), cfg)))
285 .collect();
286
287 if transformers.is_empty() {
288 return;
289 }
290
291 const PRIMARY_CT_PINS: &[&str] = &["pri_ct", "pri.ct", "primary.ct"];
293 const SECONDARY_CT_PINS: &[&str] = &["sec_ct", "sec.ct", "secondary.ct", "ct"];
294
295 const TERTIARY_PINS: &[&str] = &[
297 "e",
298 "f",
299 "tertiary.a",
300 "tertiary.b",
301 "ter.a",
302 "ter.b",
303 "ter_a",
304 "ter_b",
305 ];
306
307 let check_pin = |pin: &Pin, w: &mut Vec<PedalWarning>| {
308 if let Pin::ComponentPin { component, pin } = pin {
309 if let Some(cfg) = transformers.get(component.as_str()) {
310 let pin_str = pin.as_str();
311
312 if PRIMARY_CT_PINS.contains(&pin_str)
314 && !matches!(
315 cfg.primary_type,
316 WindingType::CenterTap | WindingType::PushPull
317 )
318 {
319 w.push(PedalWarning {
320 severity: Severity::Error,
321 code: "invalid-transformer-pin",
322 message: format!(
323 "'{}.{}' requires a center-tap or push-pull primary, \
324 but {} has a standard primary winding. \
325 Use transformer({}, {}, ct_primary) to add a center tap.",
326 component, pin, component, cfg.turns_ratio, cfg.primary_inductance,
327 ),
328 });
329 }
330
331 if SECONDARY_CT_PINS.contains(&pin_str)
333 && !matches!(
334 cfg.secondary_type,
335 WindingType::CenterTap | WindingType::PushPull
336 )
337 {
338 w.push(PedalWarning {
339 severity: Severity::Error,
340 code: "invalid-transformer-pin",
341 message: format!(
342 "'{}.{}' requires a center-tap secondary, \
343 but {} has a standard secondary winding. \
344 Use transformer({}, {}, ct_secondary) to add a center tap.",
345 component, pin, component, cfg.turns_ratio, cfg.primary_inductance,
346 ),
347 });
348 }
349
350 if TERTIARY_PINS.contains(&pin_str) && !cfg.has_tertiary() {
352 w.push(PedalWarning {
353 severity: Severity::Error,
354 code: "invalid-transformer-pin",
355 message: format!(
356 "'{}.{}' references a tertiary winding, \
357 but {} is a 2-winding transformer. \
358 Add a tertiary turns ratio: transformer({}, {}, tertiary=N)",
359 component, pin, component, cfg.turns_ratio, cfg.primary_inductance,
360 ),
361 });
362 }
363 }
364 }
365 };
366
367 for net in &pedal.nets {
368 check_pin(&net.from, w);
369 for to_pin in &net.to {
370 check_pin(to_pin, w);
371 }
372 }
373}
374
375fn check_orphaned_components(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
377 let mut referenced: HashSet<String> = HashSet::new();
378
379 fn collect_refs(pin: &Pin, refs: &mut HashSet<String>) {
380 match pin {
381 Pin::ComponentPin { component, .. } => {
382 refs.insert(component.clone());
383 }
384 Pin::Fork {
385 switch,
386 destinations,
387 } => {
388 refs.insert(switch.clone());
389 for d in destinations {
390 if let Pin::ComponentPin { component, .. } = d {
391 refs.insert(component.clone());
392 }
393 }
394 }
395 Pin::Reserved(_) => {}
396 Pin::SubcircuitPort { .. } => {}
398 }
399 }
400
401 for net in &pedal.nets {
402 collect_refs(&net.from, &mut referenced);
403 for to_pin in &net.to {
404 collect_refs(to_pin, &mut referenced);
405 }
406 }
407
408 for ctrl in &pedal.controls {
410 referenced.insert(ctrl.component.clone());
411 }
412 for trim in &pedal.trims {
413 referenced.insert(trim.component.clone());
414 }
415
416 for comp in &pedal.components {
417 if comp.kind.is_control_only() {
419 continue;
420 }
421 if !referenced.contains(&comp.id) {
422 w.push(PedalWarning {
423 severity: Severity::Warning,
424 code: "orphaned-component",
425 message: format!(
426 "Component '{}' is declared but never referenced in any net or control",
427 comp.id
428 ),
429 });
430 }
431 }
432}
433
434fn check_signal_path(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
436 let is_synth = pedal.components.iter().any(|c| c.kind.type_tag() == "VCO");
439 if is_synth {
440 return;
441 }
442
443 let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
445
446 let pin_key = |pin: &Pin| -> String {
447 match pin {
448 Pin::Reserved(s) => s.clone(),
449 Pin::ComponentPin { component, pin } => format!("{}.{}", component, pin),
450 Pin::Fork { switch, .. } => format!("__fork_{}", switch),
451 Pin::SubcircuitPort { subcircuit, port } => format!("{}.{}", subcircuit, port),
452 }
453 };
454
455 for comp in &pedal.components {
457 for (pa, pb) in comp.kind.signal_adjacencies() {
458 let ka = format!("{}.{}", comp.id, pa);
459 let kb = format!("{}.{}", comp.id, pb);
460 adj.entry(ka.clone()).or_default().insert(kb.clone());
461 adj.entry(kb).or_default().insert(ka);
462 }
463 }
464
465 for net in &pedal.nets {
467 let from = pin_key(&net.from);
468 for to_pin in &net.to {
469 let to = pin_key(to_pin);
470 adj.entry(from.clone()).or_default().insert(to.clone());
471 adj.entry(to).or_default().insert(from.clone());
472 }
473 }
474
475 let mut visited: HashSet<String> = HashSet::new();
477 let mut queue = std::collections::VecDeque::new();
478 visited.insert("in".to_string());
479 queue.push_back("in".to_string());
480
481 while let Some(node) = queue.pop_front() {
482 if let Some(neighbors) = adj.get(&node) {
483 for n in neighbors {
484 if visited.insert(n.clone()) {
485 queue.push_back(n.clone());
486 }
487 }
488 }
489 }
490
491 if !visited.contains("out") {
492 w.push(PedalWarning {
495 severity: Severity::Warning,
496 code: "no-signal-path",
497 message: "No signal path found from 'in' to 'out'".to_string(),
498 });
499 }
500}
501
502fn check_controls(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
504 use super::component::Component;
505
506 let comp_map: HashMap<&str, &Box<dyn Component>> = pedal
507 .components
508 .iter()
509 .map(|c| (c.id.as_str(), &c.kind))
510 .collect();
511
512 let check_ctrl = |ctrl: &ControlDef, w: &mut Vec<PedalWarning>, is_trim: bool| {
513 let section = if is_trim { "trims" } else { "controls" };
514 if !comp_map.contains_key(ctrl.component.as_str()) {
515 w.push(PedalWarning {
516 severity: Severity::Error,
517 code: "unknown-control-component",
518 message: format!(
519 "{} entry '{}' references component '{}' which is not declared",
520 section, ctrl.label, ctrl.component
521 ),
522 });
523 return;
524 }
525
526 let kind = comp_map[ctrl.component.as_str()].as_ref();
527 let tag = kind.type_tag();
529 if !matches!(tag, "potentiometer" | "switch" | "rotary switch") {
530 w.push(PedalWarning {
531 severity: Severity::Warning,
532 code: "unusual-control-target",
533 message: format!(
534 "{} entry '{}' targets '{}' which is a {:?} — expected a pot or switch",
535 section, ctrl.label, ctrl.component, tag
536 ),
537 });
538 }
539
540 if ctrl.range.0 == ctrl.range.1 {
544 w.push(PedalWarning {
545 severity: Severity::Warning,
546 code: "empty-control-range",
547 message: format!(
548 "{} entry '{}' has empty range [{}, {}] — min and max are equal",
549 section, ctrl.label, ctrl.range.0, ctrl.range.1
550 ),
551 });
552 }
553
554 let (lo, hi) = if ctrl.range.0 <= ctrl.range.1 {
556 (ctrl.range.0, ctrl.range.1)
557 } else {
558 (ctrl.range.1, ctrl.range.0)
559 };
560 if ctrl.default < lo || ctrl.default > hi {
561 w.push(PedalWarning {
562 severity: Severity::Warning,
563 code: "default-out-of-range",
564 message: format!(
565 "{} entry '{}' default {} is outside range [{}, {}]",
566 section, ctrl.label, ctrl.default, ctrl.range.0, ctrl.range.1
567 ),
568 });
569 }
570 };
571
572 let mut seen_labels: HashSet<&str> = HashSet::new();
574 for ctrl in &pedal.controls {
575 if !seen_labels.insert(&ctrl.label) {
576 w.push(PedalWarning {
577 severity: Severity::Warning,
578 code: "duplicate-control-label",
579 message: format!(
580 "Control label '{}' appears more than once — only the last binding will take effect",
581 ctrl.label
582 ),
583 });
584 }
585 check_ctrl(ctrl, w, false);
586 }
587
588 for ctrl in &pedal.trims {
589 check_ctrl(ctrl, w, true);
590 }
591}
592
593fn check_modulation_targets(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
597 use super::component::Component;
598
599 let comp_map: HashMap<&str, &Box<dyn Component>> = pedal
600 .components
601 .iter()
602 .map(|c| (c.id.as_str(), &c.kind))
603 .collect();
604
605 let valid_mod_targets: HashSet<&str> = [
607 "vgs",
608 "gate",
609 "led",
610 "vgk",
611 "vg1k",
612 "iabc",
613 "clock",
614 "speed_mod",
615 "delay_time",
616 ]
617 .iter()
618 .copied()
619 .collect();
620
621 let passive_connection_pins: HashSet<&str> = ["a", "b", "w", "wiper", "in", "out", "rate"]
625 .iter()
626 .copied()
627 .collect();
628
629 for comp in &pedal.components {
631 let source_type = match comp.kind.type_tag() {
632 "LFO" => "LFO",
633 "envelope follower" => "EnvelopeFollower",
634 _ => continue,
635 };
636
637 for net in &pedal.nets {
638 if let Pin::ComponentPin { component, pin } = &net.from {
640 if component == &comp.id && pin == "out" {
641 for target_pin in &net.to {
642 if let Pin::ComponentPin {
643 component: target_comp,
644 pin: target_prop,
645 } = target_pin
646 {
647 if !comp_map.contains_key(target_comp.as_str()) {
649 continue;
651 }
652
653 let target_kind = comp_map[target_comp.as_str()].as_ref();
654
655 if valid_mod_targets.contains(target_prop.as_str()) {
657 check_mod_target_compatibility(
659 source_type,
660 &comp.id,
661 target_comp,
662 target_prop,
663 target_kind,
664 w,
665 );
666 } else if passive_connection_pins.contains(target_prop.as_str()) {
667 let tag = target_kind.type_tag();
671 let target_is_pot = tag == "potentiometer";
672 let target_is_passive =
673 matches!(tag, "resistor" | "capacitor" | "inductor");
674 if !target_is_pot && !target_is_passive && tag != "LFO" {
675 w.push(PedalWarning {
678 severity: Severity::Info,
679 code: "passive-mod-connection",
680 message: format!(
681 "{} '{}'.out -> '{}.{}' creates a passive net connection, not a modulation binding. \
682 The compiler won't create an LFO/envelope modulation for this. \
683 Use a recognized modulation pin (vgs, led, clock, etc.) for direct modulation.",
684 source_type, comp.id, target_comp, target_prop
685 ),
686 });
687 }
688 } else {
689 w.push(PedalWarning {
692 severity: Severity::Warning,
693 code: "unrecognized-mod-target",
694 message: format!(
695 "{} '{}'.out -> '{}.{}' uses unrecognized modulation target '{}'. \
696 This connection will be silently ignored by the compiler. \
697 Valid modulation targets are: {}",
698 source_type, comp.id, target_comp, target_prop, target_prop,
699 valid_mod_targets.iter().copied().collect::<Vec<_>>().join(", ")
700 ),
701 });
702 }
703 }
704 }
705 }
706 }
707 }
708 }
709}
710
711fn check_mod_target_compatibility(
713 source_type: &str,
714 source_id: &str,
715 target_comp: &str,
716 target_pin: &str,
717 target_kind: &dyn super::component::Component,
718 w: &mut Vec<PedalWarning>,
719) {
720 let mismatch = !target_kind.modulation_pins().contains(&target_pin);
722
723 if mismatch {
724 w.push(PedalWarning {
725 severity: Severity::Warning,
726 code: "mod-target-type-mismatch",
727 message: format!(
728 "{} '{}'.out -> '{}.{}': pin '{}' is typically used with {} components, \
729 but '{}' is a {}",
730 source_type,
731 source_id,
732 target_comp,
733 target_pin,
734 target_pin,
735 expected_component_for_pin(target_pin),
736 target_comp,
737 target_kind.type_tag()
738 ),
739 });
740 }
741}
742
743fn expected_component_for_pin(pin: &str) -> &'static str {
744 match pin {
745 "vgs" | "gate" => "JFET/MOSFET",
746 "led" => "photocoupler",
747 "vgk" => "triode",
748 "vg1k" => "pentode",
749 "iabc" => "OTA (CA3080)",
750 "clock" => "BBD",
751 "speed_mod" | "delay_time" => "delay line",
752 _ => "unknown",
753 }
754}
755
756fn component_type_name(kind: &dyn super::component::Component) -> &'static str {
757 kind.type_tag()
758}
759
760fn check_supply_config(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
762 for supply in &pedal.supplies {
763 if supply.config.voltage == 0.0 {
764 w.push(PedalWarning {
765 severity: Severity::Warning,
766 code: "zero-supply-voltage",
767 message: format!(
768 "Supply rail '{}' has 0V — circuit won't bias correctly",
769 supply.name
770 ),
771 });
772 }
773 if let Some(imp) = supply.config.impedance {
774 if imp < 0.0 {
775 w.push(PedalWarning {
776 severity: Severity::Error,
777 code: "invalid-value",
778 message: format!(
779 "Supply rail '{}' has negative impedance {:.1} Ω",
780 supply.name, imp
781 ),
782 });
783 }
784 }
785 if let Some(cap) = supply.config.filter_cap {
786 if cap <= 0.0 {
787 w.push(PedalWarning {
788 severity: Severity::Error,
789 code: "invalid-value",
790 message: format!(
791 "Supply rail '{}' has non-positive filter cap {:.2e} F",
792 supply.name, cap
793 ),
794 });
795 }
796 }
797 }
798
799 let mut seen: HashSet<&str> = HashSet::new();
801 for supply in &pedal.supplies {
802 if !seen.insert(&supply.name) {
803 w.push(PedalWarning {
804 severity: Severity::Error,
805 code: "duplicate-supply-name",
806 message: format!(
807 "Supply rail name '{}' is declared more than once",
808 supply.name
809 ),
810 });
811 }
812 }
813}
814
815fn check_monitor_references(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
817 let comp_ids: HashSet<&str> = pedal.components.iter().map(|c| c.id.as_str()).collect();
818 let reserved_monitors = ["input", "output"];
819
820 for monitor in &pedal.monitors {
821 if !comp_ids.contains(monitor.component.as_str())
822 && !reserved_monitors.contains(&monitor.component.as_str())
823 {
824 w.push(PedalWarning {
825 severity: Severity::Warning,
826 code: "unknown-monitor-component",
827 message: format!(
828 "Monitor '{}' references component '{}' which is not declared",
829 monitor.label, monitor.component
830 ),
831 });
832 }
833 }
834}
835
836fn check_mirrors(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
844 use super::component::Component;
845
846 let comp_map: HashMap<&str, &Box<dyn Component>> = pedal
847 .components
848 .iter()
849 .map(|c| (c.id.as_str(), &c.kind))
850 .collect();
851
852 for (mirrored_id, source_id) in &pedal.mirrors {
853 let Some(source_kind) = comp_map.get(source_id.as_str()) else {
855 w.push(PedalWarning {
856 severity: Severity::Error,
857 code: "mirror-target-missing",
858 message: format!(
859 "Mirrored pot '{}' references '{}' which is not declared",
860 mirrored_id, source_id
861 ),
862 });
863 continue;
864 };
865
866 if !source_kind.is_pot() {
868 w.push(PedalWarning {
869 severity: Severity::Error,
870 code: "mirror-target-not-pot",
871 message: format!(
872 "Mirrored pot '{}' references '{}' which is a {}, not a potentiometer",
873 mirrored_id,
874 source_id,
875 component_type_name(source_kind.as_ref())
876 ),
877 });
878 continue;
879 }
880
881 if let Some(mirrored_kind) = comp_map.get(mirrored_id.as_str()) {
883 if !mirrored_kind.is_pot() {
884 w.push(PedalWarning {
885 severity: Severity::Error,
886 code: "mirror-not-pot",
887 message: format!(
888 "'{}' uses `mirrors` but is a {}, not a potentiometer",
889 mirrored_id,
890 component_type_name(mirrored_kind.as_ref())
891 ),
892 });
893 }
894 }
895
896 if pedal.mirrors.contains_key(source_id) {
898 w.push(PedalWarning {
899 severity: Severity::Error,
900 code: "mirror-chain",
901 message: format!(
902 "'{}' mirrors '{}' which itself mirrors '{}' — mirror chains are not allowed",
903 mirrored_id,
904 source_id,
905 pedal.mirrors.get(source_id).unwrap()
906 ),
907 });
908 }
909
910 if pedal.controls.iter().any(|c| c.component == *mirrored_id) {
912 w.push(PedalWarning {
913 severity: Severity::Error,
914 code: "mirror-in-controls",
915 message: format!(
916 "Mirrored pot '{}' must not appear in the controls block — \
917 its position is derived from '{}'",
918 mirrored_id, source_id
919 ),
920 });
921 }
922
923 if let (Some(mirrored_kind), Some(source_kind_inner)) = (
925 comp_map.get(mirrored_id.as_str()),
926 comp_map.get(source_id.as_str()),
927 ) {
928 if let (Some(r_mirrored), Some(r_source)) =
929 (mirrored_kind.resistance(), source_kind_inner.resistance())
930 {
931 if (r_mirrored - r_source).abs() > f64::EPSILON {
932 w.push(PedalWarning {
933 severity: Severity::Warning,
934 code: "mirror-resistance-mismatch",
935 message: format!(
936 "Mirrored pot '{}' ({:.0} Ω) has different resistance than source '{}' ({:.0} Ω) — \
937 dual-gang pots typically have matched gangs",
938 mirrored_id, r_mirrored, source_id, r_source
939 ),
940 });
941 }
942 }
943 }
944 }
945}
946
947fn check_model_names(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
951 for comp in &pedal.components {
952 let kind = comp.kind.as_ref();
953 let name = match kind.model_name() {
954 Some(n) => n,
955 None => continue,
956 };
957
958 let (kind_label, lookup, all_names): (&str, fn(&str) -> bool, fn() -> Vec<&'static str>) =
959 if kind.is_bjt() {
960 (
961 "BJT",
962 |n| models::bjt_by_name(n).is_some(),
963 models::bjt_model_names,
964 )
965 } else if kind.is_jfet() {
966 (
967 "JFET",
968 |n| models::jfet_by_name(n).is_some(),
969 models::jfet_model_names,
970 )
971 } else if kind.is_tube() {
972 if kind.type_tag() == "pentode" {
974 (
975 "pentode",
976 |n| models::pentode_by_name(n).is_some(),
977 models::pentode_model_names,
978 )
979 } else {
980 (
981 "triode",
982 |n| models::triode_by_name(n).is_some(),
983 models::triode_model_names,
984 )
985 }
986 } else {
987 continue;
988 };
989
990 if !lookup(name) {
991 let available = all_names();
992 let suggestions = find_similar_names(name, &available, 3);
993 let suggestion_text = if suggestions.is_empty() {
994 String::new()
995 } else {
996 format!(". Did you mean: {}?", suggestions.join(", "))
997 };
998 w.push(PedalWarning {
999 severity: Severity::Error,
1000 code: "unknown-model",
1001 message: format!(
1002 "Component '{}' references unknown {} model '{}'{} ({} models available)",
1003 comp.id,
1004 kind_label,
1005 name,
1006 suggestion_text,
1007 available.len()
1008 ),
1009 });
1010 }
1011 }
1012}
1013
1014fn find_similar_names<'a>(query: &str, candidates: &[&'a str], max: usize) -> Vec<&'a str> {
1017 let query_upper = query.to_uppercase();
1018 let mut scored: Vec<(&str, usize)> = candidates
1019 .iter()
1020 .filter_map(|&candidate| {
1021 let dist = edit_distance(&query_upper, &candidate.to_uppercase());
1022 let threshold = query_upper.len() / 2 + 2;
1024 if dist <= threshold {
1025 Some((candidate, dist))
1026 } else {
1027 None
1028 }
1029 })
1030 .collect();
1031 scored.sort_by_key(|&(_, d)| d);
1032 scored.into_iter().take(max).map(|(name, _)| name).collect()
1033}
1034
1035fn edit_distance(a: &str, b: &str) -> usize {
1037 let a: Vec<char> = a.chars().collect();
1038 let b: Vec<char> = b.chars().collect();
1039 let (m, n) = (a.len(), b.len());
1040 let mut dp = vec![vec![0usize; n + 1]; m + 1];
1041 for i in 0..=m {
1042 dp[i][0] = i;
1043 }
1044 for j in 0..=n {
1045 dp[0][j] = j;
1046 }
1047 for i in 1..=m {
1048 for j in 1..=n {
1049 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
1050 dp[i][j] = (dp[i - 1][j] + 1)
1051 .min(dp[i][j - 1] + 1)
1052 .min(dp[i - 1][j - 1] + cost);
1053 }
1054 }
1055 dp[m][n]
1056}
1057
1058pub fn validate_pedal_files(paths: &[&str]) {
1075 for model_file in [
1077 "models/transistors.model",
1078 "models/jfets.model",
1079 "models/triodes.model",
1080 "models/pentodes.model",
1081 "models/diodes.model",
1082 "models/leds.model",
1083 "models/schottky.model",
1084 "models/zeners.model",
1085 ] {
1086 println!("cargo:rerun-if-changed={model_file}");
1087 }
1088
1089 let mut all_errors = Vec::new();
1090
1091 for path in paths {
1092 println!("cargo:rerun-if-changed={path}");
1093 let src =
1094 std::fs::read_to_string(path).unwrap_or_else(|e| panic!("Cannot read {path}: {e}"));
1095 let pedal = crate::dsl::parse_pedal_file(&src)
1096 .unwrap_or_else(|e| panic!("{path}: parse error: {e}"));
1097 let warnings = validate_pedal(&pedal);
1098 for w in &warnings {
1099 match w.severity {
1100 Severity::Error => {
1101 println!("cargo::warning={path}: error[{}]: {}", w.code, w.message);
1102 all_errors.push(format!("{path}: {w}"));
1103 }
1104 Severity::Warning => {
1105 println!("cargo::warning={path}: warn[{}]: {}", w.code, w.message);
1106 }
1107 Severity::Info => {
1108 println!("cargo::warning={path}: info[{}]: {}", w.code, w.message);
1109 }
1110 }
1111 }
1112 }
1113
1114 if !all_errors.is_empty() {
1115 panic!(
1116 "\n{} .pedal validation error(s):\n {}\n",
1117 all_errors.len(),
1118 all_errors.join("\n ")
1119 );
1120 }
1121}
1122
1123#[cfg(test)]
1128mod tests {
1129 use super::*;
1130 use crate::compiler::component::Component;
1131 use crate::compiler::components::*;
1132
1133 fn minimal_pedal() -> PedalDef {
1135 PedalDef {
1136 name: "Test".to_string(),
1137 subtitle: None,
1138 supplies: vec![],
1139 components: vec![ComponentDef {
1140 id: "R1".to_string(),
1141 kind: Box::new(Resistor { value: 10_000.0 }),
1142 }],
1143 nets: vec![
1144 NetDef {
1145 from: Pin::Reserved("in".to_string()),
1146 to: vec![Pin::ComponentPin {
1147 component: "R1".to_string(),
1148 pin: "a".to_string(),
1149 }],
1150 },
1151 NetDef {
1152 from: Pin::ComponentPin {
1153 component: "R1".to_string(),
1154 pin: "b".to_string(),
1155 },
1156 to: vec![Pin::Reserved("out".to_string())],
1157 },
1158 ],
1159 controls: vec![],
1160 trims: vec![],
1161 monitors: vec![],
1162 sidechains: vec![],
1163 mirrors: hashbrown::HashMap::new(),
1164 calibrate: false,
1165 subcircuits: vec![],
1166 ports: vec![],
1167 init_hints: vec![],
1168 uses: vec![],
1169 }
1170 }
1171
1172 fn has_code(warnings: &[PedalWarning], code: &str) -> bool {
1173 warnings.iter().any(|w| w.code == code)
1174 }
1175
1176 fn warnings_with_code<'a>(warnings: &'a [PedalWarning], code: &str) -> Vec<&'a PedalWarning> {
1177 warnings.iter().filter(|w| w.code == code).collect()
1178 }
1179
1180 #[test]
1181 fn minimal_valid_pedal_has_no_errors() {
1182 let pedal = minimal_pedal();
1183 let warnings = validate_pedal(&pedal);
1184 let errors: Vec<_> = warnings
1185 .iter()
1186 .filter(|w| w.severity == Severity::Error)
1187 .collect();
1188 assert!(errors.is_empty(), "Unexpected errors: {:?}", errors);
1189 }
1190
1191 #[test]
1192 fn duplicate_component_id() {
1193 let mut pedal = minimal_pedal();
1194 pedal.components.push(ComponentDef {
1195 id: "R1".to_string(),
1196 kind: Box::new(Resistor { value: 1000.0 }),
1197 });
1198 let warnings = validate_pedal(&pedal);
1199 assert!(has_code(&warnings, "duplicate-component-id"));
1200 }
1201
1202 #[test]
1203 fn zero_resistance() {
1204 let mut pedal = minimal_pedal();
1205 pedal.components[0].kind = Box::new(Resistor { value: 0.0 });
1206 let warnings = validate_pedal(&pedal);
1207 assert!(has_code(&warnings, "invalid-value"));
1208 }
1209
1210 #[test]
1211 fn very_large_capacitor_warns() {
1212 let mut pedal = minimal_pedal();
1213 pedal.components.push(ComponentDef {
1214 id: "C1".to_string(),
1215 kind: Box::new(Capacitor {
1216 config: CapConfig::new(100.0),
1217 }), });
1219 pedal.nets.push(NetDef {
1221 from: Pin::ComponentPin {
1222 component: "C1".to_string(),
1223 pin: "a".to_string(),
1224 },
1225 to: vec![Pin::Reserved("gnd".to_string())],
1226 });
1227 pedal.nets.push(NetDef {
1228 from: Pin::ComponentPin {
1229 component: "C1".to_string(),
1230 pin: "b".to_string(),
1231 },
1232 to: vec![Pin::Reserved("gnd".to_string())],
1233 });
1234 let warnings = validate_pedal(&pedal);
1235 assert!(has_code(&warnings, "suspicious-value"));
1236 }
1237
1238 #[test]
1239 fn net_references_nonexistent_component() {
1240 let mut pedal = minimal_pedal();
1241 pedal.nets.push(NetDef {
1242 from: Pin::ComponentPin {
1243 component: "NOPE".to_string(),
1244 pin: "a".to_string(),
1245 },
1246 to: vec![Pin::Reserved("gnd".to_string())],
1247 });
1248 let warnings = validate_pedal(&pedal);
1249 assert!(has_code(&warnings, "unknown-component-ref"));
1250 }
1251
1252 #[test]
1253 fn unknown_pin_name() {
1254 let mut pedal = minimal_pedal();
1255 pedal.nets[0] = NetDef {
1256 from: Pin::Reserved("in".to_string()),
1257 to: vec![Pin::ComponentPin {
1258 component: "R1".to_string(),
1259 pin: "cathode".to_string(), }],
1261 };
1262 let warnings = validate_pedal(&pedal);
1263 assert!(has_code(&warnings, "unknown-pin"));
1264 }
1265
1266 #[test]
1267 fn orphaned_component() {
1268 let mut pedal = minimal_pedal();
1269 pedal.components.push(ComponentDef {
1270 id: "R_lonely".to_string(),
1271 kind: Box::new(Resistor { value: 10_000.0 }),
1272 });
1273 let warnings = validate_pedal(&pedal);
1274 assert!(has_code(&warnings, "orphaned-component"));
1275 }
1276
1277 #[test]
1278 fn no_signal_path() {
1279 let pedal = PedalDef {
1280 name: "Broken".to_string(),
1281 subtitle: None,
1282 supplies: vec![],
1283 components: vec![ComponentDef {
1284 id: "R1".to_string(),
1285 kind: Box::new(Resistor { value: 10_000.0 }),
1286 }],
1287 nets: vec![
1288 NetDef {
1290 from: Pin::Reserved("in".to_string()),
1291 to: vec![Pin::ComponentPin {
1292 component: "R1".to_string(),
1293 pin: "a".to_string(),
1294 }],
1295 },
1296 NetDef {
1297 from: Pin::ComponentPin {
1298 component: "R1".to_string(),
1299 pin: "b".to_string(),
1300 },
1301 to: vec![Pin::Reserved("gnd".to_string())],
1302 },
1303 ],
1304 controls: vec![],
1305 trims: vec![],
1306 monitors: vec![],
1307 sidechains: vec![],
1308 mirrors: hashbrown::HashMap::new(),
1309 calibrate: false,
1310 subcircuits: vec![],
1311 ports: vec![],
1312 init_hints: vec![],
1313 uses: vec![],
1314 };
1315 let warnings = validate_pedal(&pedal);
1316 assert!(has_code(&warnings, "no-signal-path"));
1317 }
1318
1319 #[test]
1320 fn control_references_nonexistent_component() {
1321 let mut pedal = minimal_pedal();
1322 pedal.controls.push(ControlDef {
1323 component: "Ghost".to_string(),
1324 property: "position".to_string(),
1325 label: "Volume".to_string(),
1326 range: (0.0, 1.0),
1327 default: 0.5,
1328 });
1329 let warnings = validate_pedal(&pedal);
1330 assert!(has_code(&warnings, "unknown-control-component"));
1331 }
1332
1333 #[test]
1334 fn control_default_out_of_range() {
1335 let mut pedal = minimal_pedal();
1336 pedal.components.push(ComponentDef {
1337 id: "Vol".to_string(),
1338 kind: Box::new(Potentiometer {
1339 max_r: 100_000.0,
1340 taper: PotTaper::B,
1341 }),
1342 });
1343 pedal.nets.push(NetDef {
1344 from: Pin::ComponentPin {
1345 component: "Vol".to_string(),
1346 pin: "a".to_string(),
1347 },
1348 to: vec![Pin::Reserved("gnd".to_string())],
1349 });
1350 pedal.controls.push(ControlDef {
1351 component: "Vol".to_string(),
1352 property: "position".to_string(),
1353 label: "Volume".to_string(),
1354 range: (0.0, 1.0),
1355 default: 1.5, });
1357 let warnings = validate_pedal(&pedal);
1358 assert!(has_code(&warnings, "default-out-of-range"));
1359 }
1360
1361 #[test]
1362 fn unrecognized_modulation_target() {
1363 let mut pedal = minimal_pedal();
1364 pedal.components.push(ComponentDef {
1365 id: "LFO1".to_string(),
1366 kind: Box::new(Lfo {
1367 waveform: LfoWaveformDsl::Triangle,
1368 timing_r: 150_000.0,
1369 timing_c: 1e-6,
1370 }),
1371 });
1372 pedal.components.push(ComponentDef {
1373 id: "Depth".to_string(),
1374 kind: Box::new(Potentiometer {
1375 max_r: 50_000.0,
1376 taper: PotTaper::B,
1377 }),
1378 });
1379 pedal.nets.push(NetDef {
1382 from: Pin::ComponentPin {
1383 component: "LFO1".to_string(),
1384 pin: "out".to_string(),
1385 },
1386 to: vec![Pin::ComponentPin {
1387 component: "R1".to_string(),
1388 pin: "modulate".to_string(), }],
1390 });
1391 let warnings = validate_pedal(&pedal);
1392 assert!(
1393 has_code(&warnings, "unrecognized-mod-target"),
1394 "Expected 'unrecognized-mod-target' warning. Got: {:?}",
1395 warnings
1396 );
1397 }
1398
1399 #[test]
1400 fn lfo_to_wrong_component_type() {
1401 let mut pedal = minimal_pedal();
1402 pedal.components.push(ComponentDef {
1403 id: "LFO1".to_string(),
1404 kind: Box::new(Lfo {
1405 waveform: LfoWaveformDsl::Sine,
1406 timing_r: 100_000.0,
1407 timing_c: 1e-6,
1408 }),
1409 });
1410 pedal.nets.push(NetDef {
1412 from: Pin::ComponentPin {
1413 component: "LFO1".to_string(),
1414 pin: "out".to_string(),
1415 },
1416 to: vec![Pin::ComponentPin {
1417 component: "R1".to_string(),
1418 pin: "vgs".to_string(),
1419 }],
1420 });
1421 let warnings = validate_pedal(&pedal);
1422 assert!(
1423 has_code(&warnings, "mod-target-type-mismatch"),
1424 "Expected 'mod-target-type-mismatch' warning. Got: {:?}",
1425 warnings
1426 );
1427 }
1428
1429 #[test]
1430 fn valid_lfo_to_jfet_vgs_no_warning() {
1431 let mut pedal = minimal_pedal();
1432 pedal.components.push(ComponentDef {
1433 id: "LFO1".to_string(),
1434 kind: Box::new(Lfo {
1435 waveform: LfoWaveformDsl::Triangle,
1436 timing_r: 1_000_000.0,
1437 timing_c: 220e-9,
1438 }),
1439 });
1440 pedal.components.push(ComponentDef {
1441 id: "J1".to_string(),
1442 kind: Box::new(NJfet {
1443 model: "2N5952".to_string(),
1444 }),
1445 });
1446 pedal.nets.push(NetDef {
1447 from: Pin::ComponentPin {
1448 component: "LFO1".to_string(),
1449 pin: "out".to_string(),
1450 },
1451 to: vec![Pin::ComponentPin {
1452 component: "J1".to_string(),
1453 pin: "vgs".to_string(),
1454 }],
1455 });
1456 pedal.nets.push(NetDef {
1457 from: Pin::ComponentPin {
1458 component: "J1".to_string(),
1459 pin: "drain".to_string(),
1460 },
1461 to: vec![Pin::Reserved("gnd".to_string())],
1462 });
1463 pedal.nets.push(NetDef {
1464 from: Pin::ComponentPin {
1465 component: "J1".to_string(),
1466 pin: "source".to_string(),
1467 },
1468 to: vec![Pin::Reserved("gnd".to_string())],
1469 });
1470 let warnings = validate_pedal(&pedal);
1471 assert!(
1472 !has_code(&warnings, "mod-target-type-mismatch"),
1473 "Got unexpected mod-target-type-mismatch: {:?}",
1474 warnings_with_code(&warnings, "mod-target-type-mismatch")
1475 );
1476 assert!(
1477 !has_code(&warnings, "unrecognized-mod-target"),
1478 "Got unexpected unrecognized-mod-target: {:?}",
1479 warnings_with_code(&warnings, "unrecognized-mod-target")
1480 );
1481 }
1482
1483 #[test]
1484 fn lfo_to_pot_is_passive_no_hard_warning() {
1485 let mut pedal = minimal_pedal();
1486 pedal.components.push(ComponentDef {
1487 id: "LFO1".to_string(),
1488 kind: Box::new(Lfo {
1489 waveform: LfoWaveformDsl::Triangle,
1490 timing_r: 150_000.0,
1491 timing_c: 1e-6,
1492 }),
1493 });
1494 pedal.components.push(ComponentDef {
1495 id: "Depth".to_string(),
1496 kind: Box::new(Potentiometer {
1497 max_r: 50_000.0,
1498 taper: PotTaper::B,
1499 }),
1500 });
1501 pedal.nets.push(NetDef {
1502 from: Pin::ComponentPin {
1503 component: "LFO1".to_string(),
1504 pin: "out".to_string(),
1505 },
1506 to: vec![Pin::ComponentPin {
1507 component: "Depth".to_string(),
1508 pin: "a".to_string(),
1509 }],
1510 });
1511 pedal.nets.push(NetDef {
1512 from: Pin::ComponentPin {
1513 component: "Depth".to_string(),
1514 pin: "b".to_string(),
1515 },
1516 to: vec![Pin::Reserved("gnd".to_string())],
1517 });
1518 let warnings = validate_pedal(&pedal);
1519 assert!(!has_code(&warnings, "unrecognized-mod-target"));
1521 }
1522
1523 #[test]
1524 fn duplicate_control_label() {
1525 let mut pedal = minimal_pedal();
1526 pedal.components.push(ComponentDef {
1527 id: "P1".to_string(),
1528 kind: Box::new(Potentiometer {
1529 max_r: 100_000.0,
1530 taper: PotTaper::B,
1531 }),
1532 });
1533 pedal.components.push(ComponentDef {
1534 id: "P2".to_string(),
1535 kind: Box::new(Potentiometer {
1536 max_r: 100_000.0,
1537 taper: PotTaper::B,
1538 }),
1539 });
1540 pedal.nets.push(NetDef {
1541 from: Pin::ComponentPin {
1542 component: "P1".to_string(),
1543 pin: "a".to_string(),
1544 },
1545 to: vec![Pin::Reserved("gnd".to_string())],
1546 });
1547 pedal.nets.push(NetDef {
1548 from: Pin::ComponentPin {
1549 component: "P2".to_string(),
1550 pin: "a".to_string(),
1551 },
1552 to: vec![Pin::Reserved("gnd".to_string())],
1553 });
1554 pedal.controls.push(ControlDef {
1555 component: "P1".to_string(),
1556 property: "position".to_string(),
1557 label: "Drive".to_string(),
1558 range: (0.0, 1.0),
1559 default: 0.5,
1560 });
1561 pedal.controls.push(ControlDef {
1562 component: "P2".to_string(),
1563 property: "position".to_string(),
1564 label: "Drive".to_string(), range: (0.0, 1.0),
1566 default: 0.5,
1567 });
1568 let warnings = validate_pedal(&pedal);
1569 assert!(has_code(&warnings, "duplicate-control-label"));
1570 }
1571
1572 #[test]
1573 fn validate_real_tube_screamer() {
1574 let src = std::fs::read_to_string("examples/pedals/overdrive/tube_screamer.pedal")
1575 .expect("tube_screamer.pedal not found");
1576 let pedal = parse_pedal_file(&src).expect("parse failed");
1577 let warnings = validate_pedal(&pedal);
1578 let errors: Vec<_> = warnings
1579 .iter()
1580 .filter(|w| w.severity == Severity::Error)
1581 .collect();
1582 assert!(
1583 errors.is_empty(),
1584 "Tube Screamer should have no errors: {:?}",
1585 errors
1586 );
1587 }
1588
1589 #[test]
1590 fn validate_real_phase90() {
1591 let src = std::fs::read_to_string("examples/pedals/phaser/phase90.pedal")
1592 .expect("phase90.pedal not found");
1593 let pedal = parse_pedal_file(&src).expect("parse failed");
1594 let warnings = validate_pedal(&pedal);
1595 let errors: Vec<_> = warnings
1596 .iter()
1597 .filter(|w| w.severity == Severity::Error)
1598 .collect();
1599 assert!(
1600 errors.is_empty(),
1601 "Phase 90 should have no errors: {:?}",
1602 errors
1603 );
1604 }
1605
1606 #[test]
1607 fn validate_real_boss_ce2() {
1608 let src = std::fs::read_to_string("examples/pedals/modulation/boss_ce2.pedal")
1609 .expect("boss_ce2.pedal not found");
1610 let pedal = parse_pedal_file(&src).expect("parse failed");
1611 let warnings = validate_pedal(&pedal);
1612 let errors: Vec<_> = warnings
1613 .iter()
1614 .filter(|w| w.severity == Severity::Error)
1615 .collect();
1616 assert!(
1617 errors.is_empty(),
1618 "Boss CE-2 should have no errors: {:?}",
1619 errors
1620 );
1621 }
1622
1623 #[test]
1626 fn scan_all_pedal_files() {
1627 fn walk(dir: &std::path::Path, results: &mut Vec<(String, Vec<PedalWarning>)>) {
1628 if let Ok(entries) = std::fs::read_dir(dir) {
1629 for entry in entries.flatten() {
1630 let path = entry.path();
1631 if path.is_dir() {
1632 walk(&path, results);
1633 } else if path.extension().map_or(false, |e| e == "pedal") {
1634 let src = std::fs::read_to_string(&path).unwrap();
1635 if let Ok(pedal) = parse_pedal_file(&src) {
1636 let warnings = validate_pedal(&pedal);
1637 if !warnings.is_empty() {
1638 results.push((path.display().to_string(), warnings));
1639 }
1640 }
1641 }
1642 }
1643 }
1644 }
1645
1646 let mut results = Vec::new();
1647 walk(std::path::Path::new("examples"), &mut results);
1648 walk(std::path::Path::new("tests/test_pedals"), &mut results);
1649
1650 if results.is_empty() {
1651 eprintln!("\n=== All .pedal files validated clean ===\n");
1652 } else {
1653 let mut total = 0;
1654 for (path, warnings) in &results {
1655 eprintln!("\n--- {} ---", path);
1656 for w in warnings {
1657 let tag = match w.severity {
1658 Severity::Info => "INFO",
1659 Severity::Warning => "WARN",
1660 Severity::Error => "ERR ",
1661 };
1662 eprintln!(" [{}] {}: {}", tag, w.code, w.message);
1663 total += 1;
1664 }
1665 }
1666 eprintln!(
1667 "\n=== {} warnings across {} files ===\n",
1668 total,
1669 results.len()
1670 );
1671 }
1672 }
1673
1674 #[test]
1677 fn valid_bjt_model_name_no_warning() {
1678 let mut pedal = minimal_pedal();
1679 pedal.components.push(ComponentDef {
1680 id: "Q1".to_string(),
1681 kind: Box::new(Npn {
1682 model: "2N3904".to_string(),
1683 }),
1684 });
1685 pedal.nets.push(NetDef {
1686 from: Pin::ComponentPin {
1687 component: "Q1".to_string(),
1688 pin: "collector".to_string(),
1689 },
1690 to: vec![Pin::Reserved("gnd".to_string())],
1691 });
1692 pedal.nets.push(NetDef {
1693 from: Pin::ComponentPin {
1694 component: "Q1".to_string(),
1695 pin: "emitter".to_string(),
1696 },
1697 to: vec![Pin::Reserved("gnd".to_string())],
1698 });
1699 pedal.nets.push(NetDef {
1700 from: Pin::ComponentPin {
1701 component: "Q1".to_string(),
1702 pin: "base".to_string(),
1703 },
1704 to: vec![Pin::Reserved("gnd".to_string())],
1705 });
1706 let warnings = validate_pedal(&pedal);
1707 assert!(
1708 !has_code(&warnings, "unknown-model"),
1709 "Valid BJT '2N3904' should not trigger unknown-model: {:?}",
1710 warnings_with_code(&warnings, "unknown-model")
1711 );
1712 }
1713
1714 #[test]
1715 fn invalid_bjt_model_name_emits_error() {
1716 let mut pedal = minimal_pedal();
1717 pedal.components.push(ComponentDef {
1718 id: "Q1".to_string(),
1719 kind: Box::new(Npn {
1720 model: "2N3904X".to_string(),
1721 }),
1722 });
1723 pedal.nets.push(NetDef {
1724 from: Pin::ComponentPin {
1725 component: "Q1".to_string(),
1726 pin: "collector".to_string(),
1727 },
1728 to: vec![Pin::Reserved("gnd".to_string())],
1729 });
1730 pedal.nets.push(NetDef {
1731 from: Pin::ComponentPin {
1732 component: "Q1".to_string(),
1733 pin: "emitter".to_string(),
1734 },
1735 to: vec![Pin::Reserved("gnd".to_string())],
1736 });
1737 pedal.nets.push(NetDef {
1738 from: Pin::ComponentPin {
1739 component: "Q1".to_string(),
1740 pin: "base".to_string(),
1741 },
1742 to: vec![Pin::Reserved("gnd".to_string())],
1743 });
1744 let warnings = validate_pedal(&pedal);
1745 let model_errors = warnings_with_code(&warnings, "unknown-model");
1746 assert!(
1747 !model_errors.is_empty(),
1748 "Invalid BJT '2N3904X' should emit unknown-model error"
1749 );
1750 assert_eq!(model_errors[0].severity, Severity::Error);
1751 assert!(
1753 model_errors[0].message.contains("2N3904"),
1754 "Should suggest '2N3904', got: {}",
1755 model_errors[0].message
1756 );
1757 }
1758
1759 #[test]
1760 fn valid_triode_model_name_no_warning() {
1761 let mut pedal = minimal_pedal();
1762 pedal.components.push(ComponentDef {
1763 id: "V1".to_string(),
1764 kind: Box::new(Triode {
1765 model: "12AX7".to_string(),
1766 }),
1767 });
1768 pedal.nets.push(NetDef {
1769 from: Pin::ComponentPin {
1770 component: "V1".to_string(),
1771 pin: "plate".to_string(),
1772 },
1773 to: vec![Pin::Reserved("gnd".to_string())],
1774 });
1775 pedal.nets.push(NetDef {
1776 from: Pin::ComponentPin {
1777 component: "V1".to_string(),
1778 pin: "cathode".to_string(),
1779 },
1780 to: vec![Pin::Reserved("gnd".to_string())],
1781 });
1782 let warnings = validate_pedal(&pedal);
1783 assert!(
1784 !has_code(&warnings, "unknown-model"),
1785 "Valid triode '12AX7' should not trigger unknown-model: {:?}",
1786 warnings_with_code(&warnings, "unknown-model")
1787 );
1788 }
1789
1790 #[test]
1791 fn invalid_jfet_model_name_emits_error() {
1792 let mut pedal = minimal_pedal();
1793 pedal.components.push(ComponentDef {
1794 id: "J1".to_string(),
1795 kind: Box::new(NJfet {
1796 model: "NOTREAL".to_string(),
1797 }),
1798 });
1799 pedal.nets.push(NetDef {
1800 from: Pin::ComponentPin {
1801 component: "J1".to_string(),
1802 pin: "drain".to_string(),
1803 },
1804 to: vec![Pin::Reserved("gnd".to_string())],
1805 });
1806 pedal.nets.push(NetDef {
1807 from: Pin::ComponentPin {
1808 component: "J1".to_string(),
1809 pin: "source".to_string(),
1810 },
1811 to: vec![Pin::Reserved("gnd".to_string())],
1812 });
1813 let warnings = validate_pedal(&pedal);
1814 assert!(
1815 has_code(&warnings, "unknown-model"),
1816 "Invalid JFET 'NOTREAL' should emit unknown-model error"
1817 );
1818 }
1819
1820 #[test]
1821 fn invalid_pentode_model_name_emits_error() {
1822 let mut pedal = minimal_pedal();
1823 pedal.components.push(ComponentDef {
1824 id: "V1".to_string(),
1825 kind: Box::new(Pentode {
1826 model: "FAKETUBE".to_string(),
1827 }),
1828 });
1829 pedal.nets.push(NetDef {
1830 from: Pin::ComponentPin {
1831 component: "V1".to_string(),
1832 pin: "plate".to_string(),
1833 },
1834 to: vec![Pin::Reserved("gnd".to_string())],
1835 });
1836 pedal.nets.push(NetDef {
1837 from: Pin::ComponentPin {
1838 component: "V1".to_string(),
1839 pin: "cathode".to_string(),
1840 },
1841 to: vec![Pin::Reserved("gnd".to_string())],
1842 });
1843 let warnings = validate_pedal(&pedal);
1844 assert!(
1845 has_code(&warnings, "unknown-model"),
1846 "Invalid pentode 'FAKETUBE' should emit unknown-model error"
1847 );
1848 }
1849
1850 #[test]
1851 fn edit_distance_basic() {
1852 assert_eq!(super::edit_distance("abc", "abc"), 0);
1853 assert_eq!(super::edit_distance("abc", "abd"), 1);
1854 assert_eq!(super::edit_distance("", "abc"), 3);
1855 assert_eq!(super::edit_distance("kitten", "sitting"), 3);
1856 }
1857
1858 #[test]
1859 fn find_similar_names_suggests_close_matches() {
1860 let candidates = ["2N3904", "2N3906", "BC184C", "2N5088"];
1861 let suggestions = super::find_similar_names("2N3904X", &candidates, 3);
1862 assert!(
1863 suggestions.contains(&"2N3904"),
1864 "Should suggest '2N3904' for '2N3904X', got: {:?}",
1865 suggestions
1866 );
1867 }
1868
1869 fn dual_gang_pedal() -> PedalDef {
1872 let mut pedal = minimal_pedal();
1873 pedal.components.push(ComponentDef {
1874 id: "Gain_A".to_string(),
1875 kind: Box::new(Potentiometer {
1876 max_r: 100_000.0,
1877 taper: PotTaper::B,
1878 }),
1879 });
1880 pedal.components.push(ComponentDef {
1881 id: "Gain_B".to_string(),
1882 kind: Box::new(Potentiometer {
1883 max_r: 100_000.0,
1884 taper: PotTaper::B,
1885 }),
1886 });
1887 pedal.nets.push(NetDef {
1889 from: Pin::ComponentPin {
1890 component: "Gain_A".to_string(),
1891 pin: "a".to_string(),
1892 },
1893 to: vec![Pin::Reserved("gnd".to_string())],
1894 });
1895 pedal.nets.push(NetDef {
1896 from: Pin::ComponentPin {
1897 component: "Gain_B".to_string(),
1898 pin: "a".to_string(),
1899 },
1900 to: vec![Pin::Reserved("gnd".to_string())],
1901 });
1902 pedal
1903 .mirrors
1904 .insert("Gain_B".to_string(), "Gain_A".to_string());
1905 pedal
1906 }
1907
1908 #[test]
1909 fn valid_mirror_no_errors() {
1910 let pedal = dual_gang_pedal();
1911 let warnings = validate_pedal(&pedal);
1912 assert!(
1913 !has_code(&warnings, "mirror-target-missing"),
1914 "Valid mirror should not trigger errors: {:?}",
1915 warnings_with_code(&warnings, "mirror-target-missing")
1916 );
1917 assert!(!has_code(&warnings, "mirror-target-not-pot"));
1918 assert!(!has_code(&warnings, "mirror-chain"));
1919 assert!(!has_code(&warnings, "mirror-in-controls"));
1920 }
1921
1922 #[test]
1923 fn mirror_target_missing() {
1924 let mut pedal = minimal_pedal();
1925 pedal
1926 .mirrors
1927 .insert("Gain_B".to_string(), "NONEXISTENT".to_string());
1928 pedal.components.push(ComponentDef {
1929 id: "Gain_B".to_string(),
1930 kind: Box::new(Potentiometer {
1931 max_r: 100_000.0,
1932 taper: PotTaper::B,
1933 }),
1934 });
1935 pedal.nets.push(NetDef {
1936 from: Pin::ComponentPin {
1937 component: "Gain_B".to_string(),
1938 pin: "a".to_string(),
1939 },
1940 to: vec![Pin::Reserved("gnd".to_string())],
1941 });
1942 let warnings = validate_pedal(&pedal);
1943 assert!(has_code(&warnings, "mirror-target-missing"));
1944 }
1945
1946 #[test]
1947 fn mirror_target_not_pot() {
1948 let mut pedal = minimal_pedal();
1949 pedal.mirrors.insert("P1".to_string(), "R1".to_string());
1951 pedal.components.push(ComponentDef {
1952 id: "P1".to_string(),
1953 kind: Box::new(Potentiometer {
1954 max_r: 100_000.0,
1955 taper: PotTaper::B,
1956 }),
1957 });
1958 pedal.nets.push(NetDef {
1959 from: Pin::ComponentPin {
1960 component: "P1".to_string(),
1961 pin: "a".to_string(),
1962 },
1963 to: vec![Pin::Reserved("gnd".to_string())],
1964 });
1965 let warnings = validate_pedal(&pedal);
1966 assert!(has_code(&warnings, "mirror-target-not-pot"));
1967 }
1968
1969 #[test]
1970 fn mirror_chain_not_allowed() {
1971 let mut pedal = dual_gang_pedal();
1972 pedal.components.push(ComponentDef {
1974 id: "Gain_C".to_string(),
1975 kind: Box::new(Potentiometer {
1976 max_r: 100_000.0,
1977 taper: PotTaper::B,
1978 }),
1979 });
1980 pedal.nets.push(NetDef {
1981 from: Pin::ComponentPin {
1982 component: "Gain_C".to_string(),
1983 pin: "a".to_string(),
1984 },
1985 to: vec![Pin::Reserved("gnd".to_string())],
1986 });
1987 pedal
1988 .mirrors
1989 .insert("Gain_C".to_string(), "Gain_B".to_string());
1990 let warnings = validate_pedal(&pedal);
1991 assert!(has_code(&warnings, "mirror-chain"));
1992 }
1993
1994 #[test]
1995 fn mirrored_pot_in_controls_is_error() {
1996 let mut pedal = dual_gang_pedal();
1997 pedal.controls.push(ControlDef {
1999 component: "Gain_B".to_string(),
2000 property: "position".to_string(),
2001 label: "Gain B".to_string(),
2002 range: (0.0, 1.0),
2003 default: 0.5,
2004 });
2005 let warnings = validate_pedal(&pedal);
2006 assert!(has_code(&warnings, "mirror-in-controls"));
2007 }
2008
2009 #[test]
2010 fn mirror_resistance_mismatch_warns() {
2011 let mut pedal = minimal_pedal();
2012 pedal.components.push(ComponentDef {
2013 id: "P_A".to_string(),
2014 kind: Box::new(Potentiometer {
2015 max_r: 100_000.0,
2016 taper: PotTaper::B,
2017 }),
2018 });
2019 pedal.components.push(ComponentDef {
2020 id: "P_B".to_string(),
2021 kind: Box::new(Potentiometer {
2022 max_r: 50_000.0,
2023 taper: PotTaper::B,
2024 }), });
2026 pedal.nets.push(NetDef {
2027 from: Pin::ComponentPin {
2028 component: "P_A".to_string(),
2029 pin: "a".to_string(),
2030 },
2031 to: vec![Pin::Reserved("gnd".to_string())],
2032 });
2033 pedal.nets.push(NetDef {
2034 from: Pin::ComponentPin {
2035 component: "P_B".to_string(),
2036 pin: "a".to_string(),
2037 },
2038 to: vec![Pin::Reserved("gnd".to_string())],
2039 });
2040 pedal.mirrors.insert("P_B".to_string(), "P_A".to_string());
2041 let warnings = validate_pedal(&pedal);
2042 assert!(has_code(&warnings, "mirror-resistance-mismatch"));
2043 }
2044
2045 fn transformer_pedal(cfg: TransformerConfig) -> PedalDef {
2050 PedalDef {
2051 name: "XfmrTest".to_string(),
2052 subtitle: None,
2053 supplies: vec![],
2054 components: vec![ComponentDef {
2055 id: "T1".to_string(),
2056 kind: Box::new(TransformerComp { config: cfg }),
2057 }],
2058 nets: vec![
2059 NetDef {
2060 from: Pin::Reserved("in".to_string()),
2061 to: vec![Pin::ComponentPin {
2062 component: "T1".to_string(),
2063 pin: "a".to_string(),
2064 }],
2065 },
2066 NetDef {
2067 from: Pin::ComponentPin {
2068 component: "T1".to_string(),
2069 pin: "b".to_string(),
2070 },
2071 to: vec![Pin::Reserved("gnd".to_string())],
2072 },
2073 NetDef {
2074 from: Pin::ComponentPin {
2075 component: "T1".to_string(),
2076 pin: "c".to_string(),
2077 },
2078 to: vec![Pin::Reserved("out".to_string())],
2079 },
2080 NetDef {
2081 from: Pin::ComponentPin {
2082 component: "T1".to_string(),
2083 pin: "d".to_string(),
2084 },
2085 to: vec![Pin::Reserved("gnd".to_string())],
2086 },
2087 ],
2088 controls: vec![],
2089 trims: vec![],
2090 monitors: vec![],
2091 sidechains: vec![],
2092 mirrors: hashbrown::HashMap::new(),
2093 calibrate: false,
2094 subcircuits: vec![],
2095 ports: vec![],
2096 init_hints: vec![],
2097 uses: vec![],
2098 }
2099 }
2100
2101 #[test]
2102 fn valid_transformer_no_errors() {
2103 let pedal = transformer_pedal(TransformerConfig::new(15.0, 10.0));
2104 let warnings = validate_pedal(&pedal);
2105 assert!(
2106 !has_code(&warnings, "invalid-transformer-pin"),
2107 "Standard transformer with standard pins should not error: {:?}",
2108 warnings_with_code(&warnings, "invalid-transformer-pin"),
2109 );
2110 }
2111
2112 #[test]
2113 fn valid_center_tap_transformer_no_errors() {
2114 let cfg = TransformerConfig::new(15.0, 10.0).with_center_tap();
2115 let mut pedal = transformer_pedal(cfg);
2116 pedal.nets.push(NetDef {
2117 from: Pin::ComponentPin {
2118 component: "T1".to_string(),
2119 pin: "sec.ct".to_string(),
2120 },
2121 to: vec![Pin::Reserved("gnd".to_string())],
2122 });
2123 let warnings = validate_pedal(&pedal);
2124 assert!(
2125 !has_code(&warnings, "invalid-transformer-pin"),
2126 "CT transformer with CT pin should not error: {:?}",
2127 warnings_with_code(&warnings, "invalid-transformer-pin"),
2128 );
2129 }
2130
2131 #[test]
2132 fn center_tap_pin_on_standard_secondary_errors() {
2133 let cfg = TransformerConfig::new(15.0, 10.0); let mut pedal = transformer_pedal(cfg);
2135 pedal.nets.push(NetDef {
2136 from: Pin::ComponentPin {
2137 component: "T1".to_string(),
2138 pin: "sec.ct".to_string(),
2139 },
2140 to: vec![Pin::Reserved("gnd".to_string())],
2141 });
2142 let warnings = validate_pedal(&pedal);
2143 assert!(
2144 has_code(&warnings, "invalid-transformer-pin"),
2145 "CT pin on standard secondary should error",
2146 );
2147 }
2148
2149 #[test]
2150 fn primary_ct_pin_on_standard_primary_errors() {
2151 let cfg = TransformerConfig::new(15.0, 10.0);
2152 let mut pedal = transformer_pedal(cfg);
2153 pedal.nets.push(NetDef {
2154 from: Pin::ComponentPin {
2155 component: "T1".to_string(),
2156 pin: "pri.ct".to_string(),
2157 },
2158 to: vec![Pin::Reserved("gnd".to_string())],
2159 });
2160 let warnings = validate_pedal(&pedal);
2161 assert!(
2162 has_code(&warnings, "invalid-transformer-pin"),
2163 "CT pin on standard primary should error",
2164 );
2165 }
2166
2167 #[test]
2168 fn tertiary_pin_on_two_winding_transformer_errors() {
2169 let cfg = TransformerConfig::new(15.0, 10.0); let mut pedal = transformer_pedal(cfg);
2171 pedal.nets.push(NetDef {
2172 from: Pin::ComponentPin {
2173 component: "T1".to_string(),
2174 pin: "e".to_string(),
2175 },
2176 to: vec![Pin::Reserved("gnd".to_string())],
2177 });
2178 let warnings = validate_pedal(&pedal);
2179 assert!(
2180 has_code(&warnings, "invalid-transformer-pin"),
2181 "Tertiary pin on 2-winding transformer should error",
2182 );
2183 }
2184
2185 #[test]
2186 fn tertiary_pin_on_three_winding_transformer_ok() {
2187 let cfg = TransformerConfig::new(15.0, 10.0).with_tertiary(5.0);
2188 let mut pedal = transformer_pedal(cfg);
2189 pedal.nets.push(NetDef {
2190 from: Pin::ComponentPin {
2191 component: "T1".to_string(),
2192 pin: "e".to_string(),
2193 },
2194 to: vec![Pin::Reserved("gnd".to_string())],
2195 });
2196 pedal.nets.push(NetDef {
2197 from: Pin::ComponentPin {
2198 component: "T1".to_string(),
2199 pin: "f".to_string(),
2200 },
2201 to: vec![Pin::Reserved("gnd".to_string())],
2202 });
2203 let warnings = validate_pedal(&pedal);
2204 assert!(
2205 !has_code(&warnings, "invalid-transformer-pin"),
2206 "Tertiary pins on 3-winding transformer should be valid: {:?}",
2207 warnings_with_code(&warnings, "invalid-transformer-pin"),
2208 );
2209 }
2210}