datadog_agent_config_overlay_model/
lib.rs

1//! Deserialization types for `schema_overlay.yaml`.
2//!
3//! This crate provides the model for management of our inventory and metadata around Datadog Agent
4//! configuration. It is designed to be used during build processes by `build.rs` and, for
5//! simplicity, should not depend on any other crates from our workspace.
6//!
7//! The overlay is validated in two passes. First, standard serde deserialization enforces its
8//! type integrity, then custom validation logic runs. This file is the source of truth on what
9//! can and can-not be present in the overlay.
10
11pub 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/// Top-level overlay structure.
24///
25/// `known` covers every key the team has reviewed and classified. `ignored` covers keys irrelevant
26/// to ADP's domain. Together they must account for every key in `core_schema.yaml`.
27#[derive(Debug, Clone, Deserialize)]
28pub struct SchemaOverlay {
29    pub inventory: IndexMap<String, KnownEntry>,
30    pub excluded: IndexMap<String, String>,
31}
32
33/// Classification of a known (non-ignored) config key.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(tag = "support", rename_all = "snake_case")]
36pub enum KnownEntry {
37    /// ADP reads and fully supports this key; behavior matches the core Agent.
38    Full(FullSupport),
39    /// ADP reads this key but behavior diverges from the core Agent in some cases.
40    Partial(PartialSupport),
41    /// ADP does not support this key.
42    #[serde(rename = "none")]
43    Unsupported(Unsupported),
44    /// ADP's compatibility with this key has not yet been determined.
45    Unknown(UnknownSupport),
46}
47
48/// Metadata for a fully supported configuration key.
49#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub struct FullSupport {
52    /// Which pipelines depend on this key (non-empty).
53    pub pipelines: PipelineAffinity,
54    /// Short description for documentation tables (<= 50 chars).
55    pub description: String,
56    /// Extended documentation (appears in generated docs).
57    #[serde(default)]
58    pub documentation: Option<String>,
59    /// GitHub issue tracking number.
60    #[serde(default)]
61    pub issue: Option<String>,
62    /// Accepted input shape when it is wider than the schema's declared type (see [`InputShape`]).
63    #[serde(default)]
64    pub input_shape: Option<InputShape>,
65    /// Fields to support the `config_registry` and configuration smoke tests.
66    pub test_support: TestSupport,
67}
68
69/// Metadata for a partially supported configuration key.
70#[derive(Debug, Clone, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub struct PartialSupport {
73    /// Which pipelines depend on this key (non-empty).
74    pub pipelines: PipelineAffinity,
75    /// Short description for documentation tables (<= 50 chars).
76    pub description: String,
77    /// Extended documentation explaining the behavioral divergence. Required for partial keys.
78    pub documentation: String,
79    /// When true, the runtime classifier emits a warning for non-default values of this key.
80    #[serde(default)]
81    pub warn: bool,
82    /// GitHub issue tracking number.
83    #[serde(default)]
84    pub issue: Option<String>,
85    /// Accepted input shape when it is wider than the schema's declared type (see [`InputShape`]).
86    #[serde(default)]
87    pub input_shape: Option<InputShape>,
88    /// Fields to support the `config_registry` and configuration smoke tests.
89    pub test_support: TestSupport,
90}
91
92/// Metadata for an unsupported configuration key.
93#[derive(Debug, Clone, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub struct Unsupported {
96    /// Pipelines affected by the lack of support.
97    pub pipelines: PipelineAffinity,
98    /// Short description for documentation tables (<= 50 chars).
99    pub description: String,
100    /// Longer explanation of why it is unsupported and future plans.
101    #[serde(default)]
102    pub documentation: Option<String>,
103    /// How severe the lack of support is.
104    pub severity: Severity,
105    /// Whether support is planned. When true, `issue` must be present.
106    pub planned: bool,
107    /// GitHub issue tracking number.
108    #[serde(default)]
109    pub issue: Option<String>,
110}
111
112/// Metadata for a key whose support level has not yet been determined.
113#[derive(Debug, Clone, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct UnknownSupport {
116    /// Short description for documentation tables (<= 50 chars), if known.
117    #[serde(default)]
118    pub description: Option<String>,
119    /// Severity estimate, if there is intuition about the impact.
120    #[serde(default)]
121    pub severity: Option<Severity>,
122    /// GitHub issue tracking the investigation.
123    #[serde(default)]
124    pub issue: Option<String>,
125}
126
127/// Metadata to support config smoke tests.
128///
129/// These fields support logic in the configuration smoke tests and are tightly bound to the
130/// behavior of the test logic. They may change if the test methodology changes.
131#[derive(Debug, Clone, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub struct TestSupport {
134    /// Environment variable overrides for this key. Checked by configuration smoke tests.
135    #[serde(default)]
136    pub env_var_override: Option<Vec<String>>,
137    /// Alias YAML paths that map to the same config key. Checked by configuration smoke tests.
138    #[serde(default)]
139    pub additional_yaml_paths: Vec<String>,
140    /// Override the type inferred from the schema.
141    #[serde(default)]
142    pub value_type_override: Option<ValueType>,
143    /// Configuration consumers that incorporate this key (non-empty).
144    pub used_by: IndexSet<ConfigurationStruct>,
145    /// Literal JSON value for smoke test injection.
146    #[serde(default)]
147    pub test_json: Option<String>,
148    /// TRANSITIONAL BANDAID. Carries metadata needed only to reproduce the hand-written
149    /// registry (filename partitioning, Saluki-only schema source/default). Delete with it.
150    #[serde(default)]
151    pub additional_attributes: IndexMap<String, String>,
152}
153
154/// Impact severity of an unsupported or unknown key.
155#[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/// A single pipeline in the ADP vocabulary.
164#[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/// Which pipelines a config key is associated with.
175///
176/// Deserialized from a flat YAML list of pipeline tokens. An empty list is rejected. A list
177/// containing only `cross_cutting` folds to [`PipelineAffinity::CrossCutting`]; `cross_cutting`
178/// may not appear alongside other tokens.
179#[derive(Debug, Clone)]
180pub enum PipelineAffinity {
181    /// The key affects all pipelines / ADP behaviour as a whole.
182    CrossCutting,
183    /// The key affects the listed pipelines (non-empty, in declaration order).
184    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/// Override type for when the schema under-specifies a key's value type.
234#[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/// A widened input shape a schema `string` leaf accepts beyond a bare string.
245///
246/// The vendored schema types some settings as `string` but documents an equivalent numeric form
247/// (for example a byte size given as `10485760` instead of `"10MB"`). The schema cannot express that
248/// union, so the overlay names it and codegen attaches a tolerant deserializer to the generated
249/// field.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum InputShape {
253    /// Accept a string unchanged, or a non-negative integer normalized to its decimal string.
254    StringOrInteger,
255}
256
257/// File paths to the two YAML files required as input by this library.
258///
259/// Defaults to the canonical location of the required schema files in this library.
260pub 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    /// Ensure that sections appear in the required order and that keys within each section are
302    /// sorted alphabetically.
303    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    /// Ensure that each core schema key appears exactly once across the overlay sections.
347    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                // `$ref`s have already been inlined by `load_resolved_schema`, so a node either
406                // carries `properties` (recurse) or is a leaf key.
407                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    /// Validate per-entry constraints: description length, `used_by` non-empty, no duplicate
417    /// `additional_yaml_paths`, and planned+issue consistency for unsupported entries.
418    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
519/// Read a YAML file into a [`serde_yaml::Value`], mapping failures onto [`Error`].
520fn 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
525/// Load the core schema and recursively inline every `$ref: <file>` reference into a single
526/// resolved document with no remaining `$ref` nodes.
527///
528/// Referenced files are resolved relative to the directory containing `schema_path`. This is the
529/// one place that reads subsystem schema files; downstream consumers traverse the returned tree
530/// and never handle `$ref` themselves. Build-time only.
531///
532/// # Errors
533///
534/// Returns [`Error::Io`] if `schema_path` or any referenced file cannot be read, and
535/// [`Error::Yaml`] if any file fails to parse. The offending path is carried in the error.
536pub 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
543/// Recursively replace any mapping node containing a `$ref: <file>` entry with the (also resolved)
544/// contents of the referenced file, found relative to `schema_dir`.
545fn 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/// Errors that can occur when loading a schema overlay.
576#[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    /// A `$ref` is inlined and its leaf keys are namespaced under the parent key.
753    #[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    /// A missing `$ref` target surfaces a clear I/O error naming the file, not a misleading
773    /// "key not covered" validation error.
774    #[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    // The per-entry validation rules (`validate_entries`) are documented in `VALIDATION_RULES` and
821    // enforced independently of the schema cross-check, so these tests deserialize an overlay in
822    // isolation (via `from_yaml`) and run only that pass — no matching core schema is needed.
823    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        // Keys are alphabetically ordered (full < partial < unknown < unsupported) so the YAML lint
832        // pass is satisfied and only per-entry validation is under test.
833        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        // A 60-character description exceeds the documented 50-char cap. Each entry kind that carries a
867        // description enforces the same limit, so this walks all four.
868        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        // Dotted aliases can't be represented as a serde field alias on the generated struct, so they're
988        // rejected outright.
989        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        // `alpha`'s alias `beta` collides with the canonical key `beta`; two fields would deserialize
1011        // from the same YAML key. Keys are alphabetically ordered so the YAML lint pass is satisfied.
1012        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}