streamdown_config/lib.rs
1//! Streamdown Config
2//!
3//! This crate handles configuration loading and management
4//! for streamdown, supporting TOML configuration files.
5//!
6//! # Overview
7//!
8//! Configuration is loaded from platform-specific locations:
9//! - Linux: `~/.config/streamdown/config.toml`
10//! - macOS: `~/Library/Application Support/streamdown/config.toml`
11//! - Windows: `%APPDATA%\streamdown\config.toml`
12//!
13//! # Example
14//!
15//! ```no_run
16//! use streamdown_config::Config;
17//!
18//! // Load config with defaults
19//! let config = Config::load().unwrap();
20//!
21//! // Or load with an override file
22//! let config = Config::load_with_override(Some("./custom.toml".as_ref())).unwrap();
23//! ```
24
25mod computed;
26mod features;
27mod style;
28
29pub use computed::ComputedStyle;
30pub use features::FeaturesConfig;
31pub use style::{HsvMultiplier, StyleConfig};
32
33use serde::{Deserialize, Serialize};
34use std::path::{Path, PathBuf};
35use streamdown_core::{Result, StreamdownError};
36
37/// Default TOML configuration string.
38///
39/// This matches the Python implementation's default_toml exactly.
40const DEFAULT_TOML: &str = r#"[features]
41CodeSpaces = false
42Clipboard = true
43Logging = false
44Timeout = 0.1
45Savebrace = true
46Images = true
47Links = true
48
49[style]
50Margin = 2
51ListIndent = 2
52PrettyPad = true
53PrettyBroken = true
54Width = 0
55HSV = [0.8, 0.5, 0.5]
56Dark = { H = 1.00, S = 1.50, V = 0.25 }
57Mid = { H = 1.00, S = 1.00, V = 0.50 }
58Symbol = { H = 1.00, S = 1.00, V = 1.50 }
59Head = { H = 1.00, S = 1.00, V = 1.75 }
60Grey = { H = 1.00, S = 0.25, V = 1.37 }
61Bright = { H = 1.00, S = 0.60, V = 2.00 }
62Syntax = "native"
63"#;
64
65/// Main configuration structure.
66///
67/// Contains all configuration sections for streamdown.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Config {
70 /// Feature flags configuration
71 #[serde(default)]
72 pub features: FeaturesConfig,
73
74 /// Style configuration
75 #[serde(default)]
76 pub style: StyleConfig,
77}
78
79impl Default for Config {
80 fn default() -> Self {
81 // Parse the default TOML to ensure consistency
82 toml::from_str(DEFAULT_TOML).expect("Default TOML should be valid")
83 }
84}
85
86impl Config {
87 /// Returns the default TOML configuration string.
88 ///
89 /// This can be used to show users the default config or
90 /// to write a default config file.
91 ///
92 /// # Example
93 ///
94 /// ```
95 /// use streamdown_config::Config;
96 /// let toml = Config::default_toml();
97 /// assert!(toml.contains("[features]"));
98 /// assert!(toml.contains("[style]"));
99 /// ```
100 pub fn default_toml() -> &'static str {
101 DEFAULT_TOML
102 }
103
104 /// Returns the platform-specific configuration file path.
105 ///
106 /// # Example
107 ///
108 /// ```
109 /// use streamdown_config::Config;
110 /// if let Some(path) = Config::config_path() {
111 /// println!("Config path: {}", path.display());
112 /// }
113 /// ```
114 pub fn config_path() -> Option<PathBuf> {
115 directories::ProjectDirs::from("", "", "streamdown")
116 .map(|dirs| dirs.config_dir().join("config.toml"))
117 }
118
119 /// Returns the platform-specific configuration directory.
120 pub fn config_dir() -> Option<PathBuf> {
121 directories::ProjectDirs::from("", "", "streamdown")
122 .map(|dirs| dirs.config_dir().to_path_buf())
123 }
124
125 /// Ensures the config file exists, creating it with defaults if not.
126 ///
127 /// This mirrors the Python `ensure_config_file` function.
128 ///
129 /// # Returns
130 ///
131 /// The path to the config file.
132 ///
133 /// # Example
134 ///
135 /// ```no_run
136 /// use streamdown_config::Config;
137 /// let path = Config::ensure_config_file().unwrap();
138 /// assert!(path.exists());
139 /// ```
140 pub fn ensure_config_file() -> Result<PathBuf> {
141 let config_dir = Self::config_dir().ok_or_else(|| {
142 StreamdownError::Config("Could not determine config directory".into())
143 })?;
144
145 // Create directory if it doesn't exist
146 std::fs::create_dir_all(&config_dir)?;
147
148 let config_path = config_dir.join("config.toml");
149
150 // Create default config if file doesn't exist
151 if !config_path.exists() {
152 std::fs::write(&config_path, DEFAULT_TOML)?;
153 }
154
155 Ok(config_path)
156 }
157
158 /// Load configuration from the default platform-specific path.
159 ///
160 /// If no config file exists, returns the default configuration.
161 ///
162 /// # Example
163 ///
164 /// ```no_run
165 /// use streamdown_config::Config;
166 /// let config = Config::load().unwrap();
167 /// ```
168 pub fn load() -> Result<Self> {
169 if let Some(config_path) = Self::config_path() {
170 if config_path.exists() {
171 let content = std::fs::read_to_string(&config_path)?;
172 return toml::from_str(&content)
173 .map_err(|e| StreamdownError::Config(format!("Parse error: {}", e)));
174 }
175 }
176
177 // Return defaults if no config found
178 Ok(Self::default())
179 }
180
181 /// Load configuration from a specific path.
182 ///
183 /// # Arguments
184 ///
185 /// * `path` - Path to the TOML configuration file
186 ///
187 /// # Example
188 ///
189 /// ```no_run
190 /// use streamdown_config::Config;
191 /// use std::path::Path;
192 /// let config = Config::load_from(Path::new("./config.toml")).unwrap();
193 /// ```
194 pub fn load_from(path: &Path) -> Result<Self> {
195 let content = std::fs::read_to_string(path)?;
196 toml::from_str(&content).map_err(|e| {
197 StreamdownError::Config(format!("Parse error in {}: {}", path.display(), e))
198 })
199 }
200
201 /// Load configuration with an optional override file or string.
202 ///
203 /// This mirrors the Python `ensure_config_file` behavior:
204 /// 1. Load the base config from the default location
205 /// 2. If override_path is provided:
206 /// - If it's a path to an existing file, load and merge it
207 /// - Otherwise, treat it as a TOML string and parse it
208 ///
209 /// # Arguments
210 ///
211 /// * `override_config` - Optional path to override file or inline TOML string
212 ///
213 /// # Example
214 ///
215 /// ```no_run
216 /// use streamdown_config::Config;
217 ///
218 /// // Load with file override
219 /// let config = Config::load_with_override(Some("./custom.toml".as_ref())).unwrap();
220 ///
221 /// // Load with inline TOML override
222 /// let config = Config::load_with_override(Some("[features]\nLinks = false".as_ref())).unwrap();
223 /// ```
224 pub fn load_with_override(override_config: Option<&str>) -> Result<Self> {
225 // Start with base config
226 let mut config = Self::load()?;
227
228 // Apply override if provided
229 if let Some(override_str) = override_config {
230 let override_path = Path::new(override_str);
231
232 let override_toml = if override_path.exists() {
233 // It's a file path
234 std::fs::read_to_string(override_path)?
235 } else {
236 // Treat as inline TOML
237 override_str.to_string()
238 };
239
240 // Parse and merge
241 let override_config: Config = toml::from_str(&override_toml)
242 .map_err(|e| StreamdownError::Config(format!("Override parse error: {}", e)))?;
243
244 config.merge(&override_config);
245 }
246
247 Ok(config)
248 }
249
250 /// Merge another config into this one.
251 ///
252 /// Values from `other` take precedence over values in `self`.
253 /// This is used for applying CLI overrides or secondary config files.
254 ///
255 /// # Arguments
256 ///
257 /// * `other` - The config to merge from
258 ///
259 /// # Example
260 ///
261 /// ```
262 /// use streamdown_config::Config;
263 ///
264 /// let mut base = Config::default();
265 /// let override_config: Config = toml::from_str(r#"
266 /// [features]
267 /// Links = false
268 /// "#).unwrap();
269 ///
270 /// base.merge(&override_config);
271 /// assert!(!base.features.links);
272 /// ```
273 pub fn merge(&mut self, other: &Config) {
274 self.features.merge(&other.features);
275 self.style.merge(&other.style);
276 }
277
278 /// Save configuration to a file.
279 ///
280 /// # Arguments
281 ///
282 /// * `path` - Path to save the configuration to
283 pub fn save_to(&self, path: &Path) -> Result<()> {
284 let toml_string = toml::to_string_pretty(self)
285 .map_err(|e| StreamdownError::Config(format!("Serialization error: {}", e)))?;
286 std::fs::write(path, toml_string)?;
287 Ok(())
288 }
289
290 /// Compute the style values (ANSI codes) from this config.
291 ///
292 /// This applies the HSV multipliers to generate actual ANSI color codes.
293 ///
294 /// # Example
295 ///
296 /// ```
297 /// use streamdown_config::Config;
298 /// let config = Config::default();
299 /// let computed = config.computed_style();
300 /// assert!(!computed.dark.is_empty());
301 /// ```
302 pub fn computed_style(&self) -> ComputedStyle {
303 ComputedStyle::from_config(&self.style)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_default_config() {
313 let config = Config::default();
314 assert!(config.features.links);
315 assert!(config.features.images);
316 assert!(!config.features.code_spaces);
317 assert_eq!(config.style.margin, 2);
318 }
319
320 #[test]
321 fn test_default_toml_parses() {
322 let config: Config = toml::from_str(DEFAULT_TOML).unwrap();
323 assert!(config.features.clipboard);
324 assert_eq!(config.style.syntax, "native");
325 }
326
327 #[test]
328 fn test_merge() {
329 let mut base = Config::default();
330 assert!(base.features.links);
331
332 let override_toml = r#"
333 [features]
334 Links = false
335 [style]
336 Margin = 4
337 "#;
338 let override_config: Config = toml::from_str(override_toml).unwrap();
339
340 base.merge(&override_config);
341 assert!(!base.features.links);
342 assert_eq!(base.style.margin, 4);
343 }
344
345 #[test]
346 fn test_config_path() {
347 // Just verify it returns something on most platforms
348 let path = Config::config_path();
349 // On CI/containers this might be None, so we just check it doesn't panic
350 if let Some(p) = path {
351 assert!(p.to_string_lossy().contains("streamdown"));
352 }
353 }
354
355 #[test]
356 fn test_computed_style() {
357 let config = Config::default();
358 let computed = config.computed_style();
359
360 // Verify computed values are non-empty ANSI-like strings
361 assert!(computed.dark.contains(';'));
362 assert!(computed.mid.contains(';'));
363 assert!(computed.margin_spaces.len() == config.style.margin);
364 }
365
366 #[test]
367 fn test_roundtrip_serialization() {
368 let config = Config::default();
369 let toml_str = toml::to_string_pretty(&config).unwrap();
370 let parsed: Config = toml::from_str(&toml_str).unwrap();
371
372 assert_eq!(config.features.links, parsed.features.links);
373 assert_eq!(config.style.margin, parsed.style.margin);
374 }
375}