Skip to main content

chromasync_cli/
lib.rs

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 theme artifacts from a seed color.
31    Generate(GenerateArgs),
32    /// Generate theme artifacts from a wallpaper image.
33    Wallpaper(WallpaperArgs),
34    /// Execute a batch manifest with multiple generation jobs.
35    Batch(BatchArgs),
36    /// Run a named sync profile from the user config.
37    Sync(SyncArgs),
38    /// List the available templates and where they were loaded from.
39    Templates,
40    /// List the discovered theme packs.
41    Packs,
42    /// Inspect a discovered theme pack.
43    Pack {
44        #[command(subcommand)]
45        command: PackCommand,
46    },
47    /// List available renderer targets and where they were loaded from.
48    Targets,
49    /// Manage installed renderer targets.
50    Target {
51        #[command(subcommand)]
52        command: TargetCommand,
53    },
54    /// Show palette families and resolved semantic tokens.
55    Preview(PreviewArgs),
56    /// Export resolved semantic tokens.
57    Tokens(TokensArgs),
58    /// Generate shell completion scripts.
59    Completions {
60        /// Shell to generate completions for.
61        #[arg(value_enum)]
62        shell: Shell,
63    },
64}
65
66#[derive(Debug, Clone, Subcommand)]
67enum PackCommand {
68    /// Show metadata and assets for an installed pack.
69    Info(PackInfoArgs),
70}
71
72#[derive(Debug, Clone, Args)]
73struct PackInfoArgs {
74    /// Pack name from pack.toml.
75    name: String,
76}
77
78#[derive(Debug, Clone, Subcommand)]
79enum TargetCommand {
80    /// Install a target TOML into the user config and record its output directory.
81    Install(TargetInstallArgs),
82}
83
84#[derive(Debug, Clone, Args)]
85struct TargetInstallArgs {
86    /// Path to the target TOML file to install.
87    #[arg(long)]
88    target: PathBuf,
89    /// Directory where this target's generated artifacts should be written.
90    #[arg(long)]
91    outdir: PathBuf,
92    /// Replace an existing installed target and mark it for forced overwrite during generation.
93    #[arg(long)]
94    overwrite: bool,
95}
96
97#[derive(Debug, Clone, Args)]
98struct GenerateArgs {
99    /// Seed color in #RRGGBB format.
100    #[arg(long)]
101    seed: String,
102    /// Template name or path to a template TOML file. Optional if targets specify preferred_template.
103    #[arg(long)]
104    template: Option<String>,
105    /// Theme mode to generate.
106    #[arg(long, value_enum, default_value_t = CliMode::Dark)]
107    mode: CliMode,
108    /// Contrast selection heuristic used when resolving readable foregrounds.
109    #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
110    contrast: CliContrast,
111    /// Chroma strategy used when generating palette families.
112    #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
113    chroma: CliChroma,
114    /// Comma-separated list of target names or target TOML paths to generate.
115    #[arg(long, value_delimiter = ',', required = true)]
116    targets: Vec<String>,
117    /// Output directory for generated artifacts.
118    #[arg(long, default_value = "chromasync")]
119    output: PathBuf,
120    /// Overwrite existing artifacts instead of refusing when a file already exists at the destination.
121    #[arg(long)]
122    force: bool,
123}
124
125#[derive(Debug, Clone, Args)]
126struct WallpaperArgs {
127    /// Wallpaper image path.
128    #[arg(long)]
129    image: PathBuf,
130    /// Template name or path to a template TOML file. Optional if targets specify preferred_template.
131    #[arg(long)]
132    template: Option<String>,
133    /// Theme mode to generate.
134    #[arg(long, value_enum, default_value_t = CliMode::Dark)]
135    mode: CliMode,
136    /// Contrast selection heuristic used when resolving readable foregrounds.
137    #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
138    contrast: CliContrast,
139    /// Chroma strategy used when generating palette families.
140    #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
141    chroma: CliChroma,
142    /// Comma-separated list of target names or target TOML paths to generate.
143    #[arg(long, value_delimiter = ',', required = true)]
144    targets: Vec<String>,
145    /// Output directory for generated artifacts.
146    #[arg(long, default_value = "chromasync")]
147    output: PathBuf,
148    /// Overwrite existing artifacts instead of refusing when a file already exists at the destination.
149    #[arg(long)]
150    force: bool,
151}
152
153#[derive(Debug, Clone, Args)]
154struct PreviewArgs {
155    /// Seed color in #RRGGBB format.
156    #[arg(long)]
157    seed: String,
158    /// Template name or path to a template TOML file.
159    #[arg(long)]
160    template: String,
161    /// Theme mode to preview.
162    #[arg(long, value_enum, default_value_t = CliMode::Dark)]
163    mode: CliMode,
164    /// Contrast selection heuristic used when resolving readable foregrounds.
165    #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
166    contrast: CliContrast,
167    /// Chroma strategy used when generating palette families.
168    #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
169    chroma: CliChroma,
170}
171
172#[derive(Debug, Clone, Args)]
173struct TokensArgs {
174    /// Seed color in #RRGGBB format.
175    #[arg(long)]
176    seed: String,
177    /// Template name or path to a template TOML file.
178    #[arg(long)]
179    template: String,
180    /// Theme mode to resolve.
181    #[arg(long, value_enum, default_value_t = CliMode::Dark)]
182    mode: CliMode,
183    /// Contrast selection heuristic used when resolving readable foregrounds.
184    #[arg(long, value_enum, default_value_t = CliContrast::RelativeLuminance)]
185    contrast: CliContrast,
186    /// Chroma strategy used when generating palette families.
187    #[arg(long, value_enum, default_value_t = CliChroma::Normal)]
188    chroma: CliChroma,
189    /// Serialization format for token export.
190    #[arg(long, value_enum, default_value_t = CliFormat::Json)]
191    format: CliFormat,
192}
193
194#[derive(Debug, Clone, Args)]
195struct BatchArgs {
196    /// Path to a TOML manifest containing multiple jobs.
197    #[arg(long)]
198    file: PathBuf,
199}
200
201#[derive(Debug, Clone, Args)]
202struct SyncArgs {
203    /// Profile name under [[configs]]. Defaults to "default".
204    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}