Skip to main content

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/// Parsed metadata for a single config field.
29pub struct FieldInfo {
30    /// Resolved value type (see [`FieldType`]).
31    pub value_type: FieldType,
32    /// Environment variable names that map to this field. Empty when the field carries a
33    /// `no-env` tag.
34    pub env_vars: Vec<String>,
35    /// Default value serialised as a JSON literal, or `None` if the schema omits one.
36    pub default: Option<String>,
37}
38
39/// Load and flatten the schema at `schema_path` into a `yaml_path → FieldInfo` map.
40///
41/// Resolves `$ref: <filename>` entries by loading the referenced files from the same directory.
42/// The map is sorted by key. Panics if the file cannot be read or parsed.
43pub fn load_schema(schema_path: &Path) -> IndexMap<String, FieldInfo> {
44    let doc = crate::load_resolved_schema(schema_path).unwrap_or_else(|e| panic!("failed to load schema: {e}"));
45    let properties = doc
46        .get("properties")
47        .and_then(|v| v.as_mapping())
48        .expect("schema root must have a 'properties' mapping");
49
50    let mut entries = Vec::new();
51    collect_entries(properties, &[], &mut entries);
52    entries.sort_by(|a, b| a.0.cmp(&b.0));
53
54    let mut map = IndexMap::new();
55    for (yaml_path, info) in entries {
56        map.insert(yaml_path, info);
57    }
58    map
59}
60
61fn collect_entries(mapping: &serde_yaml::Mapping, path_parts: &[&str], out: &mut Vec<(String, FieldInfo)>) {
62    for (key, value) in mapping {
63        let key_str = match key.as_str() {
64            Some(s) => s,
65            None => continue,
66        };
67
68        let mut parts = path_parts.to_vec();
69        parts.push(key_str);
70
71        // `$ref`s are inlined by `crate::load_resolved_schema` before we get here, so every node
72        // is either a `setting`, a `section`, or some other container with `properties`.
73        let node_type = value.get("node_type").and_then(|v| v.as_str()).unwrap_or("");
74
75        match node_type {
76            "setting" => out.push(parse_setting(&parts, value)),
77            "section" => {
78                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
79                    collect_entries(props, &parts, out);
80                }
81            }
82            _ => {
83                if let Some(props) = value.get("properties").and_then(|v| v.as_mapping()) {
84                    collect_entries(props, &parts, out);
85                }
86            }
87        }
88    }
89}
90
91fn parse_setting(path_parts: &[&str], value: &Value) -> (String, FieldInfo) {
92    let yaml_path = path_parts.join(".");
93
94    let has_no_env_tag = value
95        .get("tags")
96        .and_then(|v| v.as_sequence())
97        .map(|tags| tags.iter().any(|t| t.as_str() == Some("no-env")))
98        .unwrap_or(false);
99
100    let env_vars: Vec<String> = if has_no_env_tag {
101        Vec::new()
102    } else {
103        value
104            .get("env_vars")
105            .and_then(|v| v.as_sequence())
106            .map(|seq| seq.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
107            .unwrap_or_default()
108    };
109
110    let value_type = parse_value_type(value);
111    let default = value.get("default").and_then(yaml_value_to_json_str);
112
113    (
114        yaml_path,
115        FieldInfo {
116            value_type,
117            env_vars,
118            default,
119        },
120    )
121}
122
123fn parse_value_type(value: &Value) -> FieldType {
124    let ty = value.get("type").and_then(|v| v.as_str());
125    let format = value.get("format").and_then(|v| v.as_str());
126
127    // `format: duration` folds into a single effective `Duration` type regardless of the declared
128    // base type. The vendored Agent schema currently declares these as `type: number` (nanoseconds
129    // on the wire) but has also used `type: string` (Go duration text); both are durations to us.
130    if format == Some("duration") {
131        return match ty {
132            Some("string") | Some("number") | Some("integer") => FieldType::Duration,
133            other => panic!(
134                "config schema field has `format: duration` with unsupported base type {:?}; \
135                 expected string, number, or integer",
136                other
137            ),
138        };
139    }
140
141    match ty {
142        Some("string") => FieldType::String,
143        Some("boolean") => FieldType::Bool,
144        Some("integer") => FieldType::Integer,
145        Some("number") => FieldType::Float,
146        Some("array") => {
147            let item_type = value.get("items").and_then(|v| v.get("type")).and_then(|v| v.as_str());
148            if item_type == Some("string") {
149                FieldType::StringList
150            } else {
151                FieldType::Unknown
152            }
153        }
154        _ => FieldType::Unknown,
155    }
156}
157
158fn yaml_value_to_json_str(value: &serde_yaml::Value) -> Option<String> {
159    match value {
160        serde_yaml::Value::Null => None,
161        serde_yaml::Value::Bool(b) => Some(b.to_string()),
162        serde_yaml::Value::Number(n) => Some(n.to_string()),
163        serde_yaml::Value::String(s) => {
164            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
165            Some(format!("\"{}\"", escaped))
166        }
167        serde_yaml::Value::Sequence(seq) => {
168            let items: Option<Vec<String>> = seq.iter().map(yaml_value_to_json_str).collect();
169            items.map(|elems| format!("[{}]", elems.join(",")))
170        }
171        serde_yaml::Value::Mapping(map) if map.is_empty() => Some("{}".to_string()),
172        _ => None,
173    }
174}
175
176/// Return the `ValueType::*` token string for use in generated Rust source.
177pub fn field_type_as_rust(ft: &FieldType) -> &'static str {
178    match ft {
179        FieldType::String | FieldType::Unknown => "ValueType::String",
180        FieldType::Bool => "ValueType::Bool",
181        FieldType::Integer => "ValueType::Integer",
182        FieldType::Float => "ValueType::Float",
183        FieldType::StringList => "ValueType::StringList",
184        FieldType::Duration => "ValueType::Duration",
185    }
186}
187
188/// Return `true` if `ft` is [`FieldType::Unknown`].
189pub fn is_unknown(ft: &FieldType) -> bool {
190    matches!(ft, FieldType::Unknown)
191}
192
193/// Escape backslashes and double-quotes in `s` for use inside a Rust string literal.
194pub fn escape_str(s: &str) -> String {
195    s.replace('\\', "\\\\").replace('"', "\\\"")
196}
197
198/// Generate `schema.rs` in `dir` from `schema_map`.
199///
200/// The file contains one `pub const <NAME>: SchemaEntry = SchemaEntry { … };` block per
201/// entry, sorted alphabetically. Panics if the file cannot be written.
202pub fn generate_schema_rs(schema_map: &IndexMap<String, FieldInfo>, dir: &Path) {
203    use std::fmt::Write as _;
204
205    let mut out = String::new();
206    writeln!(
207        out,
208        "// @generated by build.rs from core_schema.yaml + schema_overlay.yaml — DO NOT EDIT"
209    )
210    .unwrap();
211    writeln!(out).unwrap();
212
213    let mut keys: Vec<&str> = schema_map.keys().map(|s| s.as_str()).collect();
214    keys.sort_unstable();
215
216    for yaml_path in &keys {
217        let info = &schema_map[*yaml_path];
218        let const_name = yaml_path_to_const(yaml_path);
219        let vt = field_type_as_rust(&info.value_type);
220
221        if is_unknown(&info.value_type) {
222            writeln!(
223                out,
224                "// TODO: unknown type for '{}' — set value_type_override in the annotation",
225                yaml_path
226            )
227            .unwrap();
228        }
229
230        let env_vars_lit = if info.env_vars.is_empty() {
231            "&[]".to_string()
232        } else {
233            let items: Vec<String> = info.env_vars.iter().map(|e| format!("\"{}\"", escape_str(e))).collect();
234            format!("&[{}]", items.join(", "))
235        };
236
237        let default_lit = match &info.default {
238            Some(d) => format!("Some(\"{}\")", escape_str(d)),
239            None => "None".to_string(),
240        };
241
242        writeln!(out, "pub const {}: SchemaEntry = SchemaEntry {{", const_name).unwrap();
243        writeln!(out, "    schema: Schema::Datadog,").unwrap();
244        writeln!(out, "    yaml_path: \"{}\",", yaml_path).unwrap();
245        writeln!(out, "    env_vars: {},", env_vars_lit).unwrap();
246        writeln!(out, "    value_type: {},", vt).unwrap();
247        writeln!(out, "    default: {},", default_lit).unwrap();
248        writeln!(out, "}};").unwrap();
249        writeln!(out).unwrap();
250    }
251
252    let path = dir.join("schema.rs");
253    std::fs::write(&path, out).unwrap_or_else(|e| panic!("cannot write {}: {}", path.display(), e));
254}
255
256/// Convert a dotted YAML path (for example, `"dogstatsd.bind_host"`) to a `SCREAMING_SNAKE_CASE`
257/// Rust identifier suitable for a `const` name.
258pub fn yaml_path_to_const(yaml_path: &str) -> String {
259    yaml_path
260        .chars()
261        .map(|c| if c == '.' || c == '-' { '_' } else { c })
262        .collect::<String>()
263        .to_uppercase()
264}