Skip to main content

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}