1use serde::{Deserialize, Serialize};
12
13use crate::pid_config::{
14 DerivativeType, IntegralType, PidRounding, PidRoundingError, ProportionalType, TimeUnit,
15};
16
17#[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 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 pub pid_rounding: PidRounding,
43
44 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 pub mode_manual_value: String,
64 pub mode_auto_value: String,
65 pub mode_attribute_program_value: Option<String>,
68 pub controller_action_direct_value: String,
71
72 #[serde(default)]
80 pub versions: Vec<String>,
81 #[serde(default)]
83 pub description: Option<String>,
84 #[serde(default)]
89 pub source: Option<String>,
90}
91
92impl DcsTemplate {
93 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#[derive(Debug, Clone, PartialEq)]
147pub enum TemplateError {
148 Toml(toml::de::Error),
150 EmptyName,
152 EmptyField { name: String, field: &'static str },
154 MissingModeValue { name: String, field: &'static str },
156 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#[derive(Debug, Serialize, Deserialize)]
209struct Catalog {
210 #[serde(rename = "template")]
211 templates: Vec<DcsTemplate>,
212}
213
214pub 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
227pub fn to_catalog_toml(templates: Vec<DcsTemplate>) -> Result<String, toml::ser::Error> {
233 toml::to_string_pretty(&Catalog { templates })
234}
235
236const BUILTIN_CATALOG: &str = include_str!("../templates/builtin.toml");
240
241#[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 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 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 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 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}