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    /// Override the theme mode configured by the selected profile.
206    #[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}