jolt_theme/
iterm2.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4const ITERM2_REPO_URL: &str =
5    "https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/master/schemes";
6const ITERM2_API_URL: &str =
7    "https://api.github.com/repos/mbadolato/iTerm2-Color-Schemes/contents/schemes";
8
9pub const ITERM2_GALLERY_URL: &str = "https://iterm2colorschemes.com/";
10
11static VARIANT_PAIRS: &[(&str, &str)] = &[
12    ("3024 Night", "3024 Day"),
13    ("Aizen Dark", "Aizen Light"),
14    ("Atom One Dark", "Atom One Light"),
15    ("Belafonte Night", "Belafonte Day"),
16    ("Bluloco Dark", "Bluloco Light"),
17    ("Builtin Dark", "Builtin Light"),
18    ("Builtin Tango Dark", "Builtin Tango Light"),
19    ("Farmhouse Dark", "Farmhouse Light"),
20    ("Flexoki Dark", "Flexoki Light"),
21    ("GitHub Dark", "GitHub"),
22    ("GitHub Dark Colorblind", "GitHub Light Colorblind"),
23    ("GitHub Dark Default", "GitHub Light Default"),
24    ("GitHub Dark High Contrast", "GitHub Light High Contrast"),
25    ("GitLab Dark", "GitLab Light"),
26    ("Gruvbox Dark", "Gruvbox Light"),
27    ("Gruvbox Dark Hard", "Gruvbox Light Hard"),
28    ("Gruvbox Material Dark", "Gruvbox Material Light"),
29    ("Iceberg Dark", "Iceberg Light"),
30    ("Melange Dark", "Melange Light"),
31    ("Neobones Dark", "Neobones Light"),
32    ("Nvim Dark", "Nvim Light"),
33    ("One Double Dark", "One Double Light"),
34    ("One Half Dark", "One Half Light"),
35    ("Pencil Dark", "Pencil Light"),
36    ("Raycast Dark", "Raycast Light"),
37    ("Selenized Dark", "Selenized Light"),
38    ("Seoulbones Dark", "Seoulbones Light"),
39    ("Tinacious Design Dark", "Tinacious Design Light"),
40    ("Violet Dark", "Violet Light"),
41    ("Xcode Dark", "Xcode Light"),
42    ("Xcode Dark hc", "Xcode Light hc"),
43    ("Zenbones Dark", "Zenbones Light"),
44    ("Zenwritten Dark", "Zenwritten Light"),
45    ("iTerm2 Dark Background", "iTerm2 Light Background"),
46    ("iTerm2 Solarized Dark", "iTerm2 Solarized Light"),
47    ("iTerm2 Tango Dark", "iTerm2 Tango Light"),
48    ("Adwaita Dark", "Adwaita"),
49    ("Night Owl", "Light Owl"),
50    ("Nord", "Nord Light"),
51    ("Onenord", "Onenord Light"),
52    ("Pro", "Pro Light"),
53    ("Terminal Basic Dark", "Terminal Basic"),
54    ("No Clown Fiesta", "No Clown Fiesta Light"),
55    ("Rose Pine Moon", "Rose Pine Dawn"),
56    ("Rose Pine", "Rose Pine Dawn"),
57    ("TokyoNight Night", "TokyoNight Day"),
58    ("TokyoNight Moon", "TokyoNight Day"),
59    ("TokyoNight Storm", "TokyoNight Day"),
60    ("TokyoNight", "TokyoNight Day"),
61    ("Ayu", "Ayu Light"),
62    ("Ayu Mirage", "Ayu Light"),
63    ("Everforest Dark Hard", "Everforest Light Med"),
64    ("Tomorrow Night", "Tomorrow"),
65    ("Tomorrow Night Blue", "Tomorrow"),
66    ("Tomorrow Night Bright", "Tomorrow"),
67    ("Tomorrow Night Burns", "Tomorrow"),
68    ("Tomorrow Night Eighties", "Tomorrow"),
69    ("Catppuccin Frappe", "Catppuccin Latte"),
70    ("Catppuccin Macchiato", "Catppuccin Latte"),
71    ("Catppuccin Mocha", "Catppuccin Latte"),
72];
73
74pub fn lookup_variant_pair(name: &str) -> Option<(&'static str, &'static str)> {
75    let lower = name.to_lowercase();
76    for &(dark, light) in VARIANT_PAIRS {
77        if dark.to_lowercase() == lower || light.to_lowercase() == lower {
78            return Some((dark, light));
79        }
80    }
81    None
82}
83
84#[derive(Debug)]
85pub enum Iterm2Error {
86    NetworkError(String),
87    ParseError(String),
88    NotFound(String),
89    IoError(String),
90}
91
92impl fmt::Display for Iterm2Error {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
96            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
97            Self::NotFound(msg) => write!(f, "Not found: {}", msg),
98            Self::IoError(msg) => write!(f, "IO error: {}", msg),
99        }
100    }
101}
102
103impl std::error::Error for Iterm2Error {}
104
105const MIN_CONTRAST_RATIO: f64 = 4.6;
106
107#[derive(Debug, Clone)]
108pub struct Iterm2Color {
109    pub r: f64,
110    pub g: f64,
111    pub b: f64,
112}
113
114impl Iterm2Color {
115    pub fn to_hex(&self) -> String {
116        let r = (self.r * 255.0).round() as u8;
117        let g = (self.g * 255.0).round() as u8;
118        let b = (self.b * 255.0).round() as u8;
119        format!("#{:02x}{:02x}{:02x}", r, g, b)
120    }
121
122    fn blend(&self, other: &Iterm2Color, ratio: f64) -> Iterm2Color {
123        Iterm2Color {
124            r: self.r * (1.0 - ratio) + other.r * ratio,
125            g: self.g * (1.0 - ratio) + other.g * ratio,
126            b: self.b * (1.0 - ratio) + other.b * ratio,
127        }
128    }
129
130    fn linearize(val: f64) -> f64 {
131        if val <= 0.03928 {
132            val / 12.92
133        } else {
134            ((val + 0.055) / 1.055).powf(2.4)
135        }
136    }
137
138    fn luminance(&self) -> f64 {
139        0.2126 * Self::linearize(self.r)
140            + 0.7152 * Self::linearize(self.g)
141            + 0.0722 * Self::linearize(self.b)
142    }
143
144    fn contrast_ratio(&self, other: &Iterm2Color) -> f64 {
145        let l1 = self.luminance();
146        let l2 = other.luminance();
147        let lighter = l1.max(l2);
148        let darker = l1.min(l2);
149        (lighter + 0.05) / (darker + 0.05)
150    }
151
152    fn lighten(&self, amount: f64) -> Iterm2Color {
153        Iterm2Color {
154            r: self.r + (1.0 - self.r) * amount,
155            g: self.g + (1.0 - self.g) * amount,
156            b: self.b + (1.0 - self.b) * amount,
157        }
158    }
159
160    fn darken(&self, amount: f64) -> Iterm2Color {
161        Iterm2Color {
162            r: self.r * (1.0 - amount),
163            g: self.g * (1.0 - amount),
164            b: self.b * (1.0 - amount),
165        }
166    }
167
168    fn ensure_contrast(&self, bg: &Iterm2Color, min_ratio: f64) -> Iterm2Color {
169        if self.contrast_ratio(bg) >= min_ratio {
170            return self.clone();
171        }
172
173        let lighten_result = self.adjust_for_contrast(bg, min_ratio, true);
174        let darken_result = self.adjust_for_contrast(bg, min_ratio, false);
175
176        let lighten_passes = lighten_result.contrast_ratio(bg) >= min_ratio;
177        let darken_passes = darken_result.contrast_ratio(bg) >= min_ratio;
178
179        match (lighten_passes, darken_passes) {
180            (true, false) => lighten_result,
181            (false, true) => darken_result,
182            (true, true) => {
183                let lighten_dist = self.color_distance(&lighten_result);
184                let darken_dist = self.color_distance(&darken_result);
185                if lighten_dist <= darken_dist {
186                    lighten_result
187                } else {
188                    darken_result
189                }
190            }
191            (false, false) => {
192                if lighten_result.contrast_ratio(bg) > darken_result.contrast_ratio(bg) {
193                    lighten_result
194                } else {
195                    darken_result
196                }
197            }
198        }
199    }
200
201    fn adjust_for_contrast(&self, bg: &Iterm2Color, min_ratio: f64, lighten: bool) -> Iterm2Color {
202        let mut low = 0.0;
203        let mut high = 1.0;
204        let mut best = self.clone();
205
206        for _ in 0..20 {
207            let mid = (low + high) / 2.0;
208            let adjusted = if lighten {
209                self.lighten(mid)
210            } else {
211                self.darken(mid)
212            };
213
214            if adjusted.contrast_ratio(bg) >= min_ratio {
215                best = adjusted;
216                high = mid;
217            } else {
218                low = mid;
219            }
220        }
221
222        best
223    }
224
225    fn color_distance(&self, other: &Iterm2Color) -> f64 {
226        let dr = self.r - other.r;
227        let dg = self.g - other.g;
228        let db = self.b - other.b;
229        (dr * dr + dg * dg + db * db).sqrt()
230    }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq)]
234pub enum SchemeVariant {
235    Dark,
236    Light,
237    Unknown,
238}
239
240#[derive(Debug)]
241pub struct Iterm2Scheme {
242    pub background: Iterm2Color,
243    pub foreground: Iterm2Color,
244    pub selection_bg: Iterm2Color,
245    pub selection_fg: Iterm2Color,
246    pub ansi: [Iterm2Color; 16],
247}
248
249fn detect_variant(name: &str) -> SchemeVariant {
250    if let Some((dark, light)) = lookup_variant_pair(name) {
251        let lower = name.to_lowercase();
252        if dark.to_lowercase() == lower {
253            return SchemeVariant::Dark;
254        } else if light.to_lowercase() == lower {
255            return SchemeVariant::Light;
256        }
257    }
258
259    let lower = name.to_lowercase();
260    if lower.contains("light") || lower.contains("day") || lower.contains("dawn") {
261        SchemeVariant::Light
262    } else if lower.contains("dark") || lower.contains("night") || lower.contains("moon") {
263        SchemeVariant::Dark
264    } else {
265        SchemeVariant::Unknown
266    }
267}
268
269fn find_counterpart_name(name: &str) -> Option<String> {
270    if let Some((dark, light)) = lookup_variant_pair(name) {
271        let lower = name.to_lowercase();
272        if dark.to_lowercase() == lower {
273            return Some(light.to_string());
274        } else {
275            return Some(dark.to_string());
276        }
277    }
278
279    let variant = detect_variant(name);
280    match variant {
281        SchemeVariant::Dark => {
282            let lower = name.to_lowercase();
283            if let Some(pos) = lower.find("dark") {
284                let mut result = name.to_string();
285                let replacement = if &name[pos..pos + 4] == "Dark" {
286                    "Light"
287                } else {
288                    "light"
289                };
290                result.replace_range(pos..pos + 4, replacement);
291                Some(result)
292            } else {
293                None
294            }
295        }
296        SchemeVariant::Light => {
297            let lower = name.to_lowercase();
298            if let Some(pos) = lower.find("light") {
299                let mut result = name.to_string();
300                let replacement = if &name[pos..pos + 5] == "Light" {
301                    "Dark"
302                } else {
303                    "dark"
304                };
305                result.replace_range(pos..pos + 5, replacement);
306                Some(result)
307            } else {
308                None
309            }
310        }
311        SchemeVariant::Unknown => None,
312    }
313}
314
315fn find_variant_names(name: &str) -> Vec<String> {
316    let variant = detect_variant(name);
317
318    if variant == SchemeVariant::Unknown {
319        vec![
320            format!("{} Light", name),
321            format!("{} Dark", name),
322            format!("{}-light", name),
323            format!("{}-dark", name),
324        ]
325    } else {
326        vec![]
327    }
328}
329
330fn parse_color_dict(dict: &plist::Dictionary) -> Option<Iterm2Color> {
331    let r = dict.get("Red Component")?.as_real()?;
332    let g = dict.get("Green Component")?.as_real()?;
333    let b = dict.get("Blue Component")?.as_real()?;
334    Some(Iterm2Color { r, g, b })
335}
336
337fn extract_color(root: &plist::Dictionary, key: &str) -> Option<Iterm2Color> {
338    let dict = root.get(key)?.as_dictionary()?;
339    parse_color_dict(dict)
340}
341
342pub fn parse_scheme(plist_content: &[u8]) -> Result<Iterm2Scheme, Iterm2Error> {
343    let value: plist::Value =
344        plist::from_bytes(plist_content).map_err(|e| Iterm2Error::ParseError(e.to_string()))?;
345
346    let root = value
347        .as_dictionary()
348        .ok_or_else(|| Iterm2Error::ParseError("Expected dictionary at root".to_string()))?;
349
350    let background = extract_color(root, "Background Color")
351        .ok_or_else(|| Iterm2Error::ParseError("Missing Background Color".to_string()))?;
352
353    let foreground = extract_color(root, "Foreground Color")
354        .ok_or_else(|| Iterm2Error::ParseError("Missing Foreground Color".to_string()))?;
355
356    let selection_bg = extract_color(root, "Selection Color")
357        .unwrap_or_else(|| background.blend(&foreground, 0.3));
358
359    let selection_fg =
360        extract_color(root, "Selected Text Color").unwrap_or_else(|| foreground.clone());
361
362    let mut ansi = Vec::with_capacity(16);
363    for i in 0..16 {
364        let key = format!("Ansi {} Color", i);
365        let color = extract_color(root, &key).unwrap_or(if i < 8 {
366            Iterm2Color {
367                r: 0.5,
368                g: 0.5,
369                b: 0.5,
370            }
371        } else {
372            Iterm2Color {
373                r: 0.7,
374                g: 0.7,
375                b: 0.7,
376            }
377        });
378        ansi.push(color);
379    }
380
381    Ok(Iterm2Scheme {
382        background,
383        foreground,
384        selection_bg,
385        selection_fg,
386        ansi: ansi.try_into().unwrap(),
387    })
388}
389
390impl Iterm2Scheme {
391    fn to_colors_toml(&self) -> String {
392        let bg = &self.background;
393        let fg = self.foreground.ensure_contrast(bg, MIN_CONTRAST_RATIO);
394        let dialog_bg = bg.blend(&self.ansi[8], 0.15);
395        let border_color = bg.blend(&self.ansi[8], 0.4);
396
397        let accent = self.ansi[4]
398            .ensure_contrast(bg, MIN_CONTRAST_RATIO)
399            .ensure_contrast(&dialog_bg, MIN_CONTRAST_RATIO);
400        let accent_secondary = self.ansi[5]
401            .ensure_contrast(bg, MIN_CONTRAST_RATIO)
402            .ensure_contrast(&dialog_bg, MIN_CONTRAST_RATIO);
403        let highlight = self.ansi[3].ensure_contrast(bg, MIN_CONTRAST_RATIO);
404        let success = self.ansi[2].ensure_contrast(bg, MIN_CONTRAST_RATIO);
405        let danger = self.ansi[1].ensure_contrast(bg, MIN_CONTRAST_RATIO);
406
407        let muted = self.derive_muted_color(bg);
408        let warning = self.derive_warning_color(bg);
409
410        let graph_line = accent.clone();
411
412        let selection_fg = self
413            .selection_fg
414            .ensure_contrast(&self.selection_bg, MIN_CONTRAST_RATIO);
415
416        format!(
417            r##"bg = "{}"
418dialog_bg = "{}"
419fg = "{}"
420accent = "{}"
421accent_secondary = "{}"
422highlight = "{}"
423muted = "{}"
424success = "{}"
425warning = "{}"
426danger = "{}"
427border = "{}"
428selection_bg = "{}"
429selection_fg = "{}"
430graph_line = "{}""##,
431            bg.to_hex(),
432            dialog_bg.to_hex(),
433            fg.to_hex(),
434            accent.to_hex(),
435            accent_secondary.to_hex(),
436            highlight.to_hex(),
437            muted.to_hex(),
438            success.to_hex(),
439            warning.to_hex(),
440            danger.to_hex(),
441            border_color.to_hex(),
442            self.selection_bg.to_hex(),
443            selection_fg.to_hex(),
444            graph_line.to_hex(),
445        )
446    }
447
448    fn derive_muted_color(&self, bg: &Iterm2Color) -> Iterm2Color {
449        let candidates = [&self.ansi[8], &self.foreground.blend(bg, 0.5)];
450
451        for candidate in candidates {
452            let adjusted = candidate.ensure_contrast(bg, MIN_CONTRAST_RATIO);
453            if adjusted.contrast_ratio(bg) >= MIN_CONTRAST_RATIO {
454                return adjusted;
455            }
456        }
457
458        self.foreground.blend(bg, 0.4)
459    }
460
461    fn derive_warning_color(&self, bg: &Iterm2Color) -> Iterm2Color {
462        let candidates = [&self.ansi[11], &self.ansi[3], &self.ansi[9]];
463
464        for candidate in candidates {
465            let adjusted = candidate.ensure_contrast(bg, MIN_CONTRAST_RATIO);
466            if adjusted.contrast_ratio(bg) >= MIN_CONTRAST_RATIO {
467                return adjusted;
468            }
469        }
470
471        self.ansi[3].ensure_contrast(bg, MIN_CONTRAST_RATIO)
472    }
473}
474
475pub struct ImportResult {
476    pub path: PathBuf,
477    pub dark_source: Option<String>,
478    pub light_source: Option<String>,
479}
480
481fn try_fetch_scheme(name: &str) -> Option<Iterm2Scheme> {
482    let url = format!("{}/{}.itermcolors", ITERM2_REPO_URL, name);
483
484    let response = ureq::get(&url).call().ok()?;
485
486    let bytes = response.into_body().read_to_vec().ok()?;
487
488    parse_scheme(&bytes).ok()
489}
490
491pub fn fetch_scheme(name: &str) -> Result<Iterm2Scheme, Iterm2Error> {
492    let url = format!("{}/{}.itermcolors", ITERM2_REPO_URL, name);
493
494    let response = ureq::get(&url)
495        .call()
496        .map_err(|e| Iterm2Error::NetworkError(e.to_string()))?;
497
498    if response.status() == 404 {
499        return Err(Iterm2Error::NotFound(format!(
500            "Scheme '{}' not found. Browse available themes at: {}",
501            name, ITERM2_GALLERY_URL
502        )));
503    }
504
505    let bytes = response
506        .into_body()
507        .read_to_vec()
508        .map_err(|e| Iterm2Error::NetworkError(e.to_string()))?;
509
510    parse_scheme(&bytes)
511}
512
513#[derive(Debug, serde::Deserialize)]
514struct GitHubFile {
515    name: String,
516    #[serde(rename = "type")]
517    file_type: String,
518}
519
520pub fn list_available_schemes() -> Result<Vec<String>, Iterm2Error> {
521    let response = ureq::get(ITERM2_API_URL)
522        .header("User-Agent", "jolt-theme-importer")
523        .call()
524        .map_err(|e: ureq::Error| Iterm2Error::NetworkError(e.to_string()))?;
525
526    let body = response
527        .into_body()
528        .read_to_string()
529        .map_err(|e: ureq::Error| Iterm2Error::NetworkError(e.to_string()))?;
530
531    let files: Vec<GitHubFile> =
532        serde_json::from_str(&body).map_err(|e| Iterm2Error::ParseError(e.to_string()))?;
533
534    let schemes: Vec<String> = files
535        .into_iter()
536        .filter(|f| f.file_type == "file" && f.name.ends_with(".itermcolors"))
537        .map(|f| f.name.trim_end_matches(".itermcolors").to_string())
538        .collect();
539
540    Ok(schemes)
541}
542
543fn derive_base_name(name: &str) -> String {
544    let lower = name.to_lowercase();
545
546    for suffix in [" light", " dark", "-light", "-dark"] {
547        if lower.ends_with(suffix) {
548            return name[..name.len() - suffix.len()].to_string();
549        }
550    }
551
552    name.to_string()
553}
554
555pub fn import_scheme(
556    name: &str,
557    custom_name: Option<&str>,
558    themes_dir: &Path,
559) -> Result<ImportResult, Iterm2Error> {
560    let primary = fetch_scheme(name)?;
561    let primary_variant = detect_variant(name);
562
563    let mut dark_scheme: Option<Iterm2Scheme> = None;
564    let mut light_scheme: Option<Iterm2Scheme> = None;
565    let mut dark_source: Option<String> = None;
566    let mut light_source: Option<String> = None;
567
568    match primary_variant {
569        SchemeVariant::Dark => {
570            dark_scheme = Some(primary);
571            dark_source = Some(name.to_string());
572
573            if let Some(counterpart) = find_counterpart_name(name) {
574                if let Some(light) = try_fetch_scheme(&counterpart) {
575                    light_scheme = Some(light);
576                    light_source = Some(counterpart);
577                }
578            }
579        }
580        SchemeVariant::Light => {
581            light_scheme = Some(primary);
582            light_source = Some(name.to_string());
583
584            if let Some(counterpart) = find_counterpart_name(name) {
585                if let Some(dark) = try_fetch_scheme(&counterpart) {
586                    dark_scheme = Some(dark);
587                    dark_source = Some(counterpart);
588                }
589            }
590        }
591        SchemeVariant::Unknown => {
592            dark_scheme = Some(primary);
593            dark_source = Some(name.to_string());
594
595            for variant_name in find_variant_names(name) {
596                if let Some(scheme) = try_fetch_scheme(&variant_name) {
597                    let variant = detect_variant(&variant_name);
598                    if variant == SchemeVariant::Light && light_scheme.is_none() {
599                        light_scheme = Some(scheme);
600                        light_source = Some(variant_name);
601                        break;
602                    }
603                }
604            }
605        }
606    }
607
608    let base_name = custom_name
609        .map(|s| s.to_string())
610        .unwrap_or_else(|| derive_base_name(name));
611
612    let file_name = base_name.to_lowercase().replace(' ', "-");
613
614    let mut toml_content = format!("name = \"{}\"\n", base_name);
615
616    if let Some(ref dark) = dark_scheme {
617        toml_content.push_str("\n[dark]\n");
618        toml_content.push_str(&dark.to_colors_toml());
619        toml_content.push('\n');
620    }
621
622    if let Some(ref light) = light_scheme {
623        toml_content.push_str("\n[light]\n");
624        toml_content.push_str(&light.to_colors_toml());
625        toml_content.push('\n');
626    }
627
628    std::fs::create_dir_all(themes_dir).map_err(|e| Iterm2Error::IoError(e.to_string()))?;
629
630    let theme_path = themes_dir.join(format!("{}.toml", file_name));
631
632    std::fs::write(&theme_path, toml_content).map_err(|e| Iterm2Error::IoError(e.to_string()))?;
633
634    Ok(ImportResult {
635        path: theme_path,
636        dark_source,
637        light_source,
638    })
639}
640
641pub fn search_schemes(query: &str) -> Result<Vec<String>, Iterm2Error> {
642    let all_schemes = list_available_schemes()?;
643    let query_lower = query.to_lowercase();
644
645    let matches: Vec<String> = all_schemes
646        .into_iter()
647        .filter(|s| s.to_lowercase().contains(&query_lower))
648        .collect();
649
650    Ok(matches)
651}
652
653pub fn find_variant_suggestions(
654    name: &str,
655    target_variant: SchemeVariant,
656) -> Result<Vec<String>, Iterm2Error> {
657    let base_name = derive_base_name(name);
658    let all_schemes = list_available_schemes()?;
659    let base_lower = base_name.to_lowercase();
660
661    let mut suggestions: Vec<String> = all_schemes
662        .into_iter()
663        .filter(|s| {
664            let s_lower = s.to_lowercase();
665            if s_lower == name.to_lowercase() {
666                return false;
667            }
668
669            let matches_base = s_lower.contains(&base_lower)
670                || base_lower.contains(&derive_base_name(s).to_lowercase());
671
672            if !matches_base {
673                return false;
674            }
675
676            let variant = detect_variant(s);
677            variant == target_variant || variant == SchemeVariant::Unknown
678        })
679        .collect();
680
681    suggestions.sort();
682    Ok(suggestions)
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn test_color_to_hex() {
691        let color = Iterm2Color {
692            r: 1.0,
693            g: 0.5,
694            b: 0.0,
695        };
696        assert_eq!(color.to_hex(), "#ff8000");
697    }
698
699    #[test]
700    fn test_color_blend() {
701        let black = Iterm2Color {
702            r: 0.0,
703            g: 0.0,
704            b: 0.0,
705        };
706        let white = Iterm2Color {
707            r: 1.0,
708            g: 1.0,
709            b: 1.0,
710        };
711        let gray = black.blend(&white, 0.5);
712        assert!((gray.r - 0.5).abs() < 0.01);
713        assert!((gray.g - 0.5).abs() < 0.01);
714        assert!((gray.b - 0.5).abs() < 0.01);
715    }
716
717    #[test]
718    fn test_detect_variant() {
719        assert_eq!(detect_variant("Gruvbox Dark"), SchemeVariant::Dark);
720        assert_eq!(detect_variant("Gruvbox Light"), SchemeVariant::Light);
721        assert_eq!(detect_variant("Dracula"), SchemeVariant::Unknown);
722        assert_eq!(detect_variant("One Dark"), SchemeVariant::Dark);
723        assert_eq!(detect_variant("Solarized Light"), SchemeVariant::Light);
724    }
725
726    #[test]
727    fn test_find_counterpart_name() {
728        assert_eq!(
729            find_counterpart_name("Gruvbox Dark"),
730            Some("Gruvbox Light".to_string())
731        );
732        assert_eq!(
733            find_counterpart_name("Gruvbox Light"),
734            Some("Gruvbox Dark".to_string())
735        );
736        assert_eq!(
737            find_counterpart_name("Gruvbox Dark Hard"),
738            Some("Gruvbox Light Hard".to_string())
739        );
740        assert_eq!(find_counterpart_name("Dracula"), None);
741    }
742
743    #[test]
744    fn test_derive_base_name() {
745        assert_eq!(derive_base_name("Gruvbox Dark"), "Gruvbox");
746        assert_eq!(derive_base_name("Gruvbox Light"), "Gruvbox");
747        assert_eq!(derive_base_name("Gruvbox Dark Hard"), "Gruvbox Dark Hard");
748        assert_eq!(derive_base_name("One Dark"), "One");
749        assert_eq!(derive_base_name("Dracula"), "Dracula");
750    }
751
752    #[test]
753    fn test_lookup_variant_pair() {
754        let mocha = lookup_variant_pair("Catppuccin Mocha");
755        assert!(mocha.is_some());
756        assert_eq!(mocha.unwrap().1, "Catppuccin Latte");
757
758        let latte = lookup_variant_pair("Catppuccin Latte");
759        assert!(latte.is_some());
760        assert_eq!(latte.unwrap().1, "Catppuccin Latte");
761
762        assert_eq!(
763            lookup_variant_pair("Tomorrow Night"),
764            Some(("Tomorrow Night", "Tomorrow"))
765        );
766        assert_eq!(lookup_variant_pair("Nord"), Some(("Nord", "Nord Light")));
767        assert_eq!(lookup_variant_pair("Dracula"), None);
768    }
769
770    #[test]
771    fn test_find_counterpart_via_lookup() {
772        assert_eq!(
773            find_counterpart_name("Catppuccin Mocha"),
774            Some("Catppuccin Latte".to_string())
775        );
776        let latte_counterpart = find_counterpart_name("Catppuccin Latte");
777        assert!(latte_counterpart.is_some());
778        assert!(latte_counterpart.unwrap().starts_with("Catppuccin"));
779
780        assert_eq!(
781            find_counterpart_name("Tomorrow Night"),
782            Some("Tomorrow".to_string())
783        );
784        assert_eq!(
785            find_counterpart_name("Nord"),
786            Some("Nord Light".to_string())
787        );
788        assert_eq!(
789            find_counterpart_name("Nord Light"),
790            Some("Nord".to_string())
791        );
792    }
793
794    #[test]
795    fn test_detect_variant_via_lookup() {
796        assert_eq!(detect_variant("Catppuccin Mocha"), SchemeVariant::Dark);
797        assert_eq!(detect_variant("Catppuccin Latte"), SchemeVariant::Light);
798        assert_eq!(detect_variant("Nord"), SchemeVariant::Dark);
799        assert_eq!(detect_variant("Tomorrow"), SchemeVariant::Light);
800    }
801}