datadog_agent_config_overlay_model/
schema_gen.rs

1//! Schema loading and Rust codegen for the Datadog agent config schema.
2//!
3//! Parses `core_schema.yaml` (with any overlay applied upstream) into a flat map of
4//! `yaml_path → FieldInfo`, then emits `schema.rs` containing one `SchemaEntry` constant
5//! per config key. Used exclusively at build time.
6//!
7//! The schema may contain `$ref: <filename>` entries that reference subsystem schema files in
8//! the same directory. These are resolved and inlined during loading.
9use std::path::Path;
10
11use indexmap::IndexMap;
12use serde_yaml::Value;
13
14/// Value type of a config field, as declared in the schema YAML.
15///
16/// `Unknown` is assigned when the YAML `type` is absent or unrecognised; affected fields
17/// are emitted with a `// TODO` comment in the generated output.
18pub enum FieldType {
19    String,
20    Bool,
21    Integer,
22    Float,
23    StringList,
24    Duration,
25    Unknown,
26}
27
28/// A named environment-variable parser declared by the schema's `env_parser` key.
29///
30/// The Datadog Agent registers a bespoke transform for these settings instead of the default
31/// type-based cast. Codegen maps supported parsers onto runtime decoders and rejects unsupported
32/// parsers for modeled keys. A setting without `env_parser` falls back to its declared type.
33pub enum EnvParser {
34    Json,
35    CommaSeparated,
36    CsvCommaSeparated,
37    CommaAndSpaceSeparated,
38    CommaThenSpaceSeparated,
39    JsonListOrCommaSeparated,
40    JsonListOrSpaceSeparated,
41    TracesSpan,
42}
43
44impl EnvParser {
45    /// Parses a schema `env_parser` value, panicking on an unrecognized name so a not-modeled parser
46    /// fails the build rather than silently degrading to the type-based fallback.
47    fn parse(name: &str) -> Self {
48        match name {
49            "json" => EnvParser::Json,
50            "comma_separated" => EnvParser::CommaSeparated,
51            "csv_comma_separated" => EnvParser::CsvCommaSeparated,
52            "comma_and_space_separated" => EnvParser::CommaAndSpaceSeparated,
53            "comma_then_space_separated" => EnvParser::CommaThenSpaceSeparated,
54            "json_list_or_comma_separated" => EnvParser::JsonListOrCommaSeparated,
55            "json_list_or_space_separated" => EnvParser::JsonListOrSpaceSeparated,
56            "traces_span" => EnvParser::TracesSpan,
57            other => panic!("unknown env_parser `{other}` in schema; add it to EnvParser and env_decode"),
58        }
59    }
60}
61
62/// How a config field is reachable through an environment variable.
63///
64/// Mirrors the Datadog Agent's `bindEnv`: a setting either has no env var, uses the standard
65/// `DD_` + `UPPER(dotted_path)` name derived from its key, or declares explicit names that
66/// replace that derived default. The distinction matters to codegen: the standard name is
67/// computable from the path, an overridden name is not, and a field with no env var must never be
68/// treated as env-reachable.
69pub enum EnvBinding {
70    /// The field carries a `no-env` tag: no environment variable maps to it.
71    None,
72    /// No explicit env vars are declared, so the Agent derives `DD_` + `UPPER(dotted_path)`.
73    Standard,
74    /// The schema declares explicit environment variable names, which replace the derived default. Non-empty.
75    Overridden(Vec<String>),
76}
77
78/// Parsed metadata for a single config field.
79pub struct FieldInfo {
80    /// Resolved value type (see [`FieldType`]).
81    pub value_type: FieldType,
82    /// How the field is reachable through an environment variable (see [`EnvBinding`]).
83    pub env: EnvBinding,
84    /// The schema's named `env_parser`, if any (see [`EnvParser`]); `None` uses the type fallback.
85    pub env_parser: Option<EnvParser>,
86    /// Default value serialised as a JSON literal, or `None` if the schema omits one.
87    pub default: Option<String>,
88}
89
90/// Load and flatten the schema at `schema_path` into a `yaml_path → FieldInfo` map.
91///
92/// Resolves `$ref: <filename>` entries by loading the referenced files from the same directory.
93/// The map is sorted by key. Panics if the file cannot be read or parsed.
94pub fn load_schema(datadog_schema: &Path, otel_schema_dir: &Path) -> IndexMap<String, FieldInfo> {
95    let doc = crate::load_composed_schema(datadog_schema, otel_schema_dir)
96        .unwrap_or_else(|e| panic!("failed to load composed schema: {e}"));
97    load_schema_from_value(&doc)
98}
99
100/// Builds the flat `yaml_path → FieldInfo` map from a pre-loaded composed schema.
101///
102/// Callers that already hold the composed schema should use this instead of [`load_schema`] to
103/// avoid a redundant disk read and guarantee every consumer sees the same document.
104pub fn load_schema_from_value(doc: &serde_yaml::Value) -> IndexMap<String, FieldInfo> {
105    let properties = doc
106        .get("properties")
107        .and_then(|v| v.as_mapping())
108        .expect("schema root must have a 'properties' mapping");
109
110    let mut entries = Vec::new();
111    collect_entries(properties, &[], &mut entries);
112    entries.sort_by(|a, b| a.0.cmp(&b.0));
113
114    let mut map = IndexMap::new();
115    for (yaml_path, info) in entries {
116        map.insert(yaml_path, info);
117    }
118    map
119}
120
121fn collect_entries(mapping: &serde_yaml::Mapping, path_parts: &[&str], out: &mut Vec<(String, FieldInfo)>) {
122    for (key, value) in mapping {
123        let key_str = match key.as_str() {
124            Some(s) => s,
125            None => continue,
126        };
127
128        let mut parts = path_parts.to_vec();
129        parts.push(key_str);
130
131        // `$ref`s are inlined by `crate::load_resolved_schema` before we get here, so every node
132        // is either a `setting`, a `section`, or some other container with `properties`.
133        let node_type = value.get("node_type").and_then(|v| v.as_str()).unwrap_or("");
134
135        match node_type {
136            "setting" => out.push(parse_setting(&parts, value)),
137            "section" => {
138                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
139                    collect_entries(props, &parts, out);
140                }
141            }
142            _ => {
143                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
144                    collect_entries(props, &parts, out);
145                }
146            }
147        }
148    }
149}
150
151fn parse_setting(path_parts: &[&str], value: &Value) -> (String, FieldInfo) {
152    let yaml_path = path_parts.join(".");
153
154    let has_no_env_tag = value
155        .get("tags")
156        .and_then(|v| v.as_sequence())
157        .map(|tags| tags.iter().any(|t| t.as_str() == Some("no-env")))
158        .unwrap_or(false);
159
160    let explicit_env_vars: Vec<String> = value
161        .get("env_vars")
162        .and_then(|v| v.as_sequence())
163        .map(|seq| seq.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
164        .unwrap_or_default();
165
166    // A `no-env` tag wins over any declared names: the field is not env-reachable at all.
167    let env = if has_no_env_tag {
168        EnvBinding::None
169    } else if explicit_env_vars.is_empty() {
170        EnvBinding::Standard
171    } else {
172        EnvBinding::Overridden(explicit_env_vars)
173    };
174
175    let value_type = parse_value_type(value);
176    let default = value.get("default").and_then(yaml_value_to_json_str);
177    let env_parser = value.get("env_parser").and_then(|v| v.as_str()).map(EnvParser::parse);
178
179    (
180        yaml_path,
181        FieldInfo {
182            value_type,
183            env,
184            env_parser,
185            default,
186        },
187    )
188}
189
190fn parse_value_type(value: &Value) -> FieldType {
191    let ty = value.get("type").and_then(|v| v.as_str());
192    let format = value.get("format").and_then(|v| v.as_str());
193
194    // `format: duration` folds into a single effective `Duration` type regardless of the declared
195    // base type. The vendored Agent schema currently declares these as `type: number` (nanoseconds
196    // on the wire) but has also used `type: string` (Go duration text); both are durations to us.
197    if format == Some("duration") {
198        return match ty {
199            Some("string") | Some("number") | Some("integer") => FieldType::Duration,
200            other => panic!(
201                "config schema field has `format: duration` with unsupported base type {:?}; \
202                 expected string, number, or integer",
203                other
204            ),
205        };
206    }
207
208    match ty {
209        Some("string") => FieldType::String,
210        Some("boolean") => FieldType::Bool,
211        Some("integer") => FieldType::Integer,
212        Some("number") => FieldType::Float,
213        Some("array") => {
214            let item_type = value.get("items").and_then(|v| v.get("type")).and_then(|v| v.as_str());
215            if item_type == Some("string") {
216                FieldType::StringList
217            } else {
218                FieldType::Unknown
219            }
220        }
221        _ => FieldType::Unknown,
222    }
223}
224
225fn yaml_value_to_json_str(value: &serde_yaml::Value) -> Option<String> {
226    match value {
227        serde_yaml::Value::Null => None,
228        serde_yaml::Value::Bool(b) => Some(b.to_string()),
229        serde_yaml::Value::Number(n) => Some(n.to_string()),
230        serde_yaml::Value::String(s) => {
231            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
232            Some(format!("\"{}\"", escaped))
233        }
234        serde_yaml::Value::Sequence(seq) => {
235            let items: Option<Vec<String>> = seq.iter().map(yaml_value_to_json_str).collect();
236            items.map(|elems| format!("[{}]", elems.join(",")))
237        }
238        serde_yaml::Value::Mapping(map) if map.is_empty() => Some("{}".to_string()),
239        _ => None,
240    }
241}
242
243/// Return the `ValueType::*` token string for use in generated Rust source.
244pub fn field_type_as_rust(ft: &FieldType) -> &'static str {
245    match ft {
246        FieldType::String | FieldType::Unknown => "ValueType::String",
247        FieldType::Bool => "ValueType::Bool",
248        FieldType::Integer => "ValueType::Integer",
249        FieldType::Float => "ValueType::Float",
250        FieldType::StringList => "ValueType::StringList",
251        FieldType::Duration => "ValueType::Duration",
252    }
253}
254
255/// Return `true` if `ft` is [`FieldType::Unknown`].
256pub fn is_unknown(ft: &FieldType) -> bool {
257    matches!(ft, FieldType::Unknown)
258}
259
260/// Escape backslashes and double-quotes in `s` for use inside a Rust string literal.
261pub fn escape_str(s: &str) -> String {
262    s.replace('\\', "\\\\").replace('"', "\\\"")
263}
264
265/// Generate `schema.rs` in `dir` from `schema_map`.
266///
267/// The file contains one `pub const <NAME>: SchemaEntry = SchemaEntry { … };` block per
268/// entry, sorted alphabetically. Panics if the file cannot be written.
269pub fn generate_schema_rs(schema_map: &IndexMap<String, FieldInfo>, dir: &Path) {
270    use std::fmt::Write as _;
271
272    let mut out = String::new();
273    writeln!(
274        out,
275        "// @generated by build.rs from core_schema.yaml + schema_overlay.yaml — DO NOT EDIT"
276    )
277    .unwrap();
278    writeln!(out).unwrap();
279
280    let mut keys: Vec<&str> = schema_map.keys().map(|s| s.as_str()).collect();
281    keys.sort_unstable();
282
283    for yaml_path in &keys {
284        let info = &schema_map[*yaml_path];
285        let const_name = yaml_path_to_const(yaml_path);
286        let vt = field_type_as_rust(&info.value_type);
287
288        if is_unknown(&info.value_type) {
289            writeln!(
290                out,
291                "// TODO: unknown type for '{}' — set value_type_override in the annotation",
292                yaml_path
293            )
294            .unwrap();
295        }
296
297        // The emitted `SchemaEntry.env_vars` carries only explicit names, matching prior output:
298        // standard (derived) and no-env fields both serialise to `&[]`.
299        let env_vars_lit = match &info.env {
300            EnvBinding::Overridden(vars) => {
301                let items: Vec<String> = vars.iter().map(|e| format!("\"{}\"", escape_str(e))).collect();
302                format!("&[{}]", items.join(", "))
303            }
304            EnvBinding::None | EnvBinding::Standard => "&[]".to_string(),
305        };
306
307        let default_lit = match &info.default {
308            Some(d) => format!("Some(\"{}\")", escape_str(d)),
309            None => "None".to_string(),
310        };
311
312        writeln!(out, "pub const {}: SchemaEntry = SchemaEntry {{", const_name).unwrap();
313        writeln!(out, "    schema: Schema::Datadog,").unwrap();
314        writeln!(out, "    yaml_path: \"{}\",", yaml_path).unwrap();
315        writeln!(out, "    env_vars: {},", env_vars_lit).unwrap();
316        writeln!(out, "    value_type: {},", vt).unwrap();
317        writeln!(out, "    default: {},", default_lit).unwrap();
318        writeln!(out, "}};").unwrap();
319        writeln!(out).unwrap();
320    }
321
322    let path = dir.join("schema.rs");
323    std::fs::write(&path, out).unwrap_or_else(|e| panic!("cannot write {}: {}", path.display(), e));
324}
325
326/// Convert a dotted YAML path (for example, `"dogstatsd.bind_host"`) to a `SCREAMING_SNAKE_CASE`
327/// Rust identifier suitable for a `const` name.
328pub fn yaml_path_to_const(yaml_path: &str) -> String {
329    yaml_path
330        .chars()
331        .map(|c| if c == '.' || c == '-' { '_' } else { c })
332        .collect::<String>()
333        .to_uppercase()
334}