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        // TODO(follow-up): .db parsing — from_db_name never yields LongIn/
547        // LongOut today, so these arms are unreachable; they're only here to
548        // satisfy exhaustiveness. Wiring longin/longout .db loading is
549        // tracked as follow-up work.
550        RecordType::NtTable
551        | RecordType::NtNdArray
552        | RecordType::Mbbi
553        | RecordType::Mbbo
554        | RecordType::Generic
555        | RecordType::LongIn
556        | RecordType::LongOut => {
557            eprintln!(
558                "Record '{}': type '{}' is not a standard EPICS Base record type and cannot be loaded from .db files",
559                record.name, record.record_type
560            );
561            return None;
562        }
563    };
564
565    Some(RecordInstance {
566        name: record.name.clone(),
567        record_type,
568        common,
569        data,
570        raw_fields: record.fields.clone(),
571    })
572}
573
574/// A `.db` parse failure, naming the file and the line that caused it.
575#[derive(Debug, Clone, PartialEq, Eq)]
576pub struct DbParseError {
577    pub path: String,
578    pub line: usize,
579    pub message: String,
580}
581
582impl std::fmt::Display for DbParseError {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        write!(f, "{}:{}: {}", self.path, self.line, self.message)
585    }
586}
587
588impl std::error::Error for DbParseError {}
589
590/// Expand `$(NAME)` and `${NAME}` from `macros`.
591///
592/// An undefined macro is an error: silently expanding to the empty string is
593/// how a typo becomes a record named `""` that no client can ever find.
594fn expand_macros(
595    raw: &str,
596    macros: &HashMap<String, String>,
597    path: &str,
598    line: usize,
599) -> Result<String, DbParseError> {
600    let mut out = String::with_capacity(raw.len());
601    let bytes: Vec<char> = raw.chars().collect();
602    let mut i = 0;
603    while i < bytes.len() {
604        if bytes[i] == '$' && i + 1 < bytes.len() && (bytes[i + 1] == '(' || bytes[i + 1] == '{') {
605            let close = if bytes[i + 1] == '(' { ')' } else { '}' };
606            let start = i + 2;
607            let Some(end) = (start..bytes.len()).find(|&j| bytes[j] == close) else {
608                return Err(DbParseError {
609                    path: path.to_string(),
610                    line,
611                    message: format!("unterminated macro reference starting at column {}", i + 1),
612                });
613            };
614            let name: String = bytes[start..end].iter().collect();
615            let Some(value) = macros.get(&name) else {
616                return Err(DbParseError {
617                    path: path.to_string(),
618                    line,
619                    message: format!("undefined macro '{name}'"),
620                });
621            };
622            out.push_str(value);
623            i = end + 1;
624        } else {
625            out.push(bytes[i]);
626            i += 1;
627        }
628    }
629    Ok(out)
630}
631
632/// Join physical lines into logical ones so a quoted value may span newlines.
633///
634/// Also strips trailing inline comments: an unquoted `#` runs to the end of
635/// the physical line. A `#` inside a quoted value is not a comment marker
636/// and is preserved untouched.
637///
638/// Returns `(logical_line_text, first_physical_line_number)` pairs.
639fn logical_lines(content: &str, path: &str) -> Result<Vec<(String, usize)>, DbParseError> {
640    let mut out = Vec::new();
641    let mut buf = String::new();
642    let mut start_line = 0usize;
643    let mut in_quote = false;
644
645    for (idx, physical) in content.lines().enumerate() {
646        let lineno = idx + 1;
647        if !in_quote {
648            start_line = lineno;
649            buf.clear();
650        } else {
651            buf.push('\n');
652        }
653        for ch in physical.chars() {
654            if !in_quote && ch == '#' {
655                break;
656            }
657            if ch == '"' {
658                in_quote = !in_quote;
659            }
660            buf.push(ch);
661        }
662        if in_quote {
663            continue;
664        }
665        out.push((buf.clone(), start_line));
666    }
667
668    if in_quote {
669        return Err(DbParseError {
670            path: path.to_string(),
671            line: start_line,
672            message: "unterminated quoted value".to_string(),
673        });
674    }
675    Ok(out)
676}
677
678/// Parse `content` into untyped records, expanding `include` and macros.
679///
680/// `path` is used only for error messages and for resolving relative
681/// `include` paths; it need not exist on disk.
682pub fn parse_db_records(
683    content: &str,
684    path: &str,
685    macros: &HashMap<String, String>,
686) -> Result<Vec<DbRecord>, DbParseError> {
687    let mut visiting = Vec::new();
688    parse_db_inner(content, path, macros, &mut visiting)
689}
690
691/// Read `path` and parse it. `include` targets resolve relative to the
692/// including file's directory, as EPICS `dbLoadRecords` does.
693pub fn load_db_records(
694    path: &str,
695    macros: &HashMap<String, String>,
696) -> Result<Vec<DbRecord>, DbParseError> {
697    let mut visiting = Vec::new();
698    load_db_inner(path, macros, &mut visiting)
699}
700
701fn load_db_inner(
702    path: &str,
703    macros: &HashMap<String, String>,
704    visiting: &mut Vec<String>,
705) -> Result<Vec<DbRecord>, DbParseError> {
706    let canonical = fs::canonicalize(path)
707        .map(|p| p.to_string_lossy().into_owned())
708        .unwrap_or_else(|_| path.to_string());
709    if visiting.contains(&canonical) {
710        return Err(DbParseError {
711            path: path.to_string(),
712            line: 0,
713            message: format!("include cycle: '{path}' is already being parsed"),
714        });
715    }
716    let content = fs::read_to_string(path).map_err(|e| DbParseError {
717        path: path.to_string(),
718        line: 0,
719        message: e.to_string(),
720    })?;
721    visiting.push(canonical);
722    let result = parse_db_inner(&content, path, macros, visiting);
723    visiting.pop();
724    result
725}
726
727fn parse_db_inner(
728    content: &str,
729    path: &str,
730    macros: &HashMap<String, String>,
731    visiting: &mut Vec<String>,
732) -> Result<Vec<DbRecord>, DbParseError> {
733    let record_re = Regex::new(r#"^record\s*\(\s*([A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*\)\s*\{?$"#)
734        .expect("static record regex");
735    let field_re = Regex::new(r#"^field\s*\(\s*([A-Za-z0-9_]+)\s*,\s*"([\s\S]*)"\s*\)$"#)
736        .expect("static field regex");
737    let include_re = Regex::new(r#"^include\s+"?([^"]+)"?$"#).expect("static include regex");
738
739    let mut records: Vec<DbRecord> = Vec::new();
740    let mut current: Option<DbRecord> = None;
741
742    for (raw_line, lineno) in logical_lines(content, path)? {
743        let line = raw_line.trim();
744        if line.is_empty() || line.starts_with('#') {
745            continue;
746        }
747
748        if let Some(caps) = include_re.captures(line) {
749            let target = expand_macros(&caps[1], macros, path, lineno)?;
750            let base = std::path::Path::new(path)
751                .parent()
752                .unwrap_or_else(|| std::path::Path::new("."));
753            let resolved = base.join(&target);
754            let nested = load_db_inner(resolved.to_str().unwrap_or(&target), macros, visiting)?;
755            records.extend(nested);
756            continue;
757        }
758
759        let line = expand_macros(line, macros, path, lineno)?;
760        let line = line.trim();
761
762        if let Some(caps) = record_re.captures(line) {
763            let name = caps[2].to_string();
764            if name.is_empty() {
765                return Err(DbParseError {
766                    path: path.to_string(),
767                    line: lineno,
768                    message: "record name must not be empty".to_string(),
769                });
770            }
771            if let Some(rec) = current.take() {
772                records.push(rec);
773            }
774            current = Some(DbRecord {
775                name,
776                record_type: caps[1].to_string(),
777                fields: HashMap::new(),
778            });
779            continue;
780        }
781        if line == "{" {
782            continue;
783        }
784        if line.starts_with('}') {
785            if let Some(rec) = current.take() {
786                records.push(rec);
787            }
788            continue;
789        }
790        if let Some(caps) = field_re.captures(line) {
791            let Some(rec) = current.as_mut() else {
792                return Err(DbParseError {
793                    path: path.to_string(),
794                    line: lineno,
795                    message: "field() outside any record() block".to_string(),
796                });
797            };
798            rec.fields.insert(caps[1].to_string(), caps[2].to_string());
799            continue;
800        }
801
802        return Err(DbParseError {
803            path: path.to_string(),
804            line: lineno,
805            message: format!("unrecognised line: {line}"),
806        });
807    }
808
809    if let Some(rec) = current.take() {
810        records.push(rec);
811    }
812    Ok(records)
813}
814
815pub fn load_db(path: &str) -> Result<HashMap<String, RecordInstance>, String> {
816    let raw = load_db_records(path, &HashMap::new()).map_err(|e| e.to_string())?;
817    typed_from_raw(&raw)
818}
819
820pub fn parse_db(content: &str) -> Result<HashMap<String, RecordInstance>, String> {
821    let raw = parse_db_records(content, "<memory>", &HashMap::new()).map_err(|e| e.to_string())?;
822    typed_from_raw(&raw)
823}
824
825/// Build the `SimplePvStore` typed model. A record the typed layer cannot
826/// represent is now an error rather than a silent omission.
827fn typed_from_raw(raw: &[DbRecord]) -> Result<HashMap<String, RecordInstance>, String> {
828    let mut map = HashMap::new();
829    for rec in raw {
830        let Some(parsed) = to_record(rec) else {
831            return Err(format!(
832                "record '{}' of type '{}' is not supported",
833                rec.name, rec.record_type
834            ));
835        };
836        map.insert(parsed.name.clone(), parsed);
837    }
838    Ok(map)
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn parse_supported_records() {
847        let input = r#"
848            record(ai, "PV:AI") {
849                field(VAL, "1.25")
850                field(EGU, "mA")
851                field(HOPR, "10")
852                field(LOPR, "-10")
853                field(SIMM, "RAW")
854                field(INP, "PV:RAW PP MS")
855            }
856            record(ao, "PV:AO") {
857                field(VAL, "2")
858                field(OMSL, "closed_loop")
859                field(DOL, "PV:SET NPP NMS")
860                field(OUT, "PV:RAW")
861            }
862            record(bi, "PV:BI") {
863                field(VAL, "1")
864            }
865            record(bo, "PV:BO") {
866                field(VAL, "0")
867            }
868            record(stringin, "PV:STRIN") {
869                field(VAL, "hello")
870            }
871            record(stringout, "PV:STROUT") {
872                field(VAL, "world")
873            }
874        "#;
875        let map = parse_db(input).expect("parse");
876        assert!(map.contains_key("PV:AI"));
877        assert!(map.contains_key("PV:AO"));
878        assert!(map.contains_key("PV:BI"));
879        assert!(map.contains_key("PV:BO"));
880        assert!(map.contains_key("PV:STRIN"));
881        assert!(map.contains_key("PV:STROUT"));
882
883        let ai = map.get("PV:AI").unwrap();
884        assert_eq!(ai.record_type, RecordType::Ai);
885        match &ai.data {
886            RecordData::Ai { inp, simm, .. } => {
887                assert!(*simm);
888                match inp {
889                    Some(LinkExpr::DbLink {
890                        target,
891                        process_passive,
892                        maximize_severity,
893                    }) => {
894                        assert_eq!(target, "PV:RAW");
895                        assert!(*process_passive);
896                        assert!(*maximize_severity);
897                    }
898                    _ => panic!("expected ai inp db link"),
899                }
900            }
901            _ => panic!("expected ai data"),
902        }
903    }
904
905    #[test]
906    fn parse_scan_modes() {
907        let input = r#"
908            record(ai, "PV:PERIODIC") {
909                field(SCAN, "0.5 second")
910            }
911            record(ai, "PV:EVENT") {
912                field(SCAN, "Event")
913                field(EVNT, "MY_EVT")
914            }
915            record(ai, "PV:IO") {
916                field(SCAN, "I/O Intr")
917                field(IOSCAN, "ADC0")
918            }
919        "#;
920        let map = parse_db(input).expect("parse");
921        let periodic = map.get("PV:PERIODIC").unwrap();
922        assert!(matches!(periodic.common.scan, ScanMode::Periodic(_)));
923        let event = map.get("PV:EVENT").unwrap();
924        assert_eq!(event.common.scan, ScanMode::Event("MY_EVT".to_string()));
925        let io = map.get("PV:IO").unwrap();
926        assert_eq!(io.common.scan, ScanMode::IoEvent("ADC0".to_string()));
927    }
928
929    #[test]
930    fn parse_error_names_the_file_and_line() {
931        let input = "record(ai, \"PV:A\") {\n    field(VAL \"1\")\n}\n";
932        let err = parse_db_records(input, "bad.db", &HashMap::new())
933            .expect_err("a malformed field line must abort the load");
934        assert_eq!(err.path, "bad.db");
935        assert_eq!(err.line, 2, "the error must name the offending line");
936        assert!(
937            err.to_string().contains("bad.db:2"),
938            "Display must render path:line, got {err}"
939        );
940    }
941
942    #[test]
943    fn unknown_record_type_is_no_longer_silently_dropped() {
944        let input = "record(nosuchtype, \"PV:X\") {\n    field(VAL, \"1\")\n}\n";
945        // parse_db_records is untyped and keeps it; to_record is what rejects.
946        let raw = parse_db_records(input, "x.db", &HashMap::new()).expect("raw parse succeeds");
947        assert_eq!(raw.len(), 1);
948        let err = parse_db(input).expect_err("the typed load must abort");
949        assert!(
950            err.contains("PV:X"),
951            "the error must name the record, got {err}"
952        );
953    }
954
955    #[test]
956    fn macro_substitution_expands_dollar_braces_and_parens() {
957        let mut macros = HashMap::new();
958        macros.insert("P".to_string(), "SYS:".to_string());
959        macros.insert("N".to_string(), "7".to_string());
960        let input = "record(ai, \"${P}AI$(N)\") {\n    field(VAL, \"$(N)\")\n}\n";
961        let recs = parse_db_records(input, "m.db", &macros).expect("expansion succeeds");
962        assert_eq!(recs[0].name, "SYS:AI7");
963        assert_eq!(recs[0].fields.get("VAL").map(String::as_str), Some("7"));
964    }
965
966    #[test]
967    fn undefined_macro_is_an_error_naming_the_macro() {
968        let input = "record(ai, \"$(NOPE)\") {\n}\n";
969        let err = parse_db_records(input, "m.db", &HashMap::new())
970            .expect_err("an undefined macro must abort the load");
971        assert!(err.message.contains("NOPE"), "got {}", err.message);
972    }
973
974    #[test]
975    fn quoted_field_values_may_continue_across_lines() {
976        let input = "record(ai, \"PV:A\") {\n    field(DESC, \"one\ntwo\")\n}\n";
977        let recs = parse_db_records(input, "c.db", &HashMap::new()).expect("continuation parses");
978        assert_eq!(
979            recs[0].fields.get("DESC").map(String::as_str),
980            Some("one\ntwo")
981        );
982    }
983
984    #[test]
985    fn unterminated_quote_reports_the_opening_line() {
986        let input = "record(ai, \"PV:A\") {\n    field(DESC, \"never closed\n}\n";
987        let err = parse_db_records(input, "c.db", &HashMap::new())
988            .expect_err("an unterminated quote must abort the load");
989        assert_eq!(err.line, 2);
990    }
991
992    #[test]
993    fn include_pulls_in_a_sibling_file() {
994        let dir = std::env::temp_dir().join("spvirit_db_include_test");
995        let _ = fs::remove_dir_all(&dir);
996        fs::create_dir_all(&dir).expect("create temp dir");
997        fs::write(dir.join("inner.db"), "record(ai, \"PV:INNER\") {\n}\n").expect("write inner");
998        let outer = dir.join("outer.db");
999        fs::write(
1000            &outer,
1001            "include \"inner.db\"\nrecord(ai, \"PV:OUTER\") {\n}\n",
1002        )
1003        .expect("write outer");
1004
1005        let recs = load_db_records(outer.to_str().expect("utf8 path"), &HashMap::new())
1006            .expect("include resolves relative to the including file");
1007        let names: Vec<&str> = recs.iter().map(|r| r.name.as_str()).collect();
1008        assert_eq!(names, vec!["PV:INNER", "PV:OUTER"]);
1009
1010        fs::remove_dir_all(&dir).expect("clean up");
1011    }
1012
1013    #[test]
1014    fn include_cycle_is_reported_rather_than_hanging() {
1015        let dir = std::env::temp_dir().join("spvirit_db_cycle_test");
1016        let _ = fs::remove_dir_all(&dir);
1017        fs::create_dir_all(&dir).expect("create temp dir");
1018        fs::write(dir.join("a.db"), "include \"b.db\"\n").expect("write a");
1019        fs::write(dir.join("b.db"), "include \"a.db\"\n").expect("write b");
1020
1021        let err = load_db_records(
1022            dir.join("a.db").to_str().expect("utf8 path"),
1023            &HashMap::new(),
1024        )
1025        .expect_err("an include cycle must be an error, not a hang");
1026        assert!(err.message.contains("cycle"), "got {}", err.message);
1027
1028        fs::remove_dir_all(&dir).expect("clean up");
1029    }
1030
1031    #[test]
1032    fn trailing_inline_comments_after_a_field_value_are_stripped() {
1033        let input = "record(ai, \"PV:A\") {\n    field(LOPR, \"0\")      # display low\n}\n";
1034        let recs = parse_db_records(input, "t.db", &HashMap::new())
1035            .expect("a trailing comment after a field must not abort the load");
1036        assert_eq!(recs[0].fields.get("LOPR").map(String::as_str), Some("0"));
1037    }
1038
1039    #[test]
1040    fn hash_inside_a_quoted_value_is_not_treated_as_a_comment() {
1041        let input = "record(ai, \"PV:A\") {\n    field(DESC, \"50% #1 unit\")\n}\n";
1042        let recs =
1043            parse_db_records(input, "t.db", &HashMap::new()).expect("a quoted # must survive");
1044        assert_eq!(
1045            recs[0].fields.get("DESC").map(String::as_str),
1046            Some("50% #1 unit")
1047        );
1048    }
1049
1050    #[test]
1051    fn empty_record_name_is_rejected() {
1052        let input = "record(ai, \"\") {\n}\n";
1053        let err = parse_db_records(input, "e.db", &HashMap::new())
1054            .expect_err("an empty record name must abort the load");
1055        assert_eq!(err.line, 1);
1056        assert!(err.message.contains("empty"), "got {}", err.message);
1057    }
1058
1059    #[test]
1060    fn shipped_example_db_parses_and_yields_expected_records() {
1061        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1062            .join("examples")
1063            .join("example.db");
1064        let recs = load_db_records(path.to_str().expect("utf8 path"), &HashMap::new())
1065            .expect("the shipped example.db must parse, trailing comments and all");
1066        let names: Vec<&str> = recs.iter().map(|r| r.name.as_str()).collect();
1067        assert_eq!(
1068            names,
1069            vec!["DEMO:TEMP", "DEMO:SETPOINT", "DEMO:ENABLE", "DEMO:SPECTRUM",]
1070        );
1071        // The typed loader must also accept it end-to-end.
1072        load_db(path.to_str().expect("utf8 path")).expect("example.db must load as typed records");
1073    }
1074
1075    #[test]
1076    fn archiver_demo_db_parses_and_yields_expected_record_count() {
1077        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1078            .join("..")
1079            .join("demo")
1080            .join("archiver_demo.db");
1081        let recs = load_db_records(path.to_str().expect("utf8 path"), &HashMap::new())
1082            .expect("demo/archiver_demo.db must parse");
1083        assert_eq!(
1084            recs.len(),
1085            14,
1086            "unexpected record count in archiver_demo.db"
1087        );
1088        load_db(path.to_str().expect("utf8 path"))
1089            .expect("archiver_demo.db must load as typed records");
1090    }
1091}