Skip to main content

spvirit_server/
db.rs

1use std::collections::HashMap;
2use std::fs;
3use std::time::Duration;
4
5use regex::Regex;
6
7use crate::types::{
8    DbCommonState, LinkExpr, NtScalar, NtScalarArray, OutputMode, RecordData, RecordInstance,
9    RecordType, ScalarArrayValue, ScalarValue, ScanMode,
10};
11
12#[derive(Debug, Clone)]
13pub struct DbRecord {
14    pub name: String,
15    pub record_type: String,
16    pub fields: HashMap<String, String>,
17}
18
19fn parse_bool(value: &str) -> Option<bool> {
20    match value.trim().to_ascii_lowercase().as_str() {
21        "1" | "true" | "yes" | "on" => Some(true),
22        "0" | "false" | "no" | "off" => Some(false),
23        _ => None,
24    }
25}
26
27fn parse_f64(value: &str) -> Option<f64> {
28    value.trim().parse::<f64>().ok()
29}
30
31fn parse_i32(value: &str) -> Option<i32> {
32    value.trim().parse::<i32>().ok()
33}
34
35fn parse_usize(value: &str) -> Option<usize> {
36    value.trim().parse::<usize>().ok()
37}
38
39fn parse_link_expr(raw: &str) -> Option<LinkExpr> {
40    let trimmed = raw.trim();
41    if trimmed.is_empty() {
42        return None;
43    }
44
45    let parts: Vec<&str> = trimmed.split_whitespace().collect();
46    if parts.is_empty() {
47        return None;
48    }
49
50    let mut process_passive = false;
51    let mut maximize_severity = false;
52    let mut only_link_opts = parts.len() > 1;
53    for opt in parts.iter().skip(1) {
54        match opt.to_ascii_uppercase().as_str() {
55            "PP" => process_passive = true,
56            "NPP" => {}
57            "MS" | "MSS" | "MSI" => maximize_severity = true,
58            "NMS" => {}
59            _ => only_link_opts = false,
60        }
61    }
62    if only_link_opts {
63        return Some(LinkExpr::DbLink {
64            target: parts[0].to_string(),
65            process_passive,
66            maximize_severity,
67        });
68    }
69
70    if parts.len() == 1 {
71        if let Some(v) = parse_bool(trimmed) {
72            return Some(LinkExpr::Constant(ScalarValue::Bool(v)));
73        }
74        if let Some(v) = parse_i32(trimmed) {
75            return Some(LinkExpr::Constant(ScalarValue::I32(v)));
76        }
77        if let Some(v) = parse_f64(trimmed) {
78            return Some(LinkExpr::Constant(ScalarValue::F64(v)));
79        }
80        return Some(LinkExpr::DbLink {
81            target: trimmed.to_string(),
82            process_passive: false,
83            maximize_severity: false,
84        });
85    }
86
87    Some(LinkExpr::DbLink {
88        target: trimmed.to_string(),
89        process_passive: false,
90        maximize_severity: false,
91    })
92}
93
94fn parse_scan_period(raw: &str) -> Option<Duration> {
95    let first = raw.split_whitespace().next()?;
96    let secs = first.parse::<f64>().ok()?;
97    if secs > 0.0 {
98        Some(Duration::from_secs_f64(secs))
99    } else {
100        None
101    }
102}
103
104fn parse_scan_mode(record_name: &str, fields: &HashMap<String, String>) -> ScanMode {
105    let raw = fields
106        .get("SCAN")
107        .map(|v| v.trim())
108        .filter(|v| !v.is_empty())
109        .unwrap_or("Passive");
110    let lowered = raw.to_ascii_lowercase();
111    if lowered == "passive" {
112        return ScanMode::Passive;
113    }
114    if lowered.contains("i/o") || lowered.contains("io intr") {
115        let source = fields
116            .get("IOSCAN")
117            .cloned()
118            .filter(|v| !v.trim().is_empty())
119            .unwrap_or_else(|| record_name.to_string());
120        return ScanMode::IoEvent(source);
121    }
122    if lowered.starts_with("event") {
123        let source = fields
124            .get("EVNT")
125            .cloned()
126            .filter(|v| !v.trim().is_empty())
127            .or_else(|| raw.split_whitespace().nth(1).map(|v| v.to_string()))
128            .unwrap_or_else(|| record_name.to_string());
129        return ScanMode::Event(source);
130    }
131    if let Some(period) = parse_scan_period(raw) {
132        return ScanMode::Periodic(period);
133    }
134    ScanMode::Passive
135}
136
137fn parse_output_mode(value: Option<&String>) -> OutputMode {
138    let lowered = value
139        .map(|v| v.trim().to_ascii_lowercase())
140        .unwrap_or_else(|| "supervisory".to_string());
141    if lowered.contains("closed") {
142        OutputMode::ClosedLoop
143    } else {
144        OutputMode::Supervisory
145    }
146}
147
148fn split_array_tokens(raw: &str) -> Vec<&str> {
149    raw.split(|c: char| c == ',' || c.is_whitespace())
150        .map(str::trim)
151        .filter(|s| !s.is_empty())
152        .collect()
153}
154
155fn parse_scalar_array(raw: Option<&String>, ftvl: &str, nelm: Option<usize>) -> ScalarArrayValue {
156    let tokens = raw.map_or_else(Vec::new, |v| split_array_tokens(v));
157    let cap = nelm.unwrap_or(tokens.len());
158    let count = if cap == 0 { tokens.len() } else { cap };
159    let type_name = ftvl.trim().to_ascii_uppercase();
160
161    let parse_bool_vec = || -> Vec<bool> {
162        let mut out = Vec::new();
163        for tok in &tokens {
164            let lowered = tok.to_ascii_lowercase();
165            let val = matches!(lowered.as_str(), "1" | "true" | "yes" | "on");
166            out.push(val);
167        }
168        out
169    };
170    let parse_i8_vec = || -> Vec<i8> {
171        let mut out = Vec::new();
172        for tok in &tokens {
173            if let Ok(v) = tok.parse::<i8>() {
174                out.push(v);
175            }
176        }
177        out
178    };
179    let parse_i16_vec = || -> Vec<i16> {
180        let mut out = Vec::new();
181        for tok in &tokens {
182            if let Ok(v) = tok.parse::<i16>() {
183                out.push(v);
184            }
185        }
186        out
187    };
188    let parse_i32_vec = || -> Vec<i32> {
189        let mut out = Vec::new();
190        for tok in &tokens {
191            if let Ok(v) = tok.parse::<i32>() {
192                out.push(v);
193            }
194        }
195        out
196    };
197    let parse_i64_vec = || -> Vec<i64> {
198        let mut out = Vec::new();
199        for tok in &tokens {
200            if let Ok(v) = tok.parse::<i64>() {
201                out.push(v);
202            }
203        }
204        out
205    };
206    let parse_u8_vec = || -> Vec<u8> {
207        let mut out = Vec::new();
208        for tok in &tokens {
209            if let Ok(v) = tok.parse::<u8>() {
210                out.push(v);
211            }
212        }
213        out
214    };
215    let parse_u16_vec = || -> Vec<u16> {
216        let mut out = Vec::new();
217        for tok in &tokens {
218            if let Ok(v) = tok.parse::<u16>() {
219                out.push(v);
220            }
221        }
222        out
223    };
224    let parse_u32_vec = || -> Vec<u32> {
225        let mut out = Vec::new();
226        for tok in &tokens {
227            if let Ok(v) = tok.parse::<u32>() {
228                out.push(v);
229            }
230        }
231        out
232    };
233    let parse_u64_vec = || -> Vec<u64> {
234        let mut out = Vec::new();
235        for tok in &tokens {
236            if let Ok(v) = tok.parse::<u64>() {
237                out.push(v);
238            }
239        }
240        out
241    };
242    let parse_f32_vec = || -> Vec<f32> {
243        let mut out = Vec::new();
244        for tok in &tokens {
245            if let Ok(v) = tok.parse::<f32>() {
246                out.push(v);
247            }
248        }
249        out
250    };
251    let parse_f64_vec = || -> Vec<f64> {
252        let mut out = Vec::new();
253        for tok in &tokens {
254            if let Ok(v) = tok.parse::<f64>() {
255                out.push(v);
256            }
257        }
258        out
259    };
260
261    let mut parsed = match type_name.as_str() {
262        "BOOL" | "BOOLEAN" => ScalarArrayValue::Bool(parse_bool_vec()),
263        "CHAR" | "INT8" => ScalarArrayValue::I8(parse_i8_vec()),
264        "SHORT" | "INT16" => ScalarArrayValue::I16(parse_i16_vec()),
265        "LONG" | "INT" | "INT32" => ScalarArrayValue::I32(parse_i32_vec()),
266        "INT64" => ScalarArrayValue::I64(parse_i64_vec()),
267        "UCHAR" | "UINT8" => ScalarArrayValue::U8(parse_u8_vec()),
268        "USHORT" | "UINT16" => ScalarArrayValue::U16(parse_u16_vec()),
269        "ULONG" | "UINT32" => ScalarArrayValue::U32(parse_u32_vec()),
270        "UINT64" => ScalarArrayValue::U64(parse_u64_vec()),
271        "FLOAT" | "FLOAT32" => ScalarArrayValue::F32(parse_f32_vec()),
272        "STRING" => ScalarArrayValue::Str(raw.map_or_else(Vec::new, |v| {
273            v.split(',')
274                .map(str::trim)
275                .filter(|s| !s.is_empty())
276                .map(ToOwned::to_owned)
277                .collect()
278        })),
279        _ => ScalarArrayValue::F64(parse_f64_vec()),
280    };
281
282    if count > 0 {
283        match &mut parsed {
284            ScalarArrayValue::Bool(v) => v.truncate(count),
285            ScalarArrayValue::I8(v) => v.truncate(count),
286            ScalarArrayValue::I16(v) => v.truncate(count),
287            ScalarArrayValue::I32(v) => v.truncate(count),
288            ScalarArrayValue::I64(v) => v.truncate(count),
289            ScalarArrayValue::U8(v) => v.truncate(count),
290            ScalarArrayValue::U16(v) => v.truncate(count),
291            ScalarArrayValue::U32(v) => v.truncate(count),
292            ScalarArrayValue::U64(v) => v.truncate(count),
293            ScalarArrayValue::F32(v) => v.truncate(count),
294            ScalarArrayValue::F64(v) => v.truncate(count),
295            ScalarArrayValue::Str(v) => v.truncate(count),
296        }
297    }
298
299    parsed
300}
301
302fn parse_simm(fields: &HashMap<String, String>) -> bool {
303    let Some(raw) = fields.get("SIMM") else {
304        return false;
305    };
306    let lowered = raw.trim().to_ascii_lowercase();
307    match lowered.as_str() {
308        "yes" | "true" | "on" | "1" | "raw" | "2" => true,
309        "no" | "false" | "off" | "0" => false,
310        _ => false,
311    }
312}
313
314fn parse_ntscalar(record: &DbRecord) -> Option<NtScalar> {
315    let rtype = RecordType::from_db_name(&record.record_type)?;
316    let fields = &record.fields;
317    let description = fields.get("DESC").cloned().unwrap_or_default();
318
319    let nt = match rtype {
320        RecordType::Ai | RecordType::Ao => {
321            let val = fields.get("VAL").and_then(|v| parse_f64(v)).unwrap_or(0.0);
322            NtScalar::from_value(ScalarValue::F64(val))
323        }
324        RecordType::Bi | RecordType::Bo => {
325            let val = fields
326                .get("VAL")
327                .and_then(|v| parse_bool(v))
328                .unwrap_or(false);
329            NtScalar::from_value(ScalarValue::Bool(val))
330        }
331        RecordType::StringIn | RecordType::StringOut => {
332            let val = fields.get("VAL").cloned().unwrap_or_default();
333            NtScalar::from_value(ScalarValue::Str(val))
334        }
335        _ => return None,
336    };
337
338    let nt = nt.with_description(description);
339
340    // EGU, HOPR, LOPR, PREC, and alarm limits (HIHI/HIGH/LOW/LOLO) are only
341    // valid for analog record types (ai, ao) per EPICS Base specification.
342    // bi/bo use ZNAM/ONAM/ZSV/OSV for state alarms; stringin/stringout have
343    // no display or alarm limit fields.
344    let nt = match rtype {
345        RecordType::Ai | RecordType::Ao => {
346            let units = fields.get("EGU").cloned().unwrap_or_default();
347            let precision = fields
348                .get("PREC")
349                .and_then(|v| v.trim().parse::<i32>().ok())
350                .unwrap_or(0);
351            let low = fields.get("LOPR").and_then(|v| parse_f64(v)).unwrap_or(0.0);
352            let high = fields.get("HOPR").and_then(|v| parse_f64(v)).unwrap_or(0.0);
353            let alarm_low = fields.get("LOW").and_then(|v| parse_f64(v));
354            let alarm_high = fields.get("HIGH").and_then(|v| parse_f64(v));
355            let alarm_lolo = fields.get("LOLO").and_then(|v| parse_f64(v));
356            let alarm_hihi = fields.get("HIHI").and_then(|v| parse_f64(v));
357            nt.with_limits(low, high)
358                .with_units(units)
359                .with_precision(precision)
360                .with_alarm_limits(alarm_low, alarm_high, alarm_lolo, alarm_hihi)
361        }
362        _ => nt,
363    };
364
365    Some(nt)
366}
367
368fn to_record(record: &DbRecord) -> Option<RecordInstance> {
369    let record_type = RecordType::from_db_name(&record.record_type)?;
370    let fields = &record.fields;
371
372    let common = DbCommonState {
373        desc: fields.get("DESC").cloned().unwrap_or_default(),
374        scan: parse_scan_mode(&record.name, fields),
375        pini: fields
376            .get("PINI")
377            .and_then(|v| parse_bool(v))
378            .unwrap_or(false),
379        phas: fields.get("PHAS").and_then(|v| parse_i32(v)).unwrap_or(0),
380        pact: false,
381        disa: fields
382            .get("DISA")
383            .and_then(|v| parse_bool(v))
384            .unwrap_or(false),
385        sdis: fields.get("SDIS").and_then(|v| parse_link_expr(v)),
386        diss: fields.get("DISS").and_then(|v| parse_i32(v)).unwrap_or(0),
387        flnk: fields.get("FLNK").and_then(|v| parse_link_expr(v)),
388    };
389
390    let simm = parse_simm(fields);
391    let siml = fields.get("SIML").and_then(|v| parse_link_expr(v));
392    let siol = fields.get("SIOL").and_then(|v| parse_link_expr(v));
393
394    let data = match record_type {
395        RecordType::Ai => RecordData::Ai {
396            nt: parse_ntscalar(record)?,
397            inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
398            siml,
399            siol,
400            simm,
401        },
402        RecordType::Ao => RecordData::Ao {
403            nt: parse_ntscalar(record)?,
404            out: fields.get("OUT").and_then(|v| parse_link_expr(v)),
405            dol: fields.get("DOL").and_then(|v| parse_link_expr(v)),
406            omsl: parse_output_mode(fields.get("OMSL")),
407            drvl: fields.get("DRVL").and_then(|v| parse_f64(v)),
408            drvh: fields.get("DRVH").and_then(|v| parse_f64(v)),
409            oroc: fields.get("OROC").and_then(|v| parse_f64(v)),
410            siml,
411            siol,
412            simm,
413        },
414        RecordType::Bi => RecordData::Bi {
415            nt: parse_ntscalar(record)?,
416            inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
417            znam: fields
418                .get("ZNAM")
419                .cloned()
420                .unwrap_or_else(|| "OFF".to_string()),
421            onam: fields
422                .get("ONAM")
423                .cloned()
424                .unwrap_or_else(|| "ON".to_string()),
425            siml,
426            siol,
427            simm,
428        },
429        RecordType::Bo => RecordData::Bo {
430            nt: parse_ntscalar(record)?,
431            out: fields.get("OUT").and_then(|v| parse_link_expr(v)),
432            dol: fields.get("DOL").and_then(|v| parse_link_expr(v)),
433            omsl: parse_output_mode(fields.get("OMSL")),
434            znam: fields
435                .get("ZNAM")
436                .cloned()
437                .unwrap_or_else(|| "OFF".to_string()),
438            onam: fields
439                .get("ONAM")
440                .cloned()
441                .unwrap_or_else(|| "ON".to_string()),
442            siml,
443            siol,
444            simm,
445        },
446        RecordType::StringIn => RecordData::StringIn {
447            nt: parse_ntscalar(record)?,
448            inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
449            siml,
450            siol,
451            simm,
452        },
453        RecordType::StringOut => RecordData::StringOut {
454            nt: parse_ntscalar(record)?,
455            out: fields.get("OUT").and_then(|v| parse_link_expr(v)),
456            dol: fields.get("DOL").and_then(|v| parse_link_expr(v)),
457            omsl: parse_output_mode(fields.get("OMSL")),
458            siml,
459            siol,
460            simm,
461        },
462        RecordType::Waveform => {
463            let ftvl = fields
464                .get("FTVL")
465                .cloned()
466                .unwrap_or_else(|| "DOUBLE".to_string());
467            let nelm = fields.get("NELM").and_then(|v| parse_usize(v));
468            let array = parse_scalar_array(fields.get("VAL"), &ftvl, nelm);
469            RecordData::Waveform {
470                nt: NtScalarArray::from_value(array),
471                inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
472                ftvl,
473                nelm: nelm.unwrap_or(0),
474                nord: fields
475                    .get("NORD")
476                    .and_then(|v| parse_usize(v))
477                    .unwrap_or_else(|| {
478                        fields.get("NELM").and_then(|v| parse_usize(v)).unwrap_or(0)
479                    }),
480            }
481        }
482        RecordType::Aai => {
483            let ftvl = fields
484                .get("FTVL")
485                .cloned()
486                .unwrap_or_else(|| "DOUBLE".to_string());
487            let nelm = fields.get("NELM").and_then(|v| parse_usize(v));
488            let array = parse_scalar_array(fields.get("VAL"), &ftvl, nelm);
489            RecordData::Aai {
490                nt: NtScalarArray::from_value(array),
491                inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
492                ftvl,
493                nelm: nelm.unwrap_or(0),
494                nord: fields
495                    .get("NORD")
496                    .and_then(|v| parse_usize(v))
497                    .unwrap_or_else(|| {
498                        fields.get("NELM").and_then(|v| parse_usize(v)).unwrap_or(0)
499                    }),
500            }
501        }
502        RecordType::Aao => {
503            let ftvl = fields
504                .get("FTVL")
505                .cloned()
506                .unwrap_or_else(|| "DOUBLE".to_string());
507            let nelm = fields.get("NELM").and_then(|v| parse_usize(v));
508            let array = parse_scalar_array(fields.get("VAL"), &ftvl, nelm);
509            RecordData::Aao {
510                nt: NtScalarArray::from_value(array),
511                out: fields.get("OUT").and_then(|v| parse_link_expr(v)),
512                dol: fields.get("DOL").and_then(|v| parse_link_expr(v)),
513                omsl: parse_output_mode(fields.get("OMSL")),
514                ftvl,
515                nelm: nelm.unwrap_or(0),
516                nord: fields
517                    .get("NORD")
518                    .and_then(|v| parse_usize(v))
519                    .unwrap_or_else(|| {
520                        fields.get("NELM").and_then(|v| parse_usize(v)).unwrap_or(0)
521                    }),
522            }
523        }
524        RecordType::SubArray => {
525            let ftvl = fields
526                .get("FTVL")
527                .cloned()
528                .unwrap_or_else(|| "DOUBLE".to_string());
529            let nelm = fields.get("NELM").and_then(|v| parse_usize(v));
530            let array = parse_scalar_array(fields.get("VAL"), &ftvl, nelm);
531            RecordData::SubArray {
532                nt: NtScalarArray::from_value(array),
533                inp: fields.get("INP").and_then(|v| parse_link_expr(v)),
534                ftvl,
535                malm: fields.get("MALM").and_then(|v| parse_usize(v)).unwrap_or(0),
536                nelm: nelm.unwrap_or(0),
537                nord: fields
538                    .get("NORD")
539                    .and_then(|v| parse_usize(v))
540                    .unwrap_or_else(|| {
541                        fields.get("NELM").and_then(|v| parse_usize(v)).unwrap_or(0)
542                    }),
543                indx: fields.get("INDX").and_then(|v| parse_usize(v)).unwrap_or(0),
544            }
545        }
546        RecordType::NtTable | RecordType::NtNdArray => {
547            eprintln!(
548                "Record '{}': type '{}' is not a standard EPICS Base record type and cannot be loaded from .db files",
549                record.name, record.record_type
550            );
551            return None;
552        }
553    };
554
555    Some(RecordInstance {
556        name: record.name.clone(),
557        record_type,
558        common,
559        data,
560        raw_fields: record.fields.clone(),
561    })
562}
563
564pub fn load_db(path: &str) -> Result<HashMap<String, RecordInstance>, String> {
565    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
566    parse_db(&content)
567}
568
569pub fn parse_db(content: &str) -> Result<HashMap<String, RecordInstance>, String> {
570    let record_re = Regex::new(r#"^\s*record\s*\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]+)"\s*\)\s*\{"#)
571        .map_err(|e| e.to_string())?;
572    let field_re = Regex::new(r#"^\s*field\s*\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*\)\s*"#)
573        .map_err(|e| e.to_string())?;
574
575    let mut records: Vec<DbRecord> = Vec::new();
576    let mut current: Option<DbRecord> = None;
577
578    for line in content.lines() {
579        let line = line.trim();
580        if line.is_empty() || line.starts_with('#') {
581            continue;
582        }
583        if let Some(caps) = record_re.captures(line) {
584            if let Some(rec) = current.take() {
585                records.push(rec);
586            }
587            current = Some(DbRecord {
588                name: caps[2].to_string(),
589                record_type: caps[1].to_string(),
590                fields: HashMap::new(),
591            });
592            continue;
593        }
594        if line.starts_with('}') {
595            if let Some(rec) = current.take() {
596                records.push(rec);
597            }
598            continue;
599        }
600        if let Some(caps) = field_re.captures(line) {
601            if let Some(rec) = current.as_mut() {
602                rec.fields.insert(caps[1].to_string(), caps[2].to_string());
603            }
604        }
605    }
606    if let Some(rec) = current.take() {
607        records.push(rec);
608    }
609
610    let mut map = HashMap::new();
611    for rec in &records {
612        if let Some(parsed) = to_record(rec) {
613            map.insert(parsed.name.clone(), parsed);
614        }
615    }
616
617    Ok(map)
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn parse_supported_records() {
626        let input = r#"
627            record(ai, "PV:AI") {
628                field(VAL, "1.25")
629                field(EGU, "mA")
630                field(HOPR, "10")
631                field(LOPR, "-10")
632                field(SIMM, "RAW")
633                field(INP, "PV:RAW PP MS")
634            }
635            record(ao, "PV:AO") {
636                field(VAL, "2")
637                field(OMSL, "closed_loop")
638                field(DOL, "PV:SET NPP NMS")
639                field(OUT, "PV:RAW")
640            }
641            record(bi, "PV:BI") {
642                field(VAL, "1")
643            }
644            record(bo, "PV:BO") {
645                field(VAL, "0")
646            }
647            record(stringin, "PV:STRIN") {
648                field(VAL, "hello")
649            }
650            record(stringout, "PV:STROUT") {
651                field(VAL, "world")
652            }
653        "#;
654        let map = parse_db(input).expect("parse");
655        assert!(map.contains_key("PV:AI"));
656        assert!(map.contains_key("PV:AO"));
657        assert!(map.contains_key("PV:BI"));
658        assert!(map.contains_key("PV:BO"));
659        assert!(map.contains_key("PV:STRIN"));
660        assert!(map.contains_key("PV:STROUT"));
661
662        let ai = map.get("PV:AI").unwrap();
663        assert_eq!(ai.record_type, RecordType::Ai);
664        match &ai.data {
665            RecordData::Ai { inp, simm, .. } => {
666                assert!(*simm);
667                match inp {
668                    Some(LinkExpr::DbLink {
669                        target,
670                        process_passive,
671                        maximize_severity,
672                    }) => {
673                        assert_eq!(target, "PV:RAW");
674                        assert!(*process_passive);
675                        assert!(*maximize_severity);
676                    }
677                    _ => panic!("expected ai inp db link"),
678                }
679            }
680            _ => panic!("expected ai data"),
681        }
682    }
683
684    #[test]
685    fn parse_scan_modes() {
686        let input = r#"
687            record(ai, "PV:PERIODIC") {
688                field(SCAN, "0.5 second")
689            }
690            record(ai, "PV:EVENT") {
691                field(SCAN, "Event")
692                field(EVNT, "MY_EVT")
693            }
694            record(ai, "PV:IO") {
695                field(SCAN, "I/O Intr")
696                field(IOSCAN, "ADC0")
697            }
698        "#;
699        let map = parse_db(input).expect("parse");
700        let periodic = map.get("PV:PERIODIC").unwrap();
701        assert!(matches!(periodic.common.scan, ScanMode::Periodic(_)));
702        let event = map.get("PV:EVENT").unwrap();
703        assert_eq!(event.common.scan, ScanMode::Event("MY_EVT".to_string()));
704        let io = map.get("PV:IO").unwrap();
705        assert_eq!(io.common.scan, ScanMode::IoEvent("ADC0".to_string()));
706    }
707}