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