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/// Generate artifacts with a target-specific output route while sharing the
217/// expensive palette preparation across every target.
218///
219/// The route callback is evaluated once per requested target. Its output
220/// directory is also exposed to declarative targets through `ctx.output_dir`.
221pub fn generate_routed_with_output_registry<F>(
222    request: &GenerationRequest,
223    output_registry: &OutputRegistry,
224    mut route: F,
225) -> Result<Vec<RoutedArtifact>, CoreError>
226where
227    F: FnMut(&str) -> (PathBuf, bool),
228{
229    use std::collections::BTreeMap;
230
231    if request.targets.is_empty() {
232        return Ok(Vec::new());
233    }
234
235    let extracted_seeds = match request.wallpaper.as_deref() {
236        Some(wallpaper) => Some(chromasync_extract::extract_seed_candidates(wallpaper)?.seeds),
237        None => None,
238    };
239
240    if extracted_seeds.is_none() && request.seed.is_none() {
241        return Err(CoreError::MissingSeed {
242            operation: "theme generation",
243        });
244    }
245
246    let mut palettes = BTreeMap::<ChromaStrategy, GeneratedPalette>::new();
247    let mut render_inputs =
248        BTreeMap::<(String, ChromaStrategy), (ListedTemplate, SemanticTokens)>::new();
249    let mut routed = Vec::new();
250
251    for target in &request.targets {
252        let template_name = request
253            .template
254            .clone()
255            .or_else(|| output_registry.resolve_preferred_template(target))
256            .ok_or_else(|| CoreError::MissingTemplate {
257                operation: "theme generation",
258                target: target.clone(),
259            })?;
260        let chroma = output_registry
261            .resolve_chroma_strategy(target)
262            .unwrap_or(request.chroma);
263        let key = (template_name.clone(), chroma);
264
265        if let std::collections::btree_map::Entry::Vacant(render_input) =
266            render_inputs.entry(key.clone())
267        {
268            if let std::collections::btree_map::Entry::Vacant(palette_entry) =
269                palettes.entry(chroma)
270            {
271                let palette = match &extracted_seeds {
272                    Some(seeds) => palette_from_extracted_seeds(seeds, request.mode, chroma)?,
273                    None => generate_palette(
274                        request.seed.as_deref().expect("seed checked above"),
275                        request.mode,
276                        chroma,
277                    )?,
278                };
279                palette_entry.insert(palette);
280            }
281
282            let template = load_template(&template_name, request.mode)?;
283            let tokens = resolve_tokens_with_strategy(
284                palettes
285                    .get(&chroma)
286                    .expect("palette inserted before token resolution"),
287                &template.definition,
288                request.contrast,
289            )?;
290            render_input.insert((template, tokens));
291        }
292
293        let (template, tokens) = render_inputs
294            .get(&key)
295            .expect("render input inserted before target generation");
296        let (output_dir, force) = route(target);
297        let context = GenerationContext {
298            mode: request.mode,
299            template_name: template.definition.name.clone(),
300            chroma,
301            output_dir: output_dir.clone(),
302            seed: request.seed.clone(),
303        };
304
305        let generated = output_registry
306            .generate(std::slice::from_ref(target), tokens, &context)
307            .map_err(CoreError::from)?;
308
309        for artifact in generated {
310            routed.push(RoutedArtifact {
311                artifact,
312                output_dir: output_dir.clone(),
313                force,
314            });
315        }
316    }
317
318    Ok(routed)
319}
320
321/// Write generated artifacts into `output_dir`.
322///
323/// Returns the full paths of every file written (in artifact order), suitable for
324/// echoing to the user. This is the single source of truth for write policy:
325/// both the CLI and the MCP server route through it.
326///
327/// By default the writer refuses to overwrite an existing file — it fails loudly
328/// before touching the disk. Pass `force = true` to replace existing files
329/// instead. The collision check (two artifacts mapping to one destination) is
330/// always enforced regardless of `force`, since that signals a target-design
331/// bug rather than stale output.
332pub fn write_artifacts(
333    output_dir: &Path,
334    artifacts: &[GeneratedArtifact],
335    force: bool,
336) -> Result<Vec<PathBuf>, CoreError> {
337    if artifacts.is_empty() {
338        return Ok(Vec::new());
339    }
340
341    let resolved: Vec<ResolvedArtifact> = artifacts
342        .iter()
343        .map(|artifact| ResolvedArtifact {
344            output_dir: output_dir.to_path_buf(),
345            file_name: artifact.file_name.clone(),
346            content: artifact.content.clone(),
347            force,
348        })
349        .collect();
350
351    write_resolved_artifacts(&resolved)
352}
353
354/// A single artifact paired with the directory it should be written to and
355/// whether existing files may be overwritten.
356///
357/// Used by [`write_resolved_artifacts`], which lets the CLI route each
358/// generated artifact to its own output directory (e.g. one per installed
359/// target) while still sharing collision and overwrite enforcement with
360/// [`write_artifacts`].
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct ResolvedArtifact {
363    pub output_dir: PathBuf,
364    pub file_name: String,
365    pub content: String,
366    pub force: bool,
367}
368
369/// A generated artifact paired with its target-specific write policy.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct RoutedArtifact {
372    pub artifact: GeneratedArtifact,
373    pub output_dir: PathBuf,
374    pub force: bool,
375}
376
377/// Write artifacts to their individually resolved destinations.
378///
379/// Each entry carries its own `output_dir` and `force` flag, so installed
380/// targets can land in different directories and opt into per-target
381/// overwrite. Collision detection runs across all destinations regardless of
382/// `force`; missing output directories are created on demand.
383pub fn write_resolved_artifacts(entries: &[ResolvedArtifact]) -> Result<Vec<PathBuf>, CoreError> {
384    if entries.is_empty() {
385        return Ok(Vec::new());
386    }
387
388    let mut seen_paths = BTreeSet::new();
389    let mut destinations = Vec::with_capacity(entries.len());
390
391    for entry in entries {
392        let path = entry.output_dir.join(&entry.file_name);
393
394        if !seen_paths.insert(path.clone()) {
395            return Err(CoreError::ArtifactCollision { path });
396        }
397
398        if !entry.force && path.exists() {
399            return Err(CoreError::ArtifactExists { path });
400        }
401
402        destinations.push(path);
403    }
404
405    for entry in entries {
406        fs::create_dir_all(&entry.output_dir).map_err(|source| CoreError::CreateOutputDir {
407            path: entry.output_dir.clone(),
408            source,
409        })?;
410    }
411
412    for (entry, path) in entries.iter().zip(&destinations) {
413        fs::write(path, &entry.content).map_err(|source| CoreError::WriteArtifact {
414            path: path.clone(),
415            source,
416        })?;
417    }
418
419    Ok(destinations)
420}
421
422fn palette_from_request(
423    request: &GenerationRequest,
424    operation: &'static str,
425) -> Result<GeneratedPalette, CoreError> {
426    let seed = request
427        .seed
428        .as_deref()
429        .ok_or(CoreError::MissingSeed { operation })?;
430
431    generate_palette(seed, request.mode, request.chroma)
432}
433
434/// Compose a palette from ranked image seed candidates.
435///
436/// The dominant seed drives the full palette. The second and third candidates,
437/// when present, replace the secondary and tertiary families respectively.
438/// This is shared by the native wallpaper pipeline and in-memory consumers such
439/// as the WebAssembly package.
440pub fn palette_from_extracted_seeds(
441    seeds: &[ExtractedSeed],
442    mode: ThemeMode,
443    chroma: ChromaStrategy,
444) -> Result<GeneratedPalette, CoreError> {
445    let primary_seed = seeds.first().ok_or(CoreError::NotYetImplemented {
446        feature: "wallpaper extraction returned no seed candidates",
447    })?;
448    let mut palette = generate_palette(&primary_seed.hex, mode, chroma)?;
449
450    apply_seed_metadata(&mut palette, primary_seed, 0);
451
452    if let Some(seed) = seeds.get(1) {
453        replace_family_from_seed(
454            &mut palette,
455            PaletteFamilyName::Secondary,
456            seed,
457            1,
458            mode,
459            chroma,
460        )?;
461    }
462
463    if let Some(seed) = seeds.get(2) {
464        replace_family_from_seed(
465            &mut palette,
466            PaletteFamilyName::Tertiary,
467            seed,
468            2,
469            mode,
470            chroma,
471        )?;
472    }
473
474    Ok(palette)
475}
476
477fn replace_family_from_seed(
478    palette: &mut GeneratedPalette,
479    family_name: PaletteFamilyName,
480    seed: &ExtractedSeed,
481    seed_index: usize,
482    mode: ThemeMode,
483    chroma: ChromaStrategy,
484) -> Result<(), CoreError> {
485    let source_palette = generate_palette(&seed.hex, mode, chroma)?;
486    let mut family = source_palette
487        .families
488        .get(&PaletteFamilyName::Primary)
489        .cloned()
490        .ok_or(CoreError::NotYetImplemented {
491            feature: "primary palette family generation",
492        })?;
493
494    family.name = family_name;
495    family.dominance = Some(seed.dominance);
496    family.source_region = seed.source_region.clone();
497    family.seed_index = Some(seed_index);
498    palette.families.insert(family_name, family);
499
500    Ok(())
501}
502
503fn apply_seed_metadata(palette: &mut GeneratedPalette, seed: &ExtractedSeed, seed_index: usize) {
504    for family in palette.families.values_mut() {
505        family.dominance = Some(seed.dominance);
506        family.source_region = seed.source_region.clone();
507        family.seed_index = Some(seed_index);
508    }
509}
510
511fn render_from_palette(
512    request: &GenerationRequest,
513    output_registry: &OutputRegistry,
514) -> Result<Vec<GeneratedArtifact>, CoreError> {
515    use std::collections::BTreeMap;
516
517    // Grouping by (template, chroma) to handle overrides
518    let mut groups: BTreeMap<(String, ChromaStrategy), Vec<String>> = BTreeMap::new();
519
520    for target in &request.targets {
521        let template_name = request
522            .template
523            .clone()
524            .or_else(|| output_registry.resolve_preferred_template(target))
525            .ok_or_else(|| CoreError::MissingTemplate {
526                operation: "theme generation",
527                target: target.clone(),
528            })?;
529        let chroma = output_registry
530            .resolve_chroma_strategy(target)
531            .unwrap_or(request.chroma);
532
533        groups
534            .entry((template_name, chroma))
535            .or_default()
536            .push(target.clone());
537    }
538
539    let mut artifacts = Vec::new();
540
541    for ((template_name, chroma), targets) in &groups {
542        let template = load_template(template_name, request.mode)?;
543        let palette = if *chroma == request.chroma {
544            palette_from_request(request, "theme generation")?
545        } else {
546            let mut req = request.clone();
547            req.chroma = *chroma;
548            palette_from_request(&req, "theme generation (chroma override)")?
549        };
550
551        let tokens =
552            resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)?;
553        let context = GenerationContext {
554            mode: request.mode,
555            template_name: template.definition.name.clone(),
556            chroma: *chroma,
557            output_dir: request.output_dir.clone(),
558            seed: request.seed.clone(),
559        };
560
561        artifacts.extend(
562            output_registry
563                .generate(targets, &tokens, &context)
564                .map_err(CoreError::from)?,
565        );
566    }
567
568    Ok(artifacts)
569}
570
571fn render_from_palette_with_wallpaper(
572    request: &GenerationRequest,
573    output_registry: &OutputRegistry,
574) -> Result<Vec<GeneratedArtifact>, CoreError> {
575    use std::collections::BTreeMap;
576
577    if request.targets.is_empty() {
578        return Ok(Vec::new());
579    }
580
581    let wallpaper = request
582        .wallpaper
583        .as_deref()
584        .ok_or(CoreError::MissingWallpaper {
585            operation: "wallpaper theme generation",
586        })?;
587    let extraction = chromasync_extract::extract_seed_candidates(wallpaper)?;
588
589    // Grouping by (template, chroma) to handle overrides
590    let mut groups: BTreeMap<(String, ChromaStrategy), Vec<String>> = BTreeMap::new();
591
592    for target in &request.targets {
593        let template_name = request
594            .template
595            .clone()
596            .or_else(|| output_registry.resolve_preferred_template(target))
597            .ok_or_else(|| CoreError::MissingTemplate {
598                operation: "theme generation",
599                target: target.clone(),
600            })?;
601        let chroma = output_registry
602            .resolve_chroma_strategy(target)
603            .unwrap_or(request.chroma);
604
605        groups
606            .entry((template_name, chroma))
607            .or_default()
608            .push(target.clone());
609    }
610
611    let mut artifacts = Vec::new();
612
613    for ((template_name, chroma), targets) in &groups {
614        let template = load_template(template_name, request.mode)?;
615        let palette = palette_from_extracted_seeds(&extraction.seeds, request.mode, *chroma)?;
616
617        let tokens =
618            resolve_tokens_with_strategy(&palette, &template.definition, request.contrast)?;
619        let context = GenerationContext {
620            mode: request.mode,
621            template_name: template.definition.name.clone(),
622            chroma: *chroma,
623            output_dir: request.output_dir.clone(),
624            seed: request.seed.clone(),
625        };
626
627        artifacts.extend(
628            output_registry
629                .generate(targets, &tokens, &context)
630                .map_err(CoreError::from)?,
631        );
632    }
633
634    Ok(artifacts)
635}
636
637fn load_template(requested: &str, mode: ThemeMode) -> Result<ListedTemplate, CoreError> {
638    let packs = PackRegistry::discover()?;
639
640    chromasync_template::load_template_with_packs(requested, mode, packs.packs())
641        .map_err(CoreError::from)
642}
643
644fn render_preview(
645    palette: &GeneratedPalette,
646    template: &ListedTemplate,
647    tokens: &SemanticTokens,
648    contrast: ContrastStrategy,
649) -> String {
650    let mut output = String::with_capacity(2048);
651
652    let _ = writeln!(output, "Seed: {}", palette.seed);
653    let _ = writeln!(output, "Mode: {}", palette.mode);
654    let _ = writeln!(output, "Template: {}", template.definition.name);
655    let _ = writeln!(output, "Contrast: {contrast}");
656    let _ = writeln!(
657        output,
658        "Template Source: {} ({})",
659        template.source.label(),
660        template.source.location()
661    );
662
663    if let Some(description) = &template.definition.description {
664        let _ = writeln!(output, "Description: {description}");
665    }
666
667    let _ = writeln!(output);
668    let _ = writeln!(output, "Palette Families");
669
670    for family_name in PaletteFamilyName::ALL {
671        if let Some(family) = palette.families.get(&family_name) {
672            let _ = write!(
673                output,
674                "{} hue={:.2} chroma={:.3}",
675                family_name, family.hue, family.base_chroma
676            );
677
678            for tone in &family.tones {
679                let _ = write!(output, " {}={}", tone.tone, tone.hex);
680            }
681
682            let _ = writeln!(output);
683        }
684    }
685
686    let _ = writeln!(output);
687    let _ = writeln!(output, "Semantic Tokens");
688
689    for (name, value) in semantic_token_rows(tokens) {
690        let _ = writeln!(output, "{name:<16} {value}");
691    }
692
693    output
694}
695
696fn semantic_token_rows(tokens: &SemanticTokens) -> [(&'static str, &str); 17] {
697    [
698        ("bg", tokens.bg.as_str()),
699        ("bg_secondary", tokens.bg_secondary.as_str()),
700        ("surface", tokens.surface.as_str()),
701        ("surface_elevated", tokens.surface_elevated.as_str()),
702        ("text", tokens.text.as_str()),
703        ("text_muted", tokens.text_muted.as_str()),
704        ("border", tokens.border.as_str()),
705        ("border_strong", tokens.border_strong.as_str()),
706        ("accent", tokens.accent.as_str()),
707        ("accent_hover", tokens.accent_hover.as_str()),
708        ("accent_active", tokens.accent_active.as_str()),
709        ("accent_fg", tokens.accent_fg.as_str()),
710        ("selection", tokens.selection.as_str()),
711        ("link", tokens.link.as_str()),
712        ("success", tokens.success.as_str()),
713        ("warning", tokens.warning.as_str()),
714        ("error", tokens.error.as_str()),
715    ]
716}
717
718#[cfg(test)]
719mod tests {
720    use std::path::PathBuf;
721
722    use chromasync_color::contrast_ratio;
723    use chromasync_types::{
724        ChromaStrategy, ContrastStrategy, GenerationRequest, PaletteFamilyName, ThemeMode,
725    };
726
727    use super::{export_tokens, generate_palette, palette_from_extracted_seeds, preview};
728
729    #[test]
730    fn palette_generation_is_available_without_renderer_code() {
731        let palette = generate_palette("#ff6b6b", ThemeMode::Dark, ChromaStrategy::Normal)
732            .expect("palette generation should work");
733
734        assert!(palette.families.contains_key(&PaletteFamilyName::Primary));
735        assert!(palette.families.contains_key(&PaletteFamilyName::Neutral));
736    }
737
738    #[test]
739    fn token_export_resolves_built_in_templates() {
740        let request = GenerationRequest {
741            seed: Some("#4ecdc4".to_owned()),
742            wallpaper: None,
743            template: Some("minimal".to_owned()),
744            mode: ThemeMode::Dark,
745            chroma: ChromaStrategy::Normal,
746            contrast: ContrastStrategy::RelativeLuminance,
747            targets: Vec::new(),
748            output_dir: "chromasync".into(),
749        };
750
751        let tokens = export_tokens(&request).expect("token export should work");
752
753        assert!(tokens.bg.starts_with('#'));
754        assert!(tokens.accent.starts_with('#'));
755        assert!(
756            contrast_ratio(&tokens.text, &tokens.bg).expect("contrast should compute")
757                >= chromasync_color::MIN_CONTRAST_RATIO
758        );
759    }
760
761    #[test]
762    fn preview_includes_palette_and_semantic_token_sections() {
763        let request = GenerationRequest {
764            seed: Some("#ff6b6b".to_owned()),
765            wallpaper: None,
766            template: Some("brutalist".to_owned()),
767            mode: ThemeMode::Dark,
768            chroma: ChromaStrategy::Normal,
769            contrast: ContrastStrategy::RelativeLuminance,
770            targets: Vec::new(),
771            output_dir: "chromasync".into(),
772        };
773
774        let rendered = preview(&request).expect("preview should render");
775
776        assert!(rendered.contains("Palette Families"));
777        assert!(rendered.contains("Semantic Tokens"));
778        assert!(rendered.contains("primary"));
779        assert!(rendered.contains("accent"));
780    }
781
782    #[test]
783    fn generate_returns_requested_artifacts() {
784        let request = GenerationRequest {
785            seed: Some("#4ecdc4".to_owned()),
786            wallpaper: None,
787            template: Some("terminal".to_owned()),
788            mode: ThemeMode::Dark,
789            chroma: ChromaStrategy::Normal,
790            contrast: ContrastStrategy::RelativeLuminance,
791            targets: vec![
792                example_target_path("gtk.toml"),
793                "hyprland".to_owned(),
794                "kitty".to_owned(),
795                example_target_path("css.toml"),
796            ],
797            output_dir: "chromasync".into(),
798        };
799
800        let artifacts = super::generate(&request).expect("generation should render artifacts");
801
802        assert_eq!(artifacts.len(), 4);
803        assert_eq!(
804            artifacts
805                .iter()
806                .map(|artifact| artifact.target.clone())
807                .collect::<Vec<_>>(),
808            vec![
809                "gtk".to_owned(),
810                "hyprland".to_owned(),
811                "kitty".to_owned(),
812                "css".to_owned(),
813            ]
814        );
815        assert_eq!(
816            artifacts
817                .iter()
818                .map(|artifact| artifact.file_name.as_str())
819                .collect::<Vec<_>>(),
820            vec!["gtk.css", "hyprland.conf", "kitty.conf", "theme.css"]
821        );
822        assert!(
823            artifacts
824                .iter()
825                .all(|artifact| !artifact.content.is_empty())
826        );
827    }
828
829    #[test]
830    fn gtk4_generation_emits_one_unguarded_stylesheet() {
831        let request = GenerationRequest {
832            seed: Some("#4ecdc4".to_owned()),
833            wallpaper: None,
834            template: Some("materialish".to_owned()),
835            mode: ThemeMode::Dark,
836            chroma: ChromaStrategy::Normal,
837            contrast: ContrastStrategy::RelativeLuminance,
838            targets: vec!["gtk4".to_owned()],
839            output_dir: "chromasync".into(),
840        };
841
842        let artifacts = super::generate(&request).expect("GTK4 generation should succeed");
843        assert_eq!(artifacts.len(), 2);
844        assert_eq!(artifacts[0].file_name, "gtk.css");
845        assert_eq!(artifacts[0].content, "@import url(\"chromasync.css\");\n");
846
847        let stylesheet = &artifacts[1];
848        assert_eq!(stylesheet.file_name, "chromasync.css");
849        assert!(!stylesheet.content.contains("prefers-color-scheme"));
850        assert_eq!(
851            stylesheet
852                .content
853                .matches("@define-color window_bg_color")
854                .count(),
855            1
856        );
857    }
858
859    #[test]
860    fn gtk4_filesystem_template_does_not_invent_an_alternate_mode() {
861        let template =
862            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../templates/materialish-dark.toml");
863        let request = GenerationRequest {
864            seed: Some("#4ecdc4".to_owned()),
865            wallpaper: None,
866            template: Some(template.display().to_string()),
867            mode: ThemeMode::Dark,
868            chroma: ChromaStrategy::Normal,
869            contrast: ContrastStrategy::RelativeLuminance,
870            targets: vec!["gtk4".to_owned()],
871            output_dir: "chromasync".into(),
872        };
873
874        let artifacts = super::generate(&request).expect("GTK4 generation should succeed");
875        let stylesheet = artifacts
876            .iter()
877            .find(|artifact| artifact.file_name == "chromasync.css")
878            .expect("GTK4 stylesheet should be generated");
879
880        assert!(!stylesheet.content.contains("prefers-color-scheme"));
881        assert_eq!(
882            stylesheet
883                .content
884                .matches("@define-color window_bg_color")
885                .count(),
886            1
887        );
888    }
889
890    #[test]
891    fn wallpaper_generation_with_no_targets_does_not_access_the_image() {
892        let request = GenerationRequest {
893            seed: None,
894            wallpaper: Some(wallpaper_fixture("does-not-exist.png")),
895            template: Some("minimal".to_owned()),
896            mode: ThemeMode::Dark,
897            chroma: ChromaStrategy::Normal,
898            contrast: ContrastStrategy::RelativeLuminance,
899            targets: Vec::new(),
900            output_dir: "chromasync".into(),
901        };
902        let registry = super::OutputRegistry::default();
903
904        let artifacts = super::generate_from_wallpaper_with_output_registry(&request, &registry)
905            .expect("empty target generation should be a no-op");
906
907        assert!(artifacts.is_empty());
908    }
909
910    #[test]
911    fn wallpaper_generation_assigns_metadata_and_multi_seed_families() {
912        let extraction =
913            chromasync_extract::extract_seed_candidates(&wallpaper_fixture("wallpaper-blocks.png"))
914                .expect("wallpaper extraction should succeed");
915        let palette = palette_from_extracted_seeds(
916            &extraction.seeds,
917            ThemeMode::Dark,
918            ChromaStrategy::Normal,
919        )
920        .expect("wallpaper palette generation should succeed");
921
922        let primary = palette
923            .families
924            .get(&PaletteFamilyName::Primary)
925            .expect("primary family should exist");
926        let secondary = palette
927            .families
928            .get(&PaletteFamilyName::Secondary)
929            .expect("secondary family should exist");
930        let tertiary = palette
931            .families
932            .get(&PaletteFamilyName::Tertiary)
933            .expect("tertiary family should exist");
934
935        assert_eq!(primary.seed_index, Some(0));
936        assert_eq!(secondary.seed_index, Some(1));
937        assert_eq!(tertiary.seed_index, Some(2));
938        assert_eq!(primary.source_region.as_deref(), Some("center-left"));
939        assert_eq!(secondary.source_region.as_deref(), Some("center"));
940        assert_eq!(tertiary.source_region.as_deref(), Some("center-right"));
941        assert!(primary.dominance.expect("primary dominance should exist") > 0.45);
942        assert!(
943            secondary
944                .dominance
945                .expect("secondary dominance should exist")
946                > 0.30
947        );
948        assert!(tertiary.dominance.expect("tertiary dominance should exist") > 0.15);
949    }
950
951    fn wallpaper_fixture(name: &str) -> PathBuf {
952        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
953            .join("../chromasync-extract/tests/fixtures")
954            .join(name)
955    }
956
957    fn example_target_path(name: &str) -> String {
958        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
959            .join("../../examples/targets")
960            .join(name)
961            .display()
962            .to_string()
963    }
964}