//! User settings: the optional `config.yml` in the pkh configuration //! directory (the same `~/.config/pkh/` that holds `contexts.json`), //! and their defaults. //! //! A missing file carries the defaults — the file is written only when //! a user creates it, never generated. A file that cannot be read or //! parsed must not take the whole program down: like the context //! configuration, it is backed up to `.bak` (best effort) so a //! later save cannot silently destroy it, and the defaults apply. Every //! struct level is individually defaulted (`#[serde(default)]`), so any //! hand-written file may carry a subset of the settings: //! //! ```yaml //! deb: //! incremental: //! staged_trees: 2 //! ``` use serde::Deserialize; use std::path::PathBuf; /// The pkh configuration directory (the one holding `contexts.json` /// and `config.yml`). pub fn config_dir() -> Option { directories::ProjectDirs::from("com", "pkh", "pkh").map(|dirs| dirs.config_dir().to_path_buf()) } /// The settings file in [`config_dir`]. fn config_path() -> Option { config_dir().map(|dir| dir.join("config.yml")) } /// The user-adjustable settings, as stored in `config.yml`. New fields /// must default — the file always may carry a subset. #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] pub struct Settings { /// `pkh deb` settings. pub deb: DebSettings, } /// The `pkh deb` settings. #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] pub struct DebSettings { /// `pkh deb --incremental` settings. pub incremental: IncrementalSettings, } /// The `pkh deb --incremental` settings. #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] pub struct IncrementalSettings { /// How many staged trees `pkh deb` retains per target: the newer /// ones first, with the newest successful one and the newest one /// always among them. pub staged_trees: Option, } /// How many staged trees `pkh deb` retains per target: the setting's /// value, or the default (2 — the newest tree and the newest successful /// one). A configured 0 cannot honor the retention invariant (the /// newest successful tree always survives), so it clamps to 1 with a /// warning. pub fn staged_trees() -> usize { const DEFAULT: usize = 2; let Some(path) = config_path() else { return DEFAULT; }; let Ok(text) = std::fs::read_to_string(&path) else { return DEFAULT; }; let settings: Settings = match serde_yaml::from_str(&text) { Ok(settings) => settings, Err(e) => { log::error!( "Settings file {} is corrupt ({e}); using the defaults", path.display() ); backup(&path); return DEFAULT; } }; match settings.deb.incremental.staged_trees { Some(0) => { log::warn!( "Settings file {} carries staged_trees: 0; the newest \ successful staged tree is always retained, so 1 applies", path.display() ); 1 } Some(count) => count, None => DEFAULT, } } /// Back up a corrupt settings file next to itself (best effort), so a /// later save cannot silently destroy its content. fn backup(path: &std::path::Path) { let mut os = path.as_os_str().to_os_string(); os.push(".bak"); let backup_path = PathBuf::from(os); match std::fs::copy(path, &backup_path) { Ok(_) => log::warn!( "Corrupt settings file backed up to {}", backup_path.display() ), Err(e) => log::warn!( "Could not back up corrupt settings file to {}: {e}", backup_path.display() ), } } #[cfg(test)] mod tests { use super::*; /// The nested setting parses; every level may be missing and every /// missing level defaults. #[test] fn settings_parse_the_nested_shape() { let settings: Settings = serde_yaml::from_str("deb:\n incremental:\n staged_trees: 3").unwrap(); assert_eq!(settings.deb.incremental.staged_trees, Some(3)); let settings: Settings = serde_yaml::from_str("deb: {}").unwrap(); assert_eq!(settings.deb.incremental.staged_trees, None); let settings: Settings = serde_yaml::from_str("{}").unwrap(); assert_eq!(settings.deb.incremental.staged_trees, None); } /// A value of the wrong type is a file error, not a default for the /// field: the whole file is corrupt (the caller backs it up and /// applies the defaults). #[test] fn a_mistyped_value_is_a_corrupt_file() { let result = serde_yaml::from_str::("deb:\n incremental:\n staged_trees: two\n"); assert!(result.is_err()); } }