1use serde::{Deserialize, Serialize};
12
13use crate::pid_config::{DerivativeType, IntegralType, ProportionalType, TimeUnit};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19pub struct DcsTemplate {
20 pub name: String,
21
22 pub revert_mode: bool,
26
27 pub proportional_type: ProportionalType,
28 pub integral_type: IntegralType,
29 pub integral_unit: TimeUnit,
30 pub derivative_type: DerivativeType,
31 pub derivative_unit: TimeUnit,
32
33 pub process_variable_suffix: String,
38 pub manipulated_variable_suffix: String,
39 pub setpoint_variable_suffix: String,
40 pub controller_direction_suffix: String,
41 pub controller_mode_suffix: String,
42 pub mode_attribute_suffix: String,
43 pub upper_pv_range_suffix: String,
44 pub lower_pv_range_suffix: String,
45 pub upper_mv_range_suffix: String,
46 pub lower_mv_range_suffix: String,
47 pub proportional_constant_suffix: String,
48 pub integral_constant_suffix: String,
49 pub derivative_constant_suffix: String,
50
51 pub mode_manual_value: String,
53 pub mode_auto_value: String,
54 pub mode_attribute_program_value: Option<String>,
57 pub controller_action_direct_value: String,
60
61 #[serde(default)]
69 pub versions: Vec<String>,
70 #[serde(default)]
72 pub description: Option<String>,
73 #[serde(default)]
78 pub source: Option<String>,
79}
80
81impl DcsTemplate {
82 pub fn validate(&self) -> Result<(), TemplateError> {
91 if self.name.trim().is_empty() {
92 return Err(TemplateError::EmptyName);
93 }
94 if self.process_variable_suffix.is_empty() {
95 return Err(TemplateError::EmptyField {
96 name: self.name.clone(),
97 field: "process_variable_suffix",
98 });
99 }
100 if self.manipulated_variable_suffix.is_empty() {
101 return Err(TemplateError::EmptyField {
102 name: self.name.clone(),
103 field: "manipulated_variable_suffix",
104 });
105 }
106 if !self.controller_mode_suffix.is_empty() {
107 if self.mode_manual_value.is_empty() {
108 return Err(TemplateError::MissingModeValue {
109 name: self.name.clone(),
110 field: "mode_manual_value",
111 });
112 }
113 if self.mode_auto_value.is_empty() {
114 return Err(TemplateError::MissingModeValue {
115 name: self.name.clone(),
116 field: "mode_auto_value",
117 });
118 }
119 }
120 if !self.mode_attribute_suffix.is_empty() && self.mode_attribute_program_value.is_none() {
121 return Err(TemplateError::MissingModeAttributeProgramValue {
122 name: self.name.clone(),
123 });
124 }
125 Ok(())
126 }
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub enum TemplateError {
132 Toml(toml::de::Error),
134 EmptyName,
136 EmptyField { name: String, field: &'static str },
138 MissingModeValue { name: String, field: &'static str },
140 MissingModeAttributeProgramValue { name: String },
142}
143
144impl std::fmt::Display for TemplateError {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 match self {
147 TemplateError::Toml(e) => write!(f, "invalid template catalog: {e}"),
148 TemplateError::EmptyName => write!(f, "template name must not be empty"),
149 TemplateError::EmptyField { name, field } => {
150 write!(f, "template '{name}': {field} must not be empty")
151 }
152 TemplateError::MissingModeValue { name, field } => write!(
153 f,
154 "template '{name}': controller_mode_suffix is set but {field} is empty"
155 ),
156 TemplateError::MissingModeAttributeProgramValue { name } => write!(
157 f,
158 "template '{name}': mode_attribute_suffix is set but \
159 mode_attribute_program_value is missing"
160 ),
161 }
162 }
163}
164
165impl std::error::Error for TemplateError {
166 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167 match self {
168 TemplateError::Toml(e) => Some(e),
169 _ => None,
170 }
171 }
172}
173
174impl From<toml::de::Error> for TemplateError {
175 fn from(error: toml::de::Error) -> Self {
176 TemplateError::Toml(error)
177 }
178}
179
180#[derive(Debug, Serialize, Deserialize)]
185struct Catalog {
186 #[serde(rename = "template")]
187 templates: Vec<DcsTemplate>,
188}
189
190pub fn parse_catalog(input: &str) -> Result<Vec<DcsTemplate>, TemplateError> {
196 let catalog: Catalog = toml::from_str(input)?;
197 for template in &catalog.templates {
198 template.validate()?;
199 }
200 Ok(catalog.templates)
201}
202
203pub fn to_catalog_toml(templates: Vec<DcsTemplate>) -> Result<String, toml::ser::Error> {
209 toml::to_string_pretty(&Catalog { templates })
210}
211
212const BUILTIN_CATALOG: &str = include_str!("../templates/builtin.toml");
216
217pub fn built_in_templates() -> Vec<DcsTemplate> {
226 parse_catalog(BUILTIN_CATALOG).expect("embedded builtin.toml catalog must parse and validate")
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use proptest::prelude::*;
233
234 #[test]
235 fn ships_exactly_four_templates() {
236 let templates = built_in_templates();
237 assert_eq!(templates.len(), 4);
238 let names: Vec<&str> = templates.iter().map(|t| t.name.as_str()).collect();
239 assert_eq!(
240 names,
241 vec![
242 "Yokogawa CentumVP",
243 "Honeywell Experion",
244 "Schneider Modicon",
245 "Allen-Bradley PlantPAx"
246 ]
247 );
248 }
249
250 #[test]
251 fn yokogawa_has_no_mode_attribute_concept() {
252 let templates = built_in_templates();
253 let yokogawa = templates
254 .iter()
255 .find(|t| t.name == "Yokogawa CentumVP")
256 .unwrap();
257 assert!(yokogawa.mode_attribute_suffix.is_empty());
258 assert_eq!(yokogawa.mode_attribute_program_value, None);
259 }
260
261 #[test]
262 fn honeywell_has_a_mode_attribute_concept() {
263 let templates = built_in_templates();
264 let honeywell = templates
265 .iter()
266 .find(|t| t.name == "Honeywell Experion")
267 .unwrap();
268 assert_eq!(honeywell.mode_attribute_suffix, "MODEATTR");
269 assert_eq!(
270 honeywell.mode_attribute_program_value,
271 Some("2".to_string())
272 );
273 }
274
275 #[test]
276 fn yokogawa_uses_proportional_band_others_use_gain() {
277 let templates = built_in_templates();
278 for template in &templates {
279 let expected = if template.name == "Yokogawa CentumVP" {
280 ProportionalType::Band
281 } else {
282 ProportionalType::Gain
283 };
284 assert_eq!(template.proportional_type, expected, "{}", template.name);
285 }
286 }
287
288 #[test]
289 fn all_templates_use_reset_time_and_derivative_time() {
290 for template in built_in_templates() {
291 assert_eq!(template.integral_type, IntegralType::ResetTime);
292 assert_eq!(template.derivative_type, DerivativeType::DerivativeTime);
293 }
294 }
295
296 #[test]
297 fn serde_round_trip() {
298 for template in built_in_templates() {
299 let json = serde_json::to_string(&template).unwrap();
300 let back: DcsTemplate = serde_json::from_str(&json).unwrap();
301 assert_eq!(template, back);
302 }
303 }
304
305 #[test]
306 fn embedded_catalog_parses_and_validates() {
307 let templates = parse_catalog(BUILTIN_CATALOG).unwrap();
312 for template in &templates {
313 assert!(template.validate().is_ok(), "{}", template.name);
314 }
315 }
316
317 #[test]
318 fn built_in_templates_carry_their_researched_versions() {
319 let templates = built_in_templates();
320 let versions = |name: &str| -> Vec<String> {
321 templates
322 .iter()
323 .find(|t| t.name == name)
324 .unwrap()
325 .versions
326 .clone()
327 };
328 assert_eq!(versions("Yokogawa CentumVP"), vec!["R5", "R6"]);
329 assert_eq!(versions("Honeywell Experion"), vec!["R400", "R410", "R430"]);
330 assert_eq!(
331 versions("Schneider Modicon"),
332 vec!["Unity Pro V8.0", "Unity Pro V8.1", "Unity Pro V11.0"]
333 );
334 assert_eq!(
335 versions("Allen-Bradley PlantPAx"),
336 vec!["3.0", "3.5", "4.0"]
337 );
338 }
339
340 #[test]
341 fn built_in_templates_have_a_description_and_source() {
342 for template in built_in_templates() {
343 assert!(template.description.is_some(), "{}", template.name);
344 assert!(template.source.is_some(), "{}", template.name);
345 }
346 }
347
348 fn minimal_valid_toml() -> &'static str {
354 r#"
355[[template]]
356name = "Test DCS"
357revert_mode = false
358proportional_type = "gain"
359integral_type = "reset_time"
360integral_unit = "seconds"
361derivative_type = "derivative_time"
362derivative_unit = "seconds"
363process_variable_suffix = "PV"
364manipulated_variable_suffix = "MV"
365setpoint_variable_suffix = "SV"
366controller_direction_suffix = "DR"
367controller_mode_suffix = ""
368mode_attribute_suffix = ""
369upper_pv_range_suffix = "SH"
370lower_pv_range_suffix = "SL"
371upper_mv_range_suffix = "MSH"
372lower_mv_range_suffix = "MSL"
373proportional_constant_suffix = "P"
374integral_constant_suffix = "I"
375derivative_constant_suffix = "D"
376mode_manual_value = ""
377mode_auto_value = ""
378controller_action_direct_value = "0"
379"#
380 }
381
382 #[test]
383 fn parse_catalog_accepts_a_minimal_valid_template() {
384 let templates = parse_catalog(minimal_valid_toml()).unwrap();
385 assert_eq!(templates.len(), 1);
386 assert_eq!(templates[0].name, "Test DCS");
387 assert!(templates[0].versions.is_empty());
388 assert_eq!(templates[0].description, None);
389 assert_eq!(templates[0].source, None);
390 }
391
392 #[test]
393 fn parse_catalog_rejects_malformed_toml() {
394 let err = parse_catalog("this is not [[ valid toml").unwrap_err();
395 assert!(matches!(err, TemplateError::Toml(_)));
396 }
397
398 #[test]
399 fn parse_catalog_rejects_an_empty_pv_suffix() {
400 let toml = minimal_valid_toml().replace(
401 r#"process_variable_suffix = "PV""#,
402 r#"process_variable_suffix = """#,
403 );
404 let err = parse_catalog(&toml).unwrap_err();
405 assert!(matches!(
406 err,
407 TemplateError::EmptyField {
408 field: "process_variable_suffix",
409 ..
410 }
411 ));
412 }
413
414 #[test]
415 fn parse_catalog_rejects_an_empty_mv_suffix() {
416 let toml = minimal_valid_toml().replace(
417 r#"manipulated_variable_suffix = "MV""#,
418 r#"manipulated_variable_suffix = """#,
419 );
420 let err = parse_catalog(&toml).unwrap_err();
421 assert!(matches!(
422 err,
423 TemplateError::EmptyField {
424 field: "manipulated_variable_suffix",
425 ..
426 }
427 ));
428 }
429
430 #[test]
431 fn parse_catalog_rejects_a_mode_suffix_without_manual_value() {
432 let toml = minimal_valid_toml().replace(
433 r#"controller_mode_suffix = """#,
434 r#"controller_mode_suffix = "MODE""#,
435 );
436 let err = parse_catalog(&toml).unwrap_err();
438 assert!(matches!(
439 err,
440 TemplateError::MissingModeValue {
441 field: "mode_manual_value",
442 ..
443 }
444 ));
445 }
446
447 #[test]
448 fn parse_catalog_rejects_a_mode_suffix_without_auto_value() {
449 let toml = minimal_valid_toml()
450 .replace(
451 r#"controller_mode_suffix = """#,
452 r#"controller_mode_suffix = "MODE""#,
453 )
454 .replace(r#"mode_manual_value = """#, r#"mode_manual_value = "MAN""#);
455 let err = parse_catalog(&toml).unwrap_err();
457 assert!(matches!(
458 err,
459 TemplateError::MissingModeValue {
460 field: "mode_auto_value",
461 ..
462 }
463 ));
464 }
465
466 #[test]
467 fn parse_catalog_rejects_a_mode_attribute_suffix_without_program_value() {
468 let toml = minimal_valid_toml().replace(
469 r#"mode_attribute_suffix = """#,
470 r#"mode_attribute_suffix = "MODEATTR""#,
471 );
472 let err = parse_catalog(&toml).unwrap_err();
473 assert!(matches!(
474 err,
475 TemplateError::MissingModeAttributeProgramValue { .. }
476 ));
477 }
478
479 #[test]
480 fn to_catalog_toml_round_trips_the_built_in_templates() {
481 let original = built_in_templates();
482 let toml = to_catalog_toml(original.clone()).unwrap();
483 let parsed = parse_catalog(&toml).unwrap();
484 assert_eq!(parsed, original);
485 }
486
487 #[test]
488 fn to_catalog_toml_with_one_template_produces_a_single_template_block() {
489 let template = parse_catalog(minimal_valid_toml()).unwrap().remove(0);
490 let toml = to_catalog_toml(vec![template.clone()]).unwrap();
491 assert_eq!(toml.matches("[[template]]").count(), 1);
492 let parsed = parse_catalog(&toml).unwrap();
493 assert_eq!(parsed, vec![template]);
494 }
495
496 #[test]
497 fn to_catalog_toml_with_no_templates_produces_an_empty_catalog() {
498 let toml = to_catalog_toml(vec![]).unwrap();
499 assert_eq!(parse_catalog(&toml).unwrap(), Vec::new());
500 }
501
502 #[test]
503 fn validate_rejects_an_empty_name() {
504 let mut template = built_in_templates().remove(0);
505 template.name = " ".to_string();
506 assert_eq!(template.validate(), Err(TemplateError::EmptyName));
507 }
508
509 #[test]
510 fn validate_accepts_every_built_in_template() {
511 for template in built_in_templates() {
512 assert!(template.validate().is_ok(), "{}", template.name);
513 }
514 }
515
516 #[test]
517 fn template_error_is_a_std_error() {
518 let err = TemplateError::EmptyName;
519 let _: Box<dyn std::error::Error> = Box::new(err);
520 }
521
522 #[test]
523 fn template_error_display_names_the_template_and_field() {
524 let err = TemplateError::EmptyField {
525 name: "My DCS".to_string(),
526 field: "process_variable_suffix",
527 };
528 let msg = err.to_string();
529 assert!(msg.contains("My DCS"));
530 assert!(msg.contains("process_variable_suffix"));
531 }
532
533 #[test]
534 fn template_error_toml_variant_has_a_source() {
535 use std::error::Error as _;
536 let err = parse_catalog("this is not [[ valid toml").unwrap_err();
537 assert!(err.source().is_some());
538 }
539
540 #[test]
541 fn template_error_display_covers_every_remaining_variant() {
542 let toml_err = parse_catalog("this is not [[ valid toml").unwrap_err();
543 assert!(toml_err.to_string().contains("invalid template catalog"));
544
545 assert_eq!(
546 TemplateError::EmptyName.to_string(),
547 "template name must not be empty"
548 );
549
550 let mode_value_err = TemplateError::MissingModeValue {
551 name: "My DCS".to_string(),
552 field: "mode_manual_value",
553 };
554 let msg = mode_value_err.to_string();
555 assert!(msg.contains("My DCS"));
556 assert!(msg.contains("mode_manual_value"));
557
558 let mode_attr_err = TemplateError::MissingModeAttributeProgramValue {
559 name: "My DCS".to_string(),
560 };
561 assert!(mode_attr_err.to_string().contains("My DCS"));
562 }
563
564 #[test]
565 fn template_error_non_toml_variants_have_no_source() {
566 use std::error::Error as _;
567 assert!(TemplateError::EmptyName.source().is_none());
568 }
569
570 proptest::proptest! {
571 #[test]
572 fn valid_templates_round_trip_through_catalog_toml(
573 name in "[A-Za-z][A-Za-z0-9 _-]{0,24}",
574 description in prop::option::of("[A-Za-z0-9 .,;:-]{0,40}"),
575 source in prop::option::of("[A-Za-z0-9 ./,:-]{0,40}"),
576 versions in prop::collection::vec("[A-Za-z0-9 ._-]{1,12}", 0..4),
577 ) {
578 let mut template = built_in_templates().remove(0);
579 template.name = name;
580 template.description = description;
581 template.source = source;
582 template.versions = versions;
583
584 let encoded = to_catalog_toml(vec![template.clone()]).unwrap();
585 prop_assert_eq!(parse_catalog(&encoded).unwrap(), vec![template]);
586 }
587
588 #[test]
589 fn arbitrary_catalog_text_never_panics(input in any::<String>()) {
590 let _ = parse_catalog(&input);
591 }
592
593 #[test]
594 fn arbitrary_json_text_never_panics(input in any::<String>()) {
595 let _ = serde_json::from_str::<DcsTemplate>(&input);
596 }
597 }
598}