1use std::{
2 collections::BTreeSet,
3 fs,
4 io::{self, Write},
5 path::{Path, PathBuf},
6 process::Command as ProcessCommand,
7};
8
9use anyhow::{Context, Result, bail};
10use chromasync_types::{
11 ChromaStrategy, ContrastStrategy, GeneratedArtifact, GenerationRequest, ThemeMode,
12};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use serde::Deserialize;
16
17#[derive(Debug, Parser)]
18#[command(
19 name = "chromasync",
20 version,
21 about = "Dynamic color engine and theme generator CLI"
22)]
23pub struct Cli {
24 #[command(subcommand)]
25 command: Command,
26}
27
28#[derive(Debug, Subcommand)]
29enum Command {
30 Generate(GenerateArgs),
32 Wallpaper(WallpaperArgs),
34 Batch(BatchArgs),
36 Sync(SyncArgs),
38 Templates,
40 Packs,
42 Pack {
44 #[command(subcommand)]
45 command: PackCommand,
46 },
47 Targets,
49 Target {
51 #[command(subcommand)]
52 command: TargetCommand,
53 },
54 Preview(PreviewArgs),
56 Tokens(TokensArgs),
58 Completions {
60 #[arg(value_enum)]
62 shell: Shell,
63 },
64}
65
66#[derive(Debug, Clone, Subcommand)]
67enum PackCommand {
68 Info(PackInfoArgs),
70}
71
72#[derive(Debug, Clone, Args)]
73struct PackInfoArgs {
74 name: String,
76}
77
78#[derive(Debug, Clone, Subcommand)]
79enum TargetCommand {
80 Install(TargetInstallArgs),
82}
83
84#[derive(Debug, Clone, Args)]
85struct TargetInstallArgs {
86 #[arg(long)]
88 target: PathBuf,
89 #[arg(long)]
91 outdir: PathBuf,
92 #[arg(long)]
94 overwrite: bool,
95}
96
97#[derive(Debug, Clone, Args)]
98struct GenerateArgs {
99 #[arg(long)]
101 seed: String,
102 #[arg(long)]
104 template: Option<String>,
105 #[arg(long, value_enum, default_value_t = CliMode::Dark)]
107 mode: CliMode,
108 #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
110 contrast: CliContrast,
111 #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
113 chroma: CliChroma,
114 #[arg(long, value_delimiter = ',', required = true)]
116 targets: Vec<String>,
117 #[arg(long, default_value = "chromasync")]
119 output: PathBuf,
120 #[arg(long)]
122 force: bool,
123}
124
125#[derive(Debug, Clone, Args)]
126struct WallpaperArgs {
127 #[arg(long)]
129 image: PathBuf,
130 #[arg(long)]
132 template: Option<String>,
133 #[arg(long, value_enum, default_value_t = CliMode::Dark)]
135 mode: CliMode,
136 #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
138 contrast: CliContrast,
139 #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
141 chroma: CliChroma,
142 #[arg(long, value_delimiter = ',', required = true)]
144 targets: Vec<String>,
145 #[arg(long, default_value = "chromasync")]
147 output: PathBuf,
148 #[arg(long)]
150 force: bool,
151}
152
153#[derive(Debug, Clone, Args)]
154struct PreviewArgs {
155 #[arg(long)]
157 seed: String,
158 #[arg(long)]
160 template: String,
161 #[arg(long, value_enum, default_value_t = CliMode::Dark)]
163 mode: CliMode,
164 #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
166 contrast: CliContrast,
167 #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
169 chroma: CliChroma,
170}
171
172#[derive(Debug, Clone, Args)]
173struct TokensArgs {
174 #[arg(long)]
176 seed: String,
177 #[arg(long)]
179 template: String,
180 #[arg(long, value_enum, default_value_t = CliMode::Dark)]
182 mode: CliMode,
183 #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
185 contrast: CliContrast,
186 #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
188 chroma: CliChroma,
189 #[arg(long, value_enum, default_value_t = CliFormat::Json)]
191 format: CliFormat,
192}
193
194#[derive(Debug, Clone, Args)]
195struct BatchArgs {
196 #[arg(long)]
198 file: PathBuf,
199}
200
201#[derive(Debug, Clone, Args)]
202struct SyncArgs {
203 profile: Option<String>,
205}
206
207#[derive(Debug, Clone, Copy, ValueEnum)]
208enum CliMode {
209 Dark,
210 Light,
211}
212
213#[derive(Debug, Clone, Copy, ValueEnum)]
214enum CliFormat {
215 Json,
216}
217
218#[derive(Debug, Clone, Copy, ValueEnum)]
219enum CliContrast {
220 RelativeLuminance,
221 ApcaExperimental,
222}
223
224#[derive(Debug, Clone, Copy, ValueEnum)]
225enum CliChroma {
226 Subtle,
227 Normal,
228 Vibrant,
229 Muted,
230 Industrial,
231}
232
233#[derive(Debug, Deserialize)]
234struct BatchManifest {
235 #[serde(default, alias = "job")]
236 jobs: Vec<BatchJob>,
237}
238
239#[derive(Debug, Deserialize)]
240struct BatchJob {
241 name: Option<String>,
242 seed: Option<String>,
243 image: Option<PathBuf>,
244 template: Option<String>,
245 #[serde(default)]
246 mode: ThemeMode,
247 #[serde(default)]
248 contrast: ContrastStrategy,
249 #[serde(default)]
250 chroma: ChromaStrategy,
251 #[serde(default)]
252 targets: Vec<String>,
253 output: PathBuf,
254 #[serde(default)]
255 force: bool,
256}
257
258impl From<CliMode> for ThemeMode {
259 fn from(value: CliMode) -> Self {
260 match value {
261 CliMode::Dark => Self::Dark,
262 CliMode::Light => Self::Light,
263 }
264 }
265}
266
267impl From<CliContrast> for ContrastStrategy {
268 fn from(value: CliContrast) -> Self {
269 match value {
270 CliContrast::RelativeLuminance => Self::RelativeLuminance,
271 CliContrast::ApcaExperimental => Self::ApcaExperimental,
272 }
273 }
274}
275
276impl From<CliChroma> for ChromaStrategy {
277 fn from(value: CliChroma) -> Self {
278 match value {
279 CliChroma::Subtle => Self::Subtle,
280 CliChroma::Normal => Self::Normal,
281 CliChroma::Vibrant => Self::Vibrant,
282 CliChroma::Muted => Self::Muted,
283 CliChroma::Industrial => Self::Industrial,
284 }
285 }
286}
287
288impl GenerateArgs {
289 fn into_request(self) -> Result<GenerationRequest> {
290 Ok(GenerationRequest {
291 seed: Some(self.seed),
292 wallpaper: None,
293 template: self.template,
294 mode: self.mode.into(),
295 contrast: self.contrast.into(),
296 chroma: self.chroma.into(),
297 targets: normalize_targets(self.targets)?,
298 output_dir: self.output,
299 })
300 }
301}
302
303impl WallpaperArgs {
304 fn into_request(self) -> Result<GenerationRequest> {
305 Ok(GenerationRequest {
306 seed: None,
307 wallpaper: Some(self.image),
308 template: self.template,
309 mode: self.mode.into(),
310 contrast: self.contrast.into(),
311 chroma: self.chroma.into(),
312 targets: normalize_targets(self.targets)?,
313 output_dir: self.output,
314 })
315 }
316}
317
318impl PreviewArgs {
319 fn into_request(self) -> GenerationRequest {
320 GenerationRequest {
321 seed: Some(self.seed),
322 wallpaper: None,
323 template: Some(self.template),
324 mode: self.mode.into(),
325 contrast: self.contrast.into(),
326 chroma: self.chroma.into(),
327 targets: Vec::new(),
328 output_dir: PathBuf::from("chromasync"),
329 }
330 }
331}
332
333impl TokensArgs {
334 fn into_request(self) -> GenerationRequest {
335 GenerationRequest {
336 seed: Some(self.seed),
337 wallpaper: None,
338 template: Some(self.template),
339 mode: self.mode.into(),
340 contrast: self.contrast.into(),
341 chroma: self.chroma.into(),
342 targets: Vec::new(),
343 output_dir: PathBuf::from("chromasync"),
344 }
345 }
346}
347
348pub fn run() -> Result<()> {
349 run_with(Cli::parse())
350}
351
352pub fn run_with(cli: Cli) -> Result<()> {
353 let output_registry = match &cli.command {
354 Command::Generate(_)
355 | Command::Wallpaper(_)
356 | Command::Batch(_)
357 | Command::Sync(_)
358 | Command::Targets => Some(chromasync_core::load_output_registry()?),
359 Command::Templates
360 | Command::Packs
361 | Command::Pack { .. }
362 | Command::Target { .. }
363 | Command::Preview(_)
364 | Command::Tokens(_)
365 | Command::Completions { .. } => None,
366 };
367
368 let config = match &cli.command {
369 Command::Generate(_) | Command::Wallpaper(_) | Command::Batch(_) | Command::Sync(_) => {
370 Some(chromasync_core::ChromasyncConfig::load()?)
371 }
372 Command::Targets
373 | Command::Templates
374 | Command::Packs
375 | Command::Pack { .. }
376 | Command::Target { .. }
377 | Command::Preview(_)
378 | Command::Tokens(_)
379 | Command::Completions { .. } => None,
380 };
381
382 match cli.command {
383 Command::Generate(args) => {
384 let force = args.force;
385 let request = args.into_request()?;
386 let artifacts = generate_routed_artifacts(
387 &request,
388 output_registry
389 .as_ref()
390 .expect("output registry should be loaded for generate"),
391 config
392 .as_ref()
393 .expect("config should be loaded for generate"),
394 force,
395 )?;
396
397 write_and_print_routed(&artifacts).map(|_| ())
398 }
399 Command::Wallpaper(args) => {
400 let force = args.force;
401 let request = args.into_request()?;
402 let artifacts = generate_routed_artifacts(
403 &request,
404 output_registry
405 .as_ref()
406 .expect("output registry should be loaded for wallpaper"),
407 config
408 .as_ref()
409 .expect("config should be loaded for wallpaper"),
410 force,
411 )?;
412
413 write_and_print_routed(&artifacts).map(|_| ())
414 }
415 Command::Batch(args) => run_batch(
416 args,
417 output_registry
418 .as_ref()
419 .expect("output registry should be loaded for batch"),
420 config.as_ref().expect("config should be loaded for batch"),
421 ),
422 Command::Sync(args) => run_sync(
423 args,
424 output_registry
425 .as_ref()
426 .expect("output registry should be loaded for sync"),
427 config.as_ref().expect("config should be loaded for sync"),
428 ),
429 Command::Templates => print_templates(),
430 Command::Packs => print_packs(),
431 Command::Pack { command } => match command {
432 PackCommand::Info(args) => print_pack_info(&args.name),
433 },
434 Command::Targets => print_targets(
435 output_registry
436 .as_ref()
437 .expect("output registry should be loaded for targets"),
438 ),
439 Command::Target { command } => match command {
440 TargetCommand::Install(args) => run_target_install(args),
441 },
442 Command::Preview(args) => {
443 let preview = chromasync_core::preview(&args.into_request())?;
444 println!("{preview}");
445 Ok(())
446 }
447 Command::Tokens(args) => {
448 let format = args.format;
449 let tokens = chromasync_core::export_tokens(&args.into_request())?;
450
451 match format {
452 CliFormat::Json => {
453 let json = serde_json::to_string_pretty(&tokens)
454 .context("failed to serialize semantic tokens")?;
455 println!("{json}");
456 }
457 }
458
459 Ok(())
460 }
461 Command::Completions { shell } => {
462 let mut cmd = Cli::command();
463 generate(shell, &mut cmd, "chromasync", &mut std::io::stdout());
464 Ok(())
465 }
466 }
467}
468
469fn generate_artifacts(
470 request: &GenerationRequest,
471 output_registry: &chromasync_core::OutputRegistry,
472) -> Result<Vec<GeneratedArtifact>> {
473 if request.wallpaper.is_some() {
474 chromasync_core::generate_from_wallpaper_with_output_registry(request, output_registry)
475 .map_err(Into::into)
476 } else {
477 chromasync_core::generate_with_output_registry(request, output_registry).map_err(Into::into)
478 }
479}
480
481#[derive(Debug, Clone)]
482struct RoutedArtifact {
483 artifact: GeneratedArtifact,
484 output_dir: PathBuf,
485 force: bool,
486}
487
488fn generate_routed_artifacts(
489 request: &GenerationRequest,
490 output_registry: &chromasync_core::OutputRegistry,
491 config: &chromasync_core::ChromasyncConfig,
492 fallback_force: bool,
493) -> Result<Vec<RoutedArtifact>> {
494 let mut routed = Vec::new();
495
496 for target in &request.targets {
497 let (output_dir, force) = output_route_for_target(target, request, fallback_force, config);
498 let mut target_request = request.clone();
499 target_request.output_dir = output_dir.clone();
500 target_request.targets = vec![target.clone()];
501
502 for artifact in generate_artifacts(&target_request, output_registry)? {
503 routed.push(RoutedArtifact {
504 artifact,
505 output_dir: output_dir.clone(),
506 force,
507 });
508 }
509 }
510
511 Ok(routed)
512}
513
514fn output_route_for_target(
515 target: &str,
516 request: &GenerationRequest,
517 fallback_force: bool,
518 config: &chromasync_core::ChromasyncConfig,
519) -> (PathBuf, bool) {
520 if !looks_like_path(target) {
521 config.resolve(target, &request.output_dir, fallback_force)
522 } else {
523 (request.output_dir.clone(), fallback_force)
524 }
525}
526
527fn run_batch(
528 args: BatchArgs,
529 output_registry: &chromasync_core::OutputRegistry,
530 config: &chromasync_core::ChromasyncConfig,
531) -> Result<()> {
532 let manifest_path = args.file;
533 let manifest_dir = manifest_path
534 .parent()
535 .unwrap_or_else(|| Path::new("."))
536 .to_path_buf();
537 let content = fs::read_to_string(&manifest_path).with_context(|| {
538 format!(
539 "failed to read batch manifest '{}'",
540 manifest_path.display()
541 )
542 })?;
543 let manifest: BatchManifest = toml::from_str(&content).with_context(|| {
544 format!(
545 "failed to parse batch manifest '{}'",
546 manifest_path.display()
547 )
548 })?;
549
550 if manifest.jobs.is_empty() {
551 bail!(
552 "batch manifest '{}' does not define any jobs",
553 manifest_path.display()
554 );
555 }
556
557 for (index, job) in manifest.jobs.into_iter().enumerate() {
558 let force = job.force;
559 let request = batch_job_into_request(job, &manifest_dir)?;
560 let artifacts = generate_routed_artifacts(&request, output_registry, config, force)
561 .with_context(|| {
562 format!(
563 "batch job {} failed for output '{}'",
564 index + 1,
565 request.output_dir.display()
566 )
567 })?;
568
569 write_and_print_routed(&artifacts)?;
570 }
571
572 Ok(())
573}
574
575fn run_sync(
576 args: SyncArgs,
577 output_registry: &chromasync_core::OutputRegistry,
578 config: &chromasync_core::ChromasyncConfig,
579) -> Result<()> {
580 let profile_name = args.profile.unwrap_or_else(|| "default".to_owned());
581 let config_path = chromasync_core::config_file_path()
582 .context("could not resolve the chromasync user config directory")?;
583
584 if !config_path.exists() {
585 bail!(
586 "chromasync config '{}' does not exist; create a [[configs]] profile first",
587 config_path.display()
588 );
589 }
590
591 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
592 let profile = config.sync_profile(&profile_name).ok_or_else(|| {
593 anyhow::anyhow!(
594 "sync profile '{}' was not found in '{}'",
595 profile_name,
596 config_path.display()
597 )
598 })?;
599
600 let force = profile.force;
601 let request = sync_profile_into_request(profile, config_dir)?;
602 let artifacts = generate_routed_artifacts(&request, output_registry, config, force)
603 .with_context(|| {
604 format!(
605 "sync profile '{}' failed for output '{}'",
606 profile.name,
607 request.output_dir.display()
608 )
609 })?;
610
611 let report = write_and_print_routed(&artifacts)?;
612 run_matching_hooks(config, &profile.name, config_dir, &report)
613}
614
615fn sync_profile_into_request(
616 profile: &chromasync_core::SyncProfile,
617 config_dir: &Path,
618) -> Result<GenerationRequest> {
619 let color_source_count = usize::from(profile.seed.is_some())
620 + usize::from(profile.image.is_some())
621 + usize::from(profile.image_fetch_command.is_some());
622
623 if color_source_count != 1 {
624 bail!(
625 "sync profile '{}' must define exactly one of 'seed', 'image', or 'image_fetch_command'",
626 profile.name
627 );
628 }
629
630 if profile.targets.is_empty() {
631 bail!(
632 "sync profile '{}' must define at least one target",
633 profile.name
634 );
635 }
636
637 Ok(GenerationRequest {
638 seed: profile.seed.clone(),
639 wallpaper: sync_profile_wallpaper(profile, config_dir)?,
640 template: profile
641 .template
642 .as_ref()
643 .map(|template| resolve_template_reference(config_dir, template)),
644 mode: resolve_sync_mode(profile.mode),
645 contrast: profile.contrast,
646 chroma: profile.chroma,
647 targets: normalize_targets_relative_to(config_dir, profile.targets.clone())?,
648 output_dir: resolve_relative_path(config_dir, &profile.output_dir),
649 })
650}
651
652fn sync_profile_wallpaper(
653 profile: &chromasync_core::SyncProfile,
654 config_dir: &Path,
655) -> Result<Option<PathBuf>> {
656 if let Some(command) = &profile.image_fetch_command {
657 return fetch_sync_profile_image(&profile.name, command, config_dir).map(Some);
658 }
659
660 Ok(profile
661 .image
662 .as_ref()
663 .map(|path| resolve_relative_path(config_dir, path)))
664}
665
666fn fetch_sync_profile_image(
667 profile_name: &str,
668 command_line: &str,
669 config_dir: &Path,
670) -> Result<PathBuf> {
671 let output = shell_command(command_line)
672 .current_dir(config_dir)
673 .output()
674 .with_context(|| {
675 format!("sync profile '{profile_name}' image_fetch_command failed to start")
676 })?;
677
678 if !output.status.success() {
679 let stderr = String::from_utf8_lossy(&output.stderr);
680 let detail = stderr.trim();
681 if detail.is_empty() {
682 bail!(
683 "sync profile '{}' image_fetch_command exited with {}",
684 profile_name,
685 output.status
686 );
687 }
688
689 bail!(
690 "sync profile '{}' image_fetch_command exited with {}: {}",
691 profile_name,
692 output.status,
693 detail
694 );
695 }
696
697 let stdout = String::from_utf8_lossy(&output.stdout);
698 let Some(path) = stdout.lines().map(str::trim).find(|line| !line.is_empty()) else {
699 bail!("sync profile '{profile_name}' image_fetch_command did not print an image path");
700 };
701
702 Ok(resolve_relative_path(config_dir, Path::new(path)))
703}
704
705fn shell_command(command_line: &str) -> ProcessCommand {
706 #[cfg(windows)]
707 {
708 let mut command = ProcessCommand::new("cmd");
709 command.args(["/C", command_line]);
710 command
711 }
712
713 #[cfg(not(windows))]
714 {
715 let mut command = ProcessCommand::new("sh");
716 command.args(["-c", command_line]);
717 command
718 }
719}
720
721fn resolve_sync_mode(mode: chromasync_core::SyncMode) -> ThemeMode {
722 match mode {
723 chromasync_core::SyncMode::Light => ThemeMode::Light,
724 chromasync_core::SyncMode::Dark => ThemeMode::Dark,
725 chromasync_core::SyncMode::Auto => detect_desktop_theme_mode(),
726 }
727}
728
729fn detect_desktop_theme_mode() -> ThemeMode {
730 let output = ProcessCommand::new("gsettings")
731 .args(["get", "org.gnome.desktop.interface", "color-scheme"])
732 .output();
733
734 match output {
735 Ok(output) if output.status.success() => {
736 let stdout = String::from_utf8_lossy(&output.stdout);
737 desktop_mode_from_gsettings_output(&stdout).unwrap_or(ThemeMode::Dark)
738 }
739 _ => ThemeMode::Dark,
740 }
741}
742
743fn desktop_mode_from_gsettings_output(output: &str) -> Option<ThemeMode> {
744 let value = output
745 .trim()
746 .trim_matches('\'')
747 .trim_matches('"')
748 .to_ascii_lowercase();
749
750 match value.as_str() {
751 "prefer-light" => Some(ThemeMode::Light),
752 "prefer-dark" => Some(ThemeMode::Dark),
753 _ => None,
754 }
755}
756
757fn batch_job_into_request(job: BatchJob, base_dir: &Path) -> Result<GenerationRequest> {
758 if job.seed.is_some() == job.image.is_some() {
759 let job_label = job.name.as_deref().unwrap_or("<unnamed>");
760
761 bail!("batch job '{job_label}' must define exactly one of 'seed' or 'image'");
762 }
763
764 Ok(GenerationRequest {
765 seed: job.seed,
766 wallpaper: job.image.map(|path| resolve_relative_path(base_dir, &path)),
767 template: job
768 .template
769 .map(|t| resolve_template_reference(base_dir, &t)),
770 mode: job.mode,
771 contrast: job.contrast,
772 chroma: job.chroma,
773 targets: normalize_targets_relative_to(base_dir, job.targets)?,
774 output_dir: resolve_relative_path(base_dir, &job.output),
775 })
776}
777
778fn resolve_template_reference(base_dir: &Path, value: &str) -> String {
779 if looks_like_path(value) {
780 resolve_relative_path(base_dir, Path::new(value))
781 .display()
782 .to_string()
783 } else {
784 value.to_owned()
785 }
786}
787
788fn resolve_target_reference(base_dir: &Path, value: &str) -> String {
789 if looks_like_path(value) {
790 resolve_relative_path(base_dir, Path::new(value))
791 .display()
792 .to_string()
793 } else {
794 value.to_owned()
795 }
796}
797
798fn resolve_relative_path(base_dir: &Path, path: &Path) -> PathBuf {
799 if path.is_absolute() {
800 path.to_path_buf()
801 } else {
802 base_dir.join(path)
803 }
804}
805
806fn looks_like_path(value: &str) -> bool {
807 let path = Path::new(value);
808
809 path.is_absolute()
810 || value.contains(std::path::MAIN_SEPARATOR)
811 || path.extension().and_then(|extension| extension.to_str()) == Some("toml")
812}
813
814fn print_templates() -> Result<()> {
815 let templates = chromasync_core::list_templates()?;
816 let mut stdout = io::BufWriter::new(io::stdout().lock());
817
818 for template in templates {
819 writeln!(
820 stdout,
821 "{}\t{}\t{}\t{}",
822 template.definition.name,
823 template.definition.mode,
824 template.source.label(),
825 template.source.location()
826 )?;
827 }
828
829 Ok(())
830}
831
832fn print_packs() -> Result<()> {
833 let packs = chromasync_core::list_packs()?;
834 let mut stdout = io::BufWriter::new(io::stdout().lock());
835
836 for pack in packs {
837 writeln!(
838 stdout,
839 "{}\t{}\t{}",
840 pack.name,
841 pack.version,
842 pack.root_dir.display()
843 )?;
844 }
845
846 Ok(())
847}
848
849fn print_pack_info(name: &str) -> Result<()> {
850 let info = chromasync_core::pack_info(name)?;
851 let mut stdout = io::BufWriter::new(io::stdout().lock());
852
853 writeln!(stdout, "name\t{}", info.pack.name)?;
854 writeln!(stdout, "version\t{}", info.pack.version)?;
855 writeln!(stdout, "root\t{}", info.pack.root_dir.display())?;
856
857 if let Some(description) = &info.pack.description {
858 writeln!(stdout, "description\t{description}")?;
859 }
860
861 if let Some(author) = &info.pack.author {
862 writeln!(stdout, "author\t{author}")?;
863 }
864
865 if let Some(license) = &info.pack.license {
866 writeln!(stdout, "license\t{license}")?;
867 }
868
869 if let Some(homepage) = &info.pack.homepage {
870 writeln!(stdout, "homepage\t{homepage}")?;
871 }
872
873 writeln!(stdout)?;
874 writeln!(stdout, "templates")?;
875
876 for template in info.templates {
877 writeln!(
878 stdout,
879 "{}\t{}\t{}",
880 template.definition.name,
881 template.definition.mode,
882 template.source.location()
883 )?;
884 }
885
886 writeln!(stdout)?;
887 writeln!(stdout, "targets")?;
888
889 for target in info.targets {
890 writeln!(stdout, "{}\t{}", target.name, target.source.location())?;
891 }
892
893 Ok(())
894}
895
896fn print_targets(output_registry: &chromasync_core::OutputRegistry) -> Result<()> {
897 let mut stdout = io::BufWriter::new(io::stdout().lock());
898
899 for target in output_registry.list_targets() {
900 writeln!(
901 stdout,
902 "{}\t{}\t{}",
903 target.name,
904 target.source.label(),
905 target.source.location()
906 )?;
907 }
908
909 Ok(())
910}
911
912fn normalize_targets(targets: Vec<String>) -> Result<Vec<String>> {
913 normalize_targets_with(targets, |target| target.to_owned())
914}
915
916fn normalize_targets_relative_to(base_dir: &Path, targets: Vec<String>) -> Result<Vec<String>> {
917 normalize_targets_with(targets, |target| resolve_target_reference(base_dir, target))
918}
919
920fn normalize_targets_with<F>(targets: Vec<String>, resolve: F) -> Result<Vec<String>>
921where
922 F: Fn(&str) -> String,
923{
924 let normalized = targets
925 .into_iter()
926 .map(|target| target.trim().to_owned())
927 .map(|target| resolve(&target))
928 .collect::<Vec<_>>();
929
930 if normalized.iter().any(|target| target.is_empty()) {
931 bail!("target names must not be empty");
932 }
933
934 Ok(normalized)
935}
936
937fn write_and_print_routed(artifacts: &[RoutedArtifact]) -> Result<WriteReport> {
938 let entries: Vec<chromasync_core::ResolvedArtifact> = artifacts
939 .iter()
940 .map(|artifact| chromasync_core::ResolvedArtifact {
941 output_dir: artifact.output_dir.clone(),
942 file_name: artifact.artifact.file_name.clone(),
943 content: artifact.artifact.content.clone(),
944 force: artifact.force,
945 })
946 .collect();
947
948 let written = chromasync_core::write_resolved_artifacts(&entries)?;
949 let generated_artifacts = artifacts
950 .iter()
951 .map(|artifact| artifact.artifact.clone())
952 .collect::<Vec<_>>();
953 let report = WriteReport::new(&generated_artifacts);
954
955 let mut stdout = io::BufWriter::new(io::stdout().lock());
956 for path in &written {
957 writeln!(stdout, "{}", path.display())?;
958 }
959
960 Ok(report)
961}
962
963#[derive(Debug, Clone, PartialEq, Eq)]
964struct WriteReport {
965 events: BTreeSet<String>,
966}
967
968impl WriteReport {
969 fn new(artifacts: &[GeneratedArtifact]) -> Self {
970 let mut events = artifacts
971 .iter()
972 .map(|artifact| format!("target:{}:done", artifact.target))
973 .collect::<BTreeSet<_>>();
974
975 if !artifacts.is_empty() {
976 events.insert("targets:done".to_owned());
977 }
978
979 Self { events }
980 }
981
982 fn has_event(&self, event: &str) -> bool {
983 self.events.contains(event)
984 }
985}
986
987fn run_matching_hooks(
988 config: &chromasync_core::ChromasyncConfig,
989 profile_name: &str,
990 config_dir: &Path,
991 report: &WriteReport,
992) -> Result<()> {
993 for hook in &config.hooks {
994 if hook_matches(hook, profile_name, report) {
995 run_hook(hook, config_dir)?;
996 }
997 }
998
999 Ok(())
1000}
1001
1002fn hook_matches(
1003 hook: &chromasync_core::ConfigHook,
1004 profile_name: &str,
1005 report: &WriteReport,
1006) -> bool {
1007 hook.on.iter().any(|event| report.has_event(event))
1008 && hook
1009 .filters
1010 .iter()
1011 .all(|filter| filter == &format!("config:{profile_name}"))
1012}
1013
1014fn run_hook(hook: &chromasync_core::ConfigHook, config_dir: &Path) -> Result<()> {
1015 let output = shell_command(&hook.command)
1016 .current_dir(config_dir)
1017 .output()
1018 .with_context(|| format!("hook '{}' failed to start", hook.name))?;
1019
1020 if output.status.success() {
1021 return Ok(());
1022 }
1023
1024 let stderr = String::from_utf8_lossy(&output.stderr);
1025 let detail = stderr.trim();
1026 if detail.is_empty() {
1027 bail!("hook '{}' exited with {}", hook.name, output.status);
1028 }
1029
1030 bail!(
1031 "hook '{}' exited with {}: {}",
1032 hook.name,
1033 output.status,
1034 detail
1035 )
1036}
1037
1038fn run_target_install(args: TargetInstallArgs) -> Result<()> {
1039 let summary = chromasync_core::install_target(&args.target, args.outdir, args.overwrite)?;
1040
1041 let mut stdout = io::BufWriter::new(io::stdout().lock());
1042 writeln!(stdout, "{}", summary.target_file.display())?;
1043 writeln!(stdout, "{}", summary.config_file.display())?;
1044
1045 Ok(())
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::{ThemeMode, desktop_mode_from_gsettings_output};
1051
1052 #[test]
1053 fn gsettings_prefer_light_maps_to_light_mode() {
1054 assert_eq!(
1055 desktop_mode_from_gsettings_output("'prefer-light'\n"),
1056 Some(ThemeMode::Light)
1057 );
1058 }
1059
1060 #[test]
1061 fn gsettings_prefer_dark_maps_to_dark_mode() {
1062 assert_eq!(
1063 desktop_mode_from_gsettings_output("'prefer-dark'\n"),
1064 Some(ThemeMode::Dark)
1065 );
1066 }
1067
1068 #[test]
1069 fn unknown_gsettings_color_scheme_is_not_inferred() {
1070 assert_eq!(desktop_mode_from_gsettings_output("'default'\n"), None);
1071 }
1072}