| 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 | /// Whether things move: fades, switches, the battery rings' sweep. Off, every change shows |
| 34 | /// at once. |
| 35 | pub motion: bool, |
| 36 | } |
| 37 | |
| 38 | impl Default for Gui { |
| 39 | fn default() -> Self { |
| 40 | Gui { |
| 41 | idle_seconds: 10, |
| 42 | start_page: "overview".to_owned(), |
| 43 | motion: true, |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// The longest `gui.idle_seconds` may be: the channel is meant to be free for other apps. |
| 49 | const MAX_IDLE_SECONDS: u64 = 600; |
| 50 | |
| 51 | impl Config { |
| 52 | /// The default device, checked. |
| 53 | pub fn device(&self) -> Result<Option<BdAddr>, String> { |
| 54 | self.device |
| 55 | .as_deref() |
| 56 | .map(|address| address.parse().map_err(|e| format!("device: {e}"))) |
| 57 | .transpose() |
| 58 | } |
| 59 | |
| 60 | fn check(&self) -> Result<(), String> { |
| 61 | self.device()?; |
| 62 | if !(1..=MAX_IDLE_SECONDS).contains(&self.gui.idle_seconds) { |
| 63 | return Err(format!("gui.idle_seconds: must be 1 to {MAX_IDLE_SECONDS}")); |
| 64 | } |
| 65 | Ok(()) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// A config from the text of a config file. |
| 70 | pub fn parse(json: &str) -> Result<Config, String> { |
| 71 | let config: Config = serde_json::from_str(json).map_err(|e| e.to_string())?; |
| 72 | config.check()?; |
| 73 | Ok(config) |
| 74 | } |
| 75 | |
| 76 | /// budctl's directory under `$XDG_CONFIG_HOME`, else `~/.config`. |
| 77 | pub fn dir() -> Option<PathBuf> { |
| 78 | xdg_dir("XDG_CONFIG_HOME", ".config") |
| 79 | } |
| 80 | |
| 81 | /// budctl's directory under `$XDG_STATE_HOME`, else `~/.local/state`: logs. |
| 82 | pub fn state_dir() -> Option<PathBuf> { |
| 83 | xdg_dir("XDG_STATE_HOME", ".local/state") |
| 84 | } |
| 85 | |
| 86 | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { |
| 87 | let base = std::env::var_os(variable) |
| 88 | .map(PathBuf::from) |
| 89 | .filter(|p| p.is_absolute()) |
| 90 | .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(fallback)))?; |
| 91 | Some(base.join("budctl")) |
| 92 | } |
| 93 | |
| 94 | /// `config.json` in [`dir`]. |
| 95 | pub fn file() -> Option<PathBuf> { |
| 96 | dir().map(|dir| dir.join("config.json")) |
| 97 | } |
| 98 | |
| 99 | /// The config, and why the file could not be used if it could not (the defaults are used then). |
| 100 | /// A missing file is written with the defaults; failing to write it is not a problem. |
| 101 | pub fn load() -> (Config, Option<String>) { |
| 102 | let Some(file) = file() else { |
| 103 | return (Config::default(), None); |
| 104 | }; |
| 105 | load_from(&file) |
| 106 | } |
| 107 | |
| 108 | fn load_from(file: &Path) -> (Config, Option<String>) { |
| 109 | match fs::read_to_string(file) { |
| 110 | Ok(json) => match parse(&json) { |
| 111 | Ok(config) => (config, None), |
| 112 | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), |
| 113 | }, |
| 114 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
| 115 | let _ = create(file, &defaults_json()); |
| 116 | (Config::default(), None) |
| 117 | } |
| 118 | Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))), |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// The default config, as written to a new file. |
| 123 | pub fn defaults_json() -> String { |
| 124 | // A plain struct of strings and numbers always serializes. |
| 125 | let mut json = serde_json::to_string_pretty(&Config::default()).unwrap_or_default(); |
| 126 | json.push('\n'); |
| 127 | json |
| 128 | } |
| 129 | |
| 130 | /// Write `contents` to `file`, creating its directory, unless the file already exists. |
| 131 | pub fn create(file: &Path, contents: &str) -> std::io::Result<()> { |
| 132 | if let Some(dir) = file.parent() { |
| 133 | fs::create_dir_all(dir)?; |
| 134 | } |
| 135 | let mut new = fs::OpenOptions::new().write(true).create_new(true).open(file)?; |
| 136 | std::io::Write::write_all(&mut new, contents.as_bytes()) |
| 137 | } |
| 138 | |
| 139 | #[cfg(test)] |
| 140 | mod tests { |
| 141 | use super::*; |
| 142 | |
| 143 | #[test] |
| 144 | fn the_defaults_read_back_as_the_defaults() { |
| 145 | assert_eq!(parse(&defaults_json()).unwrap(), Config::default()); |
| 146 | assert_eq!(parse("{}").unwrap(), Config::default()); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn keys_override_the_defaults() { |
| 151 | let config = parse(r#"{"device": "AA:BB:CC:DD:EE:FF", "gui": {"idle_seconds": 30}}"#).unwrap(); |
| 152 | assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap())); |
| 153 | assert_eq!(config.gui.idle_seconds, 30); |
| 154 | assert_eq!(config.gui.start_page, "overview"); |
| 155 | assert!(config.gui.motion, "things move unless the file says not to"); |
| 156 | assert!(!parse(r#"{"gui": {"motion": false}}"#).unwrap().gui.motion); |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn bad_files_are_errors() { |
| 161 | assert!(parse("not json").is_err()); |
| 162 | assert!( |
| 163 | parse(r#"{"devise": "AA:BB:CC:DD:EE:FF"}"#) |
| 164 | .unwrap_err() |
| 165 | .contains("devise") |
| 166 | ); |
| 167 | assert!(parse(r#"{"device": "nope"}"#).unwrap_err().contains("device")); |
| 168 | assert!( |
| 169 | parse(r#"{"gui": {"idle_seconds": 0}}"#) |
| 170 | .unwrap_err() |
| 171 | .contains("idle_seconds") |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn a_missing_file_is_created_and_an_existing_one_kept() { |
| 177 | let dir = std::env::temp_dir().join(format!("budctl-config-test-{}", std::process::id())); |
| 178 | let file = dir.join("nested/config.json"); |
| 179 | let _ = fs::remove_dir_all(&dir); |
| 180 | |
| 181 | assert_eq!(load_from(&file), (Config::default(), None)); |
| 182 | assert_eq!(fs::read_to_string(&file).unwrap(), defaults_json()); |
| 183 | |
| 184 | fs::write(&file, r#"{"gui": {"start_page": "equalizer"}}"#).unwrap(); |
| 185 | assert_eq!(load_from(&file).0.gui.start_page, "equalizer"); |
| 186 | assert!(create(&file, "{}").is_err(), "an existing file is never overwritten"); |
| 187 | |
| 188 | fs::write(&file, "{").unwrap(); |
| 189 | let (config, problem) = load_from(&file); |
| 190 | assert_eq!(config, Config::default()); |
| 191 | assert!(problem.unwrap().contains("config.json")); |
| 192 | let _ = fs::remove_dir_all(&dir); |
| 193 | } |
| 194 | } |