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(schema_path: &Path) -> IndexMap<String, FieldInfo> {
95    let doc = crate::load_resolved_schema(schema_path).unwrap_or_else(|e| panic!("failed to load schema: {e}"));
96    let properties = doc
97        .get("properties")
98        .and_then(|v| v.as_mapping())
99        .expect("schema root must have a 'properties' mapping");
100
101    let mut entries = Vec::new();
102    collect_entries(properties, &[], &mut entries);
103    entries.sort_by(|a, b| a.0.cmp(&b.0));
104
105    let mut map = IndexMap::new();
106    for (yaml_path, info) in entries {
107        map.insert(yaml_path, info);
108    }
109    map
110}
111
112fn collect_entries(mapping: &serde_yaml::Mapping, path_parts: &[&str], out: &mut Vec<(String, FieldInfo)>) {
113    for (key, value) in mapping {
114        let key_str = match key.as_str() {
115            Some(s) => s,
116            None => continue,
117        };
118
119        let mut parts = path_parts.to_vec();
120        parts.push(key_str);
121
122        // `$ref`s are inlined by `crate::load_resolved_schema` before we get here, so every node
123        // is either a `setting`, a `section`, or some other container with `properties`.
124        let node_type = value.get("node_type").and_then(|v| v.as_str()).unwrap_or("");
125
126        match node_type {
127            "setting" => out.push(parse_setting(&parts, value)),
128            "section" => {
129                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
130                    collect_entries(props, &parts, out);
131                }
132            }
133            _ => {
134                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
135                    collect_entries(props, &parts, out);
136                }
137            }
138        }
139    }
140}
141
142fn parse_setting(path_parts: &[&str], value: &Value) -> (String, FieldInfo) {
143    let yaml_path = path_parts.join(".");
144
145    let has_no_env_tag = value
146        .get("tags")
147        .and_then(|v| v.as_sequence())
148        .map(|tags| tags.iter().any(|t| t.as_str() == Some("no-env")))
149        .unwrap_or(false);
150
151    let explicit_env_vars: Vec<String> = value
152        .get("env_vars")
153        .and_then(|v| v.as_sequence())
154        .map(|seq| seq.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
155        .unwrap_or_default();
156
157    // A `no-env` tag wins over any declared names: the field is not env-reachable at all.
158    let env = if has_no_env_tag {
159        EnvBinding::None
160    } else if explicit_env_vars.is_empty() {
161        EnvBinding::Standard
162    } else {
163        EnvBinding::Overridden(explicit_env_vars)
164    };
165
166    let value_type = parse_value_type(value);
167    let default = value.get("default").and_then(yaml_value_to_json_str);
168    let env_parser = value.get("env_parser").and_then(|v| v.as_str()).map(EnvParser::parse);
169
170    (
171        yaml_path,
172        FieldInfo {
173            value_type,
174            env,
175            env_parser,
176            default,
177        },
178    )
179}
180
181fn parse_value_type(value: &Value) -> FieldType {
182    let ty = value.get("type").and_then(|v| v.as_str());
183    let format = value.get("format").and_then(|v| v.as_str());
184
185    // `format: duration` folds into a single effective `Duration` type regardless of the declared
186    // base type. The vendored Agent schema currently declares these as `type: number` (nanoseconds
187    // on the wire) but has also used `type: string` (Go duration text); both are durations to us.
188    if format == Some("duration") {
189        return match ty {
190            Some("string") | Some("number") | Some("integer") => FieldType::Duration,
191            other => panic!(
192                "config schema field has `format: duration` with unsupported base type {:?}; \
193                 expected string, number, or integer",
194                other
195            ),
196        };
197    }
198
199    match ty {
200        Some("string") => FieldType::String,
201        Some("boolean") => FieldType::Bool,
202        Some("integer") => FieldType::Integer,
203        Some("number") => FieldType::Float,
204        Some("array") => {
205            let item_type = value.get("items").and_then(|v| v.get("type")).and_then(|v| v.as_str());
206            if item_type == Some("string") {
207                FieldType::StringList
208            } else {
209                FieldType::Unknown
210            }
211        }
212        _ => FieldType::Unknown,
213    }
214}
215
216fn yaml_value_to_json_str(value: &serde_yaml::Value) -> Option<String> {
217    match value {
218        serde_yaml::Value::Null => None,
219        serde_yaml::Value::Bool(b) => Some(b.to_string()),
220        serde_yaml::Value::Number(n) => Some(n.to_string()),
221        serde_yaml::Value::String(s) => {
222            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
223            Some(format!("\"{}\"", escaped))
224        }
225        serde_yaml::Value::Sequence(seq) => {
226            let items: Option<Vec<String>> = seq.iter().map(yaml_value_to_json_str).collect();
227            items.map(|elems| format!("[{}]", elems.join(",")))
228        }
229        serde_yaml::Value::Mapping(map) if map.is_empty() => Some("{}".to_string()),
230        _ => None,
231    }
232}
233
234/// Return the `ValueType::*` token string for use in generated Rust source.
235pub fn field_type_as_rust(ft: &FieldType) -> &'static str {
236    match ft {
237        FieldType::String | FieldType::Unknown => "ValueType::String",
238        FieldType::Bool => "ValueType::Bool",
239        FieldType::Integer => "ValueType::Integer",
240        FieldType::Float => "ValueType::Float",
241        FieldType::StringList => "ValueType::StringList",
242        FieldType::Duration => "ValueType::Duration",
243    }
244}
245
246/// Return `true` if `ft` is [`FieldType::Unknown`].
247pub fn is_unknown(ft: &FieldType) -> bool {
248    matches!(ft, FieldType::Unknown)
249}
250
251/// Escape backslashes and double-quotes in `s` for use inside a Rust string literal.
252pub fn escape_str(s: &str) -> String {
253    s.replace('\\', "\\\\").replace('"', "\\\"")
254}
255
256/// Generate `schema.rs` in `dir` from `schema_map`.
257///
258/// The file contains one `pub const <NAME>: SchemaEntry = SchemaEntry { … };` block per
259/// entry, sorted alphabetically. Panics if the file cannot be written.
260pub fn generate_schema_rs(schema_map: &IndexMap<String, FieldInfo>, dir: &Path) {
261    use std::fmt::Write as _;
262
263    let mut out = String::new();
264    writeln!(
265        out,
266        "// @generated by build.rs from core_schema.yaml + schema_overlay.yaml — DO NOT EDIT"
267    )
268    .unwrap();
269    writeln!(out).unwrap();
270
271    let mut keys: Vec<&str> = schema_map.keys().map(|s| s.as_str()).collect();
272    keys.sort_unstable();
273
274    for yaml_path in &keys {
275        let info = &schema_map[*yaml_path];
276        let const_name = yaml_path_to_const(yaml_path);
277        let vt = field_type_as_rust(&info.value_type);
278
279        if is_unknown(&info.value_type) {
280            writeln!(
281                out,
282                "// TODO: unknown type for '{}' — set value_type_override in the annotation",
283                yaml_path
284            )
285            .unwrap();
286        }
287
288        // The emitted `SchemaEntry.env_vars` carries only explicit names, matching prior output:
289        // standard (derived) and no-env fields both serialise to `&[]`.
290        let env_vars_lit = match &info.env {
291            EnvBinding::Overridden(vars) => {
292                let items: Vec<String> = vars.iter().map(|e| format!("\"{}\"", escape_str(e))).collect();
293                format!("&[{}]", items.join(", "))
294            }
295            EnvBinding::None | EnvBinding::Standard => "&[]".to_string(),
296        };
297
298        let default_lit = match &info.default {
299            Some(d) => format!("Some(\"{}\")", escape_str(d)),
300            None => "None".to_string(),
301        };
302
303        writeln!(out, "pub const {}: SchemaEntry = SchemaEntry {{", const_name).unwrap();
304        writeln!(out, "    schema: Schema::Datadog,").unwrap();
305        writeln!(out, "    yaml_path: \"{}\",", yaml_path).unwrap();
306        writeln!(out, "    env_vars: {},", env_vars_lit).unwrap();
307        writeln!(out, "    value_type: {},", vt).unwrap();
308        writeln!(out, "    default: {},", default_lit).unwrap();
309        writeln!(out, "}};").unwrap();
310        writeln!(out).unwrap();
311    }
312
313    let path = dir.join("schema.rs");
314    std::fs::write(&path, out).unwrap_or_else(|e| panic!("cannot write {}: {}", path.display(), e));
315}
316
317/// Convert a dotted YAML path (for example, `"dogstatsd.bind_host"`) to a `SCREAMING_SNAKE_CASE`
318/// Rust identifier suitable for a `const` name.
319pub fn yaml_path_to_const(yaml_path: &str) -> String {
320    yaml_path
321        .chars()
322        .map(|c| if c == '.' || c == '-' { '_' } else { c })
323        .collect::<String>()
324        .to_uppercase()
325}