1use std::fs;
13use std::path::{Path, PathBuf};
14
15use crate::theme::config::skeletons::{
16 KEYBINDS_SKELETON_CONTENT, REPOS_SKELETON_CONTENT, SETTINGS_SKELETON_CONTENT,
17 THEME_SKELETON_CONTENT,
18};
19use crate::theme::paths::{
20 config_dir, resolve_keybinds_config_path, resolve_repos_config_path,
21 resolve_settings_config_path, resolve_theme_config_path,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ConfigFile {
31 Settings,
33 Keybinds,
35 Theme,
37 Repos,
39}
40
41impl ConfigFile {
42 pub(crate) const fn skeleton(self) -> &'static str {
55 match self {
56 Self::Settings => SETTINGS_SKELETON_CONTENT,
57 Self::Keybinds => KEYBINDS_SKELETON_CONTENT,
58 Self::Theme => THEME_SKELETON_CONTENT,
59 Self::Repos => REPOS_SKELETON_CONTENT,
60 }
61 }
62
63 const fn default_filename(self) -> &'static str {
75 match self {
76 Self::Settings => "settings.conf",
77 Self::Keybinds => "keybinds.conf",
78 Self::Theme => "theme.conf",
79 Self::Repos => "repos.conf",
80 }
81 }
82
83 fn try_resolve(self) -> Option<PathBuf> {
92 match self {
93 Self::Settings => resolve_settings_config_path(),
94 Self::Keybinds => resolve_keybinds_config_path(),
95 Self::Theme => resolve_theme_config_path(),
96 Self::Repos => resolve_repos_config_path(),
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
106pub struct PatchRequest<'a> {
107 pub file: ConfigFile,
109 pub key: &'a str,
112 pub aliases: &'a [&'a str],
116 pub value: &'a str,
119 pub dry_run: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum PatchOutcome {
126 NoChange {
128 path: PathBuf,
130 },
131 Written {
133 path: PathBuf,
135 },
136 DryRun {
138 path: PathBuf,
140 proposed: String,
142 },
143}
144
145#[derive(Debug)]
147pub enum ConfigWriteError {
148 Io {
150 path: PathBuf,
152 source: std::io::Error,
154 },
155 Invalid(String),
158}
159
160impl std::fmt::Display for ConfigWriteError {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Io { path, source } => {
164 write!(f, "I/O error on {}: {source}", path.display())
165 }
166 Self::Invalid(msg) => write!(f, "invalid patch request: {msg}"),
167 }
168 }
169}
170
171impl std::error::Error for ConfigWriteError {
172 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
173 match self {
174 Self::Io { source, .. } => Some(source),
175 Self::Invalid(_) => None,
176 }
177 }
178}
179
180fn normalize_key(key: &str) -> String {
192 key.trim().to_lowercase().replace(['.', '-', ' '], "_")
193}
194
195fn resolve_path(file: ConfigFile) -> PathBuf {
209 if let Some(p) = file.try_resolve() {
210 return p;
211 }
212 let base = std::env::var("XDG_CONFIG_HOME")
213 .ok()
214 .map(PathBuf::from)
215 .or_else(|| {
216 std::env::var("HOME")
217 .ok()
218 .map(|h| Path::new(&h).join(".config"))
219 });
220 if let Some(base) = base {
221 return base.join("pacsea").join(file.default_filename());
222 }
223 config_dir().join(file.default_filename())
224}
225
226#[must_use]
238pub fn resolved_config_path(file: ConfigFile) -> PathBuf {
239 resolve_path(file)
240}
241
242fn read_or_seed_lines(path: &Path, file: ConfigFile) -> Result<Vec<String>, ConfigWriteError> {
253 let meta = fs::metadata(path).ok();
254 let exists = meta.is_some();
255 let empty = meta.is_none_or(|m| m.len() == 0);
256 if !exists || empty {
257 return Ok(file.skeleton().lines().map(ToString::to_string).collect());
258 }
259 match fs::read_to_string(path) {
260 Ok(content) => Ok(content.lines().map(ToString::to_string).collect()),
261 Err(source) => Err(ConfigWriteError::Io {
262 path: path.to_path_buf(),
263 source,
264 }),
265 }
266}
267
268fn replace_in_place(lines: &mut [String], primary: &str, aliases: &[String], value: &str) -> bool {
280 let primary_norm = normalize_key(primary);
281 let mut replaced = false;
282 for line in lines.iter_mut() {
283 let trimmed = line.trim();
284 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
285 continue;
286 }
287 let Some(eq) = trimmed.find('=') else {
288 continue;
289 };
290 let key = normalize_key(&trimmed[..eq]);
291 if key == primary_norm || aliases.iter().any(|a| a == &key) {
292 *line = format!("{primary} = {value}");
293 replaced = true;
294 }
295 }
296 replaced
297}
298
299fn lines_to_content(lines: &[String], fallback_key: &str, fallback_value: &str) -> String {
311 if lines.is_empty() {
312 return format!("{fallback_key} = {fallback_value}\n");
313 }
314 let mut out = lines.join("\n");
315 if !out.ends_with('\n') {
316 out.push('\n');
317 }
318 out
319}
320
321fn atomic_write(path: &Path, content: &str) -> Result<(), ConfigWriteError> {
337 if let Some(dir) = path.parent() {
338 fs::create_dir_all(dir).map_err(|source| ConfigWriteError::Io {
339 path: dir.to_path_buf(),
340 source,
341 })?;
342 }
343 let tmp = make_temp_path(path);
344 write_temp_file(&tmp, content)?;
345 fs::rename(&tmp, path).map_err(|source| {
346 let _ = fs::remove_file(&tmp);
348 ConfigWriteError::Io {
349 path: path.to_path_buf(),
350 source,
351 }
352 })?;
353 Ok(())
354}
355
356fn make_temp_path(target: &Path) -> PathBuf {
368 let nanos = std::time::SystemTime::now()
369 .duration_since(std::time::UNIX_EPOCH)
370 .map_or(0, |d| d.as_nanos());
371 let pid = std::process::id();
372 let leaf = target.file_name().map_or_else(
373 || "pacsea.conf".to_string(),
374 |n| n.to_string_lossy().into_owned(),
375 );
376 let dir = target
377 .parent()
378 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
379 dir.join(format!(".{leaf}.tmp.{pid}.{nanos}"))
380}
381
382fn write_temp_file(tmp: &Path, content: &str) -> Result<(), ConfigWriteError> {
397 use std::io::Write;
398 let mut opts = fs::OpenOptions::new();
399 opts.write(true).create_new(true);
400 #[cfg(unix)]
401 {
402 use std::os::unix::fs::OpenOptionsExt;
403 opts.mode(0o600);
404 }
405 let mut f = opts.open(tmp).map_err(|source| ConfigWriteError::Io {
406 path: tmp.to_path_buf(),
407 source,
408 })?;
409 f.write_all(content.as_bytes())
410 .map_err(|source| ConfigWriteError::Io {
411 path: tmp.to_path_buf(),
412 source,
413 })?;
414 f.sync_all().map_err(|source| ConfigWriteError::Io {
415 path: tmp.to_path_buf(),
416 source,
417 })?;
418 Ok(())
419}
420
421pub fn patch_key(req: &PatchRequest<'_>) -> Result<PatchOutcome, ConfigWriteError> {
448 if req.key.trim().is_empty() {
449 return Err(ConfigWriteError::Invalid(
450 "patch key must not be empty".into(),
451 ));
452 }
453 let path = resolve_path(req.file);
454 let alias_norms: Vec<String> = req
455 .aliases
456 .iter()
457 .map(|a| normalize_key(a))
458 .filter(|a| !a.is_empty())
459 .collect();
460 let mut lines = read_or_seed_lines(&path, req.file)?;
461 let replaced = replace_in_place(&mut lines, req.key, &alias_norms, req.value);
462 if !replaced {
463 lines.push(format!("{} = {}", req.key, req.value));
464 }
465 let new_content = lines_to_content(&lines, req.key, req.value);
466
467 let current = fs::read_to_string(&path).ok();
469 if current.as_deref() == Some(new_content.as_str()) {
470 return Ok(PatchOutcome::NoChange { path });
471 }
472
473 if req.dry_run {
474 return Ok(PatchOutcome::DryRun {
475 path,
476 proposed: new_content,
477 });
478 }
479 atomic_write(&path, &new_content)?;
480 Ok(PatchOutcome::Written { path })
481}
482
483pub fn write_full_content(
501 file: ConfigFile,
502 content: &str,
503 dry_run: bool,
504) -> Result<PatchOutcome, ConfigWriteError> {
505 let path = resolve_path(file);
506 let current = fs::read_to_string(&path).ok();
507 if current.as_deref() == Some(content) {
508 return Ok(PatchOutcome::NoChange { path });
509 }
510 if dry_run {
511 return Ok(PatchOutcome::DryRun {
512 path,
513 proposed: content.to_string(),
514 });
515 }
516 atomic_write(&path, content)?;
517 Ok(PatchOutcome::Written { path })
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use std::fs;
524
525 struct EnvGuard {
531 base: PathBuf,
532 cfg_dir: PathBuf,
533 orig_home: Option<std::ffi::OsString>,
534 orig_xdg: Option<std::ffi::OsString>,
535 }
536
537 impl EnvGuard {
538 fn new(tag: &str) -> Self {
539 let orig_home = std::env::var_os("HOME");
540 let orig_xdg = std::env::var_os("XDG_CONFIG_HOME");
541 let base = std::env::temp_dir().join(format!(
542 "pacsea_patch_{tag}_{}_{}",
543 std::process::id(),
544 std::time::SystemTime::now()
545 .duration_since(std::time::UNIX_EPOCH)
546 .expect("time")
547 .as_nanos()
548 ));
549 let cfg_dir = base.join(".config").join("pacsea");
550 fs::create_dir_all(&cfg_dir).expect("create cfg dir");
551 unsafe {
552 std::env::set_var("HOME", base.display().to_string());
553 std::env::remove_var("XDG_CONFIG_HOME");
554 }
555 Self {
556 base,
557 cfg_dir,
558 orig_home,
559 orig_xdg,
560 }
561 }
562 }
563
564 impl Drop for EnvGuard {
565 fn drop(&mut self) {
566 unsafe {
567 if let Some(v) = self.orig_home.as_ref() {
568 std::env::set_var("HOME", v);
569 } else {
570 std::env::remove_var("HOME");
571 }
572 if let Some(v) = self.orig_xdg.as_ref() {
573 std::env::set_var("XDG_CONFIG_HOME", v);
574 } else {
575 std::env::remove_var("XDG_CONFIG_HOME");
576 }
577 }
578 let _ = fs::remove_dir_all(&self.base);
579 }
580 }
581
582 #[test]
583 fn patch_dry_run_does_not_touch_disk() {
584 let _g = crate::theme::test_mutex().lock().expect("mutex");
585 let env = EnvGuard::new("dry_run");
586 let path = env.cfg_dir.join("settings.conf");
587 fs::write(&path, "sort_mode = best_matches\n").expect("write");
588
589 let req = PatchRequest {
590 file: ConfigFile::Settings,
591 key: "sort_mode",
592 aliases: &["results_sort"],
593 value: "alphabetical",
594 dry_run: true,
595 };
596 let outcome = patch_key(&req).expect("patch ok");
597 match outcome {
598 PatchOutcome::DryRun { path: p, proposed } => {
599 assert_eq!(p, path);
600 assert!(
601 proposed.contains("sort_mode = alphabetical"),
602 "proposed should contain new value, got: {proposed}"
603 );
604 }
605 other => panic!("expected DryRun, got {other:?}"),
606 }
607 let actual = fs::read_to_string(&path).expect("read after dry-run");
608 assert_eq!(
609 actual, "sort_mode = best_matches\n",
610 "dry-run must not modify disk"
611 );
612 drop(env);
613 }
614
615 #[test]
616 fn patch_writes_and_preserves_comments() {
617 let _g = crate::theme::test_mutex().lock().expect("mutex");
618 let env = EnvGuard::new("preserve");
619 let path = env.cfg_dir.join("settings.conf");
620 let original = "# header comment\n\
621 # describes sort_mode\n\
622 sort_mode = best_matches\n\
623 # below is unrelated\n\
624 show_install_pane = true\n";
625 fs::write(&path, original).expect("write");
626
627 let req = PatchRequest {
628 file: ConfigFile::Settings,
629 key: "sort_mode",
630 aliases: &[],
631 value: "alphabetical",
632 dry_run: false,
633 };
634 let outcome = patch_key(&req).expect("patch ok");
635 assert!(matches!(outcome, PatchOutcome::Written { .. }));
636
637 let after = fs::read_to_string(&path).expect("read after");
638 assert!(after.contains("# header comment"));
639 assert!(after.contains("# describes sort_mode"));
640 assert!(after.contains("sort_mode = alphabetical"));
641 assert!(after.contains("show_install_pane = true"));
642 assert!(!after.contains("sort_mode = best_matches"));
643 drop(env);
644 }
645
646 #[test]
647 fn patch_no_change_when_value_matches() {
648 let _g = crate::theme::test_mutex().lock().expect("mutex");
649 let env = EnvGuard::new("nochange");
650 let path = env.cfg_dir.join("settings.conf");
651 fs::write(&path, "sort_mode = alphabetical\n").expect("write");
652
653 let req = PatchRequest {
654 file: ConfigFile::Settings,
655 key: "sort_mode",
656 aliases: &[],
657 value: "alphabetical",
658 dry_run: false,
659 };
660 let outcome = patch_key(&req).expect("patch ok");
661 assert!(matches!(outcome, PatchOutcome::NoChange { .. }));
662 drop(env);
663 }
664
665 #[test]
666 fn patch_migrates_alias_to_primary_key() {
667 let _g = crate::theme::test_mutex().lock().expect("mutex");
668 let env = EnvGuard::new("alias");
669 let path = env.cfg_dir.join("settings.conf");
670 fs::write(&path, "results_sort = best_matches\n").expect("write");
671
672 let req = PatchRequest {
673 file: ConfigFile::Settings,
674 key: "sort_mode",
675 aliases: &["results_sort"],
676 value: "alphabetical",
677 dry_run: false,
678 };
679 let outcome = patch_key(&req).expect("patch ok");
680 assert!(matches!(outcome, PatchOutcome::Written { .. }));
681
682 let after = fs::read_to_string(&path).expect("read after");
683 assert!(
684 after.contains("sort_mode = alphabetical"),
685 "alias should migrate to primary key, got: {after}"
686 );
687 assert!(
688 !after.contains("results_sort = best_matches"),
689 "alias line should be replaced, got: {after}"
690 );
691 drop(env);
692 }
693
694 #[test]
695 fn patch_appends_when_key_missing() {
696 let _g = crate::theme::test_mutex().lock().expect("mutex");
697 let env = EnvGuard::new("append");
698 let path = env.cfg_dir.join("settings.conf");
699 fs::write(&path, "show_install_pane = true\n").expect("write");
700
701 let req = PatchRequest {
702 file: ConfigFile::Settings,
703 key: "sort_mode",
704 aliases: &[],
705 value: "alphabetical",
706 dry_run: false,
707 };
708 let outcome = patch_key(&req).expect("patch ok");
709 assert!(matches!(outcome, PatchOutcome::Written { .. }));
710
711 let after = fs::read_to_string(&path).expect("read after");
712 assert!(after.contains("show_install_pane = true"));
713 assert!(after.contains("sort_mode = alphabetical"));
714 drop(env);
715 }
716
717 #[test]
718 fn patch_seeds_skeleton_when_file_missing() {
719 let _g = crate::theme::test_mutex().lock().expect("mutex");
720 let env = EnvGuard::new("seed");
721 let path = env.cfg_dir.join("settings.conf");
722 assert!(!path.exists());
723
724 let req = PatchRequest {
725 file: ConfigFile::Settings,
726 key: "sort_mode",
727 aliases: &[],
728 value: "alphabetical",
729 dry_run: false,
730 };
731 let outcome = patch_key(&req).expect("patch ok");
732 assert!(matches!(outcome, PatchOutcome::Written { .. }));
733 let after = fs::read_to_string(&path).expect("read after");
734 assert!(
736 after.contains("Pacsea settings"),
737 "should bootstrap from skeleton, got: {after}"
738 );
739 assert!(after.contains("sort_mode = alphabetical"));
740 drop(env);
741 }
742
743 #[test]
744 fn patch_rejects_empty_key() {
745 let req = PatchRequest {
746 file: ConfigFile::Settings,
747 key: " ",
748 aliases: &[],
749 value: "x",
750 dry_run: true,
751 };
752 let err = patch_key(&req).expect_err("should reject empty key");
753 assert!(matches!(err, ConfigWriteError::Invalid(_)));
754 }
755
756 #[test]
757 #[cfg(unix)]
758 fn atomic_write_uses_restrictive_mode() {
759 use std::os::unix::fs::PermissionsExt;
760 let _g = crate::theme::test_mutex().lock().expect("mutex");
761 let env = EnvGuard::new("mode");
762 let path = env.cfg_dir.join("settings.conf");
763 let req = PatchRequest {
764 file: ConfigFile::Settings,
765 key: "sort_mode",
766 aliases: &[],
767 value: "alphabetical",
768 dry_run: false,
769 };
770 patch_key(&req).expect("patch ok");
771 let mode = fs::metadata(&path).expect("meta").permissions().mode();
772 assert_eq!(
773 mode & 0o777,
774 0o600,
775 "atomic write should leave file mode 0o600"
776 );
777 drop(env);
778 }
779}