Skip to main content

chromasync_template/
lib.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5    str::FromStr,
6};
7
8use chromasync_color::{
9    ColorError, contrast_score, meets_contrast_threshold, resolve_color_from_components,
10    resolve_family_color, select_readable_color, select_readable_color_with_strategy,
11};
12use chromasync_types::{
13    ContrastStrategy, GeneratedPalette, HexColor, PaletteFamilyName, SemanticTokenName,
14    SemanticTokens, TemplateDefinition, TemplateTokenRule, ThemeMode, ThemePack,
15};
16use directories::ProjectDirs;
17use serde::Deserialize;
18use thiserror::Error;
19
20const BUILTIN_TEMPLATES: [(&str, &str); 8] = [
21    (
22        "minimal-dark.toml",
23        include_str!("../templates/minimal-dark.toml"),
24    ),
25    (
26        "minimal-light.toml",
27        include_str!("../templates/minimal-light.toml"),
28    ),
29    (
30        "brutalist-dark.toml",
31        include_str!("../templates/brutalist-dark.toml"),
32    ),
33    (
34        "brutalist-light.toml",
35        include_str!("../templates/brutalist-light.toml"),
36    ),
37    (
38        "terminal-dark.toml",
39        include_str!("../templates/terminal-dark.toml"),
40    ),
41    (
42        "terminal-light.toml",
43        include_str!("../templates/terminal-light.toml"),
44    ),
45    (
46        "materialish-dark.toml",
47        include_str!("../templates/materialish-dark.toml"),
48    ),
49    (
50        "materialish-light.toml",
51        include_str!("../templates/materialish-light.toml"),
52    ),
53];
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum TemplateSource {
57    BuiltIn(&'static str),
58    Filesystem(PathBuf),
59    UserConfig(PathBuf),
60    Pack { pack: String, path: PathBuf },
61}
62
63impl TemplateSource {
64    pub fn label(&self) -> &'static str {
65        match self {
66            Self::BuiltIn(_) => "built-in",
67            Self::Filesystem(_) => "filesystem",
68            Self::UserConfig(_) => "user-config",
69            Self::Pack { .. } => "pack",
70        }
71    }
72
73    pub fn location(&self) -> String {
74        match self {
75            Self::BuiltIn(file_name) => (*file_name).to_owned(),
76            Self::Filesystem(path) | Self::UserConfig(path) | Self::Pack { path, .. } => {
77                path.display().to_string()
78            }
79        }
80    }
81
82    pub fn pack_name(&self) -> Option<&str> {
83        match self {
84            Self::Pack { pack, .. } => Some(pack.as_str()),
85            _ => None,
86        }
87    }
88
89    const fn precedence(&self) -> u8 {
90        match self {
91            Self::BuiltIn(_) => 0,
92            Self::Pack { .. } => 1,
93            Self::UserConfig(_) => 2,
94            Self::Filesystem(_) => 3,
95        }
96    }
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub struct ListedTemplate {
101    pub definition: TemplateDefinition,
102    pub source: TemplateSource,
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(deny_unknown_fields)]
107struct RawTemplateDefinition {
108    name: String,
109    mode: ThemeMode,
110    description: Option<String>,
111    #[serde(default)]
112    tokens: BTreeMap<String, RawTemplateTokenRule>,
113}
114
115#[derive(Debug, Clone, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct RawTemplateTokenRule {
118    family: String,
119    tone: f32,
120    chroma: Option<f32>,
121    chroma_scale: Option<f32>,
122}
123
124#[derive(Debug, Error)]
125pub enum TemplateError {
126    #[error("failed to parse template '{name}' from {source}: {error}")]
127    Parse {
128        name: String,
129        source: String,
130        #[source]
131        error: Box<toml::de::Error>,
132    },
133    #[error("failed to read templates directory '{path}': {source}")]
134    ReadDir {
135        path: PathBuf,
136        #[source]
137        source: std::io::Error,
138    },
139    #[error("failed to read template file '{path}': {source}")]
140    ReadFile {
141        path: PathBuf,
142        #[source]
143        source: std::io::Error,
144    },
145    #[error("template '{requested}' was not found")]
146    TemplateNotFound { requested: String },
147    #[error(
148        "template '{name}' ({mode}) is defined multiple times: first at {first_source}, second at {second_source}"
149    )]
150    DuplicateTemplateName {
151        name: String,
152        mode: ThemeMode,
153        first_source: String,
154        second_source: String,
155    },
156    #[error("template '{template}' uses an unknown token '{token}'")]
157    UnknownTokenName { template: String, token: String },
158    #[error("template '{template}' token '{token}' uses an unknown family '{family}'")]
159    UnknownFamily {
160        template: String,
161        token: String,
162        family: String,
163    },
164    #[error("template '{template}' is missing required tokens: {missing}")]
165    MissingRequiredTokens { template: String, missing: String },
166    #[error("template '{template}' token '{token}' uses invalid tone {tone}; expected 0.0..=1.0")]
167    InvalidTone {
168        template: String,
169        token: String,
170        tone: f32,
171    },
172    #[error(
173        "template '{template}' token '{token}' uses invalid chroma {chroma}; expected a finite value >= 0.0"
174    )]
175    InvalidChroma {
176        template: String,
177        token: String,
178        chroma: f32,
179    },
180    #[error(
181        "template '{template}' token '{token}' uses invalid chroma_scale {chroma_scale}; expected a finite value >= 0.0"
182    )]
183    InvalidChromaScale {
184        template: String,
185        token: String,
186        chroma_scale: f32,
187    },
188    #[error("palette is missing family '{family}' required by the template")]
189    MissingPaletteFamily { family: PaletteFamilyName },
190    #[error(transparent)]
191    Color(#[from] ColorError),
192}
193
194pub fn built_in_templates() -> Result<Vec<ListedTemplate>, TemplateError> {
195    BUILTIN_TEMPLATES
196        .iter()
197        .map(|(file_name, content)| {
198            let definition =
199                parse_template(file_name, content, format!("built-in template {file_name}"))?;
200            Ok(ListedTemplate {
201                definition,
202                source: TemplateSource::BuiltIn(file_name),
203            })
204        })
205        .collect()
206}
207
208pub fn user_templates_dir() -> Option<PathBuf> {
209    ProjectDirs::from("io", "chromasync", "chromasync")
210        .map(|dirs| dirs.config_dir().join("templates"))
211}
212
213pub fn template_from_file(path: &Path, user_config: bool) -> Result<ListedTemplate, TemplateError> {
214    let source = if user_config {
215        TemplateSource::UserConfig(path.to_path_buf())
216    } else {
217        TemplateSource::Filesystem(path.to_path_buf())
218    };
219
220    template_from_file_with_source(path, source)
221}
222
223fn template_from_file_with_source(
224    path: &Path,
225    source: TemplateSource,
226) -> Result<ListedTemplate, TemplateError> {
227    let content = fs::read_to_string(path).map_err(|source| TemplateError::ReadFile {
228        path: path.to_path_buf(),
229        source,
230    })?;
231    let definition = parse_template(
232        path.file_name()
233            .and_then(|name| name.to_str())
234            .unwrap_or("template.toml"),
235        &content,
236        path.display().to_string(),
237    )?;
238
239    Ok(ListedTemplate { definition, source })
240}
241
242pub fn templates_from_dir(
243    path: &Path,
244    user_config: bool,
245) -> Result<Vec<ListedTemplate>, TemplateError> {
246    let source = if user_config {
247        TemplateSource::UserConfig(path.to_path_buf())
248    } else {
249        TemplateSource::Filesystem(path.to_path_buf())
250    };
251
252    templates_from_dir_with_source(path, &source)
253}
254
255fn templates_from_dir_with_source(
256    path: &Path,
257    source: &TemplateSource,
258) -> Result<Vec<ListedTemplate>, TemplateError> {
259    if !path.exists() {
260        return Ok(Vec::new());
261    }
262
263    let mut templates = Vec::new();
264
265    for entry in fs::read_dir(path).map_err(|source| TemplateError::ReadDir {
266        path: path.to_path_buf(),
267        source,
268    })? {
269        let entry = entry.map_err(|source| TemplateError::ReadDir {
270            path: path.to_path_buf(),
271            source,
272        })?;
273        let entry_path = entry.path();
274
275        if entry_path
276            .extension()
277            .and_then(|extension| extension.to_str())
278            != Some("toml")
279        {
280            continue;
281        }
282
283        let source = match source {
284            TemplateSource::Pack { pack, .. } => TemplateSource::Pack {
285                pack: pack.clone(),
286                path: entry_path.clone(),
287            },
288            TemplateSource::UserConfig(_) => TemplateSource::UserConfig(entry_path.clone()),
289            TemplateSource::Filesystem(_) => TemplateSource::Filesystem(entry_path.clone()),
290            TemplateSource::BuiltIn(_) => TemplateSource::Filesystem(entry_path.clone()),
291        };
292        templates.push(template_from_file_with_source(&entry_path, source)?);
293    }
294
295    templates.sort_by(|left, right| left.definition.name.cmp(&right.definition.name));
296
297    Ok(templates)
298}
299
300pub fn list_templates() -> Result<Vec<ListedTemplate>, TemplateError> {
301    list_templates_with_packs(&[])
302}
303
304pub fn list_templates_with_packs(
305    packs: &[ThemePack],
306) -> Result<Vec<ListedTemplate>, TemplateError> {
307    let mut templates = built_in_templates()?;
308
309    if let Some(path) = user_templates_dir() {
310        templates.extend(templates_from_dir(&path, true)?);
311    }
312
313    for pack in packs {
314        for path in &pack.template_dirs {
315            templates.extend(templates_from_dir_with_source(
316                path,
317                &TemplateSource::Pack {
318                    pack: pack.name.clone(),
319                    path: path.clone(),
320                },
321            )?);
322        }
323    }
324
325    templates.sort_by(|left, right| {
326        left.definition
327            .name
328            .cmp(&right.definition.name)
329            .then(left.source.precedence().cmp(&right.source.precedence()))
330            .then(left.source.location().cmp(&right.source.location()))
331    });
332    validate_pack_template_collisions(&templates)?;
333
334    Ok(templates)
335}
336
337pub fn load_template(requested: &str, mode: ThemeMode) -> Result<ListedTemplate, TemplateError> {
338    load_template_with_packs(requested, mode, &[])
339}
340
341pub fn load_template_with_packs(
342    requested: &str,
343    mode: ThemeMode,
344    packs: &[ThemePack],
345) -> Result<ListedTemplate, TemplateError> {
346    if looks_like_path(requested) || Path::new(requested).exists() {
347        return template_from_file(Path::new(requested), false);
348    }
349
350    list_templates_with_packs(packs)?
351        .into_iter()
352        .filter(|template| template.definition.name == requested)
353        .max_by_key(|template| {
354            (
355                u8::from(template.definition.mode == mode),
356                template.source.precedence(),
357            )
358        })
359        .ok_or_else(|| TemplateError::TemplateNotFound {
360            requested: requested.to_owned(),
361        })
362}
363
364pub fn pack_templates(pack: &ThemePack) -> Result<Vec<ListedTemplate>, TemplateError> {
365    let mut templates = Vec::new();
366
367    for path in &pack.template_dirs {
368        templates.extend(templates_from_dir_with_source(
369            path,
370            &TemplateSource::Pack {
371                pack: pack.name.clone(),
372                path: path.clone(),
373            },
374        )?);
375    }
376
377    templates.sort_by(|left, right| {
378        left.definition
379            .name
380            .cmp(&right.definition.name)
381            .then(
382                left.definition
383                    .mode
384                    .as_str()
385                    .cmp(right.definition.mode.as_str()),
386            )
387            .then(left.source.location().cmp(&right.source.location()))
388    });
389    validate_pack_template_collisions(&templates)?;
390
391    Ok(templates)
392}
393
394fn validate_pack_template_collisions(templates: &[ListedTemplate]) -> Result<(), TemplateError> {
395    let mut seen: BTreeMap<(String, String), &ListedTemplate> = BTreeMap::new();
396
397    for template in templates {
398        let key = (
399            template.definition.name.clone(),
400            template.definition.mode.as_str().to_owned(),
401        );
402
403        if let Some(previous) = seen.get(&key) {
404            if previous.source.pack_name().is_some() || template.source.pack_name().is_some() {
405                return Err(TemplateError::DuplicateTemplateName {
406                    name: template.definition.name.clone(),
407                    mode: template.definition.mode,
408                    first_source: previous.source.location(),
409                    second_source: template.source.location(),
410                });
411            }
412        } else {
413            seen.insert(key, template);
414        }
415    }
416
417    Ok(())
418}
419
420pub fn resolve_tokens(
421    palette: &GeneratedPalette,
422    template: &TemplateDefinition,
423) -> Result<SemanticTokens, TemplateError> {
424    resolve_tokens_with_strategy(palette, template, ContrastStrategy::RelativeLuminance)
425}
426
427pub fn resolve_tokens_with_strategy(
428    palette: &GeneratedPalette,
429    template: &TemplateDefinition,
430    contrast_strategy: ContrastStrategy,
431) -> Result<SemanticTokens, TemplateError> {
432    let mut resolved = BTreeMap::new();
433
434    for token_name in SemanticTokenName::ALL {
435        let rule = template
436            .tokens
437            .get(&token_name)
438            .expect("template validation should guarantee the canonical token set");
439        let family =
440            palette
441                .families
442                .get(&rule.family)
443                .ok_or(TemplateError::MissingPaletteFamily {
444                    family: rule.family,
445                })?;
446        let chroma = rule.chroma.unwrap_or(family.base_chroma) * rule.chroma_scale.unwrap_or(1.0);
447        let color = resolve_color_from_components(family.hue, chroma, rule.tone)?;
448
449        resolved.insert(token_name, color);
450    }
451
452    let neutral = palette.families.get(&PaletteFamilyName::Neutral).ok_or(
453        TemplateError::MissingPaletteFamily {
454            family: PaletteFamilyName::Neutral,
455        },
456    )?;
457    let light_fallback = resolve_family_color(neutral, 0.98)?;
458    let dark_fallback = resolve_family_color(neutral, 0.06)?;
459    let bg = resolved
460        .get(&SemanticTokenName::Bg)
461        .cloned()
462        .expect("bg token should be present");
463    let accent = resolved
464        .get(&SemanticTokenName::Accent)
465        .cloned()
466        .expect("accent token should be present");
467    let text = ensure_preferred_readable(
468        resolved
469            .get(&SemanticTokenName::Text)
470            .expect("text token should be present"),
471        &bg,
472        &[light_fallback.clone(), dark_fallback.clone()],
473        contrast_strategy,
474    )?;
475
476    let mut accent_fg_fallbacks = vec![text.clone()];
477    if let Some(primary) = palette.families.get(&PaletteFamilyName::Primary) {
478        if let Ok(primary_light) = resolve_family_color(primary, 0.95) {
479            accent_fg_fallbacks.push(primary_light);
480        }
481        if let Ok(primary_dark) = resolve_family_color(primary, 0.10) {
482            accent_fg_fallbacks.push(primary_dark);
483        }
484    }
485    accent_fg_fallbacks.push(light_fallback);
486    accent_fg_fallbacks.push(dark_fallback);
487
488    let accent_fg = ensure_preferred_readable(
489        resolved
490            .get(&SemanticTokenName::AccentFg)
491            .expect("accent_fg token should be present"),
492        &accent,
493        &accent_fg_fallbacks,
494        contrast_strategy,
495    )?;
496
497    resolved.insert(SemanticTokenName::Text, text);
498    resolved.insert(SemanticTokenName::AccentFg, accent_fg);
499
500    Ok(semantic_tokens_from_map(&resolved))
501}
502
503fn parse_template(
504    name: &str,
505    content: &str,
506    source: String,
507) -> Result<TemplateDefinition, TemplateError> {
508    let raw: RawTemplateDefinition =
509        toml::from_str(content).map_err(|error| TemplateError::Parse {
510            name: name.to_owned(),
511            source,
512            error: Box::new(error),
513        })?;
514
515    validate_template(raw)
516}
517
518fn validate_template(raw: RawTemplateDefinition) -> Result<TemplateDefinition, TemplateError> {
519    let template_name = raw.name.clone();
520    let mut tokens = BTreeMap::new();
521
522    for (token_name, raw_rule) in raw.tokens {
523        let token = SemanticTokenName::from_str(&token_name).map_err(|_| {
524            TemplateError::UnknownTokenName {
525                template: template_name.clone(),
526                token: token_name.clone(),
527            }
528        })?;
529        let family = PaletteFamilyName::from_str(&raw_rule.family).map_err(|_| {
530            TemplateError::UnknownFamily {
531                template: template_name.clone(),
532                token: token_name.clone(),
533                family: raw_rule.family.clone(),
534            }
535        })?;
536
537        validate_tone(&template_name, &token_name, raw_rule.tone)?;
538
539        if let Some(chroma) = raw_rule.chroma {
540            validate_chroma(&template_name, &token_name, chroma)?;
541        }
542
543        if let Some(chroma_scale) = raw_rule.chroma_scale {
544            validate_chroma_scale(&template_name, &token_name, chroma_scale)?;
545        }
546
547        tokens.insert(
548            token,
549            TemplateTokenRule {
550                family,
551                tone: raw_rule.tone,
552                chroma: raw_rule.chroma,
553                chroma_scale: raw_rule.chroma_scale,
554            },
555        );
556    }
557
558    let missing = SemanticTokenName::ALL
559        .into_iter()
560        .filter(|token| !tokens.contains_key(token))
561        .map(SemanticTokenName::as_str)
562        .collect::<Vec<_>>();
563
564    if !missing.is_empty() {
565        return Err(TemplateError::MissingRequiredTokens {
566            template: template_name,
567            missing: missing.join(", "),
568        });
569    }
570
571    Ok(TemplateDefinition {
572        name: raw.name,
573        mode: raw.mode,
574        description: raw.description,
575        tokens,
576    })
577}
578
579fn validate_tone(template: &str, token: &str, tone: f32) -> Result<(), TemplateError> {
580    if tone.is_finite() && (0.0..=1.0).contains(&tone) {
581        Ok(())
582    } else {
583        Err(TemplateError::InvalidTone {
584            template: template.to_owned(),
585            token: token.to_owned(),
586            tone,
587        })
588    }
589}
590
591fn validate_chroma(template: &str, token: &str, chroma: f32) -> Result<(), TemplateError> {
592    if chroma.is_finite() && chroma >= 0.0 {
593        Ok(())
594    } else {
595        Err(TemplateError::InvalidChroma {
596            template: template.to_owned(),
597            token: token.to_owned(),
598            chroma,
599        })
600    }
601}
602
603fn validate_chroma_scale(
604    template: &str,
605    token: &str,
606    chroma_scale: f32,
607) -> Result<(), TemplateError> {
608    if chroma_scale.is_finite() && chroma_scale >= 0.0 {
609        Ok(())
610    } else {
611        Err(TemplateError::InvalidChromaScale {
612            template: template.to_owned(),
613            token: token.to_owned(),
614            chroma_scale,
615        })
616    }
617}
618
619fn ensure_preferred_readable(
620    preferred: &str,
621    background: &str,
622    fallbacks: &[HexColor],
623    contrast_strategy: ContrastStrategy,
624) -> Result<HexColor, TemplateError> {
625    let preferred_score = contrast_score(preferred, background, contrast_strategy)?;
626
627    if meets_contrast_threshold(preferred_score, contrast_strategy) {
628        return Ok(preferred.to_owned());
629    }
630
631    let mut candidates = Vec::with_capacity(fallbacks.len() + 1);
632    candidates.push(preferred.to_owned());
633
634    for fallback in fallbacks {
635        if !candidates.iter().any(|candidate| candidate == fallback) {
636            candidates.push(fallback.clone());
637        }
638    }
639
640    let selection = match contrast_strategy {
641        ContrastStrategy::RelativeLuminance => select_readable_color(background, &candidates)?,
642        ContrastStrategy::ApcaExperimental => {
643            select_readable_color_with_strategy(background, &candidates, contrast_strategy)?
644        }
645    };
646
647    Ok(selection.hex)
648}
649
650fn semantic_tokens_from_map(tokens: &BTreeMap<SemanticTokenName, HexColor>) -> SemanticTokens {
651    SemanticTokens {
652        bg: token(tokens, SemanticTokenName::Bg),
653        bg_secondary: token(tokens, SemanticTokenName::BgSecondary),
654        surface: token(tokens, SemanticTokenName::Surface),
655        surface_elevated: token(tokens, SemanticTokenName::SurfaceElevated),
656        text: token(tokens, SemanticTokenName::Text),
657        text_muted: token(tokens, SemanticTokenName::TextMuted),
658        border: token(tokens, SemanticTokenName::Border),
659        border_strong: token(tokens, SemanticTokenName::BorderStrong),
660        accent: token(tokens, SemanticTokenName::Accent),
661        accent_hover: token(tokens, SemanticTokenName::AccentHover),
662        accent_active: token(tokens, SemanticTokenName::AccentActive),
663        accent_fg: token(tokens, SemanticTokenName::AccentFg),
664        selection: token(tokens, SemanticTokenName::Selection),
665        link: token(tokens, SemanticTokenName::Link),
666        success: token(tokens, SemanticTokenName::Success),
667        warning: token(tokens, SemanticTokenName::Warning),
668        error: token(tokens, SemanticTokenName::Error),
669    }
670}
671
672fn token(tokens: &BTreeMap<SemanticTokenName, HexColor>, name: SemanticTokenName) -> HexColor {
673    tokens
674        .get(&name)
675        .cloned()
676        .expect("resolved tokens should include the full canonical token set")
677}
678
679fn looks_like_path(value: &str) -> bool {
680    let path = Path::new(value);
681
682    path.is_absolute()
683        || value.contains(std::path::MAIN_SEPARATOR)
684        || path.extension().and_then(|extension| extension.to_str()) == Some("toml")
685}
686
687#[cfg(test)]
688mod tests {
689    use std::{
690        fs,
691        path::PathBuf,
692        time::{SystemTime, UNIX_EPOCH},
693    };
694
695    use chromasync_color::{contrast_ratio, generate_palette};
696
697    use super::{
698        TemplateError, TemplateSource, built_in_templates, load_template, parse_template,
699        resolve_tokens,
700    };
701    use chromasync_types::{ChromaStrategy, PaletteFamilyName, SemanticTokenName, ThemeMode};
702
703    #[test]
704    fn built_in_templates_parse_and_define_all_canonical_tokens() {
705        let templates = built_in_templates().expect("built-in templates should parse");
706
707        assert_eq!(templates.len(), 8);
708        assert!(
709            templates
710                .iter()
711                .any(|template| template.definition.name == "minimal")
712        );
713        assert_eq!(
714            templates
715                .iter()
716                .filter(|template| template.definition.name == "minimal")
717                .count(),
718            2
719        );
720        assert!(
721            templates
722                .iter()
723                .all(|template| template.definition.tokens.len() == SemanticTokenName::ALL.len())
724        );
725    }
726
727    #[test]
728    fn load_template_by_name_uses_embedded_assets() {
729        let template = load_template("minimal", ThemeMode::Dark).expect("template should load");
730
731        assert_eq!(template.definition.name, "minimal");
732        assert_eq!(template.definition.mode, ThemeMode::Dark);
733        assert!(matches!(template.source, TemplateSource::BuiltIn(_)));
734    }
735
736    #[test]
737    fn load_template_by_name_prefers_requested_mode() {
738        let template = load_template("minimal", ThemeMode::Light).expect("template should load");
739
740        assert_eq!(template.definition.name, "minimal");
741        assert_eq!(template.definition.mode, ThemeMode::Light);
742        assert!(matches!(template.source, TemplateSource::BuiltIn(_)));
743    }
744
745    #[test]
746    fn load_template_by_path_uses_filesystem_source() {
747        let path = temp_file_path("template-path");
748        fs::write(&path, include_str!("../templates/minimal-dark.toml"))
749            .expect("temp template should be written");
750
751        let template = load_template(
752            path.to_str().expect("path should be utf-8"),
753            ThemeMode::Dark,
754        )
755        .expect("template should load from path");
756
757        assert!(matches!(template.source, TemplateSource::Filesystem(_)));
758
759        fs::remove_file(path).expect("temp template should be removed");
760    }
761
762    #[test]
763    fn built_in_templates_resolve_to_semantic_tokens() {
764        let palette = generate_palette("#4ecdc4", ThemeMode::Light, ChromaStrategy::Normal)
765            .expect("palette should build");
766        let template =
767            load_template("materialish", ThemeMode::Light).expect("template should load");
768
769        let tokens = resolve_tokens(&palette, &template.definition)
770            .expect("template should resolve to semantic tokens");
771
772        assert!(tokens.bg.starts_with('#'));
773        assert!(tokens.accent.starts_with('#'));
774        assert!(tokens.text.starts_with('#'));
775        assert!(
776            contrast_ratio(&tokens.text, &tokens.bg).expect("contrast should compute")
777                >= chromasync_color::MIN_CONTRAST_RATIO
778        );
779        assert!(
780            contrast_ratio(&tokens.accent_fg, &tokens.accent).expect("contrast should compute")
781                >= chromasync_color::MIN_CONTRAST_RATIO
782        );
783    }
784
785    #[test]
786    fn invalid_templates_report_missing_required_tokens() {
787        let error = parse_template(
788            "broken.toml",
789            r#"
790name = "broken"
791mode = "dark"
792
793[tokens.bg]
794family = "neutral"
795tone = 0.1
796"#,
797            "test fixture".to_owned(),
798        )
799        .expect_err("template should fail validation");
800
801        assert!(matches!(error, TemplateError::MissingRequiredTokens { .. }));
802    }
803
804    #[test]
805    fn invalid_templates_report_unknown_families() {
806        let error = parse_template(
807            "broken.toml",
808            r#"
809name = "broken"
810mode = "dark"
811
812[tokens.bg]
813family = "bogus"
814tone = 0.1
815
816[tokens.bg_secondary]
817family = "neutral"
818tone = 0.14
819
820[tokens.surface]
821family = "neutral"
822tone = 0.16
823
824[tokens.surface_elevated]
825family = "neutral"
826tone = 0.18
827
828[tokens.text]
829family = "neutral"
830tone = 0.94
831
832[tokens.text_muted]
833family = "neutral_variant"
834tone = 0.8
835
836[tokens.border]
837family = "neutral_variant"
838tone = 0.24
839
840[tokens.border_strong]
841family = "neutral_variant"
842tone = 0.34
843
844[tokens.accent]
845family = "primary"
846tone = 0.7
847
848[tokens.accent_hover]
849family = "primary"
850tone = 0.76
851
852[tokens.accent_active]
853family = "primary"
854tone = 0.64
855
856[tokens.accent_fg]
857family = "neutral"
858tone = 0.98
859
860[tokens.selection]
861family = "primary"
862tone = 0.32
863
864[tokens.link]
865family = "info"
866tone = 0.74
867
868[tokens.success]
869family = "success"
870tone = 0.72
871
872[tokens.warning]
873family = "warning"
874tone = 0.74
875
876[tokens.error]
877family = "error"
878tone = 0.72
879"#,
880            "test fixture".to_owned(),
881        )
882        .expect_err("template should fail validation");
883
884        assert!(matches!(error, TemplateError::UnknownFamily { .. }));
885    }
886
887    #[test]
888    fn resolved_templates_stay_bound_to_palette_families() {
889        let palette = generate_palette("#ff6b6b", ThemeMode::Light, ChromaStrategy::Normal)
890            .expect("palette should build");
891        let template = load_template("minimal", ThemeMode::Light).expect("template should load");
892
893        let tokens = resolve_tokens(&palette, &template.definition)
894            .expect("template should resolve to semantic tokens");
895
896        assert!(palette.families.contains_key(&PaletteFamilyName::Primary));
897        assert_ne!(tokens.bg, tokens.accent);
898    }
899
900    fn temp_file_path(label: &str) -> PathBuf {
901        let unique = SystemTime::now()
902            .duration_since(UNIX_EPOCH)
903            .expect("system time should be valid")
904            .as_nanos();
905
906        std::env::temp_dir().join(format!(
907            "chromasync-template-{label}-{}-{unique}.toml",
908            std::process::id()
909        ))
910    }
911}