use std::path::{Path, PathBuf};
#[allow(unused_imports)]
use crate::{derive_tonal_steps, list_themes_from_dirs, load_theme};
#[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()
}
#[must_use]
pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
self.bundled.extend(dir);
self
}
#[must_use]
pub fn system(mut self, dir: Option<PathBuf>) -> Self {
self.system.extend(dir);
self
}
#[must_use]
pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
self.custom = dir;
self
}
#[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
}
}
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
}
static EMBEDDED: include_dir::Dir<'static> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
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()?))
})
}
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;
#[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");
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)]);
}
#[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() {
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() {
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() {
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() {
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() {
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}");
}
}