//! Loading / parsing use crate::{ COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeMeta, find_theme_path, resolve, tonal, wcag_contrast, }; use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::ansi_intent; /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores). pub fn validate_theme_id(id: &str) -> Result<(), String> { if !id .chars() .all(|c| c.is_alphanumeric() || c == '-' || c == '_') { return Err(format!("Invalid theme ID: {id}")); } Ok(()) } /// Parse the `[meta]` section into `ThemeMeta`. /// /// Falls back to the file ID as the name and `"dark"` as the variant. pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta { let meta = table.get("meta").and_then(|m| m.as_table()); let name = meta .and_then(|m| m.get("name")) .and_then(|v| v.as_str()) .unwrap_or(id) .to_string(); let variant = meta .and_then(|m| m.get("variant")) .and_then(|v| v.as_str()) .unwrap_or("dark") .to_string(); ThemeMeta { id: id.to_string(), name, variant, is_custom, } } /// Extract the intent color sections into a flat `HashMap` with dotted keys /// like `"surface.page"`, `"status.danger"`, `"category.one"`. /// /// The tonal steps of `content.primary` are filled in here rather than read, by /// [`derive_tonal_steps`]. Anything a theme authored under those keys is /// replaced. pub fn extract_colors(table: &toml::Table) -> HashMap { let mut colors = HashMap::new(); for section in COLOR_SECTIONS { if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) { for (key, val) in sect { if let Some(color) = val.as_str() { colors.insert(format!("{section}.{key}"), color.to_string()); } } } } derive_tonal_steps(&mut colors); colors } /// Fill in the tonal steps of `content.primary`, overwriting whatever the theme /// authored under those keys. /// /// # Why they are not authored /// /// `content.secondary` and `content.muted` are not independent colours. They are /// the ink, one step and two steps back, and a theme that names them separately /// is stating three times something it stated once — which is how three of the /// bundled themes came to author a `secondary` *lighter* than their own /// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the /// emphasis ramp the whole vocabulary rests on. Deriving them makes /// `content` > `content-secondary` > `content-muted` true by construction in /// every theme, including one a user writes. /// /// Applied at load rather than in [`resolve`] so that there is one answer: the /// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys), /// and every consumer holding a [`ThemeColors`] all see the same value. A /// derivation visible from only one of those is how a terminal and a webview /// come to disagree about what muted means. /// /// Both keys need `content.primary` and `surface.page` to exist and parse. When /// either is missing the step is skipped and anything authored is left where it /// is, mirroring the skip-missing behaviour of the rest of the crate — a /// half-written theme keeps whatever it has rather than losing it. /// /// # The ratio is a starting point, not the answer /// /// Each step is pushed further toward the page until it clears [`STEP_FLOOR`] /// against the ink, so what the theme gets is a step that can be seen rather /// than a step of the agreed size. The two are the same number in every bundled /// theme but the two with a pure-black ink, where the ratio has no range to /// travel in and the nominal step lands 3/255 from where it started. pub fn derive_tonal_steps(colors: &mut HashMap) { let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v)); let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v)); let (Some(ink), Some(page)) = (ink, page) else { return; }; // Each step starts no nearer than the one before it landed, so pushing // secondary out cannot carry it past muted and invert the ramp. let mut reached = 0.0; for (key, step) in [ ("content.secondary", Emphasis::Secondary), ("content.muted", Emphasis::Muted), ] { let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached)); reached = ratio; colors.insert(key.to_string(), color.to_hex()); } } /// The step `from` of the way from `ink` to `page`, pushed toward `page` until /// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour /// and the ratio it was found at. /// /// A forward scan rather than a solve, because it wants the *first* ratio that /// clears: contrast against the base rises with the distance travelled, but it /// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection /// would trust a monotonicity nothing here guarantees. /// /// Travel stops at the ground. A theme whose ink and page are the same colour /// has no step to take, and the ground is the honest answer — nothing past it /// is a step of the ink any more. fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) { // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so // the scan never steps over the first colour that clears. const PROBE: f32 = 0.005; let mut ratio = from.clamp(0.0, 1.0); loop { let color = tonal(ink, page, ratio); if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 { return (color, ratio); } ratio = (ratio + PROBE).min(1.0); } } /// Scan directories for `.toml` theme files and return metadata for each. /// /// Directories are checked in order; later entries override earlier ones by ID. /// Each entry in `dirs` is `(path, is_custom)`. pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec { let mut seen: HashMap = HashMap::new(); for (dir, is_custom) in dirs { let Ok(entries) = std::fs::read_dir(dir) else { continue; }; for entry in entries { let Ok(entry) = entry else { continue; }; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; } let id = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); let Ok(content) = std::fs::read_to_string(&path) else { continue; }; let table: toml::Table = match content.parse() { Ok(t) => t, Err(_) => continue, }; seen.insert(id.clone(), parse_meta(&id, &table, *is_custom)); } } let mut themes: Vec = seen.into_values().collect(); themes.sort_by(|a, b| a.name.cmp(&b.name)); themes } /// Parse a complete theme (metadata + colors) from raw TOML content, with no /// filesystem access. For callers that embed themes at compile time. pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result { validate_theme_id(id)?; let table: toml::Table = content .parse() .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?; let meta = parse_meta(id, &table, is_custom); let colors = extract_colors(&table); Ok(ThemeColors { meta, colors }) } /// Load a complete theme (metadata + colors) by ID from the given directories. pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result { validate_theme_id(id)?; let (path, is_custom) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?; let content = std::fs::read_to_string(&path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let table: toml::Table = content .parse() .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?; let meta = parse_meta(id, &table, is_custom); let colors = extract_colors(&table); Ok(ThemeColors { meta, colors }) } /// Load a theme and resolve it to the full intent token set in one step. pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result { Ok(resolve(&load_theme(dirs, id)?)) } /// Import a theme TOML file into the custom themes directory. /// /// Validates that the file is parseable TOML with at least one intent color /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata. pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result { let content = std::fs::read_to_string(source_path) .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?; let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?; let has_colors = COLOR_SECTIONS .iter() .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some()); if !has_colors { return Err(format!( "Theme file must have at least one color section ({})", COLOR_SECTIONS.join(", ") )); } let id = source_path .file_stem() .and_then(|s| s.to_str()) .ok_or("Invalid file name")? .to_string(); validate_theme_id(&id)?; std::fs::create_dir_all(custom_dir) .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?; let dest = custom_dir.join(format!("{id}.toml")); std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?; Ok(parse_meta(&id, &table, true)) } /// Delete a custom theme by ID. /// /// Only operates on `custom_dir` — bundled themes are not deletable through /// this entry point. pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> { validate_theme_id(id)?; let path = custom_dir.join(format!("{id}.toml")); if !path.is_file() { return Err(format!("Custom theme '{id}' not found")); } std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e)) } /// A four-color preview for theme thumbnails: the representative swatch from /// each of the principal roles. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemePreview { pub meta: ThemeMeta, /// Page background (`surface.page`). pub background: Option, /// Body text (`content.primary`). pub foreground: Option, /// Brand/interactive color (`action.primary`). pub accent: Option, /// Divider/outline color (`line.border`). pub border: Option, } fn color_at(table: &toml::Table, section: &str, key: &str) -> Option { table .get(section) .and_then(|s| s.as_table()) .and_then(|s| s.get(key)) .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) } /// Load just the preview swatches for a theme — for UI thumbnails. pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result { validate_theme_id(id)?; let (path, is_custom) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?; let content = std::fs::read_to_string(&path) .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; let table: toml::Table = content .parse() .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?; Ok(ThemePreview { meta: parse_meta(id, &table, is_custom), background: color_at(&table, "surface", "page"), foreground: color_at(&table, "content", "primary"), accent: color_at(&table, "action", "primary"), border: color_at(&table, "line", "border"), }) } /// Export a theme to a user-chosen path. pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> { validate_theme_id(id)?; let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?; std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::fixture::nord_toml; use crate::{bundled_themes_dir, embedded_themes}; use std::fs; // ---- id validation ---- #[test] fn validate_theme_id_alphanumeric() { assert!(validate_theme_id("darkmode").is_ok()); assert!(validate_theme_id("Theme123").is_ok()); } #[test] fn validate_theme_id_hyphens_underscores() { assert!(validate_theme_id("dark-mode").is_ok()); assert!(validate_theme_id("my_theme_v2").is_ok()); } #[test] fn validate_theme_id_rejects_path_traversal() { assert!(validate_theme_id("../etc/passwd").is_err()); assert!(validate_theme_id("foo/bar").is_err()); assert!(validate_theme_id("theme.toml").is_err()); } // ---- meta ---- #[test] fn parse_meta_with_name_and_variant() { let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n" .parse() .unwrap(); let meta = parse_meta("nord", &table, false); assert_eq!(meta.id, "nord"); assert_eq!(meta.name, "Nord"); assert_eq!(meta.variant, "light"); assert!(!meta.is_custom); } #[test] fn parse_meta_defaults_to_id_and_dark() { let table: toml::Table = "".parse().unwrap(); let meta = parse_meta("fallback", &table, true); assert_eq!(meta.name, "fallback"); assert_eq!(meta.variant, "dark"); assert!(meta.is_custom); } #[test] fn extract_colors_reads_intent_sections() { let table: toml::Table = nord_toml().parse().unwrap(); let colors = extract_colors(&table); assert_eq!(colors.get("surface.page").unwrap(), "#2e3440"); assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9"); assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1"); assert_eq!(colors.get("status.danger").unwrap(), "#bf616a"); assert_eq!(colors.get("line.border").unwrap(), "#4c566a"); assert_eq!(colors.get("category.five").unwrap(), "#b48ead"); assert_eq!(colors.len(), 19); } #[test] fn every_shipped_theme_ramps_one_way() { // The property authoring the steps separately could not hold: three // themes had shipped a secondary lighter than their own primary, so a // renderer reading the emphasis order got the reverse of it. for (id, toml) in embedded_themes() { let theme = parse_theme_str(id, toml, false).unwrap(); let t = resolve(&theme); let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap(); let steps = ["content", "content-secondary", "content-muted"] .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page)); assert!( steps[0] > steps[1] && steps[1] > steps[2], "{id}: emphasis does not fall monotonically: {steps:?}" ); } } #[test] fn every_shipped_theme_takes_a_visible_first_step() { // The property that was missing when 2.6.0 derived these, and the // reason a pure-black ink shipped a secondary 3/255 away from it: the // ramp falling monotonically says nothing about how far it falls, and // a step nobody can see is not a step. for (id, toml) in embedded_themes() { let theme = parse_theme_str(id, toml, false).unwrap(); let t = resolve(&theme); let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap(); let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap(); let step = wcag_contrast(ink, secondary); assert!( step >= STEP_FLOOR, "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor" ); } } #[test] fn an_authored_emphasis_step_does_not_survive_loading() { // `nord_toml` still authors both, because a user's theme file might and // the answer has to be the same one. let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88"); assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0"); } #[test] fn a_theme_with_no_page_keeps_what_it_authored() { // Skip-missing: there is nothing to read the step against, so the step // is not taken and a half-written theme does not lose a colour. let mut colors = HashMap::new(); colors.insert("content.primary".to_string(), "#d8dee9".to_string()); colors.insert("content.muted".to_string(), "#616e88".to_string()); derive_tonal_steps(&mut colors); assert_eq!(colors.get("content.muted").unwrap(), "#616e88"); } // ---- loading / fs ---- #[test] fn load_and_resolve_round_trip() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let t = load_semantic(&dirs, "nord").unwrap(); assert_eq!(t.meta.name, "Nord"); assert_eq!(t.hex("action"), Some("#81a1c1")); } #[test] fn load_theme_rejects_invalid_id() { assert!(load_theme(&[], "../evil").is_err()); } #[test] fn list_themes_from_dirs_finds_toml_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap(); fs::write(dir.path().join("x.txt"), "ignored").unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let themes = list_themes_from_dirs(&dirs); assert_eq!(themes.len(), 1); assert_eq!(themes[0].id, "t"); } #[test] fn import_theme_valid_and_rejects_empty() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let good = src_dir.path().join("my-theme.toml"); fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap(); let meta = import_theme(&good, custom_dir.path()).unwrap(); assert_eq!(meta.id, "my-theme"); assert!(custom_dir.path().join("my-theme.toml").exists()); let empty = src_dir.path().join("empty.toml"); fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap(); assert!(import_theme(&empty, custom_dir.path()).is_err()); } #[test] fn import_theme_rejects_invalid_toml() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let src = src_dir.path().join("bad.toml"); fs::write(&src, "this is not [valid toml [[[").unwrap(); assert!(import_theme(&src, custom_dir.path()).is_err()); } #[test] fn delete_theme_removes_and_guards() { let custom = tempfile::tempdir().unwrap(); let path = custom.path().join("doomed.toml"); fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap(); delete_theme(custom.path(), "doomed").unwrap(); assert!(!path.exists()); assert!(delete_theme(custom.path(), "../etc/passwd").is_err()); assert!(delete_theme(custom.path(), "ghost").is_err()); } #[test] fn export_theme_copies_file() { let src_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap(); let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n"; fs::write(src_dir.path().join("e.toml"), content).unwrap(); let dirs = vec![(src_dir.path().to_path_buf(), false)]; let dest = dest_dir.path().join("out.toml"); export_theme(&dirs, "e", &dest).unwrap(); assert_eq!(fs::read_to_string(&dest).unwrap(), content); assert!(export_theme(&dirs, "missing", &dest).is_err()); } #[test] fn load_theme_preview_returns_role_swatches() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let p = load_theme_preview(&dirs, "nord").unwrap(); assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border } #[test] fn every_shipped_theme_loads() { // Guards the data, not just the loader: a malformed or truncated // .toml in themes/ is a shipping bug, and it should fail here rather // than at a user's first launch. let dir = bundled_themes_dir().unwrap(); let dirs = vec![(dir.clone(), false)]; let themes = list_themes_from_dirs(&dirs); assert!( themes.len() >= 30, "expected the full theme set, got {}", themes.len() ); for meta in &themes { load_theme(&dirs, &meta.id) .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id)); } } }