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
216pub 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
321pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct RoutedArtifact {
372 pub artifact: GeneratedArtifact,
373 pub output_dir: PathBuf,
374 pub force: bool,
375}
376
377pub 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
434pub 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 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 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, ®istry)
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}