makeover 3.8.1

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 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<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
    }

    /// 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<Item = (&'static str, &'static str)> {
    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<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;
    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.
        //
        // 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(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}");
    }
}