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