mdbook_gitinfo/
theme.rs

1use mdbook_preprocessor::PreprocessorContext;
2use std::fs;
3use std::io;
4use toml_edit::{DocumentMut, Item, Value};
5
6const CSS_REL_PATH: &str = "theme/gitinfo.css";
7
8pub fn ensure_gitinfo_assets(ctx: &PreprocessorContext, css_contents: &str) {
9    if let Err(e) = ensure_css_file(ctx, css_contents) {
10        eprintln!(
11            "[mdbook-gitinfo] Warning: unable to write {}: {e}",
12            CSS_REL_PATH
13        );
14    }
15
16    if let Err(e) = ensure_book_toml_additional_css(ctx) {
17        eprintln!("[mdbook-gitinfo] Warning: unable to update book.toml additional-css: {e}");
18    }
19}
20
21fn ensure_css_file(ctx: &PreprocessorContext, css_contents: &str) -> io::Result<()> {
22    // Put CSS under the mdBook theme override directory at the repo root.
23    // This avoids needing to modify the book source directory layout.
24    let theme_dir = ctx.root.join("theme");
25    fs::create_dir_all(&theme_dir)?;
26
27    let css_path = theme_dir.join("gitinfo.css");
28
29    // Idempotent write: only write if missing or different.
30    match fs::read_to_string(&css_path) {
31        Ok(existing) if existing == css_contents => Ok(()),
32        _ => fs::write(&css_path, css_contents),
33    }
34}
35
36fn ensure_book_toml_additional_css(ctx: &PreprocessorContext) -> io::Result<()> {
37    let book_toml = ctx.root.join("book.toml");
38
39    // If book.toml doesn't exist (rare), do nothing gracefully.
40    if !book_toml.exists() {
41        return Ok(());
42    }
43
44    let raw = fs::read_to_string(&book_toml)?;
45    let mut doc: DocumentMut = raw.parse().map_err(|e| {
46        io::Error::new(
47            io::ErrorKind::InvalidData,
48            format!("invalid book.toml: {e:?}"),
49        )
50    })?;
51
52    // Ensure [output.html] table exists
53    if doc.get("output").is_none() {
54        doc["output"] = toml_edit::table().into();
55    }
56    if doc["output"].get("html").is_none() {
57        doc["output"]["html"] = toml_edit::table().into();
58    }
59
60    // Ensure output.html.additional-css is an array, then append if missing.
61    let item = doc["output"]["html"].get_mut("additional-css");
62
63    match item {
64        None | Some(Item::None) => {
65            let mut arr = toml_edit::Array::default();
66            arr.push(Value::from(CSS_REL_PATH));
67            doc["output"]["html"]["additional-css"] = Item::Value(Value::Array(arr));
68        }
69
70        Some(Item::Value(Value::Array(arr))) => {
71            let already = arr.iter().any(|v| v.as_str() == Some(CSS_REL_PATH));
72            if !already {
73                arr.push(Value::from(CSS_REL_PATH));
74            }
75        }
76
77        // Sometimes users set a single string instead of an array; normalize to array.
78        Some(Item::Value(Value::String(s))) => {
79            let existing = s.value().to_string();
80            let needs_css = existing != CSS_REL_PATH;
81
82            let mut arr = toml_edit::Array::default();
83            arr.push(Value::from(existing));
84            if needs_css {
85                arr.push(Value::from(CSS_REL_PATH));
86            }
87
88            doc["output"]["html"]["additional-css"] = Item::Value(Value::Array(arr));
89        }
90
91        Some(other) => {
92            return Err(io::Error::new(
93                io::ErrorKind::InvalidData,
94                format!(
95                    "output.html.additional-css exists but is not a string or array (found: {:?})",
96                    other.type_name()
97                ),
98            ));
99        }
100    }
101
102    let updated = doc.to_string();
103
104    // Idempotent write: only write if it changed
105    if updated != raw {
106        fs::write(&book_toml, updated)?;
107    }
108
109    Ok(())
110}
111
112// [dev-dependencies]
113// tempfile = "3"
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use mdbook_preprocessor::{PreprocessorContext, config::Config};
119    use std::fs;
120    use tempfile::TempDir;
121
122    fn ctx_in_dir(dir: &TempDir) -> PreprocessorContext {
123        let mut config = Config::default();
124        PreprocessorContext::new(dir.path().to_path_buf(), config, "html".to_string())
125    }
126
127    #[test]
128    fn creates_theme_css_file() {
129        let dir = TempDir::new().unwrap();
130        let ctx = ctx_in_dir(&dir);
131
132        ensure_gitinfo_assets(&ctx, "/* css */");
133
134        let css_path = dir.path().join("theme/gitinfo.css");
135        assert!(css_path.exists());
136        assert_eq!(fs::read_to_string(css_path).unwrap(), "/* css */");
137    }
138
139    #[test]
140    fn css_file_write_is_idempotent() {
141        let dir = TempDir::new().unwrap();
142        let ctx = ctx_in_dir(&dir);
143
144        ensure_gitinfo_assets(&ctx, "/* css */");
145        ensure_gitinfo_assets(&ctx, "/* css */");
146
147        let css_path = dir.path().join("theme/gitinfo.css");
148        assert!(css_path.exists());
149        assert_eq!(fs::read_to_string(css_path).unwrap(), "/* css */");
150    }
151
152    #[test]
153    fn injects_additional_css_into_book_toml_when_missing() {
154        let dir = TempDir::new().unwrap();
155
156        fs::write(
157            dir.path().join("book.toml"),
158            r#"
159[book]
160title = "Test"
161"#,
162        )
163        .unwrap();
164
165        let ctx = ctx_in_dir(&dir);
166        ensure_gitinfo_assets(&ctx, "/* css */");
167
168        let book = fs::read_to_string(dir.path().join("book.toml")).unwrap();
169        assert!(book.contains("additional-css"));
170        assert!(book.contains("theme/gitinfo.css"));
171    }
172
173    #[test]
174    fn does_not_duplicate_additional_css_entry() {
175        let dir = TempDir::new().unwrap();
176
177        fs::write(
178            dir.path().join("book.toml"),
179            r#"
180[output.html]
181additional-css = ["theme/gitinfo.css"]
182"#,
183        )
184        .unwrap();
185
186        let ctx = ctx_in_dir(&dir);
187        ensure_gitinfo_assets(&ctx, "/* css */");
188
189        let book = fs::read_to_string(dir.path().join("book.toml")).unwrap();
190        let count = book.matches("theme/gitinfo.css").count();
191        assert_eq!(count, 1);
192    }
193
194    #[test]
195    fn normalizes_single_string_additional_css_to_array() {
196        let dir = TempDir::new().unwrap();
197
198        fs::write(
199            dir.path().join("book.toml"),
200            r#"
201[output.html]
202additional-css = "custom.css"
203"#,
204        )
205        .unwrap();
206
207        let ctx = ctx_in_dir(&dir);
208        ensure_gitinfo_assets(&ctx, "/* css */");
209
210        let book = fs::read_to_string(dir.path().join("book.toml")).unwrap();
211        assert!(book.contains("custom.css"));
212        assert!(book.contains("theme/gitinfo.css"));
213        assert!(book.contains("additional-css = ["));
214    }
215
216    #[test]
217    fn gracefully_handles_missing_book_toml() {
218        let dir = TempDir::new().unwrap();
219        let ctx = ctx_in_dir(&dir);
220
221        // Should not panic or error
222        ensure_gitinfo_assets(&ctx, "/* css */");
223
224        let css_path = dir.path().join("theme/gitinfo.css");
225        assert!(css_path.exists());
226    }
227}