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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct SyncProfile {
34 pub name: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub seed: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
42 pub image: Option<PathBuf>,
43 #[serde(skip_serializing_if = "Option::is_none")]
46 pub image_fetch_command: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub template: Option<String>,
50 #[serde(default)]
52 pub mode: SyncMode,
53 #[serde(default)]
55 pub contrast: ContrastStrategy,
56 #[serde(default)]
58 pub chroma: ChromaStrategy,
59 #[serde(default)]
61 pub targets: Vec<String>,
62 #[serde(default = "default_output_dir")]
64 pub output_dir: PathBuf,
65 #[serde(default)]
67 pub force: bool,
68}
69
70#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ConfigTarget {
85 pub name: String,
87 pub output_dir: PathBuf,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub source: Option<String>,
96 #[serde(default)]
99 pub overwrite: bool,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct ConfigHook {
106 pub name: String,
108 pub on: HookEvents,
110 pub command: String,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub filters: Vec<String>,
115}
116
117#[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#[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
146fn project_dirs() -> Option<ProjectDirs> {
148 ProjectDirs::from("io", "chromasync", "chromasync")
149}
150
151pub fn config_file_path() -> Option<PathBuf> {
153 project_dirs().map(|dirs| dirs.config_dir().join("config.toml"))
154}
155
156impl ChromasyncConfig {
157 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 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 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 pub fn sync_profile(&self, name: &str) -> Option<&SyncProfile> {
218 self.configs.iter().find(|entry| entry.name == name)
219 }
220
221 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
231pub 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
292pub 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}