| 1 | //! The user's config: `~/.config/budctl/config.json`, shared by the CLI and the GUI. |
| 2 | //! |
| 3 | //! It only holds preferences. Settings live on the buds, so nothing here mirrors them (CLAUDE.md, |
| 4 | //! goals). A missing file is written with the defaults, so there is something to edit; a file |
| 5 | //! that cannot be used is reported and the defaults are used instead, so a typo never stops a |
| 6 | //! status bar's `budctl --json status`. |
| 7 | |
| 8 | use std::fs; |
| 9 | use std::path::{Path, PathBuf}; |
| 10 | |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | |
| 13 | use crate::bt::BdAddr; |
| 14 | |
| 15 | /// What the file holds. A key the file lacks keeps its default; an unknown key is an error, so a |
| 16 | /// misspelt one is not silently ignored. |
| 17 | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 18 | #[serde(default, deny_unknown_fields)] |
| 19 | pub struct Config { |
| 20 | /// The buds to use when `--device` is not given, as `AA:BB:CC:DD:EE:FF`. |
| 21 | pub device: Option<String>, |
| 22 | pub gui: Gui, |
| 23 | } |
| 24 | |
| 25 | /// `budctl-gui` only. |
| 26 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 27 | #[serde(default, deny_unknown_fields)] |
| 28 | pub struct Gui { |
| 29 | /// Seconds the GUI keeps the control channel open after the last command. |
| 30 | pub idle_seconds: u64, |
| 31 | /// The page the window opens on: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`. |
| 32 | pub start_page: String, |
| 33 | } |
| 34 | |
| 35 | impl Default for Gui { |
| 36 | fn default() -> Self { |
| 37 | Gui { |
| 38 | idle_seconds: 10, |
| 39 | start_page: "overview".to_owned(), |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /// The longest `gui.idle_seconds` may be: the channel is meant to be free for other apps. |
| 45 | const MAX_IDLE_SECONDS: u64 = 600; |
| 46 | |
| 47 | impl Config { |
| 48 | /// The default device, checked. |
| 49 | pub fn device(&self) -> Result<Option<BdAddr>, String> { |
| 50 | self.device |
| 51 | .as_deref() |
| 52 | .map(|address| address.parse().map_err(|e| format!("device: {e}"))) |
| 53 | .transpose() |
| 54 | } |
| 55 | |
| 56 | fn check(&self) -> Result<(), String> { |
| 57 | self.device()?; |
| 58 | if !(1..=MAX_IDLE_SECONDS).contains(&self.gui.idle_seconds) { |
| 59 | return Err(format!("gui.idle_seconds: must be 1 to {MAX_IDLE_SECONDS}")); |
| 60 | } |
| 61 | Ok(()) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// A config from the text of a config file. |
| 66 | pub fn parse(json: &str) -> Result<Config, String> { |
| 67 | let config: Config = serde_json::from_str(json).map_err(|e| e.to_string())?; |
| 68 | config.check()?; |
| 69 | Ok(config) |
| 70 | } |
| 71 | |
| 72 | /// budctl's directory under `$XDG_CONFIG_HOME`, else `~/.config`. |
| 73 | pub fn dir() -> Option<PathBuf> { |
| 74 | xdg_dir("XDG_CONFIG_HOME", ".config") |
| 75 | } |
| 76 | |
| 77 | /// budctl's directory under `$XDG_STATE_HOME`, else `~/.local/state`: logs. |
| 78 | pub fn state_dir() -> Option<PathBuf> { |
| 79 | xdg_dir("XDG_STATE_HOME", ".local/state") |
| 80 | } |
| 81 | |
| 82 | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { |
| 83 | let base = std::env::var_os(variable) |
| 84 | .map(PathBuf::from) |
| 85 | .filter(|p| p.is_absolute()) |
| 86 | .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(fallback)))?; |
| 87 | Some(base.join("budctl")) |
| 88 | } |
| 89 | |
| 90 | /// `config.json` in [`dir`]. |
| 91 | pub fn file() -> Option<PathBuf> { |
| 92 | dir().map(|dir| dir.join("config.json")) |
| 93 | } |
| 94 | |
| 95 | /// The config, and why the file could not be used if it could not (the defaults are used then). |
| 96 | /// A missing file is written with the defaults; failing to write it is not a problem. |
| 97 | pub fn load() -> (Config, Option<String>) { |
| 98 | let Some(file) = file() else { |
| 99 | return (Config::default(), None); |
| 100 | }; |
| 101 | load_from(&file) |
| 102 | } |
| 103 | |
| 104 | fn load_from(file: &Path) -> (Config, Option<String>) { |
| 105 | match fs::read_to_string(file) { |
| 106 | Ok(json) => match parse(&json) { |
| 107 | Ok(config) => (config, None), |
| 108 | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), |
| 109 | }, |
| 110 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
| 111 | let _ = create(file, &defaults_json()); |
| 112 | (Config::default(), None) |
| 113 | } |
| 114 | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// The default config, as written to a new file. |
| 119 | pub fn defaults_json() -> String { |
| 120 | // A plain struct of strings and numbers always serializes. |
| 121 | let mut json = serde_json::to_string_pretty(&Config::default()).unwrap_or_default(); |
| 122 | json.push('\n'); |
| 123 | json |
| 124 | } |
| 125 | |
| 126 | /// Write `contents` to `file`, creating its directory, unless the file already exists. |
| 127 | pub fn create(file: &Path, contents: &str) -> std::io::Result<()> { |
| 128 | if let Some(dir) = file.parent() { |
| 129 | fs::create_dir_all(dir)?; |
| 130 | } |
| 131 | let mut new = fs::OpenOptions::new().write(true).create_new(true).open(file)?; |
| 132 | std::io::Write::write_all(&mut new, contents.as_bytes()) |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | |
| 139 | #[test] |
| 140 | fn the_defaults_read_back_as_the_defaults() { |
| 141 | assert_eq!(parse(&defaults_json()).unwrap(), Config::default()); |
| 142 | assert_eq!(parse("{}").unwrap(), Config::default()); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn keys_override_the_defaults() { |
| 147 | let config = parse(r#"{"device": "AA:BB:CC:DD:EE:FF", "gui": {"idle_seconds": 30}}"#).unwrap(); |
| 148 | assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap())); |
| 149 | assert_eq!(config.gui.idle_seconds, 30); |
| 150 | assert_eq!(config.gui.start_page, "overview"); |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn bad_files_are_errors() { |
| 155 | assert!(parse("not json").is_err()); |
| 156 | assert!( |
| 157 | parse(r#"{"devise": "AA:BB:CC:DD:EE:FF"}"#) |
| 158 | .unwrap_err() |
| 159 | .contains("devise") |
| 160 | ); |
| 161 | assert!(parse(r#"{"device": "nope"}"#).unwrap_err().contains("device")); |
| 162 | assert!( |
| 163 | parse(r#"{"gui": {"idle_seconds": 0}}"#) |
| 164 | .unwrap_err() |
| 165 | .contains("idle_seconds") |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | #[test] |
| 170 | fn a_missing_file_is_created_and_an_existing_one_kept() { |
| 171 | let dir = std::env::temp_dir().join(format!("budctl-config-test-{}", std::process::id())); |
| 172 | let file = dir.join("nested/config.json"); |
| 173 | let _ = fs::remove_dir_all(&dir); |
| 174 | |
| 175 | assert_eq!(load_from(&file), (Config::default(), None)); |
| 176 | assert_eq!(fs::read_to_string(&file).unwrap(), defaults_json()); |
| 177 | |
| 178 | fs::write(&file, r#"{"gui": {"start_page": "equalizer"}}"#).unwrap(); |
| 179 | assert_eq!(load_from(&file).0.gui.start_page, "equalizer"); |
| 180 | assert!(create(&file, "{}").is_err(), "an existing file is never overwritten"); |
| 181 | |
| 182 | fs::write(&file, "{").unwrap(); |
| 183 | let (config, problem) = load_from(&file); |
| 184 | assert_eq!(config, Config::default()); |
| 185 | assert!(problem.unwrap().contains("config.json")); |
| 186 | let _ = fs::remove_dir_all(&dir); |
| 187 | } |
| 188 | } |