Skip to main content

ssg/
schema.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Configuration Schema Generator
5//!
6//! This module generates a JSON Schema for [`crate::cmd::SsgConfig`], enabling
7//! editor auto-completion, validation, and documentation of the
8//! configuration format.
9
10use serde_json::{json, Value};
11use std::fs;
12use std::io;
13use std::path::Path;
14
15/// Generates a JSON Schema describing all [`crate::cmd::SsgConfig`] fields.
16///
17/// The returned schema follows the JSON Schema Draft-07 specification
18/// and includes type information, descriptions, and default values for
19/// every configuration field.
20#[must_use]
21pub fn generate_schema() -> Value {
22    json!({
23        "$schema": "https://json-schema.org/draft-07/schema#",
24        "title": "SsgConfig",
25        "description": "Configuration for the Static Site Generator (SSG).",
26        "type": "object",
27        "properties": {
28            "site_name": {
29                "type": "string",
30                "description": "Name of the site.",
31                "default": "MySsgSite"
32            },
33            "content_dir": {
34                "type": "string",
35                "description": "Directory containing content files.",
36                "default": "content"
37            },
38            "output_dir": {
39                "type": "string",
40                "description": "Directory for generated output files.",
41                "default": "public"
42            },
43            "template_dir": {
44                "type": "string",
45                "description": "Directory containing template files.",
46                "default": "templates"
47            },
48            "serve_dir": {
49                "type": ["string", "null"],
50                "description": "Optional directory for development server files.",
51                "default": null
52            },
53            "base_url": {
54                "type": "string",
55                "description": "Base URL of the site.",
56                "default": "http://127.0.0.1:8000",
57                "format": "uri"
58            },
59            "site_title": {
60                "type": "string",
61                "description": "Title of the site.",
62                "default": "My SSG Site"
63            },
64            "site_description": {
65                "type": "string",
66                "description": "Description of the site.",
67                "default": "A site built with SSG"
68            },
69            "language": {
70                "type": "string",
71                "description": "Language code for the site (e.g. en-GB).",
72                "default": "en-GB",
73                "pattern": "^[a-z]{2}-[A-Z]{2}$"
74            }
75        },
76        "required": [
77            "site_name",
78            "content_dir",
79            "output_dir",
80            "template_dir",
81            "base_url",
82            "site_title",
83            "site_description",
84            "language"
85        ],
86        "additionalProperties": false
87    })
88}
89
90/// Writes the JSON Schema to `path` as pretty-printed JSON.
91///
92/// # Errors
93///
94/// Returns an [`io::Error`] if the file cannot be created or written.
95///
96/// # Panics
97///
98/// Cannot panic in practice: `generate_schema()` builds a hand-authored
99/// `serde_json::Value` tree containing only strings, booleans, arrays
100/// and objects — no `f32`/`f64` NaNs — which `to_string_pretty` cannot
101/// fail to serialize. The `expect` exists only to satisfy the type
102/// system without forcing callers to handle an unreachable `Err`.
103pub fn write_schema(path: &Path) -> io::Result<()> {
104    let schema = generate_schema();
105    // The hand-authored Schema contains only strings/arrays/objects (no
106    // NaN floats), so `to_string_pretty` cannot fail. The `expect` is a
107    // type-system formality, not a runtime risk.
108    #[allow(clippy::expect_used)]
109    let content = serde_json::to_string_pretty(&schema)
110        .expect("hand-authored Schema is always serializable");
111    fs::write(path, content)
112}
113
114#[cfg(test)]
115#[allow(clippy::unwrap_used, clippy::expect_used)]
116mod tests {
117    use super::*;
118    use std::path::PathBuf;
119    use tempfile::tempdir;
120
121    #[test]
122    fn schema_has_correct_title() {
123        let schema = generate_schema();
124        assert_eq!(schema["title"], "SsgConfig");
125    }
126
127    #[test]
128    fn schema_has_all_required_fields() {
129        let schema = generate_schema();
130        let required = schema["required"]
131            .as_array()
132            .expect("required should be an array");
133        let names: Vec<&str> =
134            required.iter().map(|v| v.as_str().unwrap()).collect();
135        assert!(names.contains(&"site_name"));
136        assert!(names.contains(&"content_dir"));
137        assert!(names.contains(&"output_dir"));
138        assert!(names.contains(&"template_dir"));
139        assert!(names.contains(&"base_url"));
140        assert!(names.contains(&"site_title"));
141        assert!(names.contains(&"site_description"));
142        assert!(names.contains(&"language"));
143    }
144
145    #[test]
146    fn schema_properties_have_types() {
147        let schema = generate_schema();
148        let props = schema["properties"]
149            .as_object()
150            .expect("properties should be an object");
151        for (key, value) in props {
152            assert!(
153                value.get("type").is_some(),
154                "property '{key}' is missing a type"
155            );
156        }
157    }
158
159    #[test]
160    fn schema_defaults_match_config() {
161        let schema = generate_schema();
162        let props = &schema["properties"];
163        assert_eq!(props["site_name"]["default"], "MySsgSite");
164        assert_eq!(props["content_dir"]["default"], "content");
165        assert_eq!(props["output_dir"]["default"], "public");
166        assert_eq!(props["template_dir"]["default"], "templates");
167        assert_eq!(props["base_url"]["default"], "http://127.0.0.1:8000");
168        assert_eq!(props["site_title"]["default"], "My SSG Site");
169        assert_eq!(
170            props["site_description"]["default"],
171            "A site built with SSG"
172        );
173        assert_eq!(props["language"]["default"], "en-GB");
174    }
175
176    #[test]
177    fn schema_language_has_pattern() {
178        let schema = generate_schema();
179        let pattern = schema["properties"]["language"]["pattern"]
180            .as_str()
181            .expect("language should have a pattern");
182        assert_eq!(pattern, "^[a-z]{2}-[A-Z]{2}$");
183    }
184
185    #[test]
186    fn serve_dir_allows_null() {
187        let schema = generate_schema();
188        let types = schema["properties"]["serve_dir"]["type"]
189            .as_array()
190            .expect("serve_dir type should be an array");
191        let type_strs: Vec<&str> =
192            types.iter().map(|v| v.as_str().unwrap()).collect();
193        assert!(type_strs.contains(&"null"));
194        assert!(type_strs.contains(&"string"));
195    }
196
197    #[test]
198    fn write_schema_creates_valid_json_file() {
199        let dir = tempdir().expect("failed to create temp dir");
200        let path = dir.path().join("schema.json");
201        write_schema(&path).expect("write_schema failed");
202
203        let content =
204            fs::read_to_string(&path).expect("failed to read schema file");
205        let parsed: Value =
206            serde_json::from_str(&content).expect("output is not valid JSON");
207        assert_eq!(parsed["title"], "SsgConfig");
208    }
209
210    #[test]
211    fn write_schema_fails_on_bad_path() {
212        let path = PathBuf::from("/nonexistent/dir/schema.json");
213        assert!(write_schema(&path).is_err());
214    }
215}