Krolyxon
Commit 7b28e7d

Branches

Refs →

budctlcratesbudctl-coresrcconfig.rs

188 lines · 6.4 KB · Rust

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
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use 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)]
19pub 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)]
28pub 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
35impl 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.
45const MAX_IDLE_SECONDS: u64 = 600;
46
47impl 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.
66pub 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`.
73pub 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.
78pub fn state_dir() -> Option<PathBuf> {
79 xdg_dir("XDG_STATE_HOME", ".local/state")
80}
81
82fn 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`].
91pub 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.
97pub fn load() -> (Config, Option<String>) {
98 let Some(file) = file() else {
99 return (Config::default(), None);
100 };
101 load_from(&file)
102}
103
104fn 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.
119pub 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.
127pub 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)]
136mod 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}