Skip to main content

obd2_core/vehicle/
mod.rs

1//! Vehicle specification types and loading.
2
3pub mod loader;
4#[cfg(feature = "nhtsa")]
5pub mod nhtsa;
6pub mod vin;
7
8use serde::Deserialize;
9
10// ── Module Identity (protocol-agnostic) ──
11
12/// Logical identifier for a vehicle module. String-based for extensibility.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
14pub struct ModuleId(pub String);
15
16impl ModuleId {
17    pub const ECM: &'static str = "ecm";
18    pub const TCM: &'static str = "tcm";
19    pub const BCM: &'static str = "bcm";
20    pub const ABS: &'static str = "abs";
21    pub const IPC: &'static str = "ipc";
22    pub const AIRBAG: &'static str = "airbag";
23    pub const HVAC: &'static str = "hvac";
24    pub const FICM: &'static str = "ficm";
25
26    pub fn new(id: impl Into<String>) -> Self {
27        Self(id.into())
28    }
29}
30
31// ── Physical Addressing ──
32
33#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub enum PhysicalAddress {
36    J1850 { node: u8, header: [u8; 3] },
37    Can11Bit { request_id: u16, response_id: u16 },
38    Can29Bit { request_id: u32, response_id: u32 },
39    J1939 { source_address: u8 },
40}
41
42// ── Protocol ──
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
45#[non_exhaustive]
46pub enum Protocol {
47    J1850Vpw,
48    J1850Pwm,
49    Iso9141(KLineInit),
50    Kwp2000(KLineInit),
51    Can11Bit500,
52    Can11Bit250,
53    Can29Bit500,
54    Can29Bit250,
55    Auto,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
59pub enum KLineInit {
60    SlowInit,
61    FastInit,
62}
63
64// ── Bus Configuration ──
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
67pub struct BusId(pub String);
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct BusConfig {
71    pub id: BusId,
72    pub protocol: Protocol,
73    pub speed_bps: u32,
74    #[serde(default)]
75    pub modules: Vec<Module>,
76    pub description: Option<String>,
77}
78
79// ── Module ──
80
81#[derive(Debug, Clone, Deserialize)]
82pub struct Module {
83    pub id: ModuleId,
84    pub name: String,
85    pub address: PhysicalAddress,
86    pub bus: BusId,
87}
88
89// ── Vehicle Spec ──
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct VehicleSpec {
93    pub spec_version: Option<String>,
94    pub identity: SpecIdentity,
95    pub communication: CommunicationSpec,
96    pub thresholds: Option<ThresholdSet>,
97    #[serde(default)]
98    pub polling_groups: Vec<PollingGroup>,
99    #[serde(default)]
100    pub diagnostic_rules: Vec<DiagnosticRule>,
101    #[serde(default)]
102    pub known_issues: Vec<KnownIssue>,
103    pub dtc_library: Option<DtcLibrary>,
104    #[serde(default)]
105    pub enhanced_pids: Vec<crate::protocol::enhanced::EnhancedPid>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
109pub struct SpecIdentity {
110    pub name: String,
111    pub model_years: (u16, u16),
112    pub makes: Vec<String>,
113    pub models: Vec<String>,
114    pub engine: EngineSpec,
115    pub transmission: Option<TransmissionSpec>,
116    pub vin_match: Option<VinMatcher>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120pub struct VinMatcher {
121    pub vin_8th_digit: Option<Vec<char>>,
122    #[serde(default)]
123    pub wmi_prefixes: Vec<String>,
124    pub year_range: Option<(u16, u16)>,
125}
126
127impl VinMatcher {
128    /// Check if this matcher matches a given VIN.
129    pub fn matches(&self, vin: &str) -> bool {
130        if vin.len() < 17 {
131            return false;
132        }
133        let chars: Vec<char> = vin.chars().collect();
134
135        // Check WMI prefix (first 3 chars)
136        let wmi: String = chars[..3].iter().collect();
137        let wmi_ok = self.wmi_prefixes.is_empty()
138            || self.wmi_prefixes.iter().any(|p| wmi.eq_ignore_ascii_case(p));
139
140        // Check 8th digit (engine code)
141        let digit_ok = self
142            .vin_8th_digit
143            .as_ref()
144            .map(|digits| digits.contains(&chars[7]))
145            .unwrap_or(true);
146
147        // Check year range (decode from 10th char)
148        let year_ok = self
149            .year_range
150            .as_ref()
151            .map(|(min, max)| {
152                let (current, previous) = vin::decode_year_candidates(vin);
153                let in_range = |y: Option<i32>| {
154                    y.is_some_and(|y| y >= *min as i32 && y <= *max as i32)
155                };
156                // Accept if either VIN year cycle falls within range
157                in_range(current) || in_range(previous)
158            })
159            .unwrap_or(true);
160
161        wmi_ok && digit_ok && year_ok
162    }
163}
164
165#[derive(Debug, Clone, Deserialize)]
166pub struct EngineSpec {
167    pub code: String,
168    pub displacement_l: f64,
169    pub cylinders: u8,
170    pub layout: String,
171    pub aspiration: String,
172    pub fuel_type: String,
173    pub fuel_system: Option<String>,
174    pub compression_ratio: Option<f64>,
175    pub max_power_kw: Option<f64>,
176    pub max_torque_nm: Option<f64>,
177    pub redline_rpm: u16,
178    pub idle_rpm_warm: u16,
179    pub idle_rpm_cold: u16,
180    pub firing_order: Option<Vec<u8>>,
181    pub ecm_hardware: Option<String>,
182}
183
184#[derive(Debug, Clone, Deserialize)]
185pub struct TransmissionSpec {
186    pub model: String,
187    pub transmission_type: TransmissionType,
188    pub fluid_capacity_l: Option<f64>,
189}
190
191#[derive(Debug, Clone, Deserialize)]
192#[non_exhaustive]
193pub enum TransmissionType {
194    Geared {
195        speeds: u8,
196        gear_ratios: Vec<(String, f64)>,
197    },
198    Cvt {
199        ratio_range: (f64, f64),
200        simulated_steps: Option<u8>,
201    },
202    Dct {
203        speeds: u8,
204        gear_ratios: Vec<(String, f64)>,
205    },
206    Manual {
207        speeds: u8,
208        gear_ratios: Vec<(String, f64)>,
209    },
210}
211
212#[derive(Debug, Clone, Deserialize)]
213pub struct CommunicationSpec {
214    pub buses: Vec<BusConfig>,
215    pub elm327_protocol_code: Option<String>,
216}
217
218// ── Thresholds ──
219
220#[derive(Debug, Clone, serde::Serialize, Deserialize)]
221pub struct Threshold {
222    pub min: Option<f64>,
223    pub max: Option<f64>,
224    pub warning_low: Option<f64>,
225    pub warning_high: Option<f64>,
226    pub critical_low: Option<f64>,
227    pub critical_high: Option<f64>,
228    pub unit: String,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
232pub enum AlertLevel {
233    Normal,
234    Warning,
235    Critical,
236}
237
238#[derive(Debug, Clone, Copy)]
239pub enum AlertDirection {
240    Low,
241    High,
242}
243
244#[derive(Debug, Clone)]
245pub struct ThresholdResult {
246    pub level: AlertLevel,
247    pub reading: f64,
248    pub limit: f64,
249    pub direction: AlertDirection,
250    pub message: String,
251}
252
253impl Threshold {
254    /// Evaluate a reading against this threshold.
255    pub fn evaluate(&self, value: f64, name: &str) -> Option<ThresholdResult> {
256        // Check critical first (highest priority)
257        if let Some(limit) = self.critical_high {
258            if value >= limit {
259                return Some(ThresholdResult {
260                    level: AlertLevel::Critical,
261                    reading: value,
262                    limit,
263                    direction: AlertDirection::High,
264                    message: format!(
265                        "{} critically high: {:.1} >= {:.1} {}",
266                        name, value, limit, self.unit
267                    ),
268                });
269            }
270        }
271        if let Some(limit) = self.critical_low {
272            if value <= limit {
273                return Some(ThresholdResult {
274                    level: AlertLevel::Critical,
275                    reading: value,
276                    limit,
277                    direction: AlertDirection::Low,
278                    message: format!(
279                        "{} critically low: {:.1} <= {:.1} {}",
280                        name, value, limit, self.unit
281                    ),
282                });
283            }
284        }
285        // Then warning
286        if let Some(limit) = self.warning_high {
287            if value >= limit {
288                return Some(ThresholdResult {
289                    level: AlertLevel::Warning,
290                    reading: value,
291                    limit,
292                    direction: AlertDirection::High,
293                    message: format!(
294                        "{} warning high: {:.1} >= {:.1} {}",
295                        name, value, limit, self.unit
296                    ),
297                });
298            }
299        }
300        if let Some(limit) = self.warning_low {
301            if value <= limit {
302                return Some(ThresholdResult {
303                    level: AlertLevel::Warning,
304                    reading: value,
305                    limit,
306                    direction: AlertDirection::Low,
307                    message: format!(
308                        "{} warning low: {:.1} <= {:.1} {}",
309                        name, value, limit, self.unit
310                    ),
311                });
312            }
313        }
314        None // normal
315    }
316}
317
318#[derive(Debug, Clone, serde::Serialize, Deserialize)]
319pub struct ThresholdSet {
320    #[serde(default)]
321    pub engine: Vec<NamedThreshold>,
322    #[serde(default)]
323    pub transmission: Vec<NamedThreshold>,
324}
325
326#[derive(Debug, Clone, serde::Serialize, Deserialize)]
327pub struct NamedThreshold {
328    pub name: String,
329    pub threshold: Threshold,
330}
331
332// ── Polling Groups ──
333
334#[derive(Debug, Clone, Deserialize)]
335pub struct PollingGroup {
336    pub name: String,
337    pub description: String,
338    pub target_interval_ms: u32,
339    pub steps: Vec<PollStep>,
340}
341
342#[derive(Debug, Clone, Deserialize)]
343pub struct PollStep {
344    pub target: String, // "broadcast" or module id
345    #[serde(default)]
346    pub standard_pids: Vec<u8>, // PID codes
347    #[serde(default)]
348    pub enhanced_pids: Vec<u16>, // DID values
349}
350
351// ── Diagnostic Rules ──
352
353#[derive(Debug, Clone, Deserialize)]
354pub struct DiagnosticRule {
355    pub name: String,
356    pub trigger: RuleTrigger,
357    pub action: RuleAction,
358    pub description: String,
359}
360
361#[derive(Debug, Clone, Deserialize)]
362#[non_exhaustive]
363pub enum RuleTrigger {
364    DtcPresent(String),
365    DtcRange(String, String),
366}
367
368#[derive(Debug, Clone, Deserialize)]
369#[non_exhaustive]
370pub enum RuleAction {
371    QueryModule { module: String, service: u8 },
372    CheckFirst { pid: u16, module: String, reason: String },
373    Alert(String),
374    MonitorPids(Vec<u16>),
375}
376
377// ── Known Issues ──
378
379#[derive(Debug, Clone, Deserialize)]
380pub struct KnownIssue {
381    pub rank: u8,
382    pub name: String,
383    pub description: String,
384    pub symptoms: Vec<String>,
385    pub root_cause: String,
386    pub quick_test: Option<QuickTest>,
387    pub fix: String,
388}
389
390#[derive(Debug, Clone, Deserialize)]
391pub struct QuickTest {
392    pub description: String,
393    pub pass_criteria: String,
394}
395
396// ── Vehicle Profile (resolved identity + spec) ──
397
398#[derive(Debug, Clone)]
399pub struct VehicleProfile {
400    pub vin: String,
401    pub info: Option<crate::protocol::service::VehicleInfo>,
402    pub spec: Option<VehicleSpec>,
403    pub supported_pids: std::collections::HashSet<crate::protocol::pid::Pid>,
404}
405
406// ── DTC Library ──
407
408#[derive(Debug, Clone, Default, Deserialize)]
409pub struct DtcLibrary {
410    #[serde(default)]
411    pub ecm: Vec<DtcEntry>,
412    #[serde(default)]
413    pub tcm: Vec<DtcEntry>,
414    #[serde(default)]
415    pub bcm: Vec<DtcEntry>,
416    #[serde(default)]
417    pub network: Vec<DtcEntry>,
418}
419
420#[derive(Debug, Clone, Deserialize)]
421pub struct DtcEntry {
422    pub code: String,
423    pub meaning: String,
424    pub severity: crate::protocol::dtc::Severity,
425    pub notes: Option<String>,
426    pub related_pids: Option<Vec<u16>>,
427    pub category: Option<String>,
428}
429
430impl DtcLibrary {
431    pub fn lookup(&self, code: &str) -> Option<&DtcEntry> {
432        self.ecm
433            .iter()
434            .chain(self.tcm.iter())
435            .chain(self.bcm.iter())
436            .chain(self.network.iter())
437            .find(|e| e.code == code)
438    }
439}
440
441// ── Spec Registry ──
442
443/// Registry of loaded vehicle specs. Matches specs to vehicles by VIN.
444pub struct SpecRegistry {
445    specs: Vec<VehicleSpec>,
446}
447
448impl Default for SpecRegistry {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454impl SpecRegistry {
455    /// Create an empty registry.
456    pub fn new() -> Self {
457        Self { specs: Vec::new() }
458    }
459
460    /// Create with embedded default specs.
461    pub fn with_defaults() -> Self {
462        let specs = crate::specs::embedded::load_embedded_specs();
463        Self { specs }
464    }
465
466    /// Load a spec from a YAML file.
467    pub fn load_file(&mut self, path: &std::path::Path) -> Result<(), crate::error::Obd2Error> {
468        let spec = loader::load_spec_from_file(path)?;
469        self.specs.push(spec);
470        Ok(())
471    }
472
473    /// Load all YAML specs from a directory.
474    pub fn load_directory(
475        &mut self,
476        dir: &std::path::Path,
477    ) -> Result<usize, crate::error::Obd2Error> {
478        let mut count = 0;
479        if let Ok(entries) = std::fs::read_dir(dir) {
480            for entry in entries.flatten() {
481                let path = entry.path();
482                if path.extension().is_some_and(|e| e == "yaml" || e == "yml")
483                    && self.load_file(&path).is_ok()
484                {
485                    count += 1;
486                }
487            }
488        }
489        Ok(count)
490    }
491
492    /// Match a spec to a VIN using VinMatcher rules.
493    pub fn match_vin(&self, vin: &str) -> Option<&VehicleSpec> {
494        self.specs.iter().find(|s| {
495            s.identity
496                .vin_match
497                .as_ref()
498                .is_some_and(|m| m.matches(vin))
499        })
500    }
501
502    /// Match by make, model, and year.
503    pub fn match_vehicle(&self, make: &str, model: &str, year: u16) -> Option<&VehicleSpec> {
504        self.specs.iter().find(|s| {
505            let year_ok = year >= s.identity.model_years.0 && year <= s.identity.model_years.1;
506            let make_ok = s
507                .identity
508                .makes
509                .iter()
510                .any(|m| m.eq_ignore_ascii_case(make));
511            let model_ok = s
512                .identity
513                .models
514                .iter()
515                .any(|m| m.eq_ignore_ascii_case(model));
516            year_ok && make_ok && model_ok
517        })
518    }
519
520    /// List all loaded specs.
521    pub fn specs(&self) -> &[VehicleSpec] {
522        &self.specs
523    }
524
525    /// Look up a DTC across all loaded spec DTC libraries.
526    pub fn lookup_dtc(&self, code: &str) -> Option<&DtcEntry> {
527        for spec in &self.specs {
528            if let Some(lib) = &spec.dtc_library {
529                if let Some(entry) = lib.lookup(code) {
530                    return Some(entry);
531                }
532            }
533        }
534        None
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_module_id_constants() {
544        let ecm = ModuleId::new(ModuleId::ECM);
545        let also_ecm = ModuleId::new("ecm");
546        assert_eq!(ecm, also_ecm);
547    }
548
549    #[test]
550    fn test_module_id_custom() {
551        let vsa = ModuleId::new("vsa");
552        assert_eq!(vsa.0, "vsa");
553    }
554
555    #[test]
556    fn test_physical_address_variants() {
557        let j1850 = PhysicalAddress::J1850 {
558            node: 0x10,
559            header: [0x6C, 0x10, 0xF1],
560        };
561        let can = PhysicalAddress::Can11Bit {
562            request_id: 0x7E0,
563            response_id: 0x7E8,
564        };
565        assert!(matches!(j1850, PhysicalAddress::J1850 { .. }));
566        assert!(matches!(can, PhysicalAddress::Can11Bit { .. }));
567    }
568
569    #[test]
570    fn test_transmission_type_cvt() {
571        let cvt = TransmissionType::Cvt {
572            ratio_range: (0.4, 2.6),
573            simulated_steps: Some(7),
574        };
575        assert!(matches!(cvt, TransmissionType::Cvt { .. }));
576    }
577
578    #[test]
579    fn test_transmission_type_geared() {
580        let geared = TransmissionType::Geared {
581            speeds: 5,
582            gear_ratios: vec![("1st".into(), 3.10), ("2nd".into(), 1.81)],
583        };
584        assert!(matches!(geared, TransmissionType::Geared { speeds: 5, .. }));
585    }
586
587    #[test]
588    fn test_threshold_evaluate_normal() {
589        let t = Threshold {
590            min: Some(0.0),
591            max: Some(120.0),
592            warning_low: None,
593            warning_high: Some(105.0),
594            critical_low: None,
595            critical_high: Some(115.0),
596            unit: "\u{00B0}C".into(),
597        };
598        assert!(t.evaluate(90.0, "coolant").is_none());
599    }
600
601    #[test]
602    fn test_threshold_evaluate_warning_high() {
603        let t = Threshold {
604            min: Some(0.0),
605            max: Some(120.0),
606            warning_low: None,
607            warning_high: Some(105.0),
608            critical_low: None,
609            critical_high: Some(115.0),
610            unit: "\u{00B0}C".into(),
611        };
612        let result = t.evaluate(110.0, "coolant");
613        assert!(result.is_some());
614        assert_eq!(result.unwrap().level, AlertLevel::Warning);
615    }
616
617    #[test]
618    fn test_threshold_evaluate_critical_high() {
619        let t = Threshold {
620            min: Some(0.0),
621            max: Some(120.0),
622            warning_low: None,
623            warning_high: Some(105.0),
624            critical_low: None,
625            critical_high: Some(115.0),
626            unit: "\u{00B0}C".into(),
627        };
628        let result = t.evaluate(118.0, "coolant");
629        assert!(result.is_some());
630        assert_eq!(result.unwrap().level, AlertLevel::Critical);
631    }
632
633    #[test]
634    fn test_threshold_evaluate_warning_low() {
635        let t = Threshold {
636            min: Some(0.0),
637            max: Some(500.0),
638            warning_low: Some(100.0),
639            warning_high: None,
640            critical_low: Some(70.0),
641            critical_high: None,
642            unit: "kPa".into(),
643        };
644        let result = t.evaluate(90.0, "oil_pressure");
645        assert!(result.is_some());
646        assert_eq!(result.unwrap().level, AlertLevel::Warning);
647    }
648
649    #[test]
650    fn test_threshold_evaluate_critical_low() {
651        let t = Threshold {
652            min: Some(0.0),
653            max: Some(500.0),
654            warning_low: Some(100.0),
655            warning_high: None,
656            critical_low: Some(70.0),
657            critical_high: None,
658            unit: "kPa".into(),
659        };
660        let result = t.evaluate(60.0, "oil_pressure");
661        assert!(result.is_some());
662        assert_eq!(result.unwrap().level, AlertLevel::Critical);
663    }
664
665    #[test]
666    fn test_alert_level_ordering() {
667        assert!(AlertLevel::Critical > AlertLevel::Warning);
668        assert!(AlertLevel::Warning > AlertLevel::Normal);
669    }
670
671    #[test]
672    fn test_vin_matcher_matches() {
673        let matcher = VinMatcher {
674            vin_8th_digit: Some(vec!['2']),
675            wmi_prefixes: vec!["1GC".into()],
676            year_range: None,
677        };
678        assert!(matcher.matches("1GCHK23224F000001")); // WMI=1GC, 8th='2'
679    }
680
681    #[test]
682    fn test_vin_matcher_wrong_digit() {
683        let matcher = VinMatcher {
684            vin_8th_digit: Some(vec!['2']),
685            wmi_prefixes: vec!["1GC".into()],
686            year_range: None,
687        };
688        assert!(!matcher.matches("1GCHK23114F000001")); // 8th='1', not '2'
689    }
690
691    #[test]
692    fn test_vin_matcher_wrong_wmi() {
693        let matcher = VinMatcher {
694            vin_8th_digit: Some(vec!['2']),
695            wmi_prefixes: vec!["1GC".into()],
696            year_range: None,
697        };
698        assert!(!matcher.matches("1FTHK23124F000001")); // WMI=1FT (Ford)
699    }
700
701    #[test]
702    fn test_vin_matcher_year_range_match() {
703        let matcher = VinMatcher {
704            vin_8th_digit: None,
705            wmi_prefixes: vec![],
706            year_range: Some((2004, 2005)),
707        };
708        // 10th char '4' decodes to 2034 (current) or 2004 (previous)
709        // 2004 is within [2004, 2005], so this should match
710        assert!(matcher.matches("1GCHK23124F000001"));
711    }
712
713    #[test]
714    fn test_vin_matcher_year_range_no_match() {
715        let matcher = VinMatcher {
716            vin_8th_digit: None,
717            wmi_prefixes: vec![],
718            year_range: Some((2010, 2012)),
719        };
720        // 10th char '4' decodes to 2034 or 2004 -- neither in [2010, 2012]
721        assert!(!matcher.matches("1GCHK23124F000001"));
722    }
723
724    #[test]
725    fn test_vin_matcher_year_range_current_cycle() {
726        let matcher = VinMatcher {
727            vin_8th_digit: None,
728            wmi_prefixes: vec![],
729            year_range: Some((2018, 2020)),
730        };
731        // 10th char 'L' = 2020 (current cycle) -- matches
732        assert!(matcher.matches("1GCHK2312LF000001"));
733    }
734
735    #[test]
736    fn test_vin_matcher_year_range_combined_check() {
737        // Full matcher: WMI + 8th digit + year range all must pass
738        let matcher = VinMatcher {
739            vin_8th_digit: Some(vec!['2']),
740            wmi_prefixes: vec!["1GC".into()],
741            year_range: Some((2004, 2005)),
742        };
743        // This is the Duramax VIN: WMI=1GC, 8th='2', year='4'(2004) -- all match
744        assert!(matcher.matches("1GCHK23224F000001"));
745    }
746
747    #[test]
748    fn test_vin_matcher_year_range_fails_other_check() {
749        let matcher = VinMatcher {
750            vin_8th_digit: Some(vec!['9']), // wrong digit
751            wmi_prefixes: vec!["1GC".into()],
752            year_range: Some((2004, 2005)),
753        };
754        // Year matches but 8th digit doesn't
755        assert!(!matcher.matches("1GCHK23224F000001"));
756    }
757
758    #[test]
759    fn test_vin_matcher_short_vin() {
760        let matcher = VinMatcher {
761            vin_8th_digit: None,
762            wmi_prefixes: vec![],
763            year_range: None,
764        };
765        assert!(!matcher.matches("SHORT")); // < 17 chars
766    }
767
768    #[test]
769    fn test_dtc_library_lookup() {
770        let lib = DtcLibrary {
771            ecm: vec![DtcEntry {
772                code: "P0087".into(),
773                meaning: "Fuel Rail Pressure Too Low".into(),
774                severity: crate::protocol::dtc::Severity::Critical,
775                notes: None,
776                related_pids: None,
777                category: None,
778            }],
779            tcm: vec![],
780            bcm: vec![],
781            network: vec![],
782        };
783        assert!(lib.lookup("P0087").is_some());
784        assert_eq!(
785            lib.lookup("P0087").unwrap().meaning,
786            "Fuel Rail Pressure Too Low"
787        );
788        assert!(lib.lookup("P9999").is_none());
789    }
790
791    #[test]
792    fn test_load_embedded_duramax() {
793        let registry = SpecRegistry::with_defaults();
794        assert!(
795            !registry.specs().is_empty(),
796            "should have at least one embedded spec"
797        );
798        let spec = &registry.specs()[0];
799        assert_eq!(spec.identity.engine.code, "LLY");
800    }
801
802    #[test]
803    fn test_registry_match_vin_duramax() {
804        let registry = SpecRegistry::with_defaults();
805        let matched = registry.match_vin("1GCHK23224F000001");
806        assert!(matched.is_some(), "should match Duramax by VIN");
807        assert_eq!(matched.unwrap().identity.engine.code, "LLY");
808    }
809
810    #[test]
811    fn test_registry_no_match() {
812        let registry = SpecRegistry::with_defaults();
813        let matched = registry.match_vin("JH4KA7660PC000001");
814        assert!(matched.is_none(), "Acura should not match any spec");
815    }
816
817    #[test]
818    fn test_registry_match_vehicle() {
819        let registry = SpecRegistry::with_defaults();
820        let matched = registry.match_vehicle("Chevrolet", "Silverado 2500HD", 2004);
821        assert!(matched.is_some());
822    }
823
824    #[test]
825    fn test_load_from_str_invalid() {
826        let result = loader::load_spec_from_str("not: [valid: yaml: spec");
827        assert!(result.is_err());
828    }
829
830    #[test]
831    fn test_spec_has_thresholds() {
832        let registry = SpecRegistry::with_defaults();
833        let spec = &registry.specs()[0];
834        assert!(
835            spec.thresholds.is_some(),
836            "Duramax spec should have thresholds"
837        );
838    }
839
840    #[test]
841    fn test_spec_has_known_issues() {
842        let registry = SpecRegistry::with_defaults();
843        let spec = &registry.specs()[0];
844        assert!(
845            !spec.known_issues.is_empty(),
846            "Duramax spec should have known issues"
847        );
848    }
849}