1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum UrlPolicy {
14 Link,
16 Domain,
18 Keep,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(default)]
24pub struct CleanupConfig {
25 pub collapse_whitespace: bool,
26 pub rejoin_hyphenation: bool,
27 pub urls: UrlPolicy,
28 pub strip_markdown: bool,
29 pub drop_code_blocks: bool,
30 pub spell_acronyms: bool,
31}
32
33impl Default for CleanupConfig {
34 fn default() -> Self {
35 CleanupConfig {
36 collapse_whitespace: true,
37 rejoin_hyphenation: true,
38 urls: UrlPolicy::Link,
39 strip_markdown: true,
40 drop_code_blocks: true,
41 spell_acronyms: true,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(default)]
48pub struct ChunkConfig {
49 pub target_chars: usize,
50 pub lookahead_chunks: usize,
51}
52
53impl Default for ChunkConfig {
54 fn default() -> Self {
55 ChunkConfig { target_chars: 400, lookahead_chunks: 2 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(default)]
61pub struct Config {
62 pub voice: String,
63 pub speed: f32,
64 pub model: String,
66 pub threads: usize,
68 pub idle_unload_secs: u64,
70 pub muted: bool,
71 pub max_chars: usize,
73 pub cleanup: CleanupConfig,
74 pub chunking: ChunkConfig,
75}
76
77impl Default for Config {
78 fn default() -> Self {
79 Config {
80 voice: "af_heart".into(),
81 speed: 1.0,
82 model: "fp32".into(),
83 threads: 8,
84 idle_unload_secs: 600,
85 muted: false,
86 max_chars: 20_000,
87 cleanup: CleanupConfig::default(),
88 chunking: ChunkConfig::default(),
89 }
90 }
91}
92
93impl Config {
94 pub fn path() -> PathBuf {
96 let base = std::env::var_os("XDG_CONFIG_HOME")
97 .map(PathBuf::from)
98 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
99 .unwrap_or_else(|| PathBuf::from("."));
100 base.join("sayd").join("config.toml")
101 }
102
103 pub fn load() -> (Config, Option<String>) {
104 Self::load_from(&Self::path())
105 }
106
107 pub fn load_from(path: &Path) -> (Config, Option<String>) {
110 let txt = match std::fs::read_to_string(path) {
111 Ok(t) => t,
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113 return (Config::default(), None)
114 }
115 Err(e) => return (Config::default(), Some(format!("{}: {e}", path.display()))),
116 };
117 match toml::from_str(&txt) {
118 Ok(c) => (c, None),
119 Err(e) => (Config::default(), Some(format!("{}: {e}", path.display()))),
120 }
121 }
122
123 pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
126 if let Some(dir) = path.parent() {
127 std::fs::create_dir_all(dir)?;
128 }
129 let txt = toml::to_string_pretty(self)
130 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
131 let tmp = path.with_extension("toml.tmp");
132 std::fs::write(&tmp, txt)?;
133 std::fs::rename(&tmp, path)
134 }
135
136 pub fn save(&self) -> std::io::Result<()> {
137 self.save_to(&Self::path())
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn defaults_match_the_measured_benchmarks() {
147 let c = Config::default();
148 assert_eq!(c.model, "fp32");
149 assert_eq!(c.threads, 8);
150 assert_eq!(c.speed, 1.0);
151 assert_eq!(c.idle_unload_secs, 600);
152 assert!(!c.muted);
153 assert_eq!(c.chunking.lookahead_chunks, 2);
154 }
155
156 #[test]
157 fn roundtrips_through_toml() {
158 let c = Config {
159 voice: "am_fenrir".into(),
160 speed: 1.25,
161 cleanup: CleanupConfig {
162 urls: UrlPolicy::Domain,
163 ..CleanupConfig::default()
164 },
165 ..Config::default()
166 };
167 let dir = tempfile::tempdir().expect("tempdir");
168 let p = dir.path().join("config.toml");
169 c.save_to(&p).expect("save");
170 let (back, err) = Config::load_from(&p);
171 assert_eq!(err, None);
172 assert_eq!(back, c);
173 }
174
175 #[test]
176 fn missing_file_yields_defaults_without_an_error() {
177 let dir = tempfile::tempdir().expect("tempdir");
178 let (c, err) = Config::load_from(&dir.path().join("nope.toml"));
179 assert_eq!(c, Config::default());
180 assert_eq!(err, None, "a missing config is normal, not an error");
181 }
182
183 #[test]
184 fn malformed_file_yields_defaults_and_reports_the_error() {
185 let dir = tempfile::tempdir().expect("tempdir");
186 let p = dir.path().join("config.toml");
187 std::fs::write(&p, "voice = [this is not toml").expect("write");
188 let (c, err) = Config::load_from(&p);
189 assert_eq!(c, Config::default());
190 assert!(err.is_some(), "a malformed config must be surfaced, not swallowed");
191 }
192
193 #[test]
194 fn partial_file_fills_the_rest_from_defaults() {
195 let dir = tempfile::tempdir().expect("tempdir");
196 let p = dir.path().join("config.toml");
197 std::fs::write(&p, "voice = \"bm_george\"\n").expect("write");
198 let (c, err) = Config::load_from(&p);
199 assert_eq!(err, None);
200 assert_eq!(c.voice, "bm_george");
201 assert_eq!(c.threads, 8, "unspecified keys must keep their defaults");
202 }
203
204 #[test]
205 fn save_is_atomic_leaving_no_temp_file_behind() {
206 let dir = tempfile::tempdir().expect("tempdir");
207 let p = dir.path().join("config.toml");
208 Config::default().save_to(&p).expect("save");
209 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
210 .expect("read_dir")
211 .filter_map(|e| e.ok())
212 .map(|e| e.file_name().to_string_lossy().to_string())
213 .filter(|n| n != "config.toml")
214 .collect();
215 assert!(leftovers.is_empty(), "unexpected files: {leftovers:?}");
216 }
217}