makeover 5.14.0

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! 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 type that names the tiers rather than a vector of paths. 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.
use crate::ThemeId;
use crate::load::is_theme_file;
#[allow(unused_imports)]
use crate::{derive_tonal_steps, list_themes_from_dirs, load_theme};

/// 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<PathBuf>,
    system: Vec<PathBuf>,
    custom: Option<PathBuf>,
}

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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> Self {
        self.custom = dir;
        self
    }

    /// Every tier that exists, lowest precedence first, each with whether it
    /// is the user's own. What every loader in this crate walks.
    pub fn search_path(&self) -> impl Iterator<Item = (&Path, bool)> {
        self.bundled
            .iter()
            .chain(&self.system)
            .map(|dir| (dir.as_path(), false))
            .chain(self.custom.as_deref().map(|dir| (dir, true)))
            .filter(|(dir, _)| dir.is_dir())
    }
}

/// Find a theme file by ID on the search path.
///
/// The highest-precedence tier holding the file wins. Returns
/// `(path, is_custom)` or `None` if not found.
pub fn find_theme_path(dirs: &ThemeDirs, id: &ThemeId) -> Option<(PathBuf, bool)> {
    let filename = id.file_name();
    let tiers: Vec<(&Path, bool)> = dirs.search_path().collect();
    tiers.into_iter().rev().find_map(|(dir, is_custom)| {
        let path = dir.join(&filename);
        path.is_file().then_some((path, is_custom))
    })
}

/// 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<Item = (&'static str, &'static str)> {
    EMBEDDED.files().filter_map(|file| {
        let path = file.path();
        if !is_theme_file(path) {
            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<PathBuf> {
    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;
    #[allow(unused_imports)]
    use crate::{ThemeDirs, ThemeId, Token};
    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()));
        let path: Vec<(PathBuf, bool)> = dirs
            .search_path()
            .map(|(dir, custom)| (dir.to_path_buf(), custom))
            .collect();

        assert_eq!(
            path,
            vec![(bundled, false), (system, false), (custom.clone(), true)],
            "lowest precedence first, whatever order the tiers were added in",
        );
        assert!(path.last().unwrap().1, "only the user's tier is custom");

        // And the ordering means what the consumers think it means.
        for (dir, _) in &path {
            std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
        }
        assert_eq!(
            find_theme_path(&dirs, &ThemeId::from_static("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()));

        let path: Vec<(&Path, bool)> = dirs.search_path().collect();
        assert_eq!(path, vec![(real.as_path(), 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()));
        let path: Vec<(&Path, bool)> = dirs.search_path().collect();
        assert_eq!(
            path,
            vec![(first.as_path(), false), (second.as_path(), 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 = ThemeDirs::new()
            .bundled(Some(d1.path().to_path_buf()))
            .custom(Some(d2.path().to_path_buf()));
        let (path, is_custom) = find_theme_path(&dirs, &ThemeId::from_static("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.
        //
        // The upstream URL is the anchor, not a phrase. What the credit says
        // varies with how far a file has travelled from its source — a theme
        // whose values we have since moved is descended from its upstream
        // rather than adapted from it — and a test that pinned the wording
        // would be asking the header to lie about that rather than asking it
        // to carry the credit.
        const ORIGINALS: [&str; 5] = [
            "makenotwork",
            "goingson",
            "audiofiles",
            "high-contrast",
            "neobrute",
        ];
        for (id, source) in embedded_themes() {
            if ORIGINALS.contains(&id) {
                continue;
            }
            assert!(
                source.lines().take(6).any(|l| l.contains("https://")),
                "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<String> = 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<String> = 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(&ThemeId::new(id).unwrap(), 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}");
    }
}