Skip to main content

nixcfg/
lib.rs

1//! nixcfg - bridge config structs to NixOS module options via JSON Schema
2//!
3//! use `#[derive(JsonSchema)]` from schemars with `#[schemars(extend(...))]`
4//! to annotate fields with nixcfg extensions:
5//!
6//! - `#[schemars(extend("x-nixcfg-secret" = true))]` for secret fields
7//! - `#[schemars(extend("x-nixcfg-port" = true))]` for port types
8//!
9//! then emit the schema with `NixSchema::from::<T>("name")`, or use the
10//! one-liner [`emit::<T>("name")`] when `T: JsonSchema + Default + Serialize`
11
12pub use schemars;
13pub use schemars::JsonSchema;
14pub use serde_json;
15
16// re-export the nixcfg attribute macro for ergonomic use
17pub use nixcfg_derive::nixcfg;
18
19/// emit a schema for `T` as pretty JSON, with defaults from `T::default()`
20/// merged in. this is the one-liner equivalent of the idiomatic emitter
21/// binary every downstream project writes
22///
23/// ```no_run
24/// use nixcfg::{JsonSchema, emit};
25/// use serde::Serialize;
26///
27/// #[derive(JsonSchema, Serialize, Default)]
28/// struct Config { data_dir: String }
29///
30/// fn main() {
31///     println!("{}", emit::<Config>("myapp"));
32/// }
33/// ```
34pub fn emit<T>(name: impl Into<String>) -> String
35where
36    T: JsonSchema + Default + serde::Serialize,
37{
38    let defaults = serde_json::to_value(T::default()).expect("defaults serialise");
39    NixSchema::from::<T>(name)
40        .with_defaults(defaults)
41        .to_json_pretty()
42}
43
44/// wraps a schemars-generated JSON Schema with nixcfg metadata
45#[derive(Debug, Clone)]
46pub struct NixSchema {
47    pub name: String,
48    pub schema: schemars::Schema,
49    pub extensions: Vec<(String, serde_json::Value)>,
50}
51
52impl NixSchema {
53    /// create a nixcfg schema from a type implementing `JsonSchema`
54    pub fn from<T: schemars::JsonSchema>(name: impl Into<String>) -> Self {
55        NixSchema {
56            name: name.into(),
57            schema: schemars::schema_for!(T),
58            extensions: Vec::new(),
59        }
60    }
61
62    /// merge defaults from a serialised `T::default()` into the schema
63    ///
64    /// walks the schema's `properties` and sets `default` values from the
65    /// provided JSON object. recurses into nested objects
66    pub fn with_defaults(mut self, defaults: serde_json::Value) -> Self {
67        if let serde_json::Value::Object(map) = defaults {
68            merge_defaults(&mut self.schema, &map);
69        }
70        self
71    }
72
73    /// add a root-level extension property to the schema
74    pub fn with_extension(mut self, key: impl Into<String>, value: impl serde::Serialize) -> Self {
75        self.extensions.push((
76            key.into(),
77            serde_json::to_value(value).expect("extension value must be serialisable"),
78        ));
79        self
80    }
81
82    /// serialise to pretty JSON with nixcfg wrapper
83    pub fn to_json_pretty(&self) -> String {
84        let mut root = serde_json::to_value(&self.schema).expect("schema serialisation failed");
85        if let serde_json::Value::Object(ref mut map) = root {
86            map.insert(
87                "x-nixcfg-name".to_string(),
88                serde_json::Value::String(self.name.clone()),
89            );
90            for (k, v) in &self.extensions {
91                map.insert(k.clone(), v.clone());
92            }
93        }
94        serde_json::to_string_pretty(&root).expect("schema serialisation failed")
95    }
96}
97
98fn merge_defaults(
99    schema: &mut schemars::Schema,
100    defaults: &serde_json::Map<String, serde_json::Value>,
101) {
102    let obj = match schema.as_object_mut() {
103        Some(o) => o,
104        None => return,
105    };
106
107    let props = match obj.get_mut("properties") {
108        Some(serde_json::Value::Object(p)) => p,
109        _ => return,
110    };
111
112    for (key, default_val) in defaults {
113        let Some(serde_json::Value::Object(prop)) = props.get_mut(key) else {
114            continue;
115        };
116
117        // skip secrets (they shouldn't carry defaults)
118        if prop.get("x-nixcfg-secret") == Some(&serde_json::Value::Bool(true)) {
119            continue;
120        }
121
122        // recurse into nested objects
123        if prop.get("type") == Some(&serde_json::Value::String("object".to_string()))
124            && let serde_json::Value::Object(sub_defaults) = default_val
125        {
126            let mut sub_schema: schemars::Schema =
127                serde_json::from_value(serde_json::Value::Object(prop.clone())).unwrap_or_default();
128            merge_defaults(&mut sub_schema, sub_defaults);
129            *prop = serde_json::to_value(&sub_schema)
130                .unwrap()
131                .as_object()
132                .unwrap()
133                .clone();
134            continue;
135        }
136
137        // for anyOf (optional types), check if there's an object variant to recurse into
138        if let Some(serde_json::Value::Array(any_of)) = prop.get("anyOf")
139            && let serde_json::Value::Object(sub_defaults) = default_val
140        {
141            for variant in any_of {
142                if let serde_json::Value::Object(v) = variant
143                    && v.get("type") == Some(&serde_json::Value::String("object".to_string()))
144                {
145                    let sub_value = serde_json::Value::Object(v.clone());
146                    let mut sub_schema: schemars::Schema =
147                        serde_json::from_value(sub_value).unwrap_or_default();
148                    merge_defaults(&mut sub_schema, sub_defaults);
149                    break;
150                }
151            }
152        }
153
154        // set the default value (annotation defaults take priority)
155        if !prop.contains_key("default") {
156            prop.insert("default".to_string(), default_val.clone());
157        }
158    }
159}
160
161#[cfg(test)]
162#[allow(dead_code)]
163mod tests {
164    use super::*;
165    use schemars::JsonSchema;
166    use serde::Serialize;
167
168    #[derive(JsonSchema, Serialize)]
169    /// mycel discord bot configuration
170    struct Config {
171        /// directory for the database, models, and workspace
172        #[serde(default = "default_data_dir")]
173        data_dir: String,
174
175        /// log level
176        log_level: LogLevel,
177
178        /// discord bot token
179        #[schemars(extend("x-nixcfg-secret" = true))]
180        discord_token: String,
181
182        /// listen port
183        #[schemars(extend("x-nixcfg-port" = true))]
184        port: u16,
185    }
186
187    fn default_data_dir() -> String {
188        "/var/lib/mycel".to_string()
189    }
190
191    #[derive(JsonSchema, Serialize)]
192    enum LogLevel {
193        Trace,
194        Debug,
195        Info,
196        Warn,
197        Error,
198    }
199
200    #[test]
201    fn schema_has_name() {
202        let schema = NixSchema::from::<Config>("mycel");
203        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
204        assert_eq!(json["x-nixcfg-name"], "mycel");
205    }
206
207    #[test]
208    fn schema_has_properties() {
209        let schema = NixSchema::from::<Config>("mycel");
210        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
211        assert!(json["properties"]["data_dir"].is_object());
212        assert!(json["properties"]["discord_token"].is_object());
213        assert!(json["properties"]["port"].is_object());
214    }
215
216    #[test]
217    fn secret_extension() {
218        let schema = NixSchema::from::<Config>("mycel");
219        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
220        assert_eq!(json["properties"]["discord_token"]["x-nixcfg-secret"], true);
221        // non-secret fields don't have the extension
222        assert!(
223            json["properties"]["data_dir"]
224                .get("x-nixcfg-secret")
225                .is_none()
226        );
227    }
228
229    #[test]
230    fn port_extension() {
231        let schema = NixSchema::from::<Config>("mycel");
232        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
233        assert_eq!(json["properties"]["port"]["x-nixcfg-port"], true);
234    }
235
236    #[test]
237    fn descriptions_from_doc_comments() {
238        let schema = NixSchema::from::<Config>("mycel");
239        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
240        assert_eq!(
241            json["properties"]["data_dir"]["description"],
242            "directory for the database, models, and workspace"
243        );
244        assert_eq!(json["description"], "mycel discord bot configuration");
245    }
246
247    #[test]
248    fn defaults_merged() {
249        let schema = NixSchema::from::<Config>("mycel");
250        let defaults = serde_json::json!({
251            "data_dir": "/var/lib/mycel",
252            "log_level": "Info",
253            "discord_token": "secret",
254            "port": 8080
255        });
256        let schema = schema.with_defaults(defaults);
257        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
258
259        // default set on data_dir (from serde default attr, should already be there)
260        // port gets default from with_defaults
261        assert_eq!(json["properties"]["port"]["default"], 8080);
262        // secret fields don't get defaults
263        assert!(json["properties"]["discord_token"].get("default").is_none());
264    }
265
266    #[test]
267    fn enum_generates_variants() {
268        let schema = NixSchema::from::<Config>("mycel");
269        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
270        // log_level should reference LogLevel enum via $ref or inline
271        // the exact shape depends on schemars, but it should exist
272        assert!(json["properties"]["log_level"].is_object());
273    }
274
275    // ── nixcfg attribute macro ────────────────────────────────────
276
277    #[crate::nixcfg]
278    #[derive(JsonSchema, Serialize)]
279    #[allow(dead_code)]
280    struct MacroConfig {
281        /// api key
282        #[nixcfg(secret)]
283        api_key: String,
284
285        /// listen port
286        #[nixcfg(port)]
287        listen_port: u16,
288
289        /// state dir
290        #[nixcfg(path)]
291        data_dir: String,
292
293        /// runtime-only
294        #[nixcfg(skip)]
295        runtime_handle: String,
296
297        /// combined secret path
298        #[nixcfg(secret, path)]
299        pem_path: String,
300
301        /// override description and example
302        #[nixcfg(
303            description = "long prose description for nix option docs",
304            example = "/var/lib/app"
305        )]
306        hooks_cwd: String,
307    }
308
309    #[test]
310    fn macro_rewrites_flags() {
311        let schema = NixSchema::from::<MacroConfig>("macro-test");
312        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
313
314        assert_eq!(
315            json["properties"]["api_key"]["x-nixcfg-secret"], true,
316            "secret flag should be emitted"
317        );
318        assert_eq!(
319            json["properties"]["listen_port"]["x-nixcfg-port"], true,
320            "port flag should be emitted"
321        );
322        assert_eq!(
323            json["properties"]["data_dir"]["x-nixcfg-path"], true,
324            "path flag should be emitted"
325        );
326        assert_eq!(
327            json["properties"]["runtime_handle"]["x-nixcfg-skip"], true,
328            "skip flag should be emitted"
329        );
330    }
331
332    #[test]
333    fn macro_combines_flags() {
334        let schema = NixSchema::from::<MacroConfig>("macro-test");
335        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
336
337        assert_eq!(json["properties"]["pem_path"]["x-nixcfg-secret"], true);
338        assert_eq!(json["properties"]["pem_path"]["x-nixcfg-path"], true);
339    }
340
341    #[test]
342    fn macro_key_value_pairs() {
343        let schema = NixSchema::from::<MacroConfig>("macro-test");
344        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
345
346        assert_eq!(
347            json["properties"]["hooks_cwd"]["x-nixcfg-description"],
348            "long prose description for nix option docs"
349        );
350        assert_eq!(
351            json["properties"]["hooks_cwd"]["x-nixcfg-example"],
352            "/var/lib/app"
353        );
354    }
355
356    // ── schema_with escape hatch ──────────────────────────────────
357    //
358    // verifies the pattern used for foreign types that can't (or shouldn't)
359    // impl JsonSchema locally: hand-roll the schema fragment including any
360    // x-nixcfg-* extensions, hook it up via #[schemars(schema_with = ...)].
361    // nixcfg reads the resulting schema the same way it reads any other.
362
363    // a foreign type we don't want (or can't) derive JsonSchema on
364    #[derive(Serialize, Default)]
365    #[allow(dead_code)]
366    struct OpaqueForeign {
367        host: String,
368    }
369
370    fn opaque_schema(_g: &mut schemars::SchemaGenerator) -> schemars::Schema {
371        serde_json::from_value(serde_json::json!({
372            "type": "object",
373            "properties": {
374                "host": { "type": "string" }
375            },
376            "x-nixcfg-skip": true,
377            "description": "provided via schema_with"
378        }))
379        .unwrap()
380    }
381
382    #[derive(JsonSchema, Serialize)]
383    #[allow(dead_code)]
384    struct ContainerWithOpaque {
385        /// normal field
386        name: String,
387
388        // no doc comment here: schemars would apply it as the field description,
389        // overriding whatever the schema_with function produces. leaving it off
390        // lets the hand-rolled schema's own description win
391        #[schemars(schema_with = "opaque_schema")]
392        opaque: OpaqueForeign,
393    }
394
395    #[test]
396    fn schema_with_round_trips_extensions() {
397        let schema = NixSchema::from::<ContainerWithOpaque>("schema-with-test");
398        let json: serde_json::Value = serde_json::from_str(&schema.to_json_pretty()).unwrap();
399
400        // extension from the hand-rolled fragment lands in the schema
401        assert_eq!(json["properties"]["opaque"]["x-nixcfg-skip"], true);
402        assert_eq!(
403            json["properties"]["opaque"]["description"],
404            "provided via schema_with"
405        );
406        // normal derived field is untouched
407        assert_eq!(json["properties"]["name"]["type"], "string");
408    }
409}