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