1pub mod saluki_keys;
12pub mod schema_gen;
13pub mod smoke_test_support;
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18use indexmap::{IndexMap, IndexSet};
19use serde::Deserialize;
20
21use crate::smoke_test_support::ConfigurationStruct;
22
23#[derive(Debug, Clone, Deserialize)]
28pub struct SchemaOverlay {
29 pub inventory: IndexMap<String, KnownEntry>,
30 pub excluded: IndexMap<String, String>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
35#[serde(tag = "support", rename_all = "snake_case")]
36pub enum KnownEntry {
37 Full(FullSupport),
39 Partial(PartialSupport),
41 #[serde(rename = "none")]
43 Unsupported(Unsupported),
44 Unknown(UnknownSupport),
46}
47
48#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub struct FullSupport {
52 pub pipelines: PipelineAffinity,
54 pub description: String,
56 #[serde(default)]
58 pub documentation: Option<String>,
59 #[serde(default)]
61 pub issue: Option<String>,
62 #[serde(default)]
64 pub input_shape: Option<InputShape>,
65 pub test_support: TestSupport,
67}
68
69#[derive(Debug, Clone, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub struct PartialSupport {
73 pub pipelines: PipelineAffinity,
75 pub description: String,
77 pub documentation: String,
79 #[serde(default)]
81 pub warn: bool,
82 #[serde(default)]
84 pub issue: Option<String>,
85 #[serde(default)]
87 pub input_shape: Option<InputShape>,
88 pub test_support: TestSupport,
90}
91
92#[derive(Debug, Clone, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub struct Unsupported {
96 pub pipelines: PipelineAffinity,
98 pub description: String,
100 #[serde(default)]
102 pub documentation: Option<String>,
103 pub severity: Severity,
105 pub planned: bool,
107 #[serde(default)]
109 pub issue: Option<String>,
110}
111
112#[derive(Debug, Clone, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct UnknownSupport {
116 #[serde(default)]
118 pub description: Option<String>,
119 #[serde(default)]
121 pub severity: Option<Severity>,
122 #[serde(default)]
124 pub issue: Option<String>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub struct TestSupport {
134 #[serde(default)]
136 pub env_var_override: Option<Vec<String>>,
137 #[serde(default)]
139 pub additional_yaml_paths: Vec<String>,
140 #[serde(default)]
142 pub value_type_override: Option<ValueType>,
143 pub used_by: IndexSet<ConfigurationStruct>,
145 #[serde(default)]
147 pub test_json: Option<String>,
148 #[serde(default)]
151 pub additional_attributes: IndexMap<String, String>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum Severity {
158 Low,
159 Medium,
160 High,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum Pipeline {
167 #[serde(rename = "dogstatsd")]
168 DogStatsD,
169 Checks,
170 Otlp,
171 Traces,
172}
173
174#[derive(Debug, Clone)]
180pub enum PipelineAffinity {
181 CrossCutting,
183 Pipelines(Vec<Pipeline>),
185}
186
187impl<'de> serde::Deserialize<'de> for PipelineAffinity {
188 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
189 #[derive(Deserialize, PartialEq)]
190 #[serde(rename_all = "snake_case")]
191 enum Token {
192 CrossCutting,
193 #[serde(rename = "dogstatsd")]
194 DogStatsD,
195 Checks,
196 Otlp,
197 Traces,
198 }
199
200 let tokens: Vec<Token> = Vec::deserialize(d)?;
201
202 if tokens.is_empty() {
203 return Err(serde::de::Error::custom("pipelines must be non-empty"));
204 }
205
206 let has_cc = tokens.iter().any(|t| t == &Token::CrossCutting);
207
208 if has_cc && tokens.len() > 1 {
209 return Err(serde::de::Error::custom(
210 "cross_cutting must appear alone in pipelines list",
211 ));
212 }
213
214 if has_cc {
215 return Ok(PipelineAffinity::CrossCutting);
216 }
217
218 let pipelines = tokens
219 .into_iter()
220 .map(|t| match t {
221 Token::DogStatsD => Pipeline::DogStatsD,
222 Token::Checks => Pipeline::Checks,
223 Token::Otlp => Pipeline::Otlp,
224 Token::Traces => Pipeline::Traces,
225 Token::CrossCutting => unreachable!(),
226 })
227 .collect();
228
229 Ok(PipelineAffinity::Pipelines(pipelines))
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum ValueType {
237 Boolean,
238 Integer,
239 Float,
240 String,
241 StringList,
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum InputShape {
253 StringOrInteger,
255}
256
257pub struct Files {
261 pub schema: PathBuf,
262 pub overlay: PathBuf,
263}
264
265impl Default for Files {
266 fn default() -> Self {
267 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
268 .join("..")
269 .join("config")
270 .join("schema");
271 let schema = dir.join("core").join("core_schema.yaml");
272 let overlay = dir.join("schema_overlay.yaml");
273 Files { schema, overlay }
274 }
275}
276
277impl SchemaOverlay {
278 pub fn load(files: Files) -> Result<Self, Error> {
279 let loaded = Self::from_file(&files.overlay)?;
280 loaded.validate(&files.schema)?;
281 Ok(loaded)
282 }
283
284 fn from_yaml(s: &str) -> Result<Self, Error> {
285 let yaml: serde_yaml::Value = serde_yaml::from_str(s).map_err(Error::Yaml)?;
286 Self::lint_yaml(&yaml)?;
287 serde_yaml::from_value(yaml).map_err(Error::Yaml)
288 }
289
290 fn from_file(path: &Path) -> Result<Self, Error> {
291 let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
292 Self::from_yaml(&contents)
293 }
294
295 fn validate(&self, core_schema: &Path) -> Result<(), Error> {
296 self.validate_keys_match(core_schema)?;
297 self.validate_entries()?;
298 Ok(())
299 }
300
301 fn lint_yaml(yaml: &serde_yaml::Value) -> Result<(), Error> {
304 let mapping = yaml
305 .as_mapping()
306 .ok_or_else(|| Error::Validation("overlay must be a YAML mapping".to_string()))?;
307
308 let section_names: Vec<&str> = mapping.keys().filter_map(|k| k.as_str()).collect();
309
310 for required in ["inventory", "excluded"] {
311 if !section_names.contains(&required) {
312 return Err(Error::Validation(format!(
313 "overlay missing required section '{}'",
314 required
315 )));
316 }
317 }
318
319 let pos_known = section_names.iter().position(|&k| k == "inventory").unwrap();
320 let pos_ignored = section_names.iter().position(|&k| k == "excluded").unwrap();
321
322 if pos_known >= pos_ignored {
323 return Err(Error::Validation(
324 "sections must appear in order: known, ignored".to_string(),
325 ));
326 }
327
328 for section_name in ["inventory", "excluded"] {
329 if let Some(section) = yaml.get(section_name).and_then(|v| v.as_mapping()) {
330 let mut prev = "";
331 for key in section.keys().filter_map(|k| k.as_str()) {
332 if key < prev {
333 return Err(Error::Validation(format!(
334 "{}: key '{}' is out of alphabetical order (after '{}')",
335 section_name, key, prev
336 )));
337 }
338 prev = key;
339 }
340 }
341 }
342
343 Ok(())
344 }
345
346 fn validate_keys_match(&self, core_schema: &Path) -> Result<(), Error> {
348 let schema_keys = Self::schema_keys(core_schema)?;
349
350 for key in self.excluded.keys() {
351 if self.inventory.contains_key(key.as_str()) {
352 return Err(Error::Validation(format!(
353 "key '{}' appears in more than one overlay section",
354 key
355 )));
356 }
357 }
358
359 for key in self.inventory.keys().chain(self.excluded.keys()) {
360 if !schema_keys.contains(key.as_str()) {
361 return Err(Error::Validation(format!(
362 "overlay key '{}' is not present in the schema",
363 key
364 )));
365 }
366 }
367
368 let overlay_keys: HashSet<&str> = self
369 .inventory
370 .keys()
371 .chain(self.excluded.keys())
372 .map(|s| s.as_str())
373 .collect();
374 for key in &schema_keys {
375 if !overlay_keys.contains(key.as_str()) {
376 return Err(Error::Validation(format!(
377 "schema key '{}' is not covered by the overlay",
378 key
379 )));
380 }
381 }
382
383 Ok(())
384 }
385
386 fn schema_keys(schema_path: &Path) -> Result<HashSet<String>, Error> {
387 let schema = load_resolved_schema(schema_path)?;
388 let props = schema
389 .get("properties")
390 .and_then(|v| v.as_mapping())
391 .ok_or_else(|| Error::Validation("schema missing 'properties' section".to_string()))?;
392 let mut keys = HashSet::new();
393 Self::collect_schema_keys(props, "", &mut keys);
394 Ok(keys)
395 }
396
397 fn collect_schema_keys(props: &serde_yaml::Mapping, prefix: &str, keys: &mut HashSet<String>) {
398 for (k, v) in props {
399 if let Some(name) = k.as_str() {
400 let full_key = if prefix.is_empty() {
401 name.to_string()
402 } else {
403 format!("{}.{}", prefix, name)
404 };
405 if let Some(sub_props) = v.get("properties").and_then(|p| p.as_mapping()) {
408 Self::collect_schema_keys(sub_props, &full_key, keys);
409 } else {
410 keys.insert(full_key);
411 }
412 }
413 }
414 }
415
416 fn validate_entries(&self) -> Result<(), Error> {
419 let canonical_keys: HashSet<&str> = self.inventory.keys().map(String::as_str).collect();
420
421 for (key, entry) in &self.inventory {
422 match entry {
423 KnownEntry::Full(f) => {
424 if f.test_support.used_by.is_empty() {
425 return Err(Error::Validation(format!(
426 "full key '{}': used_by must be non-empty",
427 key
428 )));
429 }
430 if f.description.len() > 50 {
431 return Err(Error::Validation(format!(
432 "full key '{}': description exceeds 50 chars ({} chars)",
433 key,
434 f.description.len()
435 )));
436 }
437 Self::validate_additional_yaml_paths(key, &f.test_support.additional_yaml_paths, &canonical_keys)?;
438 }
439 KnownEntry::Partial(p) => {
440 if p.test_support.used_by.is_empty() {
441 return Err(Error::Validation(format!(
442 "partial key '{}': used_by must be non-empty",
443 key
444 )));
445 }
446 if p.description.len() > 50 {
447 return Err(Error::Validation(format!(
448 "partial key '{}': description exceeds 50 chars ({} chars)",
449 key,
450 p.description.len()
451 )));
452 }
453 Self::validate_additional_yaml_paths(key, &p.test_support.additional_yaml_paths, &canonical_keys)?;
454 }
455 KnownEntry::Unsupported(u) => {
456 if u.description.len() > 50 {
457 return Err(Error::Validation(format!(
458 "unsupported key '{}': description exceeds 50 chars ({} chars)",
459 key,
460 u.description.len()
461 )));
462 }
463 if u.planned && u.issue.is_none() {
464 return Err(Error::Validation(format!(
465 "unsupported key '{}': planned requires an issue",
466 key
467 )));
468 }
469 }
470 KnownEntry::Unknown(u) => {
471 if let Some(desc) = &u.description {
472 if desc.len() > 50 {
473 return Err(Error::Validation(format!(
474 "unknown key '{}': description exceeds 50 chars ({} chars)",
475 key,
476 desc.len()
477 )));
478 }
479 }
480 }
481 }
482 }
483 Ok(())
484 }
485
486 fn validate_additional_yaml_paths(
487 key: &str, paths: &[String], canonical_keys: &HashSet<&str>,
488 ) -> Result<(), Error> {
489 let mut seen: HashSet<&str> = HashSet::new();
490 for path in paths {
491 if !seen.insert(path.as_str()) {
492 return Err(Error::Validation(format!(
493 "key '{}': duplicate additional_yaml_path '{}'",
494 key, path
495 )));
496 }
497 if path.contains('.') {
498 return Err(Error::Validation(format!(
499 "key '{}': additional_yaml_path '{}' contains a dot. \
500 Dotted aliases land at a different nesting depth in the YAML tree, \
501 which cannot be represented as a serde field alias on the generated \
502 struct. Supporting dotted aliases requires a post-deserialization \
503 merge step or custom Deserialize impl.",
504 key, path
505 )));
506 }
507 if canonical_keys.contains(path.as_str()) {
508 return Err(Error::Validation(format!(
509 "key '{}': additional_yaml_path '{}' collides with a canonical \
510 overlay key. Two fields would deserialize from the same YAML key.",
511 key, path
512 )));
513 }
514 }
515 Ok(())
516 }
517}
518
519fn read_yaml(path: &Path) -> Result<serde_yaml::Value, Error> {
521 let contents = std::fs::read_to_string(path).map_err(|e| Error::Io((path.into(), e)))?;
522 serde_yaml::from_str(&contents).map_err(Error::Yaml)
523}
524
525pub fn load_resolved_schema(schema_path: &Path) -> Result<serde_yaml::Value, Error> {
537 let schema_dir = schema_path.parent().unwrap_or_else(|| Path::new("."));
538 let mut doc = read_yaml(schema_path)?;
539 resolve_refs(&mut doc, schema_dir)?;
540 Ok(doc)
541}
542
543fn resolve_refs(value: &mut serde_yaml::Value, schema_dir: &Path) -> Result<(), Error> {
546 if let Some(map) = value.as_mapping_mut() {
547 if let Some(ref_path) = map.get("$ref").and_then(|v| v.as_str()) {
548 let ref_file = schema_dir.join(ref_path);
549 let mut ref_doc = read_yaml(&ref_file)?;
550 resolve_refs(&mut ref_doc, schema_dir)?;
551 *value = ref_doc;
552 return Ok(());
553 }
554 for (_k, v) in map.iter_mut() {
555 resolve_refs(v, schema_dir)?;
556 }
557 }
558 Ok(())
559}
560
561const VALIDATION_RULES: &str = "\n\
562 \n\
563 Rules that must hold in schema_overlay.yaml:\n\
564 - Every core_schema.yaml key appears in exactly one section (known / ignored).\n\
565 - No key appears in more than one section.\n\
566 - Sections appear in order: known, ignored.\n\
567 - Keys within each section are sorted alphabetically.\n\
568 - full entries: pipelines non-empty, used_by non-empty, description <= 50 chars.\n\
569 - partial entries: pipelines non-empty, used_by non-empty, description <= 50 chars, documentation required.\n\
570 - unsupported entries: pipelines non-empty, description <= 50 chars, planned+issue consistent.\n\
571 - unknown entries: description <= 50 chars (when present).\n\
572 - additional_yaml_paths: no duplicates within a single entry, no dots, no collisions with canonical keys.\n\
573 Fix: edit lib/datadog-agent/config/schema/schema_overlay.yaml.";
574
575#[derive(Debug)]
577pub enum Error {
578 Io((PathBuf, std::io::Error)),
579 Yaml(serde_yaml::Error),
580 Validation(String),
581}
582
583impl std::fmt::Display for Error {
584 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585 match self {
586 Error::Io(e) => write!(f, "Error reading {}: {}", e.0.display(), e.1),
587 Error::Yaml(e) => write!(f, "YAML parse error in overlay: {e}"),
588 Error::Validation(s) => write!(f, "schema_overlay.yaml validation failed: {s}{VALIDATION_RULES}"),
589 }
590 }
591}
592
593impl std::error::Error for Error {
594 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
595 match self {
596 Error::Io(e) => Some(&e.1),
597 Error::Yaml(e) => Some(e),
598 Error::Validation(_) => None,
599 }
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 #[test]
608 fn overlay_loads() {
609 let test_files = Files {
610 schema: Path::new(env!("CARGO_MANIFEST_DIR"))
611 .join("test")
612 .join("fake_schema.yaml"),
613 overlay: Path::new(env!("CARGO_MANIFEST_DIR"))
614 .join("test")
615 .join("fake_overlay.yaml"),
616 };
617 let validated = SchemaOverlay::load(test_files).unwrap();
618 assert_eq!(validated.inventory.len(), 18);
619 }
620
621 #[test]
622 fn pipeline_affinity_cross_cutting() {
623 let yaml = "pipelines: [cross_cutting]";
624 #[derive(Deserialize)]
625 struct W {
626 pipelines: PipelineAffinity,
627 }
628 let w: W = serde_yaml::from_str(yaml).unwrap();
629 assert!(matches!(w.pipelines, PipelineAffinity::CrossCutting));
630 }
631
632 #[test]
633 fn pipeline_affinity_multi() {
634 let yaml = "pipelines: [dogstatsd, traces]";
635 #[derive(Deserialize)]
636 struct W {
637 pipelines: PipelineAffinity,
638 }
639 let w: W = serde_yaml::from_str(yaml).unwrap();
640 if let PipelineAffinity::Pipelines(ps) = w.pipelines {
641 assert_eq!(ps.len(), 2);
642 assert!(matches!(ps[0], Pipeline::DogStatsD));
643 assert!(matches!(ps[1], Pipeline::Traces));
644 } else {
645 panic!("expected Pipelines");
646 }
647 }
648
649 #[test]
650 fn pipeline_affinity_cross_cutting_must_be_alone() {
651 let yaml = "pipelines: [cross_cutting, dogstatsd]";
652 #[derive(Deserialize)]
653 #[allow(dead_code)]
654 struct W {
655 pipelines: PipelineAffinity,
656 }
657 assert!(serde_yaml::from_str::<W>(yaml).is_err());
658 }
659
660 fn load_from_strs(schema: &str, overlay: &str) -> Result<SchemaOverlay, Error> {
661 let dir = tempfile::tempdir().unwrap();
662 let schema_path = dir.path().join("schema.yaml");
663 let overlay_path = dir.path().join("overlay.yaml");
664 std::fs::write(&schema_path, schema).unwrap();
665 std::fs::write(&overlay_path, overlay).unwrap();
666 SchemaOverlay::load(Files {
667 schema: schema_path,
668 overlay: overlay_path,
669 })
670 }
671
672 #[test]
673 fn validation_rejects_schema_key_missing_from_overlay() {
674 let schema = "\
675properties:
676 key_a:
677 type: string
678 key_b:
679 type: string
680";
681 let overlay = "\
682inventory:
683 key_a:
684 support: full
685 pipelines: [cross_cutting]
686 description: \"Key A\"
687 test_support:
688 used_by: [ForwarderConfiguration]
689excluded: {}
690";
691 let err = load_from_strs(schema, overlay).unwrap_err();
692 assert!(
693 err.to_string().contains("schema key 'key_b' is not covered"),
694 "unexpected error: {err}"
695 );
696 }
697
698 #[test]
699 fn validation_rejects_overlay_key_absent_from_schema() {
700 let schema = "\
701properties:
702 key_a:
703 type: string
704";
705 let overlay = "\
706inventory:
707 key_a:
708 support: full
709 pipelines: [cross_cutting]
710 description: \"Key A\"
711 test_support:
712 used_by: [ForwarderConfiguration]
713excluded:
714 key_b: \"not in schema\"
715";
716 let err = load_from_strs(schema, overlay).unwrap_err();
717 assert!(
718 err.to_string().contains("overlay key 'key_b' is not present"),
719 "unexpected error: {err}"
720 );
721 }
722
723 #[test]
724 fn validation_rejects_key_in_two_sections() {
725 let schema = "\
726properties:
727 key_a:
728 type: string
729 key_b:
730 type: string
731";
732 let overlay = "\
733inventory:
734 key_a:
735 support: full
736 pipelines: [cross_cutting]
737 description: \"Key A\"
738 test_support:
739 used_by: [ForwarderConfiguration]
740excluded:
741 key_a: \"duplicate\"
742 key_b: \"ok\"
743";
744 let err = load_from_strs(schema, overlay).unwrap_err();
745 assert!(
746 err.to_string()
747 .contains("key 'key_a' appears in more than one overlay section"),
748 "unexpected error: {err}"
749 );
750 }
751
752 #[test]
754 fn schema_ref_is_resolved_and_keys_namespaced() {
755 let dir = tempfile::tempdir().unwrap();
756 std::fs::write(
757 dir.path().join("sub.yaml"),
758 "properties:\n enabled:\n type: boolean\n",
759 )
760 .unwrap();
761 let schema_path = dir.path().join("schema.yaml");
762 std::fs::write(&schema_path, "properties:\n feature:\n $ref: sub.yaml\n").unwrap();
763
764 let keys = SchemaOverlay::schema_keys(&schema_path).unwrap();
765 assert_eq!(
766 keys,
767 HashSet::from(["feature.enabled".to_string()]),
768 "unexpected keys: {keys:?}"
769 );
770 }
771
772 #[test]
775 fn missing_schema_ref_reports_io_error() {
776 let dir = tempfile::tempdir().unwrap();
777 let schema_path = dir.path().join("schema.yaml");
778 std::fs::write(&schema_path, "properties:\n feature:\n $ref: does_not_exist.yaml\n").unwrap();
779
780 let err = SchemaOverlay::schema_keys(&schema_path).unwrap_err();
781 assert!(matches!(err, Error::Io(_)), "expected Io error, got: {err}");
782 assert!(
783 err.to_string().contains("does_not_exist.yaml"),
784 "error should name the missing file: {err}"
785 );
786 }
787
788 #[test]
789 fn validation_rejects_unsorted_inventory_keys() {
790 let schema = "\
791properties:
792 key_a:
793 type: string
794 key_b:
795 type: string
796";
797 let overlay = "\
798inventory:
799 key_b:
800 support: full
801 pipelines: [cross_cutting]
802 description: \"Key B\"
803 test_support:
804 used_by: [ForwarderConfiguration]
805 key_a:
806 support: full
807 pipelines: [cross_cutting]
808 description: \"Key A\"
809 test_support:
810 used_by: [ForwarderConfiguration]
811excluded: {}
812";
813 let err = load_from_strs(schema, overlay).unwrap_err();
814 assert!(
815 err.to_string().contains("out of alphabetical order"),
816 "unexpected error: {err}"
817 );
818 }
819
820 fn validate_entries_of(overlay: &str) -> Result<(), Error> {
824 SchemaOverlay::from_yaml(overlay)
825 .expect("overlay should deserialize")
826 .validate_entries()
827 }
828
829 #[test]
830 fn per_entry_validation_accepts_a_well_formed_entry_of_every_kind() {
831 let overlay = "\
834inventory:
835 full_key:
836 support: full
837 pipelines: [dogstatsd]
838 description: \"Fully supported key\"
839 test_support:
840 used_by: [ForwarderConfiguration]
841 additional_yaml_paths: [full_alias]
842 partial_key:
843 support: partial
844 pipelines: [traces]
845 description: \"Partially supported key\"
846 documentation: \"Behaves differently from the core agent.\"
847 test_support:
848 used_by: [ForwarderConfiguration]
849 unknown_key:
850 support: unknown
851 description: \"Not yet classified\"
852 unsupported_key:
853 support: none
854 pipelines: [checks]
855 description: \"Unsupported key\"
856 severity: high
857 planned: true
858 issue: \"1234\"
859excluded: {}
860";
861 validate_entries_of(overlay).expect("a well-formed overlay should pass per-entry validation");
862 }
863
864 #[test]
865 fn per_entry_validation_rejects_over_long_description_for_every_entry_kind() {
866 let long = "x".repeat(60);
869 let cases: &[(&str, String)] = &[
870 (
871 "full",
872 format!(
873 "inventory:\n key_a:\n support: full\n pipelines: [dogstatsd]\n \
874 description: \"{long}\"\n test_support:\n used_by: [ForwarderConfiguration]\nexcluded: {{}}\n"
875 ),
876 ),
877 (
878 "partial",
879 format!(
880 "inventory:\n key_a:\n support: partial\n pipelines: [dogstatsd]\n \
881 description: \"{long}\"\n documentation: \"diverges\"\n test_support:\n \
882 used_by: [ForwarderConfiguration]\nexcluded: {{}}\n"
883 ),
884 ),
885 (
886 "unsupported",
887 format!(
888 "inventory:\n key_a:\n support: none\n pipelines: [dogstatsd]\n \
889 description: \"{long}\"\n severity: low\n planned: false\nexcluded: {{}}\n"
890 ),
891 ),
892 (
893 "unknown",
894 format!("inventory:\n key_a:\n support: unknown\n description: \"{long}\"\nexcluded: {{}}\n"),
895 ),
896 ];
897
898 for (kind, overlay) in cases {
899 let err = validate_entries_of(overlay).expect_err(&format!("{kind} entry should be rejected"));
900 assert!(
901 err.to_string().contains("description exceeds 50 chars"),
902 "{kind}: unexpected error: {err}"
903 );
904 }
905 }
906
907 #[test]
908 fn per_entry_validation_rejects_empty_used_by_for_full_and_partial_entries() {
909 let full = "\
910inventory:
911 key_a:
912 support: full
913 pipelines: [dogstatsd]
914 description: \"Key A\"
915 test_support:
916 used_by: []
917excluded: {}
918";
919 let err = validate_entries_of(full).expect_err("full entry with empty used_by should be rejected");
920 assert!(
921 err.to_string().contains("full key 'key_a': used_by must be non-empty"),
922 "unexpected error: {err}"
923 );
924
925 let partial = "\
926inventory:
927 key_a:
928 support: partial
929 pipelines: [dogstatsd]
930 description: \"Key A\"
931 documentation: \"diverges\"
932 test_support:
933 used_by: []
934excluded: {}
935";
936 let err = validate_entries_of(partial).expect_err("partial entry with empty used_by should be rejected");
937 assert!(
938 err.to_string()
939 .contains("partial key 'key_a': used_by must be non-empty"),
940 "unexpected error: {err}"
941 );
942 }
943
944 #[test]
945 fn per_entry_validation_rejects_planned_unsupported_entry_without_issue() {
946 let overlay = "\
947inventory:
948 key_a:
949 support: none
950 pipelines: [dogstatsd]
951 description: \"Key A\"
952 severity: medium
953 planned: true
954excluded: {}
955";
956 let err = validate_entries_of(overlay).expect_err("planned unsupported entry without issue should be rejected");
957 assert!(
958 err.to_string()
959 .contains("unsupported key 'key_a': planned requires an issue"),
960 "unexpected error: {err}"
961 );
962 }
963
964 #[test]
965 fn per_entry_validation_rejects_duplicate_additional_yaml_path() {
966 let overlay = "\
967inventory:
968 key_a:
969 support: full
970 pipelines: [dogstatsd]
971 description: \"Key A\"
972 test_support:
973 used_by: [ForwarderConfiguration]
974 additional_yaml_paths: [dup_alias, dup_alias]
975excluded: {}
976";
977 let err = validate_entries_of(overlay).expect_err("duplicate additional_yaml_path should be rejected");
978 assert!(
979 err.to_string()
980 .contains("key 'key_a': duplicate additional_yaml_path 'dup_alias'"),
981 "unexpected error: {err}"
982 );
983 }
984
985 #[test]
986 fn per_entry_validation_rejects_dotted_additional_yaml_path() {
987 let overlay = "\
990inventory:
991 key_a:
992 support: full
993 pipelines: [dogstatsd]
994 description: \"Key A\"
995 test_support:
996 used_by: [ForwarderConfiguration]
997 additional_yaml_paths: [\"nested.alias\"]
998excluded: {}
999";
1000 let err = validate_entries_of(overlay).expect_err("dotted additional_yaml_path should be rejected");
1001 assert!(
1002 err.to_string()
1003 .contains("additional_yaml_path 'nested.alias' contains a dot"),
1004 "unexpected error: {err}"
1005 );
1006 }
1007
1008 #[test]
1009 fn per_entry_validation_rejects_additional_yaml_path_colliding_with_canonical_key() {
1010 let overlay = "\
1013inventory:
1014 alpha:
1015 support: full
1016 pipelines: [dogstatsd]
1017 description: \"Alpha\"
1018 test_support:
1019 used_by: [ForwarderConfiguration]
1020 additional_yaml_paths: [beta]
1021 beta:
1022 support: full
1023 pipelines: [dogstatsd]
1024 description: \"Beta\"
1025 test_support:
1026 used_by: [ForwarderConfiguration]
1027excluded: {}
1028";
1029 let err = validate_entries_of(overlay).expect_err("aliasing a canonical key should be rejected");
1030 assert!(
1031 err.to_string()
1032 .contains("additional_yaml_path 'beta' collides with a canonical"),
1033 "unexpected error: {err}"
1034 );
1035 }
1036}