Skip to main content

sayd_core/
config.rs

1//! Persistent settings. TOML at `$XDG_CONFIG_HOME/sayd/config.toml`.
2//!
3//! Loading never fails: a missing file yields defaults silently, a malformed
4//! one yields defaults plus a message for the UI to surface. The user's file
5//! is never overwritten just because it failed to parse.
6
7use 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    /// Replace a bare URL with the word "link".
15    Link,
16    /// Replace a bare URL with its host, e.g. "example.com".
17    Domain,
18    /// Leave it alone.
19    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    /// `fp32` | `fp16` | `q8`. Measured: fp32 RTF 4.78, fp16 4.66, q8 1.40.
65    pub model: String,
66    /// Measured peak at 8; 16 and 24 both regress.
67    pub threads: usize,
68    /// Seconds of an empty queue before the ~1.27 GB ORT session is dropped.
69    pub idle_unload_secs: u64,
70    pub muted: bool,
71    /// Submissions longer than this are refused.
72    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    /// `$XDG_CONFIG_HOME/sayd/config.toml`, falling back to `~/.config`.
95    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    /// Returns the config and, if the file existed but could not be parsed,
108    /// a human-readable reason. A missing file is not an error.
109    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    /// Write atomically: a temp file in the same directory, then rename. The
124    /// caller is responsible for ignoring the resulting inotify event.
125    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}