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()
142 .ok_or_else(|| StreamdownError::Config("Could not determine config directory".into()))?;
143
144 // Create directory if it doesn't exist
145 std::fs::create_dir_all(&config_dir)?;
146
147 let config_path = config_dir.join("config.toml");
148
149 // Create default config if file doesn't exist
150 if !config_path.exists() {
151 std::fs::write(&config_path, DEFAULT_TOML)?;
152 }
153
154 Ok(config_path)
155 }
156
157 /// Load configuration from the default platform-specific path.
158 ///
159 /// If no config file exists, returns the default configuration.
160 ///
161 /// # Example
162 ///
163 /// ```no_run
164 /// use streamdown_config::Config;
165 /// let config = Config::load().unwrap();
166 /// ```
167 pub fn load() -> Result<Self> {
168 if let Some(config_path) = Self::config_path() {
169 if config_path.exists() {
170 let content = std::fs::read_to_string(&config_path)?;
171 return toml::from_str(&content)
172 .map_err(|e| StreamdownError::Config(format!("Parse error: {}", e)));
173 }
174 }
175
176 // Return defaults if no config found
177 Ok(Self::default())
178 }
179
180 /// Load configuration from a specific path.
181 ///
182 /// # Arguments
183 ///
184 /// * `path` - Path to the TOML configuration file
185 ///
186 /// # Example
187 ///
188 /// ```no_run
189 /// use streamdown_config::Config;
190 /// use std::path::Path;
191 /// let config = Config::load_from(Path::new("./config.toml")).unwrap();
192 /// ```
193 pub fn load_from(path: &Path) -> Result<Self> {
194 let content = std::fs::read_to_string(path)?;
195 toml::from_str(&content)
196 .map_err(|e| StreamdownError::Config(format!("Parse error in {}: {}", path.display(), e)))
197 }
198
199 /// Load configuration with an optional override file or string.
200 ///
201 /// This mirrors the Python `ensure_config_file` behavior:
202 /// 1. Load the base config from the default location
203 /// 2. If override_path is provided:
204 /// - If it's a path to an existing file, load and merge it
205 /// - Otherwise, treat it as a TOML string and parse it
206 ///
207 /// # Arguments
208 ///
209 /// * `override_config` - Optional path to override file or inline TOML string
210 ///
211 /// # Example
212 ///
213 /// ```no_run
214 /// use streamdown_config::Config;
215 ///
216 /// // Load with file override
217 /// let config = Config::load_with_override(Some("./custom.toml".as_ref())).unwrap();
218 ///
219 /// // Load with inline TOML override
220 /// let config = Config::load_with_override(Some("[features]\nLinks = false".as_ref())).unwrap();
221 /// ```
222 pub fn load_with_override(override_config: Option<&str>) -> Result<Self> {
223 // Start with base config
224 let mut config = Self::load()?;
225
226 // Apply override if provided
227 if let Some(override_str) = override_config {
228 let override_path = Path::new(override_str);
229
230 let override_toml = if override_path.exists() {
231 // It's a file path
232 std::fs::read_to_string(override_path)?
233 } else {
234 // Treat as inline TOML
235 override_str.to_string()
236 };
237
238 // Parse and merge
239 let override_config: Config = toml::from_str(&override_toml)
240 .map_err(|e| StreamdownError::Config(format!("Override parse error: {}", e)))?;
241
242 config.merge(&override_config);
243 }
244
245 Ok(config)
246 }
247
248 /// Merge another config into this one.
249 ///
250 /// Values from `other` take precedence over values in `self`.
251 /// This is used for applying CLI overrides or secondary config files.
252 ///
253 /// # Arguments
254 ///
255 /// * `other` - The config to merge from
256 ///
257 /// # Example
258 ///
259 /// ```
260 /// use streamdown_config::Config;
261 ///
262 /// let mut base = Config::default();
263 /// let override_config: Config = toml::from_str(r#"
264 /// [features]
265 /// Links = false
266 /// "#).unwrap();
267 ///
268 /// base.merge(&override_config);
269 /// assert!(!base.features.links);
270 /// ```
271 pub fn merge(&mut self, other: &Config) {
272 self.features.merge(&other.features);
273 self.style.merge(&other.style);
274 }
275
276 /// Save configuration to a file.
277 ///
278 /// # Arguments
279 ///
280 /// * `path` - Path to save the configuration to
281 pub fn save_to(&self, path: &Path) -> Result<()> {
282 let toml_string = toml::to_string_pretty(self)
283 .map_err(|e| StreamdownError::Config(format!("Serialization error: {}", e)))?;
284 std::fs::write(path, toml_string)?;
285 Ok(())
286 }
287
288 /// Compute the style values (ANSI codes) from this config.
289 ///
290 /// This applies the HSV multipliers to generate actual ANSI color codes.
291 ///
292 /// # Example
293 ///
294 /// ```
295 /// use streamdown_config::Config;
296 /// let config = Config::default();
297 /// let computed = config.computed_style();
298 /// assert!(!computed.dark.is_empty());
299 /// ```
300 pub fn computed_style(&self) -> ComputedStyle {
301 ComputedStyle::from_config(&self.style)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_default_config() {
311 let config = Config::default();
312 assert!(config.features.links);
313 assert!(config.features.images);
314 assert!(!config.features.code_spaces);
315 assert_eq!(config.style.margin, 2);
316 }
317
318 #[test]
319 fn test_default_toml_parses() {
320 let config: Config = toml::from_str(DEFAULT_TOML).unwrap();
321 assert!(config.features.clipboard);
322 assert_eq!(config.style.syntax, "native");
323 }
324
325 #[test]
326 fn test_merge() {
327 let mut base = Config::default();
328 assert!(base.features.links);
329
330 let override_toml = r#"
331 [features]
332 Links = false
333 [style]
334 Margin = 4
335 "#;
336 let override_config: Config = toml::from_str(override_toml).unwrap();
337
338 base.merge(&override_config);
339 assert!(!base.features.links);
340 assert_eq!(base.style.margin, 4);
341 }
342
343 #[test]
344 fn test_config_path() {
345 // Just verify it returns something on most platforms
346 let path = Config::config_path();
347 // On CI/containers this might be None, so we just check it doesn't panic
348 if let Some(p) = path {
349 assert!(p.to_string_lossy().contains("streamdown"));
350 }
351 }
352
353 #[test]
354 fn test_computed_style() {
355 let config = Config::default();
356 let computed = config.computed_style();
357
358 // Verify computed values are non-empty ANSI-like strings
359 assert!(computed.dark.contains(';'));
360 assert!(computed.mid.contains(';'));
361 assert!(computed.margin_spaces.len() == config.style.margin);
362 }
363
364 #[test]
365 fn test_roundtrip_serialization() {
366 let config = Config::default();
367 let toml_str = toml::to_string_pretty(&config).unwrap();
368 let parsed: Config = toml::from_str(&toml_str).unwrap();
369
370 assert_eq!(config.features.links, parsed.features.links);
371 assert_eq!(config.style.margin, parsed.style.margin);
372 }
373}