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    /// Offline VIN decode: manufacturer, year, vehicle class.
402    /// Always populated after `identify_vehicle()`.
403    pub decoded_vin: Option<vin::DecodedVin>,
404    pub info: Option<crate::protocol::service::VehicleInfo>,
405    pub spec: Option<VehicleSpec>,
406    pub supported_pids: std::collections::HashSet<crate::protocol::pid::Pid>,
407}
408
409// ── DTC Library ──
410
411#[derive(Debug, Clone, Default, Deserialize)]
412pub struct DtcLibrary {
413    #[serde(default)]
414    pub ecm: Vec<DtcEntry>,
415    #[serde(default)]
416    pub tcm: Vec<DtcEntry>,
417    #[serde(default)]
418    pub bcm: Vec<DtcEntry>,
419    #[serde(default)]
420    pub network: Vec<DtcEntry>,
421}
422
423#[derive(Debug, Clone, Deserialize)]
424pub struct DtcEntry {
425    pub code: String,
426    pub meaning: String,
427    pub severity: crate::protocol::dtc::Severity,
428    pub notes: Option<String>,
429    pub related_pids: Option<Vec<u16>>,
430    pub category: Option<String>,
431}
432
433impl DtcLibrary {
434    pub fn lookup(&self, code: &str) -> Option<&DtcEntry> {
435        self.ecm
436            .iter()
437            .chain(self.tcm.iter())
438            .chain(self.bcm.iter())
439            .chain(self.network.iter())
440            .find(|e| e.code == code)
441    }
442}
443
444// ── Spec Registry ──
445
446/// Registry of loaded vehicle specs. Matches specs to vehicles by VIN.
447pub struct SpecRegistry {
448    specs: Vec<VehicleSpec>,
449}
450
451impl Default for SpecRegistry {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457impl SpecRegistry {
458    /// Create an empty registry.
459    pub fn new() -> Self {
460        Self { specs: Vec::new() }
461    }
462
463    /// Create with embedded default specs.
464    pub fn with_defaults() -> Self {
465        let specs = crate::specs::embedded::load_embedded_specs();
466        Self { specs }
467    }
468
469    /// Load a spec from a YAML file.
470    pub fn load_file(&mut self, path: &std::path::Path) -> Result<(), crate::error::Obd2Error> {
471        let spec = loader::load_spec_from_file(path)?;
472        self.specs.push(spec);
473        Ok(())
474    }
475
476    /// Load all YAML specs from a directory.
477    pub fn load_directory(
478        &mut self,
479        dir: &std::path::Path,
480    ) -> Result<usize, crate::error::Obd2Error> {
481        let mut count = 0;
482        if let Ok(entries) = std::fs::read_dir(dir) {
483            for entry in entries.flatten() {
484                let path = entry.path();
485                if path.extension().is_some_and(|e| e == "yaml" || e == "yml")
486                    && self.load_file(&path).is_ok()
487                {
488                    count += 1;
489                }
490            }
491        }
492        Ok(count)
493    }
494
495    /// Match a spec to a VIN using VinMatcher rules.
496    pub fn match_vin(&self, vin: &str) -> Option<&VehicleSpec> {
497        self.specs.iter().find(|s| {
498            s.identity
499                .vin_match
500                .as_ref()
501                .is_some_and(|m| m.matches(vin))
502        })
503    }
504
505    /// Match by make, model, and year.
506    pub fn match_vehicle(&self, make: &str, model: &str, year: u16) -> Option<&VehicleSpec> {
507        self.specs.iter().find(|s| {
508            let year_ok = year >= s.identity.model_years.0 && year <= s.identity.model_years.1;
509            let make_ok = s
510                .identity
511                .makes
512                .iter()
513                .any(|m| m.eq_ignore_ascii_case(make));
514            let model_ok = s
515                .identity
516                .models
517                .iter()
518                .any(|m| m.eq_ignore_ascii_case(model));
519            year_ok && make_ok && model_ok
520        })
521    }
522
523    /// List all loaded specs.
524    pub fn specs(&self) -> &[VehicleSpec] {
525        &self.specs
526    }
527
528    /// Look up a DTC across all loaded spec DTC libraries.
529    pub fn lookup_dtc(&self, code: &str) -> Option<&DtcEntry> {
530        for spec in &self.specs {
531            if let Some(lib) = &spec.dtc_library {
532                if let Some(entry) = lib.lookup(code) {
533                    return Some(entry);
534                }
535            }
536        }
537        None
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_module_id_constants() {
547        let ecm = ModuleId::new(ModuleId::ECM);
548        let also_ecm = ModuleId::new("ecm");
549        assert_eq!(ecm, also_ecm);
550    }
551
552    #[test]
553    fn test_module_id_custom() {
554        let vsa = ModuleId::new("vsa");
555        assert_eq!(vsa.0, "vsa");
556    }
557
558    #[test]
559    fn test_physical_address_variants() {
560        let j1850 = PhysicalAddress::J1850 {
561            node: 0x10,
562            header: [0x6C, 0x10, 0xF1],
563        };
564        let can = PhysicalAddress::Can11Bit {
565            request_id: 0x7E0,
566            response_id: 0x7E8,
567        };
568        assert!(matches!(j1850, PhysicalAddress::J1850 { .. }));
569        assert!(matches!(can, PhysicalAddress::Can11Bit { .. }));
570    }
571
572    #[test]
573    fn test_transmission_type_cvt() {
574        let cvt = TransmissionType::Cvt {
575            ratio_range: (0.4, 2.6),
576            simulated_steps: Some(7),
577        };
578        assert!(matches!(cvt, TransmissionType::Cvt { .. }));
579    }
580
581    #[test]
582    fn test_transmission_type_geared() {
583        let geared = TransmissionType::Geared {
584            speeds: 5,
585            gear_ratios: vec![("1st".into(), 3.10), ("2nd".into(), 1.81)],
586        };
587        assert!(matches!(geared, TransmissionType::Geared { speeds: 5, .. }));
588    }
589
590    #[test]
591    fn test_threshold_evaluate_normal() {
592        let t = Threshold {
593            min: Some(0.0),
594            max: Some(120.0),
595            warning_low: None,
596            warning_high: Some(105.0),
597            critical_low: None,
598            critical_high: Some(115.0),
599            unit: "\u{00B0}C".into(),
600        };
601        assert!(t.evaluate(90.0, "coolant").is_none());
602    }
603
604    #[test]
605    fn test_threshold_evaluate_warning_high() {
606        let t = Threshold {
607            min: Some(0.0),
608            max: Some(120.0),
609            warning_low: None,
610            warning_high: Some(105.0),
611            critical_low: None,
612            critical_high: Some(115.0),
613            unit: "\u{00B0}C".into(),
614        };
615        let result = t.evaluate(110.0, "coolant");
616        assert!(result.is_some());
617        assert_eq!(result.unwrap().level, AlertLevel::Warning);
618    }
619
620    #[test]
621    fn test_threshold_evaluate_critical_high() {
622        let t = Threshold {
623            min: Some(0.0),
624            max: Some(120.0),
625            warning_low: None,
626            warning_high: Some(105.0),
627            critical_low: None,
628            critical_high: Some(115.0),
629            unit: "\u{00B0}C".into(),
630        };
631        let result = t.evaluate(118.0, "coolant");
632        assert!(result.is_some());
633        assert_eq!(result.unwrap().level, AlertLevel::Critical);
634    }
635
636    #[test]
637    fn test_threshold_evaluate_warning_low() {
638        let t = Threshold {
639            min: Some(0.0),
640            max: Some(500.0),
641            warning_low: Some(100.0),
642            warning_high: None,
643            critical_low: Some(70.0),
644            critical_high: None,
645            unit: "kPa".into(),
646        };
647        let result = t.evaluate(90.0, "oil_pressure");
648        assert!(result.is_some());
649        assert_eq!(result.unwrap().level, AlertLevel::Warning);
650    }
651
652    #[test]
653    fn test_threshold_evaluate_critical_low() {
654        let t = Threshold {
655            min: Some(0.0),
656            max: Some(500.0),
657            warning_low: Some(100.0),
658            warning_high: None,
659            critical_low: Some(70.0),
660            critical_high: None,
661            unit: "kPa".into(),
662        };
663        let result = t.evaluate(60.0, "oil_pressure");
664        assert!(result.is_some());
665        assert_eq!(result.unwrap().level, AlertLevel::Critical);
666    }
667
668    #[test]
669    fn test_alert_level_ordering() {
670        assert!(AlertLevel::Critical > AlertLevel::Warning);
671        assert!(AlertLevel::Warning > AlertLevel::Normal);
672    }
673
674    #[test]
675    fn test_vin_matcher_matches() {
676        let matcher = VinMatcher {
677            vin_8th_digit: Some(vec!['2']),
678            wmi_prefixes: vec!["1GC".into()],
679            year_range: None,
680        };
681        assert!(matcher.matches("1GCHK23224F000001")); // WMI=1GC, 8th='2'
682    }
683
684    #[test]
685    fn test_vin_matcher_wrong_digit() {
686        let matcher = VinMatcher {
687            vin_8th_digit: Some(vec!['2']),
688            wmi_prefixes: vec!["1GC".into()],
689            year_range: None,
690        };
691        assert!(!matcher.matches("1GCHK23114F000001")); // 8th='1', not '2'
692    }
693
694    #[test]
695    fn test_vin_matcher_wrong_wmi() {
696        let matcher = VinMatcher {
697            vin_8th_digit: Some(vec!['2']),
698            wmi_prefixes: vec!["1GC".into()],
699            year_range: None,
700        };
701        assert!(!matcher.matches("1FTHK23124F000001")); // WMI=1FT (Ford)
702    }
703
704    #[test]
705    fn test_vin_matcher_year_range_match() {
706        let matcher = VinMatcher {
707            vin_8th_digit: None,
708            wmi_prefixes: vec![],
709            year_range: Some((2004, 2005)),
710        };
711        // 10th char '4' decodes to 2034 (current) or 2004 (previous)
712        // 2004 is within [2004, 2005], so this should match
713        assert!(matcher.matches("1GCHK23124F000001"));
714    }
715
716    #[test]
717    fn test_vin_matcher_year_range_no_match() {
718        let matcher = VinMatcher {
719            vin_8th_digit: None,
720            wmi_prefixes: vec![],
721            year_range: Some((2010, 2012)),
722        };
723        // 10th char '4' decodes to 2034 or 2004 -- neither in [2010, 2012]
724        assert!(!matcher.matches("1GCHK23124F000001"));
725    }
726
727    #[test]
728    fn test_vin_matcher_year_range_current_cycle() {
729        let matcher = VinMatcher {
730            vin_8th_digit: None,
731            wmi_prefixes: vec![],
732            year_range: Some((2018, 2020)),
733        };
734        // 10th char 'L' = 2020 (current cycle) -- matches
735        assert!(matcher.matches("1GCHK2312LF000001"));
736    }
737
738    #[test]
739    fn test_vin_matcher_year_range_combined_check() {
740        // Full matcher: WMI + 8th digit + year range all must pass
741        let matcher = VinMatcher {
742            vin_8th_digit: Some(vec!['2']),
743            wmi_prefixes: vec!["1GC".into()],
744            year_range: Some((2004, 2005)),
745        };
746        // This is the Duramax VIN: WMI=1GC, 8th='2', year='4'(2004) -- all match
747        assert!(matcher.matches("1GCHK23224F000001"));
748    }
749
750    #[test]
751    fn test_vin_matcher_year_range_fails_other_check() {
752        let matcher = VinMatcher {
753            vin_8th_digit: Some(vec!['9']), // wrong digit
754            wmi_prefixes: vec!["1GC".into()],
755            year_range: Some((2004, 2005)),
756        };
757        // Year matches but 8th digit doesn't
758        assert!(!matcher.matches("1GCHK23224F000001"));
759    }
760
761    #[test]
762    fn test_vin_matcher_short_vin() {
763        let matcher = VinMatcher {
764            vin_8th_digit: None,
765            wmi_prefixes: vec![],
766            year_range: None,
767        };
768        assert!(!matcher.matches("SHORT")); // < 17 chars
769    }
770
771    #[test]
772    fn test_dtc_library_lookup() {
773        let lib = DtcLibrary {
774            ecm: vec![DtcEntry {
775                code: "P0087".into(),
776                meaning: "Fuel Rail Pressure Too Low".into(),
777                severity: crate::protocol::dtc::Severity::Critical,
778                notes: None,
779                related_pids: None,
780                category: None,
781            }],
782            tcm: vec![],
783            bcm: vec![],
784            network: vec![],
785        };
786        assert!(lib.lookup("P0087").is_some());
787        assert_eq!(
788            lib.lookup("P0087").unwrap().meaning,
789            "Fuel Rail Pressure Too Low"
790        );
791        assert!(lib.lookup("P9999").is_none());
792    }
793
794    #[test]
795    fn test_load_embedded_duramax() {
796        let registry = SpecRegistry::with_defaults();
797        assert!(
798            !registry.specs().is_empty(),
799            "should have at least one embedded spec"
800        );
801        let spec = &registry.specs()[0];
802        assert_eq!(spec.identity.engine.code, "LLY");
803    }
804
805    #[test]
806    fn test_registry_match_vin_duramax() {
807        let registry = SpecRegistry::with_defaults();
808        let matched = registry.match_vin("1GCHK23224F000001");
809        assert!(matched.is_some(), "should match Duramax by VIN");
810        assert_eq!(matched.unwrap().identity.engine.code, "LLY");
811    }
812
813    #[test]
814    fn test_registry_no_match() {
815        let registry = SpecRegistry::with_defaults();
816        let matched = registry.match_vin("JH4KA7660PC000001");
817        assert!(matched.is_none(), "Acura should not match any spec");
818    }
819
820    #[test]
821    fn test_registry_match_vehicle() {
822        let registry = SpecRegistry::with_defaults();
823        let matched = registry.match_vehicle("Chevrolet", "Silverado 2500HD", 2004);
824        assert!(matched.is_some());
825    }
826
827    #[test]
828    fn test_load_from_str_invalid() {
829        let result = loader::load_spec_from_str("not: [valid: yaml: spec");
830        assert!(result.is_err());
831    }
832
833    #[test]
834    fn test_spec_has_thresholds() {
835        let registry = SpecRegistry::with_defaults();
836        let spec = &registry.specs()[0];
837        assert!(
838            spec.thresholds.is_some(),
839            "Duramax spec should have thresholds"
840        );
841    }
842
843    #[test]
844    fn test_spec_has_known_issues() {
845        let registry = SpecRegistry::with_defaults();
846        let spec = &registry.specs()[0];
847        assert!(
848            !spec.known_issues.is_empty(),
849            "Duramax spec should have known issues"
850        );
851    }
852}