1use std::path::{Path, PathBuf};
28
29const LAYOUT_DIRS: [&str; 2] = ["_layouts", "templates"];
32
33#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ThemeError {
40 NotFound {
42 name: String,
44 searched: Vec<PathBuf>,
46 available: Vec<String>,
48 },
49 NoLayouts {
51 name: String,
53 dir: PathBuf,
55 },
56}
57
58impl std::fmt::Display for ThemeError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::NotFound {
62 name,
63 searched,
64 available,
65 } => {
66 write!(f, "no theme named `{name}`. Searched: ")?;
67 let paths: Vec<String> =
68 searched.iter().map(|p| p.display().to_string()).collect();
69 write!(f, "{}", paths.join(", "))?;
70 if available.is_empty() {
71 write!(
72 f,
73 ". No themes found. Put one under `themes/{name}/`, \
74 or set SSG_THEME_PATH to the directory holding your \
75 themes."
76 )
77 } else {
78 write!(f, ". Available: {}", available.join(", "))
79 }
80 }
81 Self::NoLayouts { name, dir } => write!(
82 f,
83 "theme `{name}` at {} has no layouts. Expected one of {} \
84 inside it.",
85 dir.display(),
86 LAYOUT_DIRS.join(" or ")
87 ),
88 }
89 }
90}
91
92impl std::error::Error for ThemeError {}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Theme {
97 pub root: PathBuf,
99 pub template_dir: PathBuf,
101}
102
103#[must_use]
109pub fn search_roots(base: &Path) -> Vec<PathBuf> {
110 let mut roots = vec![base.join("themes")];
111 if let Ok(cwd) = std::env::current_dir() {
112 let cwd_themes = cwd.join("themes");
113 if !roots.contains(&cwd_themes) {
114 roots.push(cwd_themes);
115 }
116 }
117 if let Ok(extra) = std::env::var("SSG_THEME_PATH") {
118 for part in extra.split(if cfg!(windows) { ';' } else { ':' }) {
119 let part = part.trim();
120 if !part.is_empty() {
121 let p = PathBuf::from(part);
122 if !roots.contains(&p) {
123 roots.push(p);
124 }
125 }
126 }
127 }
128 roots
129}
130
131#[must_use]
133pub fn available(roots: &[PathBuf]) -> Vec<String> {
134 let mut names: Vec<String> = roots
135 .iter()
136 .filter_map(|r| std::fs::read_dir(r).ok())
137 .flat_map(|entries| {
138 entries
139 .flatten()
140 .filter(|e| e.path().is_dir())
141 .filter_map(|e| e.file_name().into_string().ok())
142 })
143 .filter(|n| !n.starts_with('.'))
144 .collect();
145 names.sort();
146 names.dedup();
147 names
148}
149
150pub fn resolve(name: &str, base: &Path) -> Result<Theme, ThemeError> {
168 let roots = search_roots(base);
169 for root in &roots {
170 let dir = root.join(name);
171 if !dir.is_dir() {
172 continue;
173 }
174 for layout in LAYOUT_DIRS {
175 let candidate = dir.join(layout);
176 if candidate.is_dir() {
177 return Ok(Theme {
178 root: dir,
179 template_dir: candidate,
180 });
181 }
182 }
183 return Err(ThemeError::NoLayouts {
184 name: name.to_string(),
185 dir,
186 });
187 }
188 Err(ThemeError::NotFound {
189 name: name.to_string(),
190 available: available(&roots),
191 searched: roots,
192 })
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::fs;
199 use tempfile::TempDir;
200
201 fn theme_at(root: &Path, name: &str, layout_dir: &str) {
202 fs::create_dir_all(root.join("themes").join(name).join(layout_dir))
203 .expect("create theme");
204 }
205
206 #[test]
207 fn resolves_a_layouts_theme() {
208 let tmp = TempDir::new().expect("tempdir");
209 theme_at(tmp.path(), "quill", "_layouts");
210 let theme = resolve("quill", tmp.path()).expect("resolve");
211 assert!(theme.template_dir.ends_with("_layouts"));
212 assert!(theme.root.ends_with("quill"));
213 }
214
215 #[test]
219 fn resolves_a_templates_theme() {
220 let tmp = TempDir::new().expect("tempdir");
221 theme_at(tmp.path(), "housestyle", "templates");
222 let theme = resolve("housestyle", tmp.path()).expect("resolve");
223 assert!(theme.template_dir.ends_with("templates"));
224 }
225
226 #[test]
229 fn layouts_wins_when_a_theme_carries_both() {
230 let tmp = TempDir::new().expect("tempdir");
231 theme_at(tmp.path(), "both", "_layouts");
232 theme_at(tmp.path(), "both", "templates");
233 let theme = resolve("both", tmp.path()).expect("resolve");
234 assert!(theme.template_dir.ends_with("_layouts"));
235 }
236
237 #[test]
241 fn a_missing_theme_reports_the_paths_searched_and_what_exists() {
242 let tmp = TempDir::new().expect("tempdir");
243 theme_at(tmp.path(), "quill", "_layouts");
244 theme_at(tmp.path(), "stablo", "_layouts");
245 let err = resolve("nosuch", tmp.path()).expect_err("must not resolve");
246 let msg = err.to_string();
247 assert!(msg.contains("nosuch"), "{msg}");
248 assert!(msg.contains("themes"), "should name a search root: {msg}");
249 assert!(msg.contains("quill"), "should list what exists: {msg}");
250 assert!(msg.contains("stablo"), "should list what exists: {msg}");
251 }
252
253 #[test]
256 fn a_theme_without_layouts_is_reported_as_such() {
257 let tmp = TempDir::new().expect("tempdir");
258 fs::create_dir_all(tmp.path().join("themes/hollow"))
259 .expect("create dir");
260 let err = resolve("hollow", tmp.path()).expect_err("must not resolve");
261 let msg = err.to_string();
262 assert!(msg.contains("has no layouts"), "{msg}");
263 assert!(msg.contains("_layouts"), "should say what it wanted: {msg}");
264 }
265
266 #[test]
267 fn available_lists_theme_names_sorted_without_dotfiles() {
268 let tmp = TempDir::new().expect("tempdir");
269 theme_at(tmp.path(), "voxt", "_layouts");
270 theme_at(tmp.path(), "apex", "_layouts");
271 fs::create_dir_all(tmp.path().join("themes/.hidden"))
272 .expect("hidden dir");
273 let names = available(&[tmp.path().join("themes")]);
274 assert_eq!(names, vec!["apex".to_string(), "voxt".to_string()]);
275 }
276
277 #[test]
280 fn the_base_directory_is_the_first_search_root() {
281 let tmp = TempDir::new().expect("tempdir");
282 let roots = search_roots(tmp.path());
283 assert_eq!(roots.first(), Some(&tmp.path().join("themes")));
284 }
285}