akari_theme/
palette.rs

1use crate::{Error, Rgb, Variant};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7// Raw types for TOML deserialization (before reference resolution)
8#[derive(Debug, Deserialize)]
9struct RawPalette {
10    name: String,
11    description: String,
12    colors: Colors,
13    base: Base,
14    layers: RawLayers,
15    state: RawState,
16    semantic: RawSemantic,
17    ansi: RawAnsi,
18}
19
20#[derive(Debug, Deserialize)]
21struct RawLayers {
22    base: ColorExpr,
23    surface: ColorExpr,
24    sunken: ColorExpr,
25    raised: ColorExpr,
26    border: ColorExpr,
27    inset: ColorExpr,
28}
29
30impl RawLayers {
31    fn resolve(&self, resolver: &Resolver) -> Result<Layers, Error> {
32        Ok(Layers {
33            base: resolve_expr(resolver, &self.base)?,
34            surface: resolve_expr(resolver, &self.surface)?,
35            sunken: resolve_expr(resolver, &self.sunken)?,
36            raised: resolve_expr(resolver, &self.raised)?,
37            border: resolve_expr(resolver, &self.border)?,
38            inset: resolve_expr(resolver, &self.inset)?,
39        })
40    }
41}
42
43#[derive(Debug, Deserialize)]
44struct RawState {
45    selection_bg: ColorExpr,
46    selection_fg: ColorExpr,
47    match_bg: ColorExpr,
48    cursor: ColorExpr,
49    cursor_text: ColorExpr,
50    info: ColorExpr,
51    hint: ColorExpr,
52    warning: ColorExpr,
53    error: ColorExpr,
54    active_bg: ColorExpr,
55    diff_added: ColorExpr,
56    diff_removed: ColorExpr,
57    diff_changed: ColorExpr,
58    diff_moved: ColorExpr,
59    conflict: ColorExpr,
60}
61
62impl RawState {
63    fn resolve(&self, resolver: &Resolver) -> Result<State, Error> {
64        Ok(State {
65            selection_bg: resolve_expr(resolver, &self.selection_bg)?,
66            selection_fg: resolve_expr(resolver, &self.selection_fg)?,
67            match_bg: resolve_expr(resolver, &self.match_bg)?,
68            cursor: resolve_expr(resolver, &self.cursor)?,
69            cursor_text: resolve_expr(resolver, &self.cursor_text)?,
70            info: resolve_expr(resolver, &self.info)?,
71            hint: resolve_expr(resolver, &self.hint)?,
72            warning: resolve_expr(resolver, &self.warning)?,
73            error: resolve_expr(resolver, &self.error)?,
74            active_bg: resolve_expr(resolver, &self.active_bg)?,
75            diff_added: resolve_expr(resolver, &self.diff_added)?,
76            diff_removed: resolve_expr(resolver, &self.diff_removed)?,
77            diff_changed: resolve_expr(resolver, &self.diff_changed)?,
78            diff_moved: resolve_expr(resolver, &self.diff_moved)?,
79            conflict: resolve_expr(resolver, &self.conflict)?,
80        })
81    }
82}
83
84/// Common structure for ANSI color definitions (used by both ansi and ansi.bright)
85#[derive(Debug, Deserialize)]
86struct RawAnsiColors {
87    black: ColorExpr,
88    red: ColorExpr,
89    green: ColorExpr,
90    yellow: ColorExpr,
91    blue: ColorExpr,
92    magenta: ColorExpr,
93    cyan: ColorExpr,
94    white: ColorExpr,
95}
96
97impl RawAnsiColors {
98    fn resolve(&self, resolver: &impl ResolveRef) -> Result<Ansi, Error> {
99        Ok(Ansi {
100            black: resolve_expr(resolver, &self.black)?,
101            red: resolve_expr(resolver, &self.red)?,
102            green: resolve_expr(resolver, &self.green)?,
103            yellow: resolve_expr(resolver, &self.yellow)?,
104            blue: resolve_expr(resolver, &self.blue)?,
105            magenta: resolve_expr(resolver, &self.magenta)?,
106            cyan: resolve_expr(resolver, &self.cyan)?,
107            white: resolve_expr(resolver, &self.white)?,
108        })
109    }
110}
111
112#[derive(Debug, Deserialize)]
113struct RawAnsi {
114    #[serde(flatten)]
115    base: RawAnsiColors,
116    bright: RawAnsiColors,
117}
118
119#[derive(Debug, Deserialize)]
120struct RawSemantic {
121    text: ColorExpr,
122    comment: ColorExpr,
123    string: ColorExpr,
124    keyword: ColorExpr,
125    number: ColorExpr,
126    constant: ColorExpr,
127    r#type: ColorExpr,
128    function: ColorExpr,
129    variable: ColorExpr,
130    success: ColorExpr,
131    path: ColorExpr,
132    r#macro: ColorExpr,
133    escape: ColorExpr,
134    regexp: ColorExpr,
135    link: ColorExpr,
136    directory: ColorExpr,
137}
138
139impl RawSemantic {
140    fn resolve(&self, resolver: &Resolver) -> Result<Semantic, Error> {
141        Ok(Semantic {
142            text: resolve_expr(resolver, &self.text)?,
143            comment: resolve_expr(resolver, &self.comment)?,
144            string: resolve_expr(resolver, &self.string)?,
145            keyword: resolve_expr(resolver, &self.keyword)?,
146            number: resolve_expr(resolver, &self.number)?,
147            constant: resolve_expr(resolver, &self.constant)?,
148            r#type: resolve_expr(resolver, &self.r#type)?,
149            function: resolve_expr(resolver, &self.function)?,
150            variable: resolve_expr(resolver, &self.variable)?,
151            success: resolve_expr(resolver, &self.success)?,
152            path: resolve_expr(resolver, &self.path)?,
153            r#macro: resolve_expr(resolver, &self.r#macro)?,
154            escape: resolve_expr(resolver, &self.escape)?,
155            regexp: resolve_expr(resolver, &self.regexp)?,
156            link: resolve_expr(resolver, &self.link)?,
157            directory: resolve_expr(resolver, &self.directory)?,
158        })
159    }
160}
161
162// Resolved types (used for both deserialization and template rendering)
163#[derive(Debug, Clone, Deserialize, Serialize)]
164pub struct Lantern {
165    pub ember: String, // inner heat — flame, fuel, origin of light
166    pub near: String,  // hibukuro — paper seen up close
167    pub mid: String,   // glow — lantern as perceived light
168    pub far: String,   // warm blur — light at a distance
169}
170
171#[derive(Debug, Clone, Deserialize, Serialize)]
172pub struct Colors {
173    pub lantern: Lantern,
174    pub life: String,
175    pub night: String,
176    pub rain: String,
177    pub muted: String,
178}
179
180#[derive(Debug, Clone, Deserialize, Serialize)]
181pub struct Base {
182    pub background: String,
183    pub foreground: String,
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize)]
187pub struct Layers {
188    pub base: String,
189    pub surface: String,
190    pub sunken: String,
191    pub raised: String,
192    pub border: String,
193    pub inset: String,
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct State {
198    pub selection_bg: String,
199    pub selection_fg: String,
200    pub match_bg: String,
201    pub cursor: String,
202    pub cursor_text: String,
203    pub info: String,
204    pub hint: String,
205    pub warning: String,
206    pub error: String,
207    pub active_bg: String,
208    pub diff_added: String,
209    pub diff_removed: String,
210    pub diff_changed: String,
211    pub diff_moved: String,
212    pub conflict: String,
213}
214
215#[derive(Debug, Clone, Serialize)]
216pub struct Semantic {
217    pub text: String,
218    pub comment: String,
219    pub string: String,
220    pub keyword: String,
221    pub number: String,
222    pub constant: String,
223    pub r#type: String,
224    pub function: String,
225    pub variable: String,
226    pub success: String,
227    pub path: String,
228    pub r#macro: String,
229    pub escape: String,
230    pub regexp: String,
231    pub link: String,
232    pub directory: String,
233}
234
235#[derive(Debug, Clone, Serialize)]
236pub struct Ansi {
237    pub black: String,
238    pub red: String,
239    pub green: String,
240    pub yellow: String,
241    pub blue: String,
242    pub magenta: String,
243    pub cyan: String,
244    pub white: String,
245}
246
247impl Ansi {
248    /// Convert to a map for use in Resolver
249    fn to_map(&self) -> BTreeMap<&'static str, String> {
250        self.into_iter().map(|(k, v)| (k, v.to_string())).collect()
251    }
252}
253
254impl<'a> IntoIterator for &'a Ansi {
255    type Item = (&'static str, &'a str);
256    type IntoIter = std::array::IntoIter<Self::Item, 8>;
257
258    fn into_iter(self) -> Self::IntoIter {
259        [
260            ("black", self.black.as_str()),
261            ("red", self.red.as_str()),
262            ("green", self.green.as_str()),
263            ("yellow", self.yellow.as_str()),
264            ("blue", self.blue.as_str()),
265            ("magenta", self.magenta.as_str()),
266            ("cyan", self.cyan.as_str()),
267            ("white", self.white.as_str()),
268        ]
269        .into_iter()
270    }
271}
272
273/// Sections that can be referenced in color expressions.
274///
275/// Only `colors`, `base`, and `ansi` are valid reference targets.
276/// `ansi.bright.*` is accessed via `Section::Ansi` with key `"bright.*"`.
277/// Other sections like `layers`, `state`, and `semantic` are consumers of colors,
278/// not sources, and cannot be referenced.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280enum Section {
281    Colors,
282    Base,
283    Ansi,
284}
285
286impl Section {
287    /// Referenceable sections in color expressions.
288    const ALLOWED: &[&str] = &["colors", "base", "ansi"];
289
290    fn parse(s: &str) -> Result<Self, Error> {
291        match s {
292            "colors" => Ok(Self::Colors),
293            "base" => Ok(Self::Base),
294            "ansi" => Ok(Self::Ansi),
295            _ => Err(Error::InvalidColorExpr(format!(
296                "'{s}' cannot be referenced (allowed: {})",
297                Self::ALLOWED.join(", ")
298            ))),
299        }
300    }
301
302    const fn as_str(&self) -> &'static str {
303        match self {
304            Self::Colors => "colors",
305            Self::Base => "base",
306            Self::Ansi => "ansi",
307        }
308    }
309}
310
311/// A color expression that can be deserialized from TOML.
312///
313/// Supports:
314/// - Literal hex colors: `"#E26A3B"`
315/// - References: `"colors.lantern"`
316/// - Functions:
317///   - `"lighten(colors.lantern, 0.1)"` — increase lightness proportionally
318///   - `"darken(base.background, 0.2)"` — decrease lightness proportionally
319///   - `"brighten(ansi.red, 0.1)"` — adjust lightness by absolute amount
320///   - `"mix(base.background, colors.night, 0.15)"` — blend two colors
321#[derive(Debug, Clone, Deserialize)]
322#[serde(try_from = "String")]
323enum ColorExpr {
324    /// A literal hex color (e.g., "#E26A3B")
325    Literal(String),
326    /// A reference to another field (e.g., "colors.lantern")
327    Ref { section: Section, key: String },
328    /// Lighten a color by a factor (0.0 = unchanged, 1.0 = white)
329    Lighten(Box<ColorExpr>, f64),
330    /// Darken a color by a factor (0.0 = unchanged, 1.0 = black)
331    Darken(Box<ColorExpr>, f64),
332    /// Brighten a color by absolute amount (positive = brighter, negative = dimmer)
333    Brighten(Box<ColorExpr>, f64),
334    /// Mix two colors (0.0 = first color, 1.0 = second color)
335    Mix(Box<ColorExpr>, Box<ColorExpr>, f64),
336}
337
338impl TryFrom<String> for ColorExpr {
339    type Error = Error;
340
341    fn try_from(s: String) -> Result<Self, Self::Error> {
342        parse_color_expr(&s)
343    }
344}
345
346/// Strip function call syntax: "fn_name(args)" -> Some("args")
347fn strip_fn_call<'a>(s: &'a str, name: &str) -> Option<&'a str> {
348    s.strip_prefix(name)
349        .and_then(|r| r.strip_prefix('('))
350        .and_then(|r| r.strip_suffix(')'))
351}
352
353/// Parse a color expression string into a ColorExpr.
354fn parse_color_expr(s: &str) -> Result<ColorExpr, Error> {
355    let s = s.trim();
356
357    // Literal hex color
358    if s.starts_with('#') {
359        return Ok(ColorExpr::Literal(s.to_string()));
360    }
361
362    // Function call: lighten(...), darken(...), brighten(...), mix(...)
363    if let Some(args) = strip_fn_call(s, "lighten") {
364        let (inner, factor) = parse_unary_fn_args(args)?;
365        return Ok(ColorExpr::Lighten(Box::new(inner), factor));
366    }
367    if let Some(args) = strip_fn_call(s, "darken") {
368        let (inner, factor) = parse_unary_fn_args(args)?;
369        return Ok(ColorExpr::Darken(Box::new(inner), factor));
370    }
371    if let Some(args) = strip_fn_call(s, "brighten") {
372        let (inner, amount) = parse_unary_fn_args(args)?;
373        return Ok(ColorExpr::Brighten(Box::new(inner), amount));
374    }
375    if let Some(args) = strip_fn_call(s, "mix") {
376        let (color1, color2, factor) = parse_mix_args(args)?;
377        return Ok(ColorExpr::Mix(Box::new(color1), Box::new(color2), factor));
378    }
379
380    // Reference: section.key (e.g., "colors.lantern.mid", "ansi.bright.red")
381    let (section_str, key) = s
382        .split_once('.')
383        .ok_or_else(|| Error::InvalidColorExpr(s.to_string()))?;
384    let section = Section::parse(section_str)?;
385    Ok(ColorExpr::Ref {
386        section,
387        key: key.to_string(),
388    })
389}
390
391/// Parse unary function arguments: "colors.lantern, 0.1" -> (ColorExpr, f64)
392fn parse_unary_fn_args(args: &str) -> Result<(ColorExpr, f64), Error> {
393    let (color_str, factor_str) = args
394        .rsplit_once(',')
395        .ok_or_else(|| Error::InvalidColorExpr(format!("expected 'color, factor': {args}")))?;
396    let inner = parse_color_expr(color_str.trim())?;
397    let factor = factor_str
398        .trim()
399        .parse::<f64>()
400        .map_err(|_| Error::InvalidColorExpr(format!("invalid factor: {}", factor_str.trim())))?;
401    Ok((inner, factor))
402}
403
404/// Parse mix function arguments: "color1, color2, 0.15" -> (ColorExpr, ColorExpr, f64)
405fn parse_mix_args(args: &str) -> Result<(ColorExpr, ColorExpr, f64), Error> {
406    // Split from right to get factor first
407    let (rest, factor_str) = args.rsplit_once(',').ok_or_else(|| {
408        Error::InvalidColorExpr(format!("expected 'color1, color2, factor': {args}"))
409    })?;
410    let factor = factor_str
411        .trim()
412        .parse::<f64>()
413        .map_err(|_| Error::InvalidColorExpr(format!("invalid factor: {}", factor_str.trim())))?;
414
415    // Split remaining to get two colors
416    let (color1_str, color2_str) = rest.rsplit_once(',').ok_or_else(|| {
417        Error::InvalidColorExpr(format!("expected 'color1, color2, factor': {args}"))
418    })?;
419    let color1 = parse_color_expr(color1_str.trim())?;
420    let color2 = parse_color_expr(color2_str.trim())?;
421
422    Ok((color1, color2, factor))
423}
424
425/// Trait for resolving color references.
426trait ResolveRef {
427    fn resolve_ref(&self, section: Section, key: &str) -> Result<String, Error>;
428}
429
430/// Resolve a color expression using a resolver.
431fn resolve_expr(resolver: &impl ResolveRef, expr: &ColorExpr) -> Result<String, Error> {
432    match expr {
433        ColorExpr::Literal(hex) => Ok(hex.clone()),
434        ColorExpr::Ref { section, key } => resolver.resolve_ref(*section, key),
435        ColorExpr::Lighten(inner, factor) => {
436            let hex = resolve_expr(resolver, inner)?;
437            Ok(hex.parse::<Rgb>()?.lighten(*factor).to_string())
438        }
439        ColorExpr::Darken(inner, factor) => {
440            let hex = resolve_expr(resolver, inner)?;
441            Ok(hex.parse::<Rgb>()?.darken(*factor).to_string())
442        }
443        ColorExpr::Brighten(inner, amount) => {
444            let hex = resolve_expr(resolver, inner)?;
445            Ok(hex.parse::<Rgb>()?.brighten(*amount).to_string())
446        }
447        ColorExpr::Mix(color1, color2, factor) => {
448            let rgb1: Rgb = resolve_expr(resolver, color1)?.parse()?;
449            let rgb2: Rgb = resolve_expr(resolver, color2)?.parse()?;
450            Ok(rgb1.mix(rgb2, *factor).to_string())
451        }
452    }
453}
454
455struct Resolver<'a> {
456    colors: BTreeMap<&'a str, &'a str>,
457    base: BTreeMap<&'a str, &'a str>,
458    /// Resolved hex values for ansi (includes both ansi.* and ansi.bright.*)
459    ansi_map: BTreeMap<String, String>,
460    /// Resolved ansi colors (to avoid re-resolving)
461    resolved_ansi: Ansi,
462    /// Resolved ansi.bright colors (to avoid re-resolving)
463    resolved_ansi_bright: Ansi,
464}
465
466impl<'a> Resolver<'a> {
467    fn new(raw: &'a RawPalette) -> Result<Self, Error> {
468        // Flatten nested lantern structure into colors map
469        let colors: BTreeMap<&str, &str> = [
470            ("lantern.ember", raw.colors.lantern.ember.as_str()),
471            ("lantern.near", raw.colors.lantern.near.as_str()),
472            ("lantern.mid", raw.colors.lantern.mid.as_str()),
473            ("lantern.far", raw.colors.lantern.far.as_str()),
474            ("life", raw.colors.life.as_str()),
475            ("night", raw.colors.night.as_str()),
476            ("rain", raw.colors.rain.as_str()),
477            ("muted", raw.colors.muted.as_str()),
478        ]
479        .into_iter()
480        .collect();
481        let base: BTreeMap<&str, &str> = [
482            ("background", raw.base.background.as_str()),
483            ("foreground", raw.base.foreground.as_str()),
484        ]
485        .into_iter()
486        .collect();
487
488        // Resolve ansi first (it only depends on colors/base)
489        let partial = PartialResolver {
490            colors: &colors,
491            base: &base,
492            ansi: None,
493        };
494        let resolved_ansi = raw.ansi.base.resolve(&partial)?;
495
496        // Build ansi_map with keys like "red", "green", etc.
497        let mut ansi_map: BTreeMap<String, String> = resolved_ansi
498            .to_map()
499            .into_iter()
500            .map(|(k, v)| (k.to_string(), v))
501            .collect();
502
503        // Resolve ansi.bright (depends on ansi)
504        let partial_with_ansi = PartialResolver {
505            colors: &colors,
506            base: &base,
507            ansi: Some(&ansi_map),
508        };
509        let resolved_ansi_bright = raw.ansi.bright.resolve(&partial_with_ansi)?;
510
511        // Add ansi.bright.* to ansi_map with keys like "bright.red", "bright.green", etc.
512        for (k, v) in resolved_ansi_bright.to_map() {
513            ansi_map.insert(format!("bright.{k}"), v);
514        }
515
516        Ok(Self {
517            colors,
518            base,
519            ansi_map,
520            resolved_ansi,
521            resolved_ansi_bright,
522        })
523    }
524}
525
526impl ResolveRef for Resolver<'_> {
527    fn resolve_ref(&self, section: Section, key: &str) -> Result<String, Error> {
528        let ref_str = || format!("{}.{key}", section.as_str());
529        match section {
530            Section::Colors => self
531                .colors
532                .get(key)
533                .copied()
534                .map(str::to_string)
535                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
536            Section::Base => self
537                .base
538                .get(key)
539                .copied()
540                .map(str::to_string)
541                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
542            Section::Ansi => self
543                .ansi_map
544                .get(key)
545                .cloned()
546                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
547        }
548    }
549}
550
551/// Resolver for bootstrapping ansi/ansi.bright resolution.
552/// Only colors, base, and optionally ansi are available.
553struct PartialResolver<'a> {
554    colors: &'a BTreeMap<&'a str, &'a str>,
555    base: &'a BTreeMap<&'a str, &'a str>,
556    ansi: Option<&'a BTreeMap<String, String>>,
557}
558
559impl ResolveRef for PartialResolver<'_> {
560    fn resolve_ref(&self, section: Section, key: &str) -> Result<String, Error> {
561        let ref_str = || format!("{}.{key}", section.as_str());
562        match section {
563            Section::Colors => self
564                .colors
565                .get(key)
566                .copied()
567                .map(str::to_string)
568                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
569            Section::Base => self
570                .base
571                .get(key)
572                .copied()
573                .map(str::to_string)
574                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
575            Section::Ansi => self
576                .ansi
577                .and_then(|m| m.get(key).cloned())
578                .ok_or_else(|| Error::UnresolvedRef(ref_str())),
579        }
580    }
581}
582
583impl RawPalette {
584    fn resolve(&self, variant: Variant) -> Result<Palette, Error> {
585        let resolver = Resolver::new(self)?;
586        Ok(Palette {
587            variant,
588            name: self.name.clone(),
589            description: self.description.clone(),
590            colors: self.colors.clone(),
591            base: self.base.clone(),
592            layers: self.layers.resolve(&resolver)?,
593            state: self.state.resolve(&resolver)?,
594            semantic: self.semantic.resolve(&resolver)?,
595            ansi: resolver.resolved_ansi,
596            ansi_bright: resolver.resolved_ansi_bright,
597        })
598    }
599}
600
601#[derive(Debug, Serialize)]
602pub struct Palette {
603    pub variant: Variant,
604    pub name: String,
605    pub description: String,
606    pub colors: Colors,
607    pub base: Base,
608    pub layers: Layers,
609    pub state: State,
610    pub semantic: Semantic,
611    pub ansi: Ansi,
612    pub ansi_bright: Ansi,
613}
614
615impl Palette {
616    /// Embedded Night palette TOML content.
617    const NIGHT_TOML: &'static str = include_str!("../palette/akari-night.toml");
618
619    /// Embedded Dawn palette TOML content.
620    const DAWN_TOML: &'static str = include_str!("../palette/akari-dawn.toml");
621
622    /// Returns the embedded Night palette.
623    ///
624    /// # Panics
625    ///
626    /// Panics if the embedded palette is invalid (should never happen in normal use).
627    #[must_use]
628    pub fn night() -> Self {
629        Self::from_str(Self::NIGHT_TOML, Variant::Night)
630            .expect("embedded Night palette should be valid")
631    }
632
633    /// Returns the embedded Dawn palette.
634    ///
635    /// # Panics
636    ///
637    /// Panics if the embedded palette is invalid (should never happen in normal use).
638    #[must_use]
639    pub fn dawn() -> Self {
640        Self::from_str(Self::DAWN_TOML, Variant::Dawn)
641            .expect("embedded Dawn palette should be valid")
642    }
643
644    /// Load palette from a file path.
645    pub fn from_path(path: impl AsRef<Path>, variant: Variant) -> Result<Self, Error> {
646        let content = fs::read_to_string(path)?;
647        Self::from_str(&content, variant)
648    }
649
650    /// Parse palette from TOML string content.
651    pub fn from_str(content: &str, variant: Variant) -> Result<Self, Error> {
652        let raw: RawPalette = toml::from_str(content)?;
653        raw.resolve(variant)
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use std::path::PathBuf;
661
662    fn palette_path() -> PathBuf {
663        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("palette/akari-night.toml")
664    }
665
666    #[test]
667    fn load_night_palette() {
668        let palette = Palette::from_path(palette_path(), Variant::Night).unwrap();
669        assert_eq!(palette.name, "akari-night");
670        assert_eq!(palette.variant, Variant::Night);
671    }
672
673    #[test]
674    fn colors_are_loaded() {
675        let palette = Palette::from_path(palette_path(), Variant::Night).unwrap();
676        assert_eq!(palette.colors.lantern.mid, "#E26A3B");
677        assert_eq!(palette.colors.lantern.ember, "#D65A3A");
678        assert_eq!(palette.colors.lantern.near, "#D25046");
679        assert_eq!(palette.colors.lantern.far, "#D4A05A");
680    }
681
682    #[test]
683    fn base_colors_are_loaded() {
684        let palette = Palette::from_path(palette_path(), Variant::Night).unwrap();
685        assert_eq!(palette.base.background, "#25231F");
686        assert_eq!(palette.base.foreground, "#E6DED3");
687    }
688
689    #[test]
690    fn semantic_references_resolved() {
691        let palette = Palette::from_path(palette_path(), Variant::Night).unwrap();
692        // semantic.keyword = "colors.lantern.mid" -> "#E26A3B"
693        assert_eq!(palette.semantic.keyword, "#E26A3B");
694        // semantic.string = "colors.life" -> "#7FAF6A"
695        assert_eq!(palette.semantic.string, "#7FAF6A");
696    }
697
698    #[test]
699    fn ansi_references_resolved() {
700        let palette = Palette::from_path(palette_path(), Variant::Night).unwrap();
701        // ansi.green = "colors.life" -> "#7FAF6A"
702        assert_eq!(palette.ansi.green, "#7FAF6A");
703        // ansi.white = "base.foreground" -> "#E6DED3"
704        assert_eq!(palette.ansi.white, "#E6DED3");
705    }
706
707    #[test]
708    fn missing_semantic_field_fails() {
709        use std::io::Write;
710        use tempfile::NamedTempFile;
711
712        let toml = r##"
713name = "test"
714description = "test"
715
716[colors.lantern]
717ember = "#D65A3A"
718near = "#D25046"
719mid = "#E26A3B"
720far = "#D4A05A"
721
722[colors]
723life = "#7FAF6A"
724night = "#5A6F82"
725rain = "#6F8F8A"
726muted = "#7C6A8A"
727
728[base]
729background = "#171B22"
730foreground = "#E6DED3"
731
732[layers]
733base = "#171B22"
734surface = "#1E2329"
735sunken = "#13171D"
736raised = "#252B33"
737border = "#2E353E"
738inset = "#3A424D"
739
740[state]
741selection_bg = "#3A424D"
742selection_fg = "#E6DED3"
743match_bg = "#4A3A2A"
744cursor = "#E26A3B"
745cursor_text = "#171B22"
746info = "#5A6F82"
747hint = "#7C6A8A"
748warning = "#D4A05A"
749error = "#D65A3A"
750active_bg = "#2A3540"
751diff_added = "#7FAF6A"
752diff_removed = "#D65A3A"
753diff_changed = "#D4A05A"
754
755[semantic]
756comment = "#7D8797"
757string = "colors.life"
758keyword = "colors.lantern.mid"
759number = "colors.lantern.far"
760constant = "colors.lantern.far"
761type = "colors.lantern.far"
762function = "colors.lantern.mid"
763variable = "base.foreground"
764success = "colors.life"
765
766[ansi]
767black = "#171B22"
768red = "colors.lantern.near"
769green = "colors.life"
770yellow = "colors.lantern.far"
771blue = "colors.night"
772magenta = "colors.muted"
773cyan = "colors.rain"
774white = "base.foreground"
775
776[ansi.bright]
777black = "#3A424D"
778red = "colors.lantern.mid"
779green = "colors.life"
780yellow = "colors.lantern.far"
781blue = "colors.night"
782magenta = "colors.muted"
783cyan = "colors.rain"
784white = "base.foreground"
785"##;
786
787        let mut file = NamedTempFile::new().unwrap();
788        file.write_all(toml.as_bytes()).unwrap();
789
790        let result = Palette::from_path(file.path(), Variant::Night);
791        assert!(result.is_err());
792        let err = result.unwrap_err();
793        assert!(matches!(err, Error::ParsePalette(_)));
794    }
795
796    #[test]
797    fn invalid_reference_fails() {
798        use std::io::Write;
799        use tempfile::NamedTempFile;
800
801        let toml = r##"
802name = "test"
803description = "test"
804
805[colors.lantern]
806ember = "#D65A3A"
807near = "#D25046"
808mid = "#E26A3B"
809far = "#D4A05A"
810
811[colors]
812life = "#7FAF6A"
813night = "#5A6F82"
814rain = "#6F8F8A"
815muted = "#7C6A8A"
816
817[base]
818background = "#171B22"
819foreground = "#E6DED3"
820
821[layers]
822base = "#171B22"
823surface = "#1E2329"
824sunken = "#13171D"
825raised = "#252B33"
826border = "#2E353E"
827inset = "#3A424D"
828
829[state]
830selection_bg = "#3A424D"
831selection_fg = "#E6DED3"
832match_bg = "#4A3A2A"
833cursor = "#E26A3B"
834cursor_text = "#171B22"
835info = "#5A6F82"
836hint = "#7C6A8A"
837warning = "#D4A05A"
838error = "#D65A3A"
839active_bg = "#2A3540"
840diff_added = "#7FAF6A"
841diff_removed = "#D65A3A"
842diff_changed = "#D4A05A"
843diff_moved = "#5A6F82"
844conflict = "#D65A3A"
845
846[semantic]
847text = "base.foreground"
848comment = "#7D8797"
849string = "colors.nonexistent"
850keyword = "colors.lantern.mid"
851number = "colors.lantern.far"
852constant = "colors.lantern.far"
853type = "colors.lantern.far"
854function = "colors.lantern.mid"
855variable = "base.foreground"
856success = "colors.life"
857path = "ansi.green"
858macro = "ansi.bright.magenta"
859escape = "ansi.bright.magenta"
860regexp = "ansi.bright.green"
861link = "ansi.bright.blue"
862directory = "ansi.cyan"
863
864[ansi]
865black = "#171B22"
866red = "colors.lantern.near"
867green = "colors.life"
868yellow = "colors.lantern.far"
869blue = "colors.night"
870magenta = "colors.muted"
871cyan = "colors.rain"
872white = "base.foreground"
873
874[ansi.bright]
875black = "#3A424D"
876red = "colors.lantern.mid"
877green = "colors.life"
878yellow = "colors.lantern.far"
879blue = "colors.night"
880magenta = "colors.muted"
881cyan = "colors.rain"
882white = "base.foreground"
883"##;
884
885        let mut file = NamedTempFile::new().unwrap();
886        file.write_all(toml.as_bytes()).unwrap();
887
888        let result = Palette::from_path(file.path(), Variant::Night);
889        assert!(result.is_err());
890        let err = result.unwrap_err();
891        assert!(matches!(err, Error::UnresolvedRef(_)));
892    }
893
894    #[test]
895    fn parse_color_expr_literal() {
896        let expr = parse_color_expr("#E26A3B").unwrap();
897        assert!(matches!(expr, ColorExpr::Literal(s) if s == "#E26A3B"));
898    }
899
900    #[test]
901    fn parse_color_expr_reference() {
902        let expr = parse_color_expr("colors.lantern.mid").unwrap();
903        assert!(
904            matches!(expr, ColorExpr::Ref { section, key } if section == Section::Colors && key == "lantern.mid")
905        );
906    }
907
908    #[test]
909    fn parse_color_expr_lighten() {
910        let expr = parse_color_expr("lighten(colors.lantern.mid, 0.1)").unwrap();
911        match expr {
912            ColorExpr::Lighten(inner, factor) => {
913                assert!(
914                    matches!(*inner, ColorExpr::Ref { section, key } if section == Section::Colors && key == "lantern.mid")
915                );
916                assert!((factor - 0.1).abs() < 0.001);
917            }
918            _ => panic!("expected Lighten"),
919        }
920    }
921
922    #[test]
923    fn parse_color_expr_darken() {
924        let expr = parse_color_expr("darken(base.background, 0.2)").unwrap();
925        match expr {
926            ColorExpr::Darken(inner, factor) => {
927                assert!(
928                    matches!(*inner, ColorExpr::Ref { section, key } if section == Section::Base && key == "background")
929                );
930                assert!((factor - 0.2).abs() < 0.001);
931            }
932            _ => panic!("expected Darken"),
933        }
934    }
935
936    #[test]
937    fn parse_color_expr_nested() {
938        let expr = parse_color_expr("lighten(darken(colors.lantern.mid, 0.1), 0.2)").unwrap();
939        match expr {
940            ColorExpr::Lighten(inner, outer_factor) => {
941                assert!((outer_factor - 0.2).abs() < 0.001);
942                match *inner {
943                    ColorExpr::Darken(innermost, inner_factor) => {
944                        assert!(
945                            matches!(*innermost, ColorExpr::Ref { section, key } if section == Section::Colors && key == "lantern.mid")
946                        );
947                        assert!((inner_factor - 0.1).abs() < 0.001);
948                    }
949                    _ => panic!("expected Darken"),
950                }
951            }
952            _ => panic!("expected Lighten"),
953        }
954    }
955
956    #[test]
957    fn parse_color_expr_mix() {
958        let expr = parse_color_expr("mix(base.background, colors.night, 0.15)").unwrap();
959        match expr {
960            ColorExpr::Mix(color1, color2, factor) => {
961                assert!(
962                    matches!(*color1, ColorExpr::Ref { section, key } if section == Section::Base && key == "background")
963                );
964                assert!(
965                    matches!(*color2, ColorExpr::Ref { section, key } if section == Section::Colors && key == "night")
966                );
967                assert!((factor - 0.15).abs() < 0.001);
968            }
969            _ => panic!("expected Mix"),
970        }
971    }
972
973    #[test]
974    fn parse_color_expr_rejects_non_referenceable_sections() {
975        // layers, state, semantic exist in palette but cannot be referenced
976        let err = parse_color_expr("layers.base").unwrap_err();
977        assert!(
978            matches!(err, Error::InvalidColorExpr(msg) if msg.contains("cannot be referenced"))
979        );
980
981        let err = parse_color_expr("state.cursor").unwrap_err();
982        assert!(
983            matches!(err, Error::InvalidColorExpr(msg) if msg.contains("cannot be referenced"))
984        );
985
986        let err = parse_color_expr("semantic.keyword").unwrap_err();
987        assert!(
988            matches!(err, Error::InvalidColorExpr(msg) if msg.contains("cannot be referenced"))
989        );
990    }
991
992    #[test]
993    fn parse_color_expr_ansi_bright() {
994        // ansi.bright.* is parsed as Section::Ansi with key "bright.*"
995        let expr = parse_color_expr("ansi.bright.red").unwrap();
996        assert!(
997            matches!(expr, ColorExpr::Ref { section, key } if section == Section::Ansi && key == "bright.red")
998        );
999    }
1000}