Skip to main content

max / makeover

11.9 KB · 324 lines History Blame Raw
1 //! Where themes are looked for.
2 //!
3 //! Four apps built this vector by hand, two of them byte-for-byte identically,
4 //! and one of them built it backwards: the Alloy console pushed the user's own
5 //! directory first, under a comment saying "highest precedence first", when both
6 //! consumers of the vector resolve *last* wins. A user's custom theme lost to
7 //! the packaged one of the same id.
8 //!
9 //! Hence a builder that names the tiers rather than a function taking a vector.
10 //! The precedence is stated once, here, and a caller cannot express it backwards
11 //! because the order is not theirs to choose.
12
13 use std::path::{Path, PathBuf};
14
15 // Names this module's prose links to, resolved for rustdoc.
16 #[allow(unused_imports)]
17 use crate::{derive_tonal_steps, list_themes_from_dirs, load_theme};
18
19 /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
20 ///
21 /// Tiers are added in whatever order is convenient and always end up in
22 /// precedence order: the user's own themes win, then whatever the system
23 /// ships, then whatever the app bundles.
24 ///
25 /// A directory that does not exist is dropped rather than carried, so callers
26 /// can offer every tier they might have without checking each one.
27 #[derive(Debug, Default, Clone)]
28 pub struct ThemeDirs {
29 bundled: Vec<PathBuf>,
30 system: Vec<PathBuf>,
31 custom: Option<PathBuf>,
32 }
33
34 impl ThemeDirs {
35 #[must_use]
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 /// Themes the app ships with. Lowest precedence.
41 ///
42 /// Takes more than one because a Tauri app has two: the bundled resource
43 /// directory in production, and the tree `build.rs` materialized for a
44 /// `cargo run` that has no resource directory at all.
45 #[must_use]
46 pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
47 self.bundled.extend(dir);
48 self
49 }
50
51 /// Themes the machine ships, from an image or a package. Overrides bundled.
52 #[must_use]
53 pub fn system(mut self, dir: Option<PathBuf>) -> Self {
54 self.system.extend(dir);
55 self
56 }
57
58 /// The user's own themes. Highest precedence, and the only tier flagged
59 /// custom, which is what makes them exportable and deletable.
60 #[must_use]
61 pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
62 self.custom = dir;
63 self
64 }
65
66 /// The search path, lowest precedence first.
67 #[must_use]
68 pub fn build(self) -> Vec<(PathBuf, bool)> {
69 let mut dirs = Vec::new();
70 for dir in self.bundled.into_iter().chain(self.system) {
71 if dir.is_dir() {
72 dirs.push((dir, false));
73 }
74 }
75 if let Some(dir) = self.custom
76 && dir.is_dir()
77 {
78 dirs.push((dir, true));
79 }
80 dirs
81 }
82 }
83
84 /// Find a theme file by ID in the given directories.
85 ///
86 /// Checks directories in reverse order so the highest-priority directory wins.
87 /// Returns `(path, is_custom)` or `None` if not found.
88 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
89 let filename = format!("{id}.toml");
90
91 for (dir, is_custom) in dirs.iter().rev() {
92 let path = dir.join(&filename);
93 if path.is_file() {
94 return Some((path, *is_custom));
95 }
96 }
97
98 None
99 }
100
101 /// The themes this crate ships, embedded at compile time.
102 ///
103 /// `include_dir` is an implementation detail: the public API hands back plain
104 /// `(id, toml_source)` pairs, so how the data is embedded can change without
105 /// a breaking release.
106 static EMBEDDED: include_dir::Dir<'static> =
107 include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
108
109 /// The themes this crate ships, as `(id, toml_source)` pairs.
110 ///
111 /// This is the path-free way to reach the bundled set, for consumers that
112 /// cannot rely on a directory existing at runtime: a crate pulled from
113 /// crates.io lives in a registry checkout whose location is not knowable at
114 /// compile time, so `include_dir!` and asset-bundling globs in the depending
115 /// crate have nothing stable to point at. Embedding here and re-exporting the
116 /// contents gives them one source of truth without a path.
117 ///
118 /// Ordering follows the embedded directory and is not guaranteed; collect and
119 /// sort by id where a stable order matters (a theme picker, say).
120 pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
121 EMBEDDED.files().filter_map(|file| {
122 let path = file.path();
123 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
124 return None;
125 }
126 let id = path.file_stem()?.to_str()?;
127 Some((id, file.contents_utf8()?))
128 })
129 }
130
131 /// The theme directory this crate ships, for use as a build-from-source
132 /// fallback.
133 ///
134 /// Resolves against `makeover`'s own manifest directory, fixed at compile
135 /// time, so it works from a path dependency and from a cargo git checkout
136 /// alike. Installed systems should put their packaged theme directory ahead
137 /// of this in the search path; this is the entry that keeps `cargo run` in a
138 /// fresh clone from coming up with no themes at all.
139 ///
140 /// Returns `None` when the directory is absent — a cargo cache that has been
141 /// cleaned, or a vendored copy that dropped the data — so callers degrade to
142 /// their remaining search path rather than failing.
143 pub fn bundled_themes_dir() -> Option<PathBuf> {
144 let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
145 if themes.is_dir() { Some(themes) } else { None }
146 }
147
148 #[cfg(test)]
149 mod tests {
150 use super::*;
151 use crate::parse_theme_str;
152 use std::fs;
153
154 // The bug this builder exists to prevent: the Alloy console pushed the
155 // user's directory first under a comment reading "highest precedence
156 // first", when both consumers of this vector resolve last-wins. A custom
157 // theme lost to the packaged one of the same id.
158 #[test]
159 fn the_users_own_themes_outrank_everything() {
160 let root = tempfile::tempdir().unwrap();
161 let make = |name: &str| {
162 let dir = root.path().join(name);
163 std::fs::create_dir_all(&dir).unwrap();
164 dir
165 };
166 let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
167
168 let dirs = ThemeDirs::new()
169 .custom(Some(custom.clone()))
170 .bundled(Some(bundled.clone()))
171 .system(Some(system.clone()))
172 .build();
173
174 assert_eq!(
175 dirs,
176 vec![(bundled, false), (system, false), (custom.clone(), true)],
177 "lowest precedence first, whatever order the tiers were added in",
178 );
179 assert!(dirs.last().unwrap().1, "only the user's tier is custom");
180
181 // And the ordering means what the consumers think it means.
182 for dir in dirs.iter().map(|(dir, _)| dir) {
183 std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
184 }
185 assert_eq!(
186 find_theme_path(&dirs, "shared").unwrap().0,
187 custom.join("shared.toml"),
188 "the user's copy is the one that loads",
189 );
190 }
191
192 #[test]
193 fn a_directory_that_does_not_exist_is_dropped() {
194 let root = tempfile::tempdir().unwrap();
195 let real = root.path().join("real");
196 std::fs::create_dir_all(&real).unwrap();
197
198 let dirs = ThemeDirs::new()
199 .bundled(Some(root.path().join("nope")))
200 .system(None)
201 .custom(Some(real.clone()))
202 .build();
203
204 assert_eq!(dirs, vec![(real, true)]);
205 }
206
207 // A Tauri app has two bundled tiers: the resource dir in production and the
208 // tree build.rs materialized for a dev run with no resource dir.
209 #[test]
210 fn more_than_one_bundled_tier_is_allowed() {
211 let root = tempfile::tempdir().unwrap();
212 let (first, second) = (root.path().join("a"), root.path().join("b"));
213 std::fs::create_dir_all(&first).unwrap();
214 std::fs::create_dir_all(&second).unwrap();
215
216 let dirs = ThemeDirs::new()
217 .bundled(Some(first.clone()))
218 .bundled(Some(second.clone()))
219 .build();
220 assert_eq!(dirs, vec![(first, false), (second, false)]);
221 }
222
223 #[test]
224 fn find_theme_path_reverse_priority() {
225 let d1 = tempfile::tempdir().unwrap();
226 let d2 = tempfile::tempdir().unwrap();
227 fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
228 fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
229 let dirs = vec![
230 (d1.path().to_path_buf(), false),
231 (d2.path().to_path_buf(), true),
232 ];
233 let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
234 assert!(is_custom);
235 assert_eq!(path, d2.path().join("s.toml"));
236 }
237
238 #[test]
239 fn bundled_themes_dir_resolves_to_shipped_themes() {
240 // The crate ships its themes, so this must resolve in-tree and the
241 // Akari defaults the console falls back to must be present.
242 let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
243 assert!(dir.join("akari-dawn.toml").is_file());
244 assert!(dir.join("akari-night.toml").is_file());
245 }
246
247 #[test]
248 fn every_theme_is_accounted_for_in_third_party_notices() {
249 // Attribution is a redistribution obligation, not a nicety: adding a
250 // theme without a notice entry silently ships someone's work
251 // uncredited. Fail here instead.
252 let notices = std::fs::read_to_string(
253 Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
254 )
255 .expect("THIRD-PARTY-NOTICES.md must exist");
256 let missing: Vec<&str> = embedded_themes()
257 .map(|(id, _)| id)
258 .filter(|id| !notices.contains(*id))
259 .collect();
260 assert!(
261 missing.is_empty(),
262 "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
263 );
264 }
265
266 #[test]
267 fn adapted_themes_carry_inline_attribution() {
268 // Each adapted file must name its upstream in-file, so the credit
269 // survives someone copying a single .toml out of the crate.
270 const ORIGINALS: [&str; 5] = [
271 "makenotwork",
272 "goingson",
273 "audiofiles",
274 "high-contrast",
275 "neobrute",
276 ];
277 for (id, source) in embedded_themes() {
278 if ORIGINALS.contains(&id) {
279 continue;
280 }
281 assert!(
282 source.contains("adapted from"),
283 "adapted theme `{id}` is missing its inline attribution header"
284 );
285 }
286 }
287
288 #[test]
289 fn embedded_themes_match_the_directory() {
290 // The embedded copy and themes/ are two views of one source. If they
291 // ever disagree, path-based and path-free consumers render different
292 // theme sets, which is exactly the drift shipping the data was meant
293 // to prevent.
294 let dir = bundled_themes_dir().unwrap();
295 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
296 .unwrap()
297 .filter_map(|e| {
298 let path = e.ok()?.path();
299 if path.extension()? != "toml" {
300 return None;
301 }
302 Some(path.file_stem()?.to_str()?.to_string())
303 })
304 .collect();
305 let mut embedded: Vec<String> = embedded_themes().map(|(id, _)| id.to_string()).collect();
306 on_disk.sort();
307 embedded.sort();
308 assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
309 }
310
311 #[test]
312 fn every_embedded_theme_parses() {
313 // Guards the path-free consumers (MNW server, the Tauri build steps)
314 // the same way every_shipped_theme_loads guards the path-based ones.
315 let mut count = 0;
316 for (id, source) in embedded_themes() {
317 parse_theme_str(id, source, false)
318 .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
319 count += 1;
320 }
321 assert!(count >= 30, "expected the full theme set, got {count}");
322 }
323 }
324