Skip to main content

chromasync_core/
config.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use chromasync_types::{ChromaStrategy, ContrastStrategy};
7use directories::ProjectDirs;
8use serde::{Deserialize, Serialize};
9
10use crate::CoreError;
11
12/// Defines sync profiles and records where each configured target writes its
13/// generated artifacts.
14///
15/// Stored at `~/.config/chromasync/config.toml` (see [`config_file_path`]).
16/// Consumed by the CLI `generate`, `wallpaper`, `batch`, and `sync` commands so
17/// that a configured target writes to its recorded directory instead of the
18/// generic `--output` fallback.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct ChromasyncConfig {
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub configs: Vec<SyncProfile>,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub targets: Vec<ConfigTarget>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub hooks: Vec<ConfigHook>,
28}
29
30/// One runnable generation profile under `[[configs]]`.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct SyncProfile {
34    /// Profile name selected by `chromasync sync [name]`.
35    pub name: String,
36    /// Seed color in #RRGGBB format. Mutually exclusive with `image`.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub seed: Option<String>,
39    /// Wallpaper image path. Mutually exclusive with `seed` and
40    /// `image_fetch_command`.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub image: Option<PathBuf>,
43    /// Shell command whose stdout returns a wallpaper image path. Mutually
44    /// exclusive with `seed` and `image`.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub image_fetch_command: Option<String>,
47    /// Template name or path to a template TOML file.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub template: Option<String>,
50    /// Theme mode, with `auto` resolved by the CLI at runtime.
51    #[serde(default)]
52    pub mode: SyncMode,
53    /// Contrast selection heuristic used when resolving readable foregrounds.
54    #[serde(default)]
55    pub contrast: ContrastStrategy,
56    /// Chroma strategy used when generating palette families.
57    #[serde(default)]
58    pub chroma: ChromaStrategy,
59    /// Target names or target TOML paths to generate.
60    #[serde(default)]
61    pub targets: Vec<String>,
62    /// Fallback output directory for targets without a recorded `[[targets]]` entry.
63    #[serde(default = "default_output_dir")]
64    pub output_dir: PathBuf,
65    /// Overwrite existing artifacts for targets without per-target overwrite.
66    #[serde(default)]
67    pub force: bool,
68}
69
70/// Sync profile mode. `Auto` is deliberately separate from `ThemeMode` because
71/// resolving the user's current desktop preference belongs at the CLI boundary.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(rename_all = "kebab-case")]
74pub enum SyncMode {
75    Light,
76    #[default]
77    Dark,
78    Auto,
79}
80
81/// One row of the chromasync config under `[[targets]]`.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ConfigTarget {
85    /// Target name (matches the `name` field of the installed target TOML).
86    pub name: String,
87    /// Directory where this target's generated artifacts are written.
88    ///
89    /// Stored verbatim; a leading `~` is expanded to the user's home directory
90    /// at resolve time (see [`expand_tilde`]).
91    pub output_dir: PathBuf,
92    /// Location of the installed target file relative to the config root
93    /// (e.g. `targets/gtk.toml`).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub source: Option<String>,
96    /// When true, generation overwrites existing artifacts for this target
97    /// (acts like a per-target `--force`).
98    #[serde(default)]
99    pub overwrite: bool,
100}
101
102/// One command hook under `[[hooks]]`.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct ConfigHook {
106    /// Human-readable hook name used in error messages.
107    pub name: String,
108    /// Event or events that trigger this hook.
109    pub on: HookEvents,
110    /// Shell command to execute when this hook matches.
111    pub command: String,
112    /// Optional filters, such as `config:default`.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub filters: Vec<String>,
115}
116
117/// Hook event list that accepts either `on = "event"` or `on = ["event"]`.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(untagged)]
120pub enum HookEvents {
121    One(String),
122    Many(Vec<String>),
123}
124
125impl HookEvents {
126    pub fn iter(&self) -> impl Iterator<Item = &str> + '_ {
127        match self {
128            Self::One(event) => std::slice::from_ref(event),
129            Self::Many(events) => events.as_slice(),
130        }
131        .iter()
132        .map(String::as_str)
133    }
134}
135
136/// Summary of a successful [`install_target`] call, for echoing back to the user.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct InstallSummary {
139    pub target_name: String,
140    pub target_file: PathBuf,
141    pub config_file: PathBuf,
142}
143
144const CONFIG_HEADER: &str = "# Managed by `chromasync target install`. Records where each installed target writes its generated artifacts.\n";
145
146/// Resolve the user config directory (`~/.config/chromasync` on Linux).
147fn project_dirs() -> Option<ProjectDirs> {
148    ProjectDirs::from("io", "chromasync", "chromasync")
149}
150
151/// Path to the managed chromasync config file, if a user config directory can be located.
152pub fn config_file_path() -> Option<PathBuf> {
153    project_dirs().map(|dirs| dirs.config_dir().join("config.toml"))
154}
155
156impl ChromasyncConfig {
157    /// Load the config from disk, returning an empty config when the file does
158    /// not exist yet (the common case before any target has been installed).
159    pub fn load() -> Result<Self, CoreError> {
160        let Some(path) = config_file_path() else {
161            return Ok(Self::default());
162        };
163
164        if !path.exists() {
165            return Ok(Self::default());
166        }
167
168        let content = fs::read_to_string(&path).map_err(|source| CoreError::ConfigRead {
169            path: path.clone(),
170            source,
171        })?;
172        let config: Self =
173            toml::from_str(&content).map_err(|error| CoreError::ConfigParse { path, error })?;
174        Ok(config)
175    }
176
177    /// Write the config back to disk, creating the config directory if needed.
178    pub fn save(&self) -> Result<(), CoreError> {
179        let path = config_file_path().ok_or(CoreError::UserConfigDirUnavailable)?;
180        let body = toml::to_string(self).map_err(|error| CoreError::ConfigSerialize {
181            error: error.to_string(),
182        })?;
183
184        if let Some(parent) = path.parent() {
185            fs::create_dir_all(parent).map_err(|source| CoreError::ConfigWrite {
186                path: parent.to_path_buf(),
187                source,
188            })?;
189        }
190
191        let serialized = format!("{CONFIG_HEADER}\n{body}");
192        fs::write(&path, &serialized).map_err(|source| CoreError::ConfigWrite { path, source })?;
193        Ok(())
194    }
195
196    /// Resolve the effective output directory and force flag for a target name.
197    ///
198    /// Installed targets (present in the config) drive their own `output_dir`
199    /// and `overwrite`. Targets without an entry fall back to the caller-provided
200    /// defaults. A global `--force` (`fallback_force = true`) forces every target.
201    pub fn resolve(
202        &self,
203        target_name: &str,
204        fallback_dir: &Path,
205        fallback_force: bool,
206    ) -> (PathBuf, bool) {
207        match self.targets.iter().find(|entry| entry.name == target_name) {
208            Some(entry) => (
209                expand_tilde(&entry.output_dir),
210                entry.overwrite || fallback_force,
211            ),
212            None => (fallback_dir.to_path_buf(), fallback_force),
213        }
214    }
215
216    /// Return the sync profile with `name`, if present.
217    pub fn sync_profile(&self, name: &str) -> Option<&SyncProfile> {
218        self.configs.iter().find(|entry| entry.name == name)
219    }
220
221    /// Insert or replace the config entry for `name`.
222    fn upsert(&mut self, entry: ConfigTarget) {
223        if let Some(existing) = self.targets.iter_mut().find(|t| t.name == entry.name) {
224            *existing = entry;
225        } else {
226            self.targets.push(entry);
227        }
228    }
229}
230
231/// Install a target TOML into the user config and record its output directory.
232///
233/// The target file is validated (same rules as discovery), copied to
234/// `~/.config/chromasync/targets/<name>.toml`, and a `[[targets]]` entry is
235/// upserted into the config. Refuses to replace an already-installed target
236/// file unless `overwrite` is set; the same flag is recorded in the config so
237/// subsequent generation force-overwrites that target's artifacts.
238pub fn install_target(
239    target_path: &Path,
240    output_dir: PathBuf,
241    overwrite: bool,
242) -> Result<InstallSummary, CoreError> {
243    let spec = chromasync_renderers::parse_target_file(target_path)?;
244    let name = spec.name.clone();
245
246    let registry = chromasync_renderers::RendererRegistry::new();
247    if registry.contains(&name) {
248        return Err(CoreError::Renderer(
249            chromasync_renderers::RendererError::TargetNameCollidesWithBuiltIn { name },
250        ));
251    }
252
253    crate::load_output_registry()?.validate_path_target(target_path)?;
254
255    let targets_dir =
256        chromasync_renderers::user_targets_dir().ok_or(CoreError::UserConfigDirUnavailable)?;
257    fs::create_dir_all(&targets_dir).map_err(|source| CoreError::CreateTargetsDir {
258        path: targets_dir.clone(),
259        source,
260    })?;
261
262    let dest = targets_dir.join(format!("{name}.toml"));
263    if dest.exists() && !overwrite {
264        return Err(CoreError::TargetAlreadyInstalled {
265            name,
266            path: dest.clone(),
267        });
268    }
269
270    fs::copy(target_path, &dest).map_err(|source| CoreError::CopyTargetFile {
271        from: target_path.to_path_buf(),
272        to: dest.clone(),
273        source,
274    })?;
275
276    let mut config = ChromasyncConfig::load()?;
277    config.upsert(ConfigTarget {
278        name: name.clone(),
279        output_dir,
280        source: Some(format!("targets/{name}.toml")),
281        overwrite,
282    });
283    config.save()?;
284
285    Ok(InstallSummary {
286        target_name: name,
287        target_file: dest,
288        config_file: config_file_path().ok_or(CoreError::UserConfigDirUnavailable)?,
289    })
290}
291
292/// Expand a leading `~` or `~/` to the user's home directory.
293///
294/// Paths without a leading tilde are returned unchanged. When the home
295/// directory cannot be determined, the original path is returned as a fallback.
296pub fn expand_tilde(path: &Path) -> PathBuf {
297    let lossy = path.to_string_lossy();
298
299    if lossy == "~" {
300        return home_dir().unwrap_or_else(|| path.to_path_buf());
301    }
302
303    if let Some(rest) = lossy.strip_prefix("~/")
304        && let Some(home) = home_dir()
305    {
306        return home.join(rest);
307    }
308
309    path.to_path_buf()
310}
311
312fn home_dir() -> Option<PathBuf> {
313    directories::BaseDirs::new().map(|base| base.home_dir().to_path_buf())
314}
315
316fn default_output_dir() -> PathBuf {
317    PathBuf::from("chromasync")
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::path::PathBuf;
324
325    #[test]
326    fn resolve_falls_back_when_target_not_installed() {
327        let config = ChromasyncConfig::default();
328        let (dir, force) = config.resolve("missing", Path::new("fallback"), false);
329
330        assert_eq!(dir, PathBuf::from("fallback"));
331        assert!(!force);
332    }
333
334    #[test]
335    fn resolve_uses_installed_output_dir_and_overwrite() {
336        let config = ChromasyncConfig {
337            configs: Vec::new(),
338            targets: vec![ConfigTarget {
339                name: "gtk".to_owned(),
340                output_dir: PathBuf::from("~/.config/gtk-4.0"),
341                source: Some("targets/gtk.toml".to_owned()),
342                overwrite: true,
343            }],
344            hooks: Vec::new(),
345        };
346        let (dir, force) = config.resolve("gtk", Path::new("fallback"), false);
347
348        assert_eq!(dir, expand_tilde(&PathBuf::from("~/.config/gtk-4.0")));
349        assert!(force);
350    }
351
352    #[test]
353    fn resolve_global_force_overrides_installed_overwrite_false() {
354        let config = ChromasyncConfig {
355            configs: Vec::new(),
356            targets: vec![ConfigTarget {
357                name: "gtk".to_owned(),
358                output_dir: PathBuf::from("/tmp/gtk"),
359                source: Some("targets/gtk.toml".to_owned()),
360                overwrite: false,
361            }],
362            hooks: Vec::new(),
363        };
364        let (_, force) = config.resolve("gtk", Path::new("fallback"), true);
365
366        assert!(force);
367    }
368
369    #[test]
370    fn upsert_replaces_existing_entry() {
371        let mut config = ChromasyncConfig {
372            configs: Vec::new(),
373            targets: vec![ConfigTarget {
374                name: "gtk".to_owned(),
375                output_dir: PathBuf::from("/old"),
376                source: Some("targets/gtk.toml".to_owned()),
377                overwrite: false,
378            }],
379            hooks: Vec::new(),
380        };
381        config.upsert(ConfigTarget {
382            name: "gtk".to_owned(),
383            output_dir: PathBuf::from("/new"),
384            source: Some("targets/gtk.toml".to_owned()),
385            overwrite: true,
386        });
387
388        assert_eq!(config.targets.len(), 1);
389        assert_eq!(config.targets[0].output_dir, PathBuf::from("/new"));
390        assert!(config.targets[0].overwrite);
391    }
392
393    #[test]
394    fn config_toml_accepts_sync_profiles() {
395        let config = toml::from_str::<ChromasyncConfig>(
396            r##"
397[[configs]]
398name = "default"
399seed = "#4ecdc4"
400template = "materialish"
401mode = "auto"
402contrast = "apca-experimental"
403chroma = "industrial"
404targets = ["ghostty", "kitty"]
405output_dir = "fallback-output"
406force = true
407
408[[targets]]
409name = "kitty"
410output_dir = "~/.config/kitty"
411overwrite = true
412"##,
413        )
414        .expect("sync profile config should parse");
415
416        let profile = config
417            .sync_profile("default")
418            .expect("default profile should be present");
419        assert_eq!(profile.seed.as_deref(), Some("#4ecdc4"));
420        assert_eq!(profile.image_fetch_command, None);
421        assert_eq!(profile.template.as_deref(), Some("materialish"));
422        assert_eq!(profile.mode, SyncMode::Auto);
423        assert_eq!(profile.contrast, ContrastStrategy::ApcaExperimental);
424        assert_eq!(profile.chroma, ChromaStrategy::Industrial);
425        assert_eq!(profile.targets, ["ghostty", "kitty"]);
426        assert_eq!(profile.output_dir, PathBuf::from("fallback-output"));
427        assert!(profile.force);
428        assert_eq!(config.targets[0].source, None);
429    }
430
431    #[test]
432    fn sync_profile_defaults_match_generate_defaults() {
433        let config = toml::from_str::<ChromasyncConfig>(
434            r##"
435[[configs]]
436name = "default"
437seed = "#4ecdc4"
438"##,
439        )
440        .expect("minimal sync profile should parse");
441
442        let profile = config
443            .sync_profile("default")
444            .expect("default profile should be present");
445        assert_eq!(profile.mode, SyncMode::Dark);
446        assert_eq!(profile.contrast, ContrastStrategy::RelativeLuminance);
447        assert_eq!(profile.chroma, ChromaStrategy::Normal);
448        assert_eq!(profile.targets, Vec::<String>::new());
449        assert_eq!(profile.output_dir, PathBuf::from("chromasync"));
450        assert!(!profile.force);
451    }
452
453    #[test]
454    fn config_toml_accepts_image_fetch_command_source() {
455        let config = toml::from_str::<ChromasyncConfig>(
456            r#"
457[[configs]]
458name = "default"
459image_fetch_command = "qs -c noctalia-shell ipc call wallpaper get"
460targets = ["kitty"]
461"#,
462        )
463        .expect("image fetch command profile should parse");
464
465        let profile = config
466            .sync_profile("default")
467            .expect("default profile should be present");
468        assert_eq!(
469            profile.image_fetch_command.as_deref(),
470            Some("qs -c noctalia-shell ipc call wallpaper get")
471        );
472        assert_eq!(profile.seed, None);
473        assert_eq!(profile.image, None);
474    }
475
476    #[test]
477    fn config_toml_accepts_hooks_with_single_or_multiple_events() {
478        let config = toml::from_str::<ChromasyncConfig>(
479            r##"
480[[hooks]]
481name = "all-targets"
482on = "targets:done"
483command = "printf all"
484
485[[hooks]]
486name = "hyprland-lua"
487filters = ["config:default"]
488on = ["target:hyprland-lua:done"]
489command = "hyprctl reload"
490"##,
491        )
492        .expect("hooks should deserialize");
493
494        assert_eq!(config.hooks.len(), 2);
495        assert_eq!(
496            config.hooks[0].on.iter().collect::<Vec<_>>(),
497            vec!["targets:done"]
498        );
499        assert_eq!(
500            config.hooks[1].on.iter().collect::<Vec<_>>(),
501            vec!["target:hyprland-lua:done"]
502        );
503        assert_eq!(config.hooks[1].filters, vec!["config:default"]);
504    }
505
506    #[test]
507    fn config_toml_rejects_unknown_root_fields() {
508        let error = toml::from_str::<ChromasyncConfig>(
509            r#"
510unknown = true
511"#,
512        )
513        .expect_err("unknown root config fields should be rejected");
514
515        assert!(
516            error.to_string().contains("unknown field"),
517            "expected unknown-field parse error, got: {error}"
518        );
519    }
520
521    #[test]
522    fn config_toml_rejects_unknown_target_fields() {
523        let error = toml::from_str::<ChromasyncConfig>(
524            r#"
525[[targets]]
526name = "gtk"
527output_dir = "/tmp/gtk"
528source = "targets/gtk.toml"
529extra = true
530"#,
531        )
532        .expect_err("unknown target config fields should be rejected");
533
534        assert!(
535            error.to_string().contains("unknown field"),
536            "expected unknown-field parse error, got: {error}"
537        );
538    }
539
540    #[test]
541    fn expand_tilde_leaves_absolute_paths_untouched() {
542        assert_eq!(
543            expand_tilde(&PathBuf::from("/etc/x")),
544            PathBuf::from("/etc/x")
545        );
546        assert_eq!(
547            expand_tilde(&PathBuf::from("relative")),
548            PathBuf::from("relative")
549        );
550    }
551}