1use serde_json::{json, Value};
11use std::fs;
12use std::io;
13use std::path::Path;
14
15#[must_use]
31pub fn generate_schema() -> Value {
32 json!({
33 "$schema": "https://json-schema.org/draft-07/schema#",
34 "title": "SsgConfig",
35 "description": "Configuration for the Static Site Generator (SSG).",
36 "type": "object",
37 "properties": {
38 "site_name": {
39 "type": "string",
40 "description": "Name of the site.",
41 "default": "MySsgSite"
42 },
43 "content_dir": {
44 "type": "string",
45 "description": "Directory containing content files.",
46 "default": "content"
47 },
48 "output_dir": {
49 "type": "string",
50 "description": "Directory for generated output files.",
51 "default": "public"
52 },
53 "template_dir": {
54 "type": "string",
55 "description": "Directory containing template files.",
56 "default": "templates"
57 },
58 "serve_dir": {
59 "type": ["string", "null"],
60 "description": "Optional directory for development server files.",
61 "default": null
62 },
63 "base_url": {
64 "type": "string",
65 "description": "Base URL of the site.",
66 "default": "http://127.0.0.1:8000",
67 "format": "uri"
68 },
69 "site_title": {
70 "type": "string",
71 "description": "Title of the site. The default templates append it to every page title, so it defaults to empty rather than a placeholder: an unset title brands nothing instead of branding every page \"My SSG Site\". See DEFAULT_SITE_TITLE.",
72 "default": ""
73 },
74 "site_description": {
75 "type": "string",
76 "description": "Description of the site.",
77 "default": "A site built with SSG"
78 },
79 "language": {
80 "type": "string",
81 "description": "Language code for the site (e.g. en-GB).",
82 "default": "en-GB",
83 "pattern": "^[a-z]{2}-[A-Z]{2}$"
84 }
85 },
86 "required": [
87 "site_name",
88 "content_dir",
89 "output_dir",
90 "template_dir",
91 "base_url",
92 "site_title",
93 "site_description",
94 "language"
95 ],
96 "additionalProperties": false
97 })
98}
99
100pub fn write_schema(path: &Path) -> io::Result<()> {
126 let schema = generate_schema();
127 #[allow(clippy::expect_used)]
131 let content = serde_json::to_string_pretty(&schema)
132 .expect("hand-authored Schema is always serializable");
133 fs::write(path, content)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use std::path::PathBuf;
140 use tempfile::tempdir;
141
142 #[test]
143 fn schema_has_correct_title() {
144 let schema = generate_schema();
145 assert_eq!(schema["title"], "SsgConfig");
146 }
147
148 #[test]
149 fn schema_has_all_required_fields() {
150 let schema = generate_schema();
151 let required = schema["required"]
152 .as_array()
153 .expect("required should be an array");
154 let names: Vec<&str> =
155 required.iter().map(|v| v.as_str().unwrap()).collect();
156 assert!(names.contains(&"site_name"));
157 assert!(names.contains(&"content_dir"));
158 assert!(names.contains(&"output_dir"));
159 assert!(names.contains(&"template_dir"));
160 assert!(names.contains(&"base_url"));
161 assert!(names.contains(&"site_title"));
162 assert!(names.contains(&"site_description"));
163 assert!(names.contains(&"language"));
164 }
165
166 #[test]
167 fn schema_properties_have_types() {
168 let schema = generate_schema();
169 let props = schema["properties"]
170 .as_object()
171 .expect("properties should be an object");
172 for (key, value) in props {
173 assert!(
174 value.get("type").is_some(),
175 "property '{key}' is missing a type"
176 );
177 }
178 }
179
180 #[test]
181 fn schema_defaults_match_config() {
182 let schema = generate_schema();
183 let props = &schema["properties"];
184 assert_eq!(props["site_name"]["default"], "MySsgSite");
185 assert_eq!(props["content_dir"]["default"], "content");
186 assert_eq!(props["output_dir"]["default"], "public");
187 assert_eq!(props["template_dir"]["default"], "templates");
188 assert_eq!(props["base_url"]["default"], "http://127.0.0.1:8000");
189 assert_eq!(props["site_title"]["default"], "");
190 assert_eq!(
191 props["site_description"]["default"],
192 "A site built with SSG"
193 );
194 assert_eq!(props["language"]["default"], "en-GB");
195 }
196
197 #[test]
198 fn schema_language_has_pattern() {
199 let schema = generate_schema();
200 let pattern = schema["properties"]["language"]["pattern"]
201 .as_str()
202 .expect("language should have a pattern");
203 assert_eq!(pattern, "^[a-z]{2}-[A-Z]{2}$");
204 }
205
206 #[test]
207 fn serve_dir_allows_null() {
208 let schema = generate_schema();
209 let types = schema["properties"]["serve_dir"]["type"]
210 .as_array()
211 .expect("serve_dir type should be an array");
212 let type_strs: Vec<&str> =
213 types.iter().map(|v| v.as_str().unwrap()).collect();
214 assert!(type_strs.contains(&"null"));
215 assert!(type_strs.contains(&"string"));
216 }
217
218 #[test]
219 fn write_schema_creates_valid_json_file() {
220 let dir = tempdir().expect("failed to create temp dir");
221 let path = dir.path().join("schema.json");
222 write_schema(&path).expect("write_schema failed");
223
224 let content =
225 fs::read_to_string(&path).expect("failed to read schema file");
226 let parsed: Value =
227 serde_json::from_str(&content).expect("output is not valid JSON");
228 assert_eq!(parsed["title"], "SsgConfig");
229 }
230
231 #[test]
232 fn write_schema_fails_on_bad_path() {
233 let path = PathBuf::from("/nonexistent/dir/schema.json");
234 assert!(write_schema(&path).is_err());
235 }
236}