Skip to main content

datadog_agent_config/classifier/
classifier.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use super::{ClassifierEntry, PipelineAffinity, SupportLevel, CLASSIFIER_ENTRIES};
6
7/// Result of classifying a single config key/value pair against the registry.
8pub struct Classification {
9    /// The level at which this config key is supported by Saluki.
10    pub support_level: SupportLevel,
11    /// Whether the value matches the schema default.
12    pub is_default: bool,
13    /// Which pipelines this key's incompatibility warning applies to.
14    pub pipeline_affinity: PipelineAffinity,
15}
16
17/// Classifies the support level of config keys and determines if a value is default.
18///
19/// Only knows about annotated keys (supported + unsupported). Keys not in the registry - whether
20/// ignored, unrecognized, or anything else - return `None` from
21/// [`classify`](Self::classify).
22pub struct ConfigClassifier {
23    lookup: HashMap<&'static str, &'static ClassifierEntry>,
24}
25
26impl ConfigClassifier {
27    /// Builds a classifier from all annotated config keys.
28    pub fn new() -> Self {
29        let mut lookup = HashMap::new();
30
31        for entry in CLASSIFIER_ENTRIES {
32            lookup.insert(entry.yaml_path, entry);
33            for alias in entry.aliases {
34                lookup.insert(alias, entry);
35            }
36        }
37
38        Self { lookup }
39    }
40
41    /// Classifies a single config key/value pair against the registry.
42    ///
43    /// Returns `None` for keys not in the registry (ignored, unrecognized, etc.).
44    pub fn classify(&self, key: &str, value: &Value) -> Option<Classification> {
45        let entry = self.lookup.get(key)?;
46        Some(Classification {
47            support_level: entry.support_level,
48            is_default: is_default_value(entry.default, value),
49            pipeline_affinity: entry.pipeline_affinity,
50        })
51    }
52}
53
54fn is_default_value(default: Option<&str>, value: &Value) -> bool {
55    match default {
56        Some(default_str) => match serde_json::from_str::<Value>(default_str) {
57            Ok(default_value) => *value == default_value,
58            Err(_) => false,
59        },
60        None => match value {
61            Value::Null => true,
62            Value::String(s) => s.is_empty(),
63            _ => false,
64        },
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use crate::classifier::Severity;
72
73    fn classifier() -> ConfigClassifier {
74        ConfigClassifier::new()
75    }
76
77    #[test]
78    fn full_support_keys_not_in_classifier() {
79        // Full-support keys are not actionable at the call site; the classifier omits them.
80        // The call site treats None identically to Full (silently continues).
81        let c = classifier();
82        assert!(c.classify("dogstatsd_port", &Value::Number(9999.into())).is_none());
83        assert!(c.classify("log_payloads", &Value::Bool(true)).is_none());
84    }
85
86    #[test]
87    fn partial_non_default() {
88        let c = classifier();
89        let result = c
90            .classify("min_tls_version", &Value::String("non_default_value".into()))
91            .unwrap();
92        assert_eq!(result.support_level, SupportLevel::Partial);
93    }
94
95    #[test]
96    fn incompatible_non_default() {
97        let c = classifier();
98        let result = c.classify("tls_handshake_timeout", &Value::Number(999.into())).unwrap();
99        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
100        assert!(!result.is_default);
101    }
102
103    #[test]
104    fn incompatible_default() {
105        let c = classifier();
106        // config_id has schema default "" (empty string)
107        let result = c.classify("config_id", &Value::String("".into())).unwrap();
108        assert!(matches!(result.support_level, SupportLevel::Incompatible(_)));
109        assert!(result.is_default);
110    }
111
112    #[test]
113    fn not_in_registry_returns_none() {
114        let c = classifier();
115        assert!(c.classify("totally_made_up_key", &Value::Bool(true)).is_none());
116        assert!(c.classify("GUI_host", &Value::String("localhost".into())).is_none());
117    }
118
119    #[test]
120    fn duration_default_null_is_not_default() {
121        let c = classifier();
122        // tls_handshake_timeout now has default "10s" (a duration string); null != "10s"
123        let result = c.classify("tls_handshake_timeout", &Value::Null).unwrap();
124        assert!(!result.is_default);
125    }
126
127    #[test]
128    fn duration_default_empty_string_is_not_default() {
129        let c = classifier();
130        // tls_handshake_timeout has default "10s"; empty string does not match
131        let result = c.classify("tls_handshake_timeout", &Value::String("".into())).unwrap();
132        assert!(!result.is_default);
133    }
134
135    #[test]
136    fn none_default_non_empty_is_not_default() {
137        let c = classifier();
138        let result = c
139            .classify("tls_handshake_timeout", &Value::String("something".into()))
140            .unwrap();
141        assert!(!result.is_default);
142    }
143
144    #[test]
145    fn incompatible_severity_levels() {
146        let c = classifier();
147        let result = c.classify("tls_handshake_timeout", &Value::Number(30.into())).unwrap();
148        assert!(matches!(
149            result.support_level,
150            SupportLevel::Incompatible(Severity::Medium)
151        ));
152    }
153}