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 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}