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}