Skip to main content

bhtune_core/
template.rs

1//! DCS/PLC template semantics: one instance per control-system convention (Yokogawa,
2//! Honeywell, etc.), describing how that DCS expresses PID parameters and the OPC
3//! item-name suffix convention used to derive a full tag set from a single PV tag (see
4//! [`crate::tags::derive_tag`]).
5//!
6//! The built-in templates are not hardcoded Rust -- they are parsed from an embedded TOML
7//! catalog (`templates/builtin.toml`), so adding support for a new DCS/PLC family is a data
8//! file change, not a Rust change. See `docs/dcs-templates.md` for the catalog format and
9//! contribution guidance.
10
11use serde::{Deserialize, Serialize};
12
13use crate::pid_config::{
14    DerivativeType, IntegralType, PidRounding, PidRoundingError, ProportionalType, TimeUnit,
15};
16
17/// One DCS/PLC vendor's conventions.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
20#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
21pub struct DcsTemplate {
22    pub name: String,
23
24    /// If true, the controller mode is switched back to its original raw value after a test.
25    /// For a run whose initial raw mode matches the template's Auto value and that has a
26    /// configured setpoint tag, the initial setpoint is read and persisted before any live
27    /// write; restoration attempts to write it back when this setting permits the mode
28    /// restore. When this setting is enabled, an eligible live OPC DA Auto-start with a valid
29    /// measured period remains in Manual for one-third of that period after MV restoration.
30    /// The final restore writes Auto before attempting the setpoint and mode-attribute values.
31    /// Non-Auto starts do not capture or write back a setpoint; mode-attribute restoration is
32    /// separate.
33    pub revert_mode: bool,
34
35    pub proportional_type: ProportionalType,
36    pub integral_type: IntegralType,
37    pub integral_unit: TimeUnit,
38    pub derivative_type: DerivativeType,
39    pub derivative_unit: TimeUnit,
40    /// Rounding for new PID controller targets and their presentation, after unit/convention
41    /// conversion. Raw calculations and recorded restore values retain their precision.
42    pub pid_rounding: PidRounding,
43
44    /// OPC item-name suffixes, combined with a PV tag's path prefix by
45    /// [`crate::tags::derive_tag`] to fill in the rest of the tag set. An empty suffix
46    /// means the corresponding tag is not applicable for this DCS (e.g. some DCS families
47    /// have no mode-attribute concept).
48    pub process_variable_suffix: String,
49    pub manipulated_variable_suffix: String,
50    pub setpoint_variable_suffix: String,
51    pub controller_direction_suffix: String,
52    pub controller_mode_suffix: String,
53    pub mode_attribute_suffix: String,
54    pub upper_pv_range_suffix: String,
55    pub lower_pv_range_suffix: String,
56    pub upper_mv_range_suffix: String,
57    pub lower_mv_range_suffix: String,
58    pub proportional_constant_suffix: String,
59    pub integral_constant_suffix: String,
60    pub derivative_constant_suffix: String,
61
62    /// The DCS-specific raw values a Mode tag holds for Manual/Auto.
63    pub mode_manual_value: String,
64    pub mode_auto_value: String,
65    /// The raw value a Mode Attribute tag holds when in "Program" mode (permits external
66    /// OPC writes). `None` when the DCS has no mode-attribute concept.
67    pub mode_attribute_program_value: Option<String>,
68    /// The raw value a Controller Direction tag holds when the controller is Direct
69    /// acting; see [`crate::direction::ControllerDirection::from_raw_tag_value`].
70    pub controller_action_direct_value: String,
71
72    /// DCS/PLC releases this mapping is known to apply to (e.g. `["R5", "R6"]`), in each
73    /// vendor's own version-naming convention rather than a normalized scheme. A newer
74    /// release that changes tag conventions gets its *own* template entry with its own
75    /// `name` and `versions` list -- never an edit to this one in place, since sites still
76    /// running the older release depend on the existing mapping (see
77    /// `docs/dcs-templates.md`). May be empty for a contribution whose version coverage
78    /// isn't yet known; `name` is what makes a template unique, not `versions`.
79    #[serde(default)]
80    pub versions: Vec<String>,
81    /// Free-text description of the control system this template targets.
82    #[serde(default)]
83    pub description: Option<String>,
84    /// Citation for where this mapping came from (a manual, a field deployment).
85    /// Provenance, not a correctness guarantee -- there is deliberately no separate
86    /// "verified" trust field; everything accepted into the catalog is treated as verified,
87    /// and real mapping errors are fixed as bugs when they surface.
88    #[serde(default)]
89    pub source: Option<String>,
90}
91
92impl DcsTemplate {
93    /// Validates cross-field invariants a data file can't express on its own: a name, a PV
94    /// suffix, and an MV suffix are always required (without them tag derivation is
95    /// impossible); a mode suffix requires both a manual and an auto value; a
96    /// mode-attribute suffix requires its program value. A half-configured template should
97    /// fail loudly at parse/import time, not mid-tune against a live loop.
98    /// Called on every template parsed from the embedded catalog
99    /// ([`parse_catalog`]), an imported file, or the user catalog.
100    pub fn validate(&self) -> Result<(), TemplateError> {
101        if self.name.trim().is_empty() {
102            return Err(TemplateError::EmptyName);
103        }
104        if self.process_variable_suffix.is_empty() {
105            return Err(TemplateError::EmptyField {
106                name: self.name.clone(),
107                field: "process_variable_suffix",
108            });
109        }
110        if self.manipulated_variable_suffix.is_empty() {
111            return Err(TemplateError::EmptyField {
112                name: self.name.clone(),
113                field: "manipulated_variable_suffix",
114            });
115        }
116        if !self.controller_mode_suffix.is_empty() {
117            if self.mode_manual_value.is_empty() {
118                return Err(TemplateError::MissingModeValue {
119                    name: self.name.clone(),
120                    field: "mode_manual_value",
121                });
122            }
123            if self.mode_auto_value.is_empty() {
124                return Err(TemplateError::MissingModeValue {
125                    name: self.name.clone(),
126                    field: "mode_auto_value",
127                });
128            }
129        }
130        if !self.mode_attribute_suffix.is_empty() && self.mode_attribute_program_value.is_none() {
131            return Err(TemplateError::MissingModeAttributeProgramValue {
132                name: self.name.clone(),
133            });
134        }
135        self.pid_rounding
136            .validate()
137            .map_err(|reason| TemplateError::InvalidPidRounding {
138                name: self.name.clone(),
139                reason,
140            })?;
141        Ok(())
142    }
143}
144
145/// Why [`DcsTemplate::validate`] or [`parse_catalog`] rejected a template.
146#[derive(Debug, Clone, PartialEq)]
147pub enum TemplateError {
148    /// The catalog's TOML could not be parsed at all (malformed syntax, wrong shape).
149    Toml(toml::de::Error),
150    /// `name` was empty or all whitespace.
151    EmptyName,
152    /// A required suffix field was empty.
153    EmptyField { name: String, field: &'static str },
154    /// `controller_mode_suffix` was set but the manual or auto value for it was empty.
155    MissingModeValue { name: String, field: &'static str },
156    /// `mode_attribute_suffix` was set but `mode_attribute_program_value` was `None`.
157    MissingModeAttributeProgramValue { name: String },
158    InvalidPidRounding {
159        name: String,
160        reason: PidRoundingError,
161    },
162}
163
164impl std::fmt::Display for TemplateError {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            TemplateError::Toml(e) => write!(f, "invalid template catalog: {e}"),
168            TemplateError::EmptyName => write!(f, "template name must not be empty"),
169            TemplateError::EmptyField { name, field } => {
170                write!(f, "template '{name}': {field} must not be empty")
171            }
172            TemplateError::MissingModeValue { name, field } => write!(
173                f,
174                "template '{name}': controller_mode_suffix is set but {field} is empty"
175            ),
176            TemplateError::MissingModeAttributeProgramValue { name } => write!(
177                f,
178                "template '{name}': mode_attribute_suffix is set but \
179                 mode_attribute_program_value is missing"
180            ),
181            TemplateError::InvalidPidRounding { name, reason } => {
182                write!(f, "template '{name}': {reason}")
183            }
184        }
185    }
186}
187
188impl std::error::Error for TemplateError {
189    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
190        match self {
191            TemplateError::Toml(e) => Some(e),
192            TemplateError::InvalidPidRounding { reason, .. } => Some(reason),
193            _ => None,
194        }
195    }
196}
197
198impl From<toml::de::Error> for TemplateError {
199    fn from(error: toml::de::Error) -> Self {
200        TemplateError::Toml(error)
201    }
202}
203
204/// The embedded/user catalog's top-level shape: a TOML `[[template]]` array of tables. Also
205/// used in reverse by [`to_catalog_toml`] (the `bhtune` CLI's `template export --format toml`), so
206/// export and import always agree on the exact same wire shape with no separate format to
207/// keep in sync by hand.
208#[derive(Debug, Serialize, Deserialize)]
209struct Catalog {
210    #[serde(rename = "template")]
211    templates: Vec<DcsTemplate>,
212}
213
214/// Parses a TOML catalog (the `[[template]]` array-of-tables format used by
215/// `templates/builtin.toml` and the user catalog file the `bhtune` CLI auto-loads) and validates
216/// every template it contains. Pure -- takes an in-memory string and does no I/O itself;
217/// all file reading is the caller's job (the runtime configuration loader and CLI commands),
218/// keeping this crate's "no I/O" rule intact.
219pub fn parse_catalog(input: &str) -> Result<Vec<DcsTemplate>, TemplateError> {
220    let catalog: Catalog = toml::from_str(input)?;
221    for template in &catalog.templates {
222        template.validate()?;
223    }
224    Ok(catalog.templates)
225}
226
227/// Serializes `templates` as a TOML catalog in the exact `[[template]]` array-of-tables
228/// shape [`parse_catalog`] reads back -- the inverse operation. Used by the `bhtune` CLI's
229/// `template export --format toml` (a single template exports as a one-entry catalog) so
230/// the contribution loop is export -> annotate -> PR with no hand-transcription step. Pure,
231/// like [`parse_catalog`]: writing the result to a file is the caller's job.
232pub fn to_catalog_toml(templates: Vec<DcsTemplate>) -> Result<String, toml::ser::Error> {
233    toml::to_string_pretty(&Catalog { templates })
234}
235
236/// The embedded catalog TOML, compiled into the binary so it can never go missing from a
237/// shipped install -- see `templates/builtin.toml` for the actual data and its contribution
238/// rules.
239const BUILTIN_CATALOG: &str = include_str!("../templates/builtin.toml");
240
241/// The DCS/PLC templates shipped by default, parsed from the embedded catalog.
242///
243/// # Panics
244///
245/// Panics if the embedded catalog fails to parse or validate. This can only happen from a
246/// bad edit to `templates/builtin.toml` itself; this module's
247/// `embedded_catalog_parses_and_validates` test proves it never does in practice, so a
248/// malformed contribution fails CI rather than shipping.
249#[allow(
250    clippy::expect_used,
251    reason = "the embedded catalog is compiled into the binary; a parse failure is a build invariant, not a live-loop error"
252)]
253pub fn built_in_templates() -> Vec<DcsTemplate> {
254    parse_catalog(BUILTIN_CATALOG).expect("embedded builtin.toml catalog must parse and validate")
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use proptest::prelude::*;
261
262    #[test]
263    fn ships_exactly_four_templates() {
264        let templates = built_in_templates();
265        assert_eq!(templates.len(), 4);
266        let names: Vec<&str> = templates.iter().map(|t| t.name.as_str()).collect();
267        assert_eq!(
268            names,
269            vec![
270                "Yokogawa CentumVP",
271                "Honeywell Experion",
272                "Schneider Modicon",
273                "Allen-Bradley PlantPAx"
274            ]
275        );
276    }
277
278    #[test]
279    fn yokogawa_has_no_mode_attribute_concept() {
280        let templates = built_in_templates();
281        let yokogawa = templates
282            .iter()
283            .find(|t| t.name == "Yokogawa CentumVP")
284            .unwrap();
285        assert!(yokogawa.mode_attribute_suffix.is_empty());
286        assert_eq!(yokogawa.mode_attribute_program_value, None);
287    }
288
289    #[test]
290    fn honeywell_has_a_mode_attribute_concept() {
291        let templates = built_in_templates();
292        let honeywell = templates
293            .iter()
294            .find(|t| t.name == "Honeywell Experion")
295            .unwrap();
296        assert_eq!(honeywell.mode_attribute_suffix, "MODEATTR");
297        assert_eq!(
298            honeywell.mode_attribute_program_value,
299            Some("2".to_string())
300        );
301    }
302
303    #[test]
304    fn yokogawa_uses_proportional_band_others_use_gain() {
305        let templates = built_in_templates();
306        for template in &templates {
307            let expected = if template.name == "Yokogawa CentumVP" {
308                ProportionalType::Band
309            } else {
310                ProportionalType::Gain
311            };
312            assert_eq!(template.proportional_type, expected, "{}", template.name);
313        }
314    }
315
316    #[test]
317    fn all_templates_use_reset_time_and_derivative_time() {
318        for template in built_in_templates() {
319            assert_eq!(template.integral_type, IntegralType::ResetTime);
320            assert_eq!(template.derivative_type, DerivativeType::DerivativeTime);
321        }
322    }
323
324    #[test]
325    fn catalog_sets_explicit_pid_precision_for_each_family() {
326        for template in built_in_templates() {
327            let expected = if template.name == "Yokogawa CentumVP" {
328                PidRounding {
329                    kind: crate::PidRoundingKind::DecimalPlaces,
330                    digits: 1,
331                }
332            } else {
333                PidRounding::default()
334            };
335            assert_eq!(template.pid_rounding, expected);
336        }
337    }
338
339    #[test]
340    fn imports_and_snapshots_require_explicit_pid_rounding() {
341        let mut value = serde_json::to_value(built_in_templates().remove(0)).unwrap();
342        value.as_object_mut().unwrap().remove("pid_rounding");
343        assert!(
344            serde_json::from_value::<DcsTemplate>(value)
345                .unwrap_err()
346                .to_string()
347                .contains("pid_rounding")
348        );
349        let toml = minimal_valid_toml().replace(
350            "pid_rounding = { kind = \"significant_digits\", digits = 3 }\n",
351            "",
352        );
353        assert!(parse_catalog(&toml).is_err());
354    }
355
356    #[test]
357    fn template_rejects_invalid_pid_rounding_with_an_explicit_reason() {
358        let mut template = built_in_templates().remove(0);
359        template.pid_rounding.digits = 8;
360        let error = template.validate().unwrap_err();
361        assert!(matches!(error, TemplateError::InvalidPidRounding { .. }));
362        assert!(error.to_string().contains("Yokogawa CentumVP"));
363        assert!(error.to_string().contains("digits"));
364        assert!(std::error::Error::source(&error).is_some());
365    }
366
367    #[test]
368    fn serde_round_trip() {
369        for template in built_in_templates() {
370            let json = serde_json::to_string(&template).unwrap();
371            let back: DcsTemplate = serde_json::from_str(&json).unwrap();
372            assert_eq!(template, back);
373        }
374    }
375
376    #[test]
377    fn embedded_catalog_parses_and_validates() {
378        // `built_in_templates()` already calls `parse_catalog(...).expect(...)`
379        // internally, so simply calling it without panicking proves this -- but assert on
380        // the result explicitly too, so a future refactor that swallows the panic still
381        // gets caught here.
382        let templates = parse_catalog(BUILTIN_CATALOG).unwrap();
383        for template in &templates {
384            assert!(template.validate().is_ok(), "{}", template.name);
385        }
386    }
387
388    #[test]
389    fn built_in_templates_carry_their_researched_versions() {
390        let templates = built_in_templates();
391        let versions = |name: &str| -> Vec<String> {
392            templates
393                .iter()
394                .find(|t| t.name == name)
395                .unwrap()
396                .versions
397                .clone()
398        };
399        assert_eq!(versions("Yokogawa CentumVP"), vec!["R5", "R6"]);
400        assert_eq!(versions("Honeywell Experion"), vec!["R400", "R410", "R430"]);
401        assert_eq!(
402            versions("Schneider Modicon"),
403            vec!["Unity Pro V8.0", "Unity Pro V8.1", "Unity Pro V11.0"]
404        );
405        assert_eq!(
406            versions("Allen-Bradley PlantPAx"),
407            vec!["3.0", "3.5", "4.0"]
408        );
409    }
410
411    #[test]
412    fn built_in_templates_have_a_description_and_source() {
413        for template in built_in_templates() {
414            assert!(template.description.is_some(), "{}", template.name);
415            assert!(template.source.is_some(), "{}", template.name);
416        }
417    }
418
419    /// A minimal single-template TOML document that passes `validate()` as-is, used as a
420    /// base for the `parse_catalog` rejection tests below via targeted `str::replace`
421    /// edits. `controller_mode_suffix`/`mode_manual_value`/`mode_auto_value` are left blank
422    /// (no mode concept) and `mode_attribute_suffix` is blank too, so none of
423    /// `validate()`'s conditional checks fire unless a test deliberately arms them.
424    fn minimal_valid_toml() -> &'static str {
425        r#"
426[[template]]
427name = "Test DCS"
428revert_mode = false
429proportional_type = "gain"
430integral_type = "reset_time"
431integral_unit = "seconds"
432derivative_type = "derivative_time"
433derivative_unit = "seconds"
434pid_rounding = { kind = "significant_digits", digits = 3 }
435process_variable_suffix = "PV"
436manipulated_variable_suffix = "MV"
437setpoint_variable_suffix = "SV"
438controller_direction_suffix = "DR"
439controller_mode_suffix = ""
440mode_attribute_suffix = ""
441upper_pv_range_suffix = "SH"
442lower_pv_range_suffix = "SL"
443upper_mv_range_suffix = "MSH"
444lower_mv_range_suffix = "MSL"
445proportional_constant_suffix = "P"
446integral_constant_suffix = "I"
447derivative_constant_suffix = "D"
448mode_manual_value = ""
449mode_auto_value = ""
450controller_action_direct_value = "0"
451"#
452    }
453
454    #[test]
455    fn parse_catalog_accepts_a_minimal_valid_template() {
456        let templates = parse_catalog(minimal_valid_toml()).unwrap();
457        assert_eq!(templates.len(), 1);
458        assert_eq!(templates[0].name, "Test DCS");
459        assert!(templates[0].versions.is_empty());
460        assert_eq!(templates[0].description, None);
461        assert_eq!(templates[0].source, None);
462    }
463
464    #[test]
465    fn parse_catalog_rejects_malformed_toml() {
466        let err = parse_catalog("this is not [[ valid toml").unwrap_err();
467        assert!(matches!(err, TemplateError::Toml(_)));
468    }
469
470    #[test]
471    fn parse_catalog_rejects_an_empty_pv_suffix() {
472        let toml = minimal_valid_toml().replace(
473            r#"process_variable_suffix = "PV""#,
474            r#"process_variable_suffix = """#,
475        );
476        let err = parse_catalog(&toml).unwrap_err();
477        assert!(matches!(
478            err,
479            TemplateError::EmptyField {
480                field: "process_variable_suffix",
481                ..
482            }
483        ));
484    }
485
486    #[test]
487    fn parse_catalog_rejects_an_empty_mv_suffix() {
488        let toml = minimal_valid_toml().replace(
489            r#"manipulated_variable_suffix = "MV""#,
490            r#"manipulated_variable_suffix = """#,
491        );
492        let err = parse_catalog(&toml).unwrap_err();
493        assert!(matches!(
494            err,
495            TemplateError::EmptyField {
496                field: "manipulated_variable_suffix",
497                ..
498            }
499        ));
500    }
501
502    #[test]
503    fn parse_catalog_rejects_a_mode_suffix_without_manual_value() {
504        let toml = minimal_valid_toml().replace(
505            r#"controller_mode_suffix = """#,
506            r#"controller_mode_suffix = "MODE""#,
507        );
508        // mode_manual_value/mode_auto_value are still "" in this variant.
509        let err = parse_catalog(&toml).unwrap_err();
510        assert!(matches!(
511            err,
512            TemplateError::MissingModeValue {
513                field: "mode_manual_value",
514                ..
515            }
516        ));
517    }
518
519    #[test]
520    fn parse_catalog_rejects_a_mode_suffix_without_auto_value() {
521        let toml = minimal_valid_toml()
522            .replace(
523                r#"controller_mode_suffix = """#,
524                r#"controller_mode_suffix = "MODE""#,
525            )
526            .replace(r#"mode_manual_value = """#, r#"mode_manual_value = "MAN""#);
527        // mode_auto_value stays "" in this variant.
528        let err = parse_catalog(&toml).unwrap_err();
529        assert!(matches!(
530            err,
531            TemplateError::MissingModeValue {
532                field: "mode_auto_value",
533                ..
534            }
535        ));
536    }
537
538    #[test]
539    fn parse_catalog_rejects_a_mode_attribute_suffix_without_program_value() {
540        let toml = minimal_valid_toml().replace(
541            r#"mode_attribute_suffix = """#,
542            r#"mode_attribute_suffix = "MODEATTR""#,
543        );
544        let err = parse_catalog(&toml).unwrap_err();
545        assert!(matches!(
546            err,
547            TemplateError::MissingModeAttributeProgramValue { .. }
548        ));
549    }
550
551    #[test]
552    fn to_catalog_toml_round_trips_the_built_in_templates() {
553        let original = built_in_templates();
554        let toml = to_catalog_toml(original.clone()).unwrap();
555        let parsed = parse_catalog(&toml).unwrap();
556        assert_eq!(parsed, original);
557    }
558
559    #[test]
560    fn to_catalog_toml_with_one_template_produces_a_single_template_block() {
561        let template = parse_catalog(minimal_valid_toml()).unwrap().remove(0);
562        let toml = to_catalog_toml(vec![template.clone()]).unwrap();
563        assert_eq!(toml.matches("[[template]]").count(), 1);
564        let parsed = parse_catalog(&toml).unwrap();
565        assert_eq!(parsed, vec![template]);
566    }
567
568    #[test]
569    fn to_catalog_toml_with_no_templates_produces_an_empty_catalog() {
570        let toml = to_catalog_toml(vec![]).unwrap();
571        assert_eq!(parse_catalog(&toml).unwrap(), Vec::new());
572    }
573
574    #[test]
575    fn validate_rejects_an_empty_name() {
576        let mut template = built_in_templates().remove(0);
577        template.name = "   ".to_string();
578        assert_eq!(template.validate(), Err(TemplateError::EmptyName));
579    }
580
581    #[test]
582    fn validate_accepts_every_built_in_template() {
583        for template in built_in_templates() {
584            assert!(template.validate().is_ok(), "{}", template.name);
585        }
586    }
587
588    #[test]
589    fn template_error_is_a_std_error() {
590        let err = TemplateError::EmptyName;
591        let _: Box<dyn std::error::Error> = Box::new(err);
592    }
593
594    #[test]
595    fn template_error_display_names_the_template_and_field() {
596        let err = TemplateError::EmptyField {
597            name: "My DCS".to_string(),
598            field: "process_variable_suffix",
599        };
600        let msg = err.to_string();
601        assert!(msg.contains("My DCS"));
602        assert!(msg.contains("process_variable_suffix"));
603    }
604
605    #[test]
606    fn template_error_toml_variant_has_a_source() {
607        use std::error::Error as _;
608        let err = parse_catalog("this is not [[ valid toml").unwrap_err();
609        assert!(err.source().is_some());
610    }
611
612    #[test]
613    fn template_error_display_covers_every_remaining_variant() {
614        let toml_err = parse_catalog("this is not [[ valid toml").unwrap_err();
615        assert!(toml_err.to_string().contains("invalid template catalog"));
616
617        assert_eq!(
618            TemplateError::EmptyName.to_string(),
619            "template name must not be empty"
620        );
621
622        let mode_value_err = TemplateError::MissingModeValue {
623            name: "My DCS".to_string(),
624            field: "mode_manual_value",
625        };
626        let msg = mode_value_err.to_string();
627        assert!(msg.contains("My DCS"));
628        assert!(msg.contains("mode_manual_value"));
629
630        let mode_attr_err = TemplateError::MissingModeAttributeProgramValue {
631            name: "My DCS".to_string(),
632        };
633        assert!(mode_attr_err.to_string().contains("My DCS"));
634    }
635
636    #[test]
637    fn template_error_non_toml_variants_have_no_source() {
638        use std::error::Error as _;
639        assert!(TemplateError::EmptyName.source().is_none());
640    }
641
642    proptest::proptest! {
643        #[test]
644        fn valid_templates_round_trip_through_catalog_toml(
645            name in "[A-Za-z][A-Za-z0-9 _-]{0,24}",
646            description in prop::option::of("[A-Za-z0-9 .,;:-]{0,40}"),
647            source in prop::option::of("[A-Za-z0-9 ./,:-]{0,40}"),
648            versions in prop::collection::vec("[A-Za-z0-9 ._-]{1,12}", 0..4),
649        ) {
650            let mut template = built_in_templates().remove(0);
651            template.name = name;
652            template.description = description;
653            template.source = source;
654            template.versions = versions;
655
656            let encoded = to_catalog_toml(vec![template.clone()]).unwrap();
657            prop_assert_eq!(parse_catalog(&encoded).unwrap(), vec![template]);
658        }
659
660        #[test]
661        fn arbitrary_catalog_text_never_panics(input in any::<String>()) {
662            let _ = parse_catalog(&input);
663        }
664
665        #[test]
666        fn arbitrary_json_text_never_panics(input in any::<String>()) {
667            let _ = serde_json::from_str::<DcsTemplate>(&input);
668        }
669    }
670}