Skip to main content

pedalkernel/compiler/
validate.rs

1//! Validation layer for `.pedal` files.
2//!
3//! Runs at compile time (not runtime) to catch common errors in `.pedal`
4//! circuit definitions. Collects warnings without hard-failing — the goal
5//! is to surface issues that would otherwise be silently ignored by the
6//! compiler pipeline.
7//!
8//! # Categories of warnings
9//!
10//! - **Component reference errors**: nets/controls referencing components that don't exist
11//! - **Invalid pin names**: using pins that don't exist for a given component type
12//! - **Unrecognized modulation targets**: LFO/envelope outputs going to pins the compiler skips
13//! - **Suspicious component values**: zero-ohm resistors, negative caps, etc.
14//! - **Duplicate component IDs**: two components sharing the same name
15//! - **Orphaned components**: components declared but never wired into any net
16//! - **Missing signal path**: no route from `in` to `out`
17//! - **Control binding issues**: controls targeting nonexistent components
18
19use hashbrown::HashMap;
20use std::collections::HashSet;
21
22use crate::dsl::*;
23use crate::models;
24
25/// Severity of a validation warning.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Severity {
28    /// Something looks off but probably works (e.g., very high resistance).
29    Info,
30    /// Likely a mistake — the compiler will silently skip or fall back.
31    Warning,
32    /// Almost certainly a bug — broken signal path, missing component, etc.
33    Error,
34}
35
36/// A single validation warning from a `.pedal` file.
37#[derive(Debug, Clone)]
38pub struct PedalWarning {
39    pub severity: Severity,
40    /// Short machine-readable code (e.g., "unknown-pin", "orphaned-component").
41    pub code: &'static str,
42    /// Human-readable explanation.
43    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
57/// Validate a parsed `PedalDef` and return all warnings.
58///
59/// This runs *after* parsing and *before* (or at the start of) compilation.
60/// It never mutates the `PedalDef` and has no runtime cost.
61pub 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
81// ═══════════════════════════════════════════════════════════════════════════
82// Individual checks
83// ═══════════════════════════════════════════════════════════════════════════
84
85/// Duplicate component IDs.
86fn 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
104/// Suspicious component values.
105fn 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
122/// Nets referencing component IDs that don't exist.
123fn 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        // Synth-specific CV/Gate nodes (from dsl.rs RESERVED_NODES)
133        "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            // SubcircuitPort pins are inter-subcircuit connections; validated separately
201            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
220/// Valid pin names for a component type (delegates to Component trait).
221fn valid_pins_for(kind: &dyn super::component::Component) -> &'static [&'static str] {
222    kind.pin_config().valid_pins
223}
224
225/// Check that pin names used in nets are valid for their component type.
226fn 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    // Modulation target pins are valid on specific component types even though
236    // they're not circuit pins (they're resolved during binding, not graph building).
237    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                // Don't warn for component types that have no pin list
245                // (switches, etc.) since they're not circuit elements
246                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            // If component doesn't exist, check_net_references already flagged it
263        }
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
274/// Check transformer pins against winding configuration.
275///
276/// Rejects center-tap pins on non-center-tap windings and tertiary pins
277/// when no tertiary winding exists. These are hard errors — the circuit
278/// would silently produce wrong results.
279fn check_transformer_pins(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
280    // Collect transformer configs by component ID.
281    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    // Pins that require a center-tap winding.
292    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    // Pins that require a tertiary winding.
296    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                // Center-tap on primary
313                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                // Center-tap on secondary
332                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                // Tertiary pins without tertiary winding
351                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
375/// Components declared but never referenced in any net.
376fn 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            // SubcircuitPort pins reference subcircuits, not components
397            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    // Controls also reference components
409    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        // Skip internal/virtual types that don't need net connections
418        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
434/// Check that there's a plausible signal path from `in` to `out`.
435fn check_signal_path(pedal: &PedalDef, w: &mut Vec<PedalWarning>) {
436    // Synths (circuits with VCOs) generate their own signal — they don't need
437    // an in → out path since audio originates from the oscillator.
438    let is_synth = pedal.components.iter().any(|c| c.kind.type_tag() == "VCO");
439    if is_synth {
440        return;
441    }
442
443    // Build adjacency based on net connections (node = pin key, edges = same net).
444    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    // Also add implicit component-internal connections via signal_adjacencies().
456    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    // Add net connections
466    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    // BFS from "in"
476    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        // Warning, not Error — sub-circuits and complex topologies may not
493        // have a direct in→out path but still compile correctly.
494        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
502/// Check control definitions reference valid components and properties.
503fn 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        // Controls typically target potentiometers or switches
528        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        // Check range validity — only reject empty ranges (min == max).
541        // Inverted ranges (min > max) are intentional for VST controls
542        // like "Cut" knobs where max position = minimum value.
543        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        // Check default is within range (handles both normal and inverted ranges)
555        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    // Check for duplicate control labels
573    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
593/// Check LFO/envelope follower modulation targets.
594///
595/// This catches the `_ => continue` silent-skip in compile.rs lines 1409 and 1536.
596fn 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    // Valid modulation target pins that the compiler recognizes
606    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    // Pins that are "destination" pins on passive components — these are valid net
622    // connections but NOT modulation targets. When an LFO connects to, e.g., Depth.a,
623    // the signal flows through the passive network but doesn't create a modulation binding.
624    let passive_connection_pins: HashSet<&str> = ["a", "b", "w", "wiper", "in", "out", "rate"]
625        .iter()
626        .copied()
627        .collect();
628
629    // Check each modulation source's output connections
630    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            // Look for nets where this mod source's .out is the source
639            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                            // Check if the target component exists
648                            if !comp_map.contains_key(target_comp.as_str()) {
649                                // Already caught by check_net_references
650                                continue;
651                            }
652
653                            let target_kind = comp_map[target_comp.as_str()].as_ref();
654
655                            // If the target pin is a known modulation target, great
656                            if valid_mod_targets.contains(target_prop.as_str()) {
657                                // Verify the target component type makes sense for this pin
658                                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                                // This is a valid passive connection (e.g., LFO1.out -> Depth.a)
668                                // but it won't create a modulation binding. Check if the user
669                                // probably intended a modulation binding.
670                                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                                    // Connecting a modulation source to a non-passive, non-pot
676                                    // component's generic pin is suspicious
677                                    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                                // Unknown target pin — this is what the compiler's
690                                // `_ => continue` silently skips
691                                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
711/// Check that a modulation target pin makes sense for the target component type.
712fn 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    // The pin is a mismatch if this component doesn't list it as a modulation pin.
721    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
760/// Check supply configuration.
761fn 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    // Check for duplicate supply names
800    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
815/// Check monitor definitions reference valid components.
816fn 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
836/// Check mirrored pot declarations.
837///
838/// Validates:
839/// - Mirror target exists and is a potentiometer
840/// - No mirror chains (A mirrors B mirrors C)
841/// - Mirrored pots do not appear in the controls block
842/// - Warns if mirrored and source pots have different resistances
843fn 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        // 1. Source must exist
854        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        // 2. Source must be a potentiometer
867        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        // 3. Mirrored component must itself be a pot
882        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        // 4. No chains: source must not itself be mirrored
897        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        // 5. Mirrored pot must not appear in controls
911        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        // 6. Warn if resistances differ
924        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
947/// Check that SPICE model names referenced by components actually exist in
948/// the model registry. Emits `"unknown-model"` errors with suggestions for
949/// near-matches.
950fn 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                // Distinguish pentode vs triode/varimu for the correct model registry
973                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
1014/// Find model names similar to `query` using case-insensitive edit distance.
1015/// Returns up to `max` suggestions sorted by distance.
1016fn 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            // Only suggest if reasonably close (within half the query length + 2)
1023            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
1035/// Simple Levenshtein edit distance.
1036fn 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
1058/// Validate `.pedal` files for use in build scripts.
1059///
1060/// Reads each file, parses it, runs full validation, and prints
1061/// `cargo::warning=` directives. Panics (failing the build) if any
1062/// errors are found.
1063///
1064/// Call this from your crate's `build.rs`:
1065/// ```no_run
1066/// // build.rs
1067/// fn main() {
1068///     pedalkernel::compiler::validate_pedal_files(&[
1069///         "pedals/my_overdrive.pedal",
1070///         "pedals/my_phaser.pedal",
1071///     ]);
1072/// }
1073/// ```
1074pub fn validate_pedal_files(paths: &[&str]) {
1075    // Re-run if any model file changes
1076    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// ═══════════════════════════════════════════════════════════════════════════
1124// Tests
1125// ═══════════════════════════════════════════════════════════════════════════
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130    use crate::compiler::component::Component;
1131    use crate::compiler::components::*;
1132
1133    /// Build a minimal valid pedal for testing.
1134    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            }), // 100 F!
1218        });
1219        // Wire it in
1220        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(), // resistors don't have cathodes
1260            }],
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                // in -> R1.a but R1.b -> gnd (not out)
1289                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, // out of range
1356        });
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        // LFO1.out -> Depth.a — this is a passive connection, not a modulation target
1380        // The compiler silently skips it at line 1409
1381        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(), // not a real pin
1389            }],
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        // LFO1.out -> R1.vgs — resistors don't have vgs
1411        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        // LFO -> pot.a is a valid passive connection, should NOT be flagged as unrecognized
1520        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(), // duplicate!
1565            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    /// Scan ALL .pedal files and print every warning found.
1624    /// This test always passes — it's for visibility, not assertions.
1625    #[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    // ── Model name validation tests ─────────────────────────────────────
1675
1676    #[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        // Should suggest the real model name
1752        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    // ── Mirror validation tests ─────────────────────────────────────
1870
1871    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        // Wire pots into the circuit
1888        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        // R1 exists but is a resistor, not a pot
1950        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        // Add a third pot that mirrors Gain_B (which itself mirrors Gain_A)
1973        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        // Add Gain_B to controls — this should be an error
1998        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            }), // different!
2025        });
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    // ═══════════════════════════════════════════════════════════════════
2046    // Transformer pin validation
2047    // ═══════════════════════════════════════════════════════════════════
2048
2049    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); // standard, no CT
2134        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); // no tertiary
2170        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}