Skip to main content

chromasync_core/
lib.rs

1mod config;
2mod packs;
3
4use std::{
5    collections::BTreeSet,
6    fmt::Write,
7    fs,
8    path::{Path, PathBuf},
9};
10
11use chromasync_color::ColorError;
12use chromasync_extract::{ExtractError, ExtractedSeed};
13use chromasync_renderers::{RendererError, RendererRegistry, TargetRegistry};
14use chromasync_template::{ListedTemplate, TemplateError, resolve_tokens_with_strategy};
15use chromasync_types::{
16    ChromaStrategy, ContrastStrategy, GeneratedArtifact, GeneratedPalette, GenerationContext,
17    GenerationRequest, PaletteFamilyName, SemanticTokens, ThemeMode, ThemePack,
18};
19use thiserror::Error;
20
21pub use chromasync_renderers::{ListedTarget, OutputRegistry};
22pub use config::{
23    ChromasyncConfig, ConfigHook, ConfigTarget, HookEvents, InstallSummary, SyncMode, SyncProfile,
24    config_file_path, expand_tilde, install_target,
25};
26pub use packs::{PackError, PackRegistry, pack_search_roots};
27
28#[derive(Debug, Clone)]
29pub struct PackInfo {
30    pub pack: ThemePack,
31    pub templates: Vec<ListedTemplate>,
32    pub targets: Vec<ListedTarget>,
33}
34
35#[derive(Debug, Error)]
36pub enum CoreError {
37    #[error("{feature} is not implemented yet")]
38    NotYetImplemented { feature: &'static str },
39    #[error("{operation} requires a seed color via --seed")]
40    MissingSeed { operation: &'static str },
41    #[error("{operation} requires an image path via --image")]
42    MissingWallpaper { operation: &'static str },
43    #[error(
44        "{operation} requires a template via --template (target '{target}' does not specify a preferred_template)"
45    )]
46    MissingTemplate {
47        operation: &'static str,
48        target: String,
49    },
50    #[error(transparent)]
51    Pack(#[from] PackError),
52    #[error(transparent)]
53    Template(#[from] TemplateError),
54    #[error(transparent)]
55    Renderer(#[from] RendererError),
56    #[error(transparent)]
57    Extract(#[from] ExtractError),
58    #[error(transparent)]
59    Color(#[from] ColorError),
60    #[error("refusing to overwrite existing artifact '{path}'")]
61    ArtifactExists { path: PathBuf },
62    #[error("multiple artifacts would write to the same destination '{path}'")]
63    ArtifactCollision { path: PathBuf },
64    #[error("failed to create output directory '{path}': {source}")]
65    CreateOutputDir {
66        path: PathBuf,
67        source: std::io::Error,
68    },
69    #[error("failed to write artifact '{path}': {source}")]
70    WriteArtifact {
71        path: PathBuf,
72        source: std::io::Error,
73    },
74    #[error("could not resolve the chromasync user config directory")]
75    UserConfigDirUnavailable,
76    #[error("failed to read chromasync config '{path}': {source}")]
77    ConfigRead {
78        path: PathBuf,
79        source: std::io::Error,
80    },
81    #[error("failed to parse chromasync config '{path}': {error}")]
82    ConfigParse {
83        path: PathBuf,
84        #[source]
85        error: toml::de::Error,
86    },
87    #[error("failed to serialize chromasync config: {error}")]
88    ConfigSerialize { error: String },
89    #[error("failed to write chromasync config '{path}': {source}")]
90    ConfigWrite {
91        path: PathBuf,
92        source: std::io::Error,
93    },
94    #[error("target '{name}' is already installed at '{path}'; pass --overwrite to replace it")]
95    TargetAlreadyInstalled { name: String, path: PathBuf },
96    #[error("failed to create targets directory '{path}': {source}")]
97    CreateTargetsDir {
98        path: PathBuf,
99        source: std::io::Error,
100    },
101    #[error("failed to copy target file from '{from}' to '{to}': {source}")]
102    CopyTargetFile {
103        from: PathBuf,
104        to: PathBuf,
105        source: std::io::Error,
106    },
107}
108
109pub fn list_templates() -> Result<Vec<ListedTemplate>, CoreError> {
110    let packs = PackRegistry::discover()?;
111
112    chromasync_template::list_templates_with_packs(packs.packs()).map_err(CoreError::from)
113}
114
115pub fn list_targets() -> Result<Vec<ListedTarget>, CoreError> {
116    Ok(load_output_registry()?.list_targets())
117}
118
119pub fn load_output_registry() -> Result<OutputRegistry, CoreError> {
120    let packs = PackRegistry::discover()?;
121
122    OutputRegistry::discover_with_packs(packs.packs()).map_err(CoreError::from)
123}
124
125pub fn list_packs() -> Result<Vec<ThemePack>, CoreError> {
126    Ok(PackRegistry::discover()?.packs().to_vec())
127}
128
129pub fn pack_info(name: &str) -> Result<PackInfo, CoreError> {
130    let packs = PackRegistry::discover()?;
131    let pack = packs
132        .get(name)
133        .cloned()
134        .ok_or_else(|| PackError::PackNotFound {
135            pack: name.to_owned(),
136        })?;
137    let templates = chromasync_template::pack_templates(&pack)?;
138    let built_in = RendererRegistry::new();
139    let targets = TargetRegistry::from_theme_packs(
140        std::slice::from_ref(&pack),
141        &built_in.built_in_name_set(),
142    )?
143    .list_targets()
144    .to_vec();
145
146    Ok(PackInfo {
147        pack,
148        templates,
149        targets,
150    })
151}
152
153pub fn generate_palette(
154    seed: &str,
155    mode: ThemeMode,
156    chroma: ChromaStrategy,
157) -> Result<GeneratedPalette, CoreError> {
158    chromasync_color::generate_palette(seed, mode, chroma).map_err(CoreError::from)
159}
160
161pub fn generate(request: &GenerationRequest) -> Result<Vec<GeneratedArtifact>, CoreError> {
162    let output_registry = load_output_registry()?;
163
164    generate_with_output_registry(request, &output_registry)
165}
166
167pub fn preview(request: &GenerationRequest) -> Result<String, CoreError> {
168    let palette = palette_from_request(request, "preview")?;
169    let template_name = request.template.as_deref().ok_or(CoreError::MissingSeed {
170        operation: "preview requires a template via --template",
171    })?;
172    let template = load_template(template_name, request.mode)?;
173    let tokens = resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)?;
174
175    Ok(render_preview(
176        &palette,
177        &template,
178        &tokens,
179        request.contrast,
180    ))
181}
182
183pub fn export_tokens(request: &GenerationRequest) -> Result<SemanticTokens, CoreError> {
184    let palette = palette_from_request(request, "token export")?;
185    let template_name = request.template.as_deref().ok_or(CoreError::MissingSeed {
186        operation: "token export requires a template via --template",
187    })?;
188    let template = load_template(template_name, request.mode)?;
189
190    resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)
191        .map_err(CoreError::from)
192}
193
194pub fn generate_from_wallpaper(
195    request: &GenerationRequest,
196) -> Result<Vec<GeneratedArtifact>, CoreError> {
197    let output_registry = load_output_registry()?;
198
199    generate_from_wallpaper_with_output_registry(request, &output_registry)
200}
201
202pub fn generate_with_output_registry(
203    request: &GenerationRequest,
204    output_registry: &OutputRegistry,
205) -> Result<Vec<GeneratedArtifact>, CoreError> {
206    render_from_palette(request, output_registry)
207}
208
209pub fn generate_from_wallpaper_with_output_registry(
210    request: &GenerationRequest,
211    output_registry: &OutputRegistry,
212) -> Result<Vec<GeneratedArtifact>, CoreError> {
213    render_from_palette_with_wallpaper(request, output_registry)
214}
215
216/// Write generated artifacts into `output_dir`.
217///
218/// Returns the full paths of every file written (in artifact order), suitable for
219/// echoing to the user. This is the single source of truth for write policy:
220/// both the CLI and the MCP server route through it.
221///
222/// By default the writer refuses to overwrite an existing file — it fails loudly
223/// before touching the disk. Pass `force = true` to replace existing files
224/// instead. The collision check (two artifacts mapping to one destination) is
225/// always enforced regardless of `force`, since that signals a target-design
226/// bug rather than stale output.
227pub fn write_artifacts(
228    output_dir: &Path,
229    artifacts: &[GeneratedArtifact],
230    force: bool,
231) -> Result<Vec<PathBuf>, CoreError> {
232    if artifacts.is_empty() {
233        return Ok(Vec::new());
234    }
235
236    let resolved: Vec<ResolvedArtifact> = artifacts
237        .iter()
238        .map(|artifact| ResolvedArtifact {
239            output_dir: output_dir.to_path_buf(),
240            file_name: artifact.file_name.clone(),
241            content: artifact.content.clone(),
242            force,
243        })
244        .collect();
245
246    write_resolved_artifacts(&resolved)
247}
248
249/// A single artifact paired with the directory it should be written to and
250/// whether existing files may be overwritten.
251///
252/// Used by [`write_resolved_artifacts`], which lets the CLI route each
253/// generated artifact to its own output directory (e.g. one per installed
254/// target) while still sharing collision and overwrite enforcement with
255/// [`write_artifacts`].
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ResolvedArtifact {
258    pub output_dir: PathBuf,
259    pub file_name: String,
260    pub content: String,
261    pub force: bool,
262}
263
264/// Write artifacts to their individually resolved destinations.
265///
266/// Each entry carries its own `output_dir` and `force` flag, so installed
267/// targets can land in different directories and opt into per-target
268/// overwrite. Collision detection runs across all destinations regardless of
269/// `force`; missing output directories are created on demand.
270pub fn write_resolved_artifacts(entries: &[ResolvedArtifact]) -> Result<Vec<PathBuf>, CoreError> {
271    if entries.is_empty() {
272        return Ok(Vec::new());
273    }
274
275    let mut seen_paths = BTreeSet::new();
276    let mut destinations = Vec::with_capacity(entries.len());
277
278    for entry in entries {
279        let path = entry.output_dir.join(&entry.file_name);
280
281        if !seen_paths.insert(path.clone()) {
282            return Err(CoreError::ArtifactCollision { path });
283        }
284
285        if !entry.force && path.exists() {
286            return Err(CoreError::ArtifactExists { path });
287        }
288
289        destinations.push(path);
290    }
291
292    for entry in entries {
293        fs::create_dir_all(&entry.output_dir).map_err(|source| CoreError::CreateOutputDir {
294            path: entry.output_dir.clone(),
295            source,
296        })?;
297    }
298
299    for (entry, path) in entries.iter().zip(&destinations) {
300        fs::write(path, &entry.content).map_err(|source| CoreError::WriteArtifact {
301            path: path.clone(),
302            source,
303        })?;
304    }
305
306    Ok(destinations)
307}
308
309fn palette_from_request(
310    request: &GenerationRequest,
311    operation: &'static str,
312) -> Result<GeneratedPalette, CoreError> {
313    let seed = request
314        .seed
315        .as_deref()
316        .ok_or(CoreError::MissingSeed { operation })?;
317
318    generate_palette(seed, request.mode, request.chroma)
319}
320
321fn palette_from_wallpaper_request(
322    request: &GenerationRequest,
323) -> Result<GeneratedPalette, CoreError> {
324    let wallpaper = request
325        .wallpaper
326        .as_deref()
327        .ok_or(CoreError::MissingWallpaper {
328            operation: "wallpaper theme generation",
329        })?;
330    let extraction = chromasync_extract::extract_seed_candidates(wallpaper)?;
331
332    palette_from_extracted_seeds(&extraction.seeds, request.mode, request.chroma)
333}
334
335fn palette_from_extracted_seeds(
336    seeds: &[ExtractedSeed],
337    mode: ThemeMode,
338    chroma: ChromaStrategy,
339) -> Result<GeneratedPalette, CoreError> {
340    let primary_seed = seeds.first().ok_or(CoreError::NotYetImplemented {
341        feature: "wallpaper extraction returned no seed candidates",
342    })?;
343    let mut palette = generate_palette(&primary_seed.hex, mode, chroma)?;
344
345    apply_seed_metadata(&mut palette, primary_seed, 0);
346
347    if let Some(seed) = seeds.get(1) {
348        replace_family_from_seed(
349            &mut palette,
350            PaletteFamilyName::Secondary,
351            seed,
352            1,
353            mode,
354            chroma,
355        )?;
356    }
357
358    if let Some(seed) = seeds.get(2) {
359        replace_family_from_seed(
360            &mut palette,
361            PaletteFamilyName::Tertiary,
362            seed,
363            2,
364            mode,
365            chroma,
366        )?;
367    }
368
369    Ok(palette)
370}
371
372fn replace_family_from_seed(
373    palette: &mut GeneratedPalette,
374    family_name: PaletteFamilyName,
375    seed: &ExtractedSeed,
376    seed_index: usize,
377    mode: ThemeMode,
378    chroma: ChromaStrategy,
379) -> Result<(), CoreError> {
380    let source_palette = generate_palette(&seed.hex, mode, chroma)?;
381    let mut family = source_palette
382        .families
383        .get(&PaletteFamilyName::Primary)
384        .cloned()
385        .ok_or(CoreError::NotYetImplemented {
386            feature: "primary palette family generation",
387        })?;
388
389    family.name = family_name;
390    family.dominance = Some(seed.dominance);
391    family.source_region = seed.source_region.clone();
392    family.seed_index = Some(seed_index);
393    palette.families.insert(family_name, family);
394
395    Ok(())
396}
397
398fn apply_seed_metadata(palette: &mut GeneratedPalette, seed: &ExtractedSeed, seed_index: usize) {
399    for family in palette.families.values_mut() {
400        family.dominance = Some(seed.dominance);
401        family.source_region = seed.source_region.clone();
402        family.seed_index = Some(seed_index);
403    }
404}
405
406fn render_from_palette(
407    request: &GenerationRequest,
408    output_registry: &OutputRegistry,
409) -> Result<Vec<GeneratedArtifact>, CoreError> {
410    use std::collections::BTreeMap;
411
412    // Grouping by (template, chroma) to handle overrides
413    let mut groups: BTreeMap<(String, ChromaStrategy), Vec<String>> = BTreeMap::new();
414
415    for target in &request.targets {
416        let template_name = request
417            .template
418            .clone()
419            .or_else(|| output_registry.resolve_preferred_template(target))
420            .ok_or_else(|| CoreError::MissingTemplate {
421                operation: "theme generation",
422                target: target.clone(),
423            })?;
424        let chroma = output_registry
425            .resolve_chroma_strategy(target)
426            .unwrap_or(request.chroma);
427
428        groups
429            .entry((template_name, chroma))
430            .or_default()
431            .push(target.clone());
432    }
433
434    let mut artifacts = Vec::new();
435
436    for ((template_name, chroma), targets) in &groups {
437        let template = load_template(template_name, request.mode)?;
438        let palette = if *chroma == request.chroma {
439            palette_from_request(request, "theme generation")?
440        } else {
441            let mut req = request.clone();
442            req.chroma = *chroma;
443            palette_from_request(&req, "theme generation (chroma override)")?
444        };
445
446        let tokens =
447            resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)?;
448        let context = GenerationContext {
449            mode: request.mode,
450            template_name: template.definition.name.clone(),
451            chroma: *chroma,
452            output_dir: request.output_dir.clone(),
453            seed: request.seed.clone(),
454        };
455
456        artifacts.extend(
457            output_registry
458                .generate(targets, &tokens, &context)
459                .map_err(CoreError::from)?,
460        );
461    }
462
463    Ok(artifacts)
464}
465
466fn render_from_palette_with_wallpaper(
467    request: &GenerationRequest,
468    output_registry: &OutputRegistry,
469) -> Result<Vec<GeneratedArtifact>, CoreError> {
470    use std::collections::BTreeMap;
471
472    // Grouping by (template, chroma) to handle overrides
473    let mut groups: BTreeMap<(String, ChromaStrategy), Vec<String>> = BTreeMap::new();
474
475    for target in &request.targets {
476        let template_name = request
477            .template
478            .clone()
479            .or_else(|| output_registry.resolve_preferred_template(target))
480            .ok_or_else(|| CoreError::MissingTemplate {
481                operation: "theme generation",
482                target: target.clone(),
483            })?;
484        let chroma = output_registry
485            .resolve_chroma_strategy(target)
486            .unwrap_or(request.chroma);
487
488        groups
489            .entry((template_name, chroma))
490            .or_default()
491            .push(target.clone());
492    }
493
494    let mut artifacts = Vec::new();
495
496    for ((template_name, chroma), targets) in &groups {
497        let template = load_template(template_name, request.mode)?;
498        let palette = if *chroma == request.chroma {
499            palette_from_wallpaper_request(request)?
500        } else {
501            let mut req = request.clone();
502            req.chroma = *chroma;
503            palette_from_wallpaper_request(&req)?
504        };
505
506        let tokens =
507            resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)?;
508        let context = GenerationContext {
509            mode: request.mode,
510            template_name: template.definition.name.clone(),
511            chroma: *chroma,
512            output_dir: request.output_dir.clone(),
513            seed: request.seed.clone(),
514        };
515
516        artifacts.extend(
517            output_registry
518                .generate(targets, &tokens, &context)
519                .map_err(CoreError::from)?,
520        );
521    }
522
523    Ok(artifacts)
524}
525
526fn load_template(requested: &str, mode: ThemeMode) -> Result<ListedTemplate, CoreError> {
527    let packs = PackRegistry::discover()?;
528
529    chromasync_template::load_template_with_packs(requested, mode, packs.packs())
530        .map_err(CoreError::from)
531}
532
533fn render_preview(
534    palette: &GeneratedPalette,
535    template: &ListedTemplate,
536    tokens: &SemanticTokens,
537    contrast: ContrastStrategy,
538) -> String {
539    let mut output = String::with_capacity(2048);
540
541    let _ = writeln!(output, "Seed: {}", palette.seed);
542    let _ = writeln!(output, "Mode: {}", palette.mode);
543    let _ = writeln!(output, "Template: {}", template.definition.name);
544    let _ = writeln!(output, "Contrast: {contrast}");
545    let _ = writeln!(
546        output,
547        "Template Source: {} ({})",
548        template.source.label(),
549        template.source.location()
550    );
551
552    if let Some(description) = &template.definition.description {
553        let _ = writeln!(output, "Description: {description}");
554    }
555
556    let _ = writeln!(output);
557    let _ = writeln!(output, "Palette Families");
558
559    for family_name in PaletteFamilyName::ALL {
560        if let Some(family) = palette.families.get(&family_name) {
561            let _ = write!(
562                output,
563                "{} hue={:.2} chroma={:.3}",
564                family_name, family.hue, family.base_chroma
565            );
566
567            for tone in &family.tones {
568                let _ = write!(output, " {}={}", tone.tone, tone.hex);
569            }
570
571            let _ = writeln!(output);
572        }
573    }
574
575    let _ = writeln!(output);
576    let _ = writeln!(output, "Semantic Tokens");
577
578    for (name, value) in semantic_token_rows(tokens) {
579        let _ = writeln!(output, "{name:<16} {value}");
580    }
581
582    output
583}
584
585fn semantic_token_rows(tokens: &SemanticTokens) -> [(&'static str, &str); 17] {
586    [
587        ("bg", tokens.bg.as_str()),
588        ("bg_secondary", tokens.bg_secondary.as_str()),
589        ("surface", tokens.surface.as_str()),
590        ("surface_elevated", tokens.surface_elevated.as_str()),
591        ("text", tokens.text.as_str()),
592        ("text_muted", tokens.text_muted.as_str()),
593        ("border", tokens.border.as_str()),
594        ("border_strong", tokens.border_strong.as_str()),
595        ("accent", tokens.accent.as_str()),
596        ("accent_hover", tokens.accent_hover.as_str()),
597        ("accent_active", tokens.accent_active.as_str()),
598        ("accent_fg", tokens.accent_fg.as_str()),
599        ("selection", tokens.selection.as_str()),
600        ("link", tokens.link.as_str()),
601        ("success", tokens.success.as_str()),
602        ("warning", tokens.warning.as_str()),
603        ("error", tokens.error.as_str()),
604    ]
605}
606
607#[cfg(test)]
608mod tests {
609    use std::path::PathBuf;
610
611    use chromasync_color::contrast_ratio;
612    use chromasync_types::{
613        ChromaStrategy, ContrastStrategy, GenerationRequest, PaletteFamilyName, ThemeMode,
614    };
615
616    use super::{export_tokens, generate_palette, palette_from_wallpaper_request, preview};
617
618    #[test]
619    fn palette_generation_is_available_without_renderer_code() {
620        let palette = generate_palette("#ff6b6b", ThemeMode::Dark, ChromaStrategy::Normal)
621            .expect("palette generation should work");
622
623        assert!(palette.families.contains_key(&PaletteFamilyName::Primary));
624        assert!(palette.families.contains_key(&PaletteFamilyName::Neutral));
625    }
626
627    #[test]
628    fn token_export_resolves_built_in_templates() {
629        let request = GenerationRequest {
630            seed: Some("#4ecdc4".to_owned()),
631            wallpaper: None,
632            template: Some("minimal".to_owned()),
633            mode: ThemeMode::Dark,
634            chroma: ChromaStrategy::Normal,
635            contrast: ContrastStrategy::RelativeLuminance,
636            targets: Vec::new(),
637            output_dir: "chromasync".into(),
638        };
639
640        let tokens = export_tokens(&request).expect("token export should work");
641
642        assert!(tokens.bg.starts_with('#'));
643        assert!(tokens.accent.starts_with('#'));
644        assert!(
645            contrast_ratio(&tokens.text, &tokens.bg).expect("contrast should compute")
646                >= chromasync_color::MIN_CONTRAST_RATIO
647        );
648    }
649
650    #[test]
651    fn preview_includes_palette_and_semantic_token_sections() {
652        let request = GenerationRequest {
653            seed: Some("#ff6b6b".to_owned()),
654            wallpaper: None,
655            template: Some("brutalist".to_owned()),
656            mode: ThemeMode::Dark,
657            chroma: ChromaStrategy::Normal,
658            contrast: ContrastStrategy::RelativeLuminance,
659            targets: Vec::new(),
660            output_dir: "chromasync".into(),
661        };
662
663        let rendered = preview(&request).expect("preview should render");
664
665        assert!(rendered.contains("Palette Families"));
666        assert!(rendered.contains("Semantic Tokens"));
667        assert!(rendered.contains("primary"));
668        assert!(rendered.contains("accent"));
669    }
670
671    #[test]
672    fn generate_returns_requested_artifacts() {
673        let request = GenerationRequest {
674            seed: Some("#4ecdc4".to_owned()),
675            wallpaper: None,
676            template: Some("terminal".to_owned()),
677            mode: ThemeMode::Dark,
678            chroma: ChromaStrategy::Normal,
679            contrast: ContrastStrategy::RelativeLuminance,
680            targets: vec![
681                example_target_path("gtk.toml"),
682                "hyprland".to_owned(),
683                "kitty".to_owned(),
684                example_target_path("css.toml"),
685            ],
686            output_dir: "chromasync".into(),
687        };
688
689        let artifacts = super::generate(&request).expect("generation should render artifacts");
690
691        assert_eq!(artifacts.len(), 4);
692        assert_eq!(
693            artifacts
694                .iter()
695                .map(|artifact| artifact.target.clone())
696                .collect::<Vec<_>>(),
697            vec![
698                "gtk".to_owned(),
699                "hyprland".to_owned(),
700                "kitty".to_owned(),
701                "css".to_owned(),
702            ]
703        );
704        assert_eq!(
705            artifacts
706                .iter()
707                .map(|artifact| artifact.file_name.as_str())
708                .collect::<Vec<_>>(),
709            vec!["gtk.css", "hyprland.conf", "kitty.conf", "theme.css"]
710        );
711        assert!(
712            artifacts
713                .iter()
714                .all(|artifact| !artifact.content.is_empty())
715        );
716    }
717
718    #[test]
719    fn wallpaper_generation_assigns_metadata_and_multi_seed_families() {
720        let request = GenerationRequest {
721            seed: None,
722            wallpaper: Some(wallpaper_fixture("wallpaper-blocks.png")),
723            template: Some("minimal".to_owned()),
724            mode: ThemeMode::Dark,
725            chroma: ChromaStrategy::Normal,
726            contrast: ContrastStrategy::RelativeLuminance,
727            targets: vec![example_target_path("css.toml")],
728            output_dir: "chromasync".into(),
729        };
730
731        let palette = palette_from_wallpaper_request(&request)
732            .expect("wallpaper palette generation should succeed");
733
734        let primary = palette
735            .families
736            .get(&PaletteFamilyName::Primary)
737            .expect("primary family should exist");
738        let secondary = palette
739            .families
740            .get(&PaletteFamilyName::Secondary)
741            .expect("secondary family should exist");
742        let tertiary = palette
743            .families
744            .get(&PaletteFamilyName::Tertiary)
745            .expect("tertiary family should exist");
746
747        assert_eq!(primary.seed_index, Some(0));
748        assert_eq!(secondary.seed_index, Some(1));
749        assert_eq!(tertiary.seed_index, Some(2));
750        assert_eq!(primary.source_region.as_deref(), Some("center-left"));
751        assert_eq!(secondary.source_region.as_deref(), Some("center"));
752        assert_eq!(tertiary.source_region.as_deref(), Some("center-right"));
753        assert!(primary.dominance.expect("primary dominance should exist") > 0.45);
754        assert!(
755            secondary
756                .dominance
757                .expect("secondary dominance should exist")
758                > 0.30
759        );
760        assert!(tertiary.dominance.expect("tertiary dominance should exist") > 0.15);
761    }
762
763    fn wallpaper_fixture(name: &str) -> PathBuf {
764        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
765            .join("../chromasync-extract/tests/fixtures")
766            .join(name)
767    }
768
769    fn example_target_path(name: &str) -> String {
770        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
771            .join("../../examples/targets")
772            .join(name)
773            .display()
774            .to_string()
775    }
776}