//! Where themes are looked for. //! //! Four apps built this vector by hand, two of them byte-for-byte identically, //! and one of them built it backwards: the Alloy console pushed the user's own //! directory first, under a comment saying "highest precedence first", when both //! consumers of the vector resolve *last* wins. A user's custom theme lost to //! the packaged one of the same id. //! //! Hence a builder that names the tiers rather than a function taking a vector. //! The precedence is stated once, here, and a caller cannot express it backwards //! because the order is not theirs to choose. use std::path::{Path, PathBuf}; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::{derive_tonal_steps, list_themes_from_dirs, load_theme}; /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take. /// /// Tiers are added in whatever order is convenient and always end up in /// precedence order: the user's own themes win, then whatever the system /// ships, then whatever the app bundles. /// /// A directory that does not exist is dropped rather than carried, so callers /// can offer every tier they might have without checking each one. #[derive(Debug, Default, Clone)] pub struct ThemeDirs { bundled: Vec, system: Vec, custom: Option, } impl ThemeDirs { #[must_use] pub fn new() -> Self { Self::default() } /// Themes the app ships with. Lowest precedence. /// /// Takes more than one because a Tauri app has two: the bundled resource /// directory in production, and the tree `build.rs` materialized for a /// `cargo run` that has no resource directory at all. #[must_use] pub fn bundled(mut self, dir: Option) -> Self { self.bundled.extend(dir); self } /// Themes the machine ships, from an image or a package. Overrides bundled. #[must_use] pub fn system(mut self, dir: Option) -> Self { self.system.extend(dir); self } /// The user's own themes. Highest precedence, and the only tier flagged /// custom, which is what makes them exportable and deletable. #[must_use] pub fn custom(mut self, dir: Option) -> Self { self.custom = dir; self } /// The search path, lowest precedence first. #[must_use] pub fn build(self) -> Vec<(PathBuf, bool)> { let mut dirs = Vec::new(); for dir in self.bundled.into_iter().chain(self.system) { if dir.is_dir() { dirs.push((dir, false)); } } if let Some(dir) = self.custom && dir.is_dir() { dirs.push((dir, true)); } dirs } } /// Find a theme file by ID in the given directories. /// /// Checks directories in reverse order so the highest-priority directory wins. /// Returns `(path, is_custom)` or `None` if not found. pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> { let filename = format!("{id}.toml"); for (dir, is_custom) in dirs.iter().rev() { let path = dir.join(&filename); if path.is_file() { return Some((path, *is_custom)); } } None } /// The themes this crate ships, embedded at compile time. /// /// `include_dir` is an implementation detail: the public API hands back plain /// `(id, toml_source)` pairs, so how the data is embedded can change without /// a breaking release. static EMBEDDED: include_dir::Dir<'static> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes"); /// The themes this crate ships, as `(id, toml_source)` pairs. /// /// This is the path-free way to reach the bundled set, for consumers that /// cannot rely on a directory existing at runtime: a crate pulled from /// crates.io lives in a registry checkout whose location is not knowable at /// compile time, so `include_dir!` and asset-bundling globs in the depending /// crate have nothing stable to point at. Embedding here and re-exporting the /// contents gives them one source of truth without a path. /// /// Ordering follows the embedded directory and is not guaranteed; collect and /// sort by id where a stable order matters (a theme picker, say). pub fn embedded_themes() -> impl Iterator { EMBEDDED.files().filter_map(|file| { let path = file.path(); if path.extension().and_then(|e| e.to_str()) != Some("toml") { return None; } let id = path.file_stem()?.to_str()?; Some((id, file.contents_utf8()?)) }) } /// The theme directory this crate ships, for use as a build-from-source /// fallback. /// /// Resolves against `makeover`'s own manifest directory, fixed at compile /// time, so it works from a path dependency and from a cargo git checkout /// alike. Installed systems should put their packaged theme directory ahead /// of this in the search path; this is the entry that keeps `cargo run` in a /// fresh clone from coming up with no themes at all. /// /// Returns `None` when the directory is absent — a cargo cache that has been /// cleaned, or a vendored copy that dropped the data — so callers degrade to /// their remaining search path rather than failing. pub fn bundled_themes_dir() -> Option { let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes"); if themes.is_dir() { Some(themes) } else { None } } #[cfg(test)] mod tests { use super::*; use crate::parse_theme_str; use std::fs; // The bug this builder exists to prevent: the Alloy console pushed the // user's directory first under a comment reading "highest precedence // first", when both consumers of this vector resolve last-wins. A custom // theme lost to the packaged one of the same id. #[test] fn the_users_own_themes_outrank_everything() { let root = tempfile::tempdir().unwrap(); let make = |name: &str| { let dir = root.path().join(name); std::fs::create_dir_all(&dir).unwrap(); dir }; let (bundled, system, custom) = (make("bundled"), make("system"), make("custom")); let dirs = ThemeDirs::new() .custom(Some(custom.clone())) .bundled(Some(bundled.clone())) .system(Some(system.clone())) .build(); assert_eq!( dirs, vec![(bundled, false), (system, false), (custom.clone(), true)], "lowest precedence first, whatever order the tiers were added in", ); assert!(dirs.last().unwrap().1, "only the user's tier is custom"); // And the ordering means what the consumers think it means. for dir in dirs.iter().map(|(dir, _)| dir) { std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap(); } assert_eq!( find_theme_path(&dirs, "shared").unwrap().0, custom.join("shared.toml"), "the user's copy is the one that loads", ); } #[test] fn a_directory_that_does_not_exist_is_dropped() { let root = tempfile::tempdir().unwrap(); let real = root.path().join("real"); std::fs::create_dir_all(&real).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(root.path().join("nope"))) .system(None) .custom(Some(real.clone())) .build(); assert_eq!(dirs, vec![(real, true)]); } // A Tauri app has two bundled tiers: the resource dir in production and the // tree build.rs materialized for a dev run with no resource dir. #[test] fn more_than_one_bundled_tier_is_allowed() { let root = tempfile::tempdir().unwrap(); let (first, second) = (root.path().join("a"), root.path().join("b")); std::fs::create_dir_all(&first).unwrap(); std::fs::create_dir_all(&second).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(first.clone())) .bundled(Some(second.clone())) .build(); assert_eq!(dirs, vec![(first, false), (second, false)]); } #[test] fn find_theme_path_reverse_priority() { let d1 = tempfile::tempdir().unwrap(); let d2 = tempfile::tempdir().unwrap(); fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap(); fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap(); let dirs = vec![ (d1.path().to_path_buf(), false), (d2.path().to_path_buf(), true), ]; let (path, is_custom) = find_theme_path(&dirs, "s").unwrap(); assert!(is_custom); assert_eq!(path, d2.path().join("s.toml")); } #[test] fn bundled_themes_dir_resolves_to_shipped_themes() { // The crate ships its themes, so this must resolve in-tree and the // Akari defaults the console falls back to must be present. let dir = bundled_themes_dir().expect("makeover ships a themes/ directory"); assert!(dir.join("akari-dawn.toml").is_file()); assert!(dir.join("akari-night.toml").is_file()); } #[test] fn every_theme_is_accounted_for_in_third_party_notices() { // Attribution is a redistribution obligation, not a nicety: adding a // theme without a notice entry silently ships someone's work // uncredited. Fail here instead. let notices = std::fs::read_to_string( Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"), ) .expect("THIRD-PARTY-NOTICES.md must exist"); let missing: Vec<&str> = embedded_themes() .map(|(id, _)| id) .filter(|id| !notices.contains(*id)) .collect(); assert!( missing.is_empty(), "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}" ); } #[test] fn adapted_themes_carry_inline_attribution() { // Each adapted file must name its upstream in-file, so the credit // survives someone copying a single .toml out of the crate. const ORIGINALS: [&str; 5] = [ "makenotwork", "goingson", "audiofiles", "high-contrast", "neobrute", ]; for (id, source) in embedded_themes() { if ORIGINALS.contains(&id) { continue; } assert!( source.contains("adapted from"), "adapted theme `{id}` is missing its inline attribution header" ); } } #[test] fn embedded_themes_match_the_directory() { // The embedded copy and themes/ are two views of one source. If they // ever disagree, path-based and path-free consumers render different // theme sets, which is exactly the drift shipping the data was meant // to prevent. let dir = bundled_themes_dir().unwrap(); let mut on_disk: Vec = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| { let path = e.ok()?.path(); if path.extension()? != "toml" { return None; } Some(path.file_stem()?.to_str()?.to_string()) }) .collect(); let mut embedded: Vec = embedded_themes().map(|(id, _)| id.to_string()).collect(); on_disk.sort(); embedded.sort(); assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/"); } #[test] fn every_embedded_theme_parses() { // Guards the path-free consumers (MNW server, the Tauri build steps) // the same way every_shipped_theme_loads guards the path-based ones. let mut count = 0; for (id, source) in embedded_themes() { parse_theme_str(id, source, false) .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}")); count += 1; } assert!(count >= 30, "expected the full theme set, got {count}"); } }