Skip to main content

pacsea/theme/config/
patch.rs

1//! Result-returning, dry-run-aware config patch foundation.
2//!
3//! What: Provides `patch_key` and atomic-write helpers that preserve comments
4//! and unknown keys when changing one value in a Pacsea `*.conf` file.
5//!
6//! Used as the substrate for the integrated config editor described in
7//! `dev/IMPROVEMENTS/IMPLEMENTATION_PLAN_tui_integrated_config_editing.md`.
8//! Existing fire-and-forget `save_*` helpers in [`crate::theme::config::settings_save`]
9//! continue to live alongside this module; new editor code paths should prefer
10//! [`patch_key`] so failures are surfaced to the UI and dry-run is honored.
11
12use 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/// Identifies which Pacsea config file a patch targets.
25///
26/// Used by [`patch_key`] and [`PatchRequest`] to resolve the on-disk location
27/// and, when bootstrapping a missing/empty file, to seed it from the matching
28/// skeleton.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum ConfigFile {
31    /// `settings.conf` — user preferences (toggles, layout, mirrors, scans, …).
32    Settings,
33    /// `keybinds.conf` — keychord-to-action mappings.
34    Keybinds,
35    /// `theme.conf` — theme color overrides.
36    Theme,
37    /// `repos.conf` — additional repository recipes.
38    Repos,
39}
40
41impl ConfigFile {
42    /// What: Skeleton content used when bootstrapping a missing file.
43    ///
44    /// Inputs:
45    /// - `self`: target file enum value.
46    ///
47    /// Output:
48    /// - Static skeleton string.
49    ///
50    /// Details:
51    /// - Mirrors the seeding behavior of the existing `save_*` helpers and the
52    ///   `ensure_*` migration paths, so a fresh patch on a missing file produces
53    ///   the same starting content the rest of the app expects.
54    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    /// What: Default filename inside `~/.config/pacsea/`.
64    ///
65    /// Inputs:
66    /// - `self`: target file enum value.
67    ///
68    /// Output:
69    /// - Filename like `settings.conf`.
70    ///
71    /// Details:
72    /// - Used as the leaf name when falling back to `XDG_CONFIG_HOME`/`HOME` if
73    ///   the file does not yet exist on disk.
74    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    /// What: Try the existing path resolver for this config file.
84    ///
85    /// Inputs:
86    /// - `self`: target file enum value.
87    ///
88    /// Output:
89    /// - `Some(PathBuf)` when an existing file (or canonical fallback) was located.
90    /// - `None` when no resolver candidate matched.
91    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/// Description of a single key/value patch against one config file.
102///
103/// `aliases` enables forward migration: when a deprecated key is encountered on
104/// disk it is rewritten to `key` while keeping the surrounding comments intact.
105#[derive(Debug, Clone)]
106pub struct PatchRequest<'a> {
107    /// Which config file to update.
108    pub file: ConfigFile,
109    /// Canonical (output) key name. Will be used as the left-hand side when
110    /// the line is rewritten or appended.
111    pub key: &'a str,
112    /// Alternate keys (already-deprecated names) that should also match an
113    /// existing line. Use the same casing convention as the file; matching is
114    /// done after normalization (lowercase, `[. - <space>]` -> `_`).
115    pub aliases: &'a [&'a str],
116    /// Value to write on the right-hand side. Caller is responsible for
117    /// formatting (e.g. `"true"`/`"false"`, hex colors, `sort_mode` keys, …).
118    pub value: &'a str,
119    /// When `true`, compute the diff and target path but do not modify disk.
120    pub dry_run: bool,
121}
122
123/// Successful outcome of a [`patch_key`] call.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum PatchOutcome {
126    /// Disk already held the requested value; no I/O performed.
127    NoChange {
128        /// Resolved absolute path that was inspected.
129        path: PathBuf,
130    },
131    /// Value was written atomically.
132    Written {
133        /// Resolved absolute path that was updated.
134        path: PathBuf,
135    },
136    /// Dry-run mode: this is the content that would have been written.
137    DryRun {
138        /// Resolved absolute path that would have been updated.
139        path: PathBuf,
140        /// Full content that would have replaced the file.
141        proposed: String,
142    },
143}
144
145/// Failure modes of [`patch_key`].
146#[derive(Debug)]
147pub enum ConfigWriteError {
148    /// Filesystem I/O failure during read, temp write, or atomic rename.
149    Io {
150        /// Path involved in the failed I/O operation (target or temp file).
151        path: PathBuf,
152        /// Underlying I/O error.
153        source: std::io::Error,
154    },
155    /// Validation rejected the request before any I/O. The string explains
156    /// which constraint failed (e.g. "key must be non-empty").
157    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
180/// What: Normalize a config key the same way the rest of Pacsea does.
181///
182/// Inputs:
183/// - `key`: Raw key string from the config file or a request.
184///
185/// Output:
186/// - Lowercased, underscore-normalized owned string.
187///
188/// Details:
189/// - Mirrors the inline normalization used in `settings_save` and
190///   `settings_ensure` so behavior stays identical across modules.
191fn normalize_key(key: &str) -> String {
192    key.trim().to_lowercase().replace(['.', '-', ' '], "_")
193}
194
195/// What: Resolve a target path, falling back to `XDG_CONFIG_HOME`/`HOME` when
196/// no candidate file exists yet.
197///
198/// Inputs:
199/// - `file`: The target config file kind.
200///
201/// Output:
202/// - Resolved absolute path. Falls back to `config_dir()` (which itself
203///   defaults to a sensible XDG path) when no resolver candidate matches.
204///
205/// Details:
206/// - Matches the fallback logic used by existing fire-and-forget helpers so the
207///   patch API and the `save_*` functions write to the same file.
208fn 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/// What: Resolve the active path for a Pacsea config file.
227///
228/// Inputs:
229/// - `file`: Target config file kind.
230///
231/// Output:
232/// - Absolute path the patch layer will read/write for this file.
233///
234/// Details:
235/// - Uses the same resolution logic as [`patch_key`], including existing-file
236///   discovery (split files and legacy fallbacks) and default path fallback.
237#[must_use]
238pub fn resolved_config_path(file: ConfigFile) -> PathBuf {
239    resolve_path(file)
240}
241
242/// What: Read the existing file as a list of lines, seeding from the skeleton
243/// when the file is missing or empty.
244///
245/// Inputs:
246/// - `path`: Resolved target path.
247/// - `file`: Config file kind, used to pick the skeleton when seeding.
248///
249/// Output:
250/// - `Ok(Vec<String>)` of lines (skeleton or existing content).
251/// - `ConfigWriteError::Io` if the file exists but cannot be read.
252fn 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
268/// What: Replace an existing line for `primary` (or any alias) with the desired
269/// `key = value` pair, in place.
270///
271/// Inputs:
272/// - `lines`: Mutable line buffer, modified in place.
273/// - `primary`: Canonical key name (used both for matching and for the rewritten line).
274/// - `aliases`: Already-normalized alias list to also match.
275/// - `value`: Value to serialize.
276///
277/// Output:
278/// - `true` if at least one line was rewritten; `false` if the key was not found.
279fn 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
299/// What: Join lines back into a single string, ensuring non-empty content has a
300/// trailing newline.
301///
302/// Inputs:
303/// - `lines`: Final ordered list of lines.
304/// - `fallback_key`: When `lines` is empty, this key/value pair seeds a
305///   one-line file.
306/// - `fallback_value`: See `fallback_key`.
307///
308/// Output:
309/// - Full file content as `String`.
310fn 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
321/// What: Atomically replace `path` with `content`.
322///
323/// Inputs:
324/// - `path`: Target path. Parent directory is created on demand.
325/// - `content`: Bytes to write.
326///
327/// Output:
328/// - `Ok(())` on success.
329/// - `ConfigWriteError::Io` describing the failed step.
330///
331/// Details:
332/// - Writes to a sibling temp file with `create_new(true)` (refuses to follow a
333///   symlink) and `rename`s into place so partially-written files never become
334///   visible. On Unix, the temp file is created with mode `0o600` to avoid
335///   leaking secrets such as `virustotal_api_key` while the rename is racing.
336fn 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        // Best-effort cleanup so we don't leave a stray temp file behind.
347        let _ = fs::remove_file(&tmp);
348        ConfigWriteError::Io {
349            path: path.to_path_buf(),
350            source,
351        }
352    })?;
353    Ok(())
354}
355
356/// What: Build a unique temp path adjacent to `target`.
357///
358/// Inputs:
359/// - `target`: Final destination file.
360///
361/// Output:
362/// - Path in the same directory using PID and nanosecond timestamp as suffix.
363///
364/// Details:
365/// - Same-directory placement guarantees that `rename` is atomic on the same
366///   filesystem, which is the universal case for `~/.config/pacsea/`.
367fn 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
382/// What: Write `content` to a fresh temp file with restrictive permissions.
383///
384/// Inputs:
385/// - `tmp`: Temp file path; must not yet exist.
386/// - `content`: Bytes to write.
387///
388/// Output:
389/// - `Ok(())` on success or `ConfigWriteError::Io` describing the failure.
390///
391/// Details:
392/// - Uses `create_new(true)` so a pre-existing temp file or symlink causes the
393///   call to fail rather than overwrite something unexpected.
394/// - Calls `sync_all` to flush data and metadata before the rename so a crash
395///   cannot leave a zero-length file.
396fn 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
421/// What: Apply a single key/value patch to a Pacsea config file.
422///
423/// Inputs:
424/// - `req`: [`PatchRequest`] describing target file, key (with aliases), value,
425///   and dry-run flag.
426///
427/// Output:
428/// - [`PatchOutcome::NoChange`] when the file already holds the requested value.
429/// - [`PatchOutcome::Written`] when the file was updated atomically.
430/// - [`PatchOutcome::DryRun`] when `req.dry_run` is `true` and a change would
431///   have been made.
432///
433/// # Errors
434/// - [`ConfigWriteError::Invalid`] when `req.key` is empty after trimming.
435/// - [`ConfigWriteError::Io`] when reading the existing file or performing the
436///   atomic temp-file write/rename fails.
437///
438/// Details:
439/// - Comments and unrelated keys are preserved: only the matched line is
440///   rewritten in place. When no existing line matches, the canonical
441///   `key = value` pair is appended.
442/// - Aliases are migrated in the same pass: an alias line on disk is rewritten
443///   with the primary key.
444/// - File is bootstrapped from the matching skeleton when missing or empty.
445/// - Writes go through [`atomic_write`] (temp file + rename, mode `0o600` on
446///   Unix), so a partially-written file is never visible.
447pub 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    // Compare against current on-disk state to short-circuit no-op writes.
468    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
483/// What: Atomically replace the entire content of a config file.
484///
485/// Inputs:
486/// - `file`: Target [`ConfigFile`].
487/// - `content`: Full new content.
488/// - `dry_run`: When `true`, do not modify disk.
489///
490/// Output:
491/// - [`PatchOutcome`] mirroring [`patch_key`] semantics.
492///
493/// # Errors
494/// - [`ConfigWriteError::Io`] when the atomic temp-file write or rename fails.
495///
496/// Details:
497/// - Used by callers that have already produced a fully serialized buffer
498///   (e.g. theme pre-commit validation that writes to a temp path, validates,
499///   then commits).
500pub 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    /// What: RAII guard that points HOME/XDG to a temp dir and restores them.
526    ///
527    /// Details:
528    /// - Mirrors the helper inside `theme/config/tests.rs` but lives here so
529    ///   patch tests can be self-contained.
530    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        // Skeleton text should be present (header sentinel comment)
735        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}