Krolyxon
Commit 0155f71

Branches

Refs →

budctlcratesbudctl-coresrcconfig.rs

194 lines · 6.7 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 /// Whether things move: fades, switches, the battery rings' sweep. Off, every change shows
34 /// at once.
35 pub motion: bool,
36}
37
38impl 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.
49const MAX_IDLE_SECONDS: u64 = 600;
50
51impl 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.
70pub 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`.
77pub 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.
82pub fn state_dir() -> Option<PathBuf> {
83 xdg_dir("XDG_STATE_HOME", ".local/state")
84}
85
86fn 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`].
95pub 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.
101pub fn load() -> (Config, Option<String>) {
102 let Some(file) = file() else {
103 return (Config::default(), None);
104 };
105 load_from(&file)
106}
107
108fn 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.
123pub 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.
131pub 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)]
140mod 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}