1pub use schemars;
13pub use schemars::JsonSchema;
14pub use serde_json;
15
16pub use nixcfg_derive::nixcfg;
18
19pub 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#[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 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 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 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 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 if prop.get("x-nixcfg-secret") == Some(&serde_json::Value::Bool(true)) {
119 continue;
120 }
121
122 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 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 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 struct Config {
171 #[serde(default = "default_data_dir")]
173 data_dir: String,
174
175 log_level: LogLevel,
177
178 #[schemars(extend("x-nixcfg-secret" = true))]
180 discord_token: String,
181
182 #[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 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 assert_eq!(json["properties"]["port"]["default"], 8080);
262 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 assert!(json["properties"]["log_level"].is_object());
273 }
274
275 #[crate::nixcfg]
278 #[derive(JsonSchema, Serialize)]
279 #[allow(dead_code)]
280 struct MacroConfig {
281 #[nixcfg(secret)]
283 api_key: String,
284
285 #[nixcfg(port)]
287 listen_port: u16,
288
289 #[nixcfg(path)]
291 data_dir: String,
292
293 #[nixcfg(skip)]
295 runtime_handle: String,
296
297 #[nixcfg(secret, path)]
299 pem_path: String,
300
301 #[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 #[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 name: String,
387
388 #[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 assert_eq!(json["properties"]["opaque"]["x-nixcfg-skip"], true);
402 assert_eq!(
403 json["properties"]["opaque"]["description"],
404 "provided via schema_with"
405 );
406 assert_eq!(json["properties"]["name"]["type"], "string");
408 }
409}