Skip to main content

cfgmatic_source/domain/
format.rs

1//! Configuration format detection and parsing.
2//!
3//! This module provides the [`Format`] enum which represents
4//! supported configuration file formats and provides parsing capabilities.
5//!
6//! # Supported Formats
7//!
8//! - **TOML** - Tom's Obvious Minimal Language (enabled by `toml` feature)
9//! - **JSON** - JavaScript Object Notation (enabled by `json` feature)
10//! - **YAML** - YAML Ain't Markup Language (enabled by `yaml` feature)
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use cfgmatic_source::domain::Format;
16//! use std::path::Path;
17//!
18//! // Detect format from path
19//! let format = Format::from_path(&Path::new("config.toml"));
20//! assert_eq!(format, Some(Format::Toml));
21//!
22//! // Parse content
23//! let content = r#"server = { host = "localhost", port = 8080 }"#;
24//! let parsed = Format::Toml.parse(content)?;
25//! ```
26
27use std::path::Path;
28
29use serde::{Deserialize, Serialize, de::DeserializeOwned};
30
31use super::{ParsedContent, Result, SourceError};
32
33/// Supported configuration file formats.
34///
35/// Each variant corresponds to a specific file format
36/// and provides parsing capabilities.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum Format {
40    /// TOML format (requires `toml` feature).
41    #[cfg(feature = "toml")]
42    Toml,
43
44    /// JSON format (requires `json` feature).
45    #[cfg(feature = "json")]
46    Json,
47
48    /// YAML format (requires `yaml` feature).
49    #[cfg(feature = "yaml")]
50    Yaml,
51
52    /// Unknown or unsupported format.
53    Unknown,
54}
55
56impl Format {
57    /// All supported formats.
58    #[must_use]
59    pub const fn all() -> &'static [Self] {
60        &[
61            #[cfg(feature = "toml")]
62            Self::Toml,
63            #[cfg(feature = "json")]
64            Self::Json,
65            #[cfg(feature = "yaml")]
66            Self::Yaml,
67        ]
68    }
69
70    /// Get the file extension for this format.
71    #[must_use]
72    pub const fn extension(&self) -> &'static str {
73        match self {
74            #[cfg(feature = "toml")]
75            Self::Toml => "toml",
76            #[cfg(feature = "json")]
77            Self::Json => "json",
78            #[cfg(feature = "yaml")]
79            Self::Yaml => "yaml",
80            Self::Unknown => "",
81        }
82    }
83
84    /// Get additional file extensions for this format.
85    #[must_use]
86    pub const fn alternate_extensions(&self) -> &'static [&'static str] {
87        match self {
88            #[cfg(feature = "yaml")]
89            Self::Yaml => &["yml"],
90            _ => &[],
91        }
92    }
93
94    /// Get the MIME type for this format.
95    #[must_use]
96    pub const fn mime_type(&self) -> &'static str {
97        match self {
98            #[cfg(feature = "toml")]
99            Self::Toml => "application/toml",
100            #[cfg(feature = "json")]
101            Self::Json => "application/json",
102            #[cfg(feature = "yaml")]
103            Self::Yaml => "application/x-yaml",
104            Self::Unknown => "application/octet-stream",
105        }
106    }
107
108    /// Get the display name for this format.
109    #[must_use]
110    pub const fn as_str(&self) -> &'static str {
111        match self {
112            #[cfg(feature = "toml")]
113            Self::Toml => "toml",
114            #[cfg(feature = "json")]
115            Self::Json => "json",
116            #[cfg(feature = "yaml")]
117            Self::Yaml => "yaml",
118            Self::Unknown => "unknown",
119        }
120    }
121
122    /// Detect format from a file path.
123    ///
124    /// Examines the file extension to determine the format.
125    ///
126    /// # Example
127    ///
128    /// ```rust,ignore
129    /// use std::path::Path;
130    /// use cfgmatic_source::domain::Format;
131    ///
132    /// let format = Format::from_path(Path::new("config.toml"));
133    /// assert_eq!(format, Some(Format::Toml));
134    /// ```
135    #[must_use]
136    pub fn from_path(path: &Path) -> Option<Self> {
137        let ext = path.extension()?.to_str()?.to_lowercase();
138        Self::from_extension(&ext)
139    }
140
141    /// Detect format from a file extension.
142    ///
143    /// # Example
144    ///
145    /// ```rust,ignore
146    /// use cfgmatic_source::domain::Format;
147    ///
148    /// let format = Format::from_extension("toml");
149    /// assert_eq!(format, Some(Format::Toml));
150    /// ```
151    #[must_use]
152    pub fn from_extension(ext: &str) -> Option<Self> {
153        let ext_lower = ext.to_lowercase();
154
155        #[cfg(feature = "toml")]
156        if ext_lower == "toml" {
157            return Some(Self::Toml);
158        }
159
160        #[cfg(feature = "json")]
161        if ext_lower == "json" {
162            return Some(Self::Json);
163        }
164
165        #[cfg(feature = "yaml")]
166        if ext_lower == "yaml" || ext_lower == "yml" {
167            return Some(Self::Yaml);
168        }
169
170        None
171    }
172
173    /// Detect format from content by examining the beginning.
174    ///
175    /// This is a heuristic and may not always be accurate.
176    ///
177    /// # Example
178    ///
179    /// ```rust,ignore
180    /// use cfgmatic_source::domain::Format;
181    ///
182    /// let content = r#"{ "server": { "host": "localhost" } }"#;
183    /// let format = Format::from_content(content);
184    /// assert_eq!(format, Some(Format::Json));
185    /// ```
186    #[must_use]
187    pub fn from_content(content: &str) -> Option<Self> {
188        let trimmed = content.trim_start();
189
190        // JSON objects or arrays start with { or [
191        #[cfg(feature = "json")]
192        if trimmed.starts_with('{') || trimmed.starts_with('[') {
193            // Could be JSON or YAML (YAML can have flow style)
194            // Try to parse as JSON first
195            if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
196                return Some(Self::Json);
197            }
198        }
199
200        // YAML can start with various characters
201        // Check for common YAML patterns
202        #[cfg(feature = "yaml")]
203        if trimmed.starts_with("---")
204            || trimmed.contains(": ")
205            || trimmed.lines().any(|line| line.trim().starts_with("- "))
206        {
207            return Some(Self::Yaml);
208        }
209
210        // TOML typically has key = value pairs or [sections]
211        #[cfg(feature = "toml")]
212        if trimmed.contains(" = ")
213            || trimmed.starts_with('[')
214            || trimmed.lines().any(|line| {
215                let line = line.trim();
216                line.starts_with('#') || line.contains('=')
217            })
218        {
219            return Some(Self::Toml);
220        }
221
222        None
223    }
224
225    /// Parse content string into a generic value.
226    ///
227    /// # Errors
228    ///
229    /// Returns a [`SourceError::ParseFailed`] if parsing fails.
230    ///
231    /// # Example
232    ///
233    /// ```rust,ignore
234    /// use cfgmatic_source::domain::Format;
235    ///
236    /// let content = r#"host = "localhost""#;
237    /// let parsed = Format::Toml.parse(content)?;
238    /// ```
239    pub fn parse(&self, content: &str) -> Result<ParsedContent> {
240        match self {
241            #[cfg(feature = "toml")]
242            Self::Toml => self.parse_toml(content),
243
244            #[cfg(feature = "json")]
245            Self::Json => self.parse_json(content),
246
247            #[cfg(feature = "yaml")]
248            Self::Yaml => self.parse_yaml(content),
249
250            Self::Unknown => Err(SourceError::unsupported("cannot parse unknown format")),
251        }
252    }
253
254    /// Parse content into a specific type.
255    ///
256    /// # Errors
257    ///
258    /// Returns a [`SourceError::ParseFailed`] if parsing or deserialization fails.
259    ///
260    /// # Example
261    ///
262    /// ```rust,ignore
263    /// use cfgmatic_source::domain::Format;
264    /// use serde::Deserialize;
265    ///
266    /// #[derive(Deserialize)]
267    /// struct Config {
268    ///     host: String,
269    /// }
270    ///
271    /// let content = r#"host = "localhost""#;
272    /// let config: Config = Format::Toml.parse_as(content)?;
273    /// ```
274    pub fn parse_as<T: DeserializeOwned>(&self, content: &str) -> Result<T> {
275        match self {
276            #[cfg(feature = "toml")]
277            Self::Toml => toml::from_str(content)
278                .map_err(|e| SourceError::parse_failed("", "toml", &e.to_string())),
279
280            #[cfg(feature = "json")]
281            Self::Json => serde_json::from_str(content)
282                .map_err(|e| SourceError::parse_failed("", "json", &e.to_string())),
283
284            #[cfg(feature = "yaml")]
285            Self::Yaml => serde_yaml::from_str(content)
286                .map_err(|e| SourceError::parse_failed("", "yaml", &e.to_string())),
287
288            Self::Unknown => Err(SourceError::unsupported("cannot parse unknown format")),
289        }
290    }
291
292    #[cfg(feature = "toml")]
293    fn parse_toml(&self, content: &str) -> Result<ParsedContent> {
294        let value: toml::Value = toml::from_str(content)
295            .map_err(|e| SourceError::parse_failed("", "toml", &e.to_string()))?;
296        Ok(ParsedContent::from_toml(value))
297    }
298
299    #[cfg(feature = "json")]
300    fn parse_json(&self, content: &str) -> Result<ParsedContent> {
301        let value: serde_json::Value = serde_json::from_str(content)
302            .map_err(|e| SourceError::parse_failed("", "json", &e.to_string()))?;
303        Ok(ParsedContent::from_json(value))
304    }
305
306    #[cfg(feature = "yaml")]
307    fn parse_yaml(&self, content: &str) -> Result<ParsedContent> {
308        let value: serde_yaml::Value = serde_yaml::from_str(content)
309            .map_err(|e| SourceError::parse_failed("", "yaml", &e.to_string()))?;
310        Ok(ParsedContent::from_yaml(value))
311    }
312
313    /// Check if this format is known (not Unknown).
314    #[must_use]
315    pub const fn is_known(&self) -> bool {
316        !matches!(self, Self::Unknown)
317    }
318
319    /// Check if this format is binary.
320    ///
321    /// All currently supported formats are text-based.
322    #[must_use]
323    pub const fn is_binary(&self) -> bool {
324        false
325    }
326}
327
328impl std::fmt::Display for Format {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        write!(f, "{}", self.as_str())
331    }
332}
333
334impl Default for Format {
335    fn default() -> Self {
336        Self::Unknown
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_format_extension() {
346        #[cfg(feature = "toml")]
347        assert_eq!(Format::Toml.extension(), "toml");
348
349        #[cfg(feature = "json")]
350        assert_eq!(Format::Json.extension(), "json");
351
352        #[cfg(feature = "yaml")]
353        assert_eq!(Format::Yaml.extension(), "yaml");
354
355        assert_eq!(Format::Unknown.extension(), "");
356    }
357
358    #[test]
359    fn test_format_from_extension() {
360        #[cfg(feature = "toml")]
361        assert_eq!(Format::from_extension("toml"), Some(Format::Toml));
362
363        #[cfg(feature = "json")]
364        assert_eq!(Format::from_extension("json"), Some(Format::Json));
365
366        #[cfg(feature = "yaml")]
367        {
368            assert_eq!(Format::from_extension("yaml"), Some(Format::Yaml));
369            assert_eq!(Format::from_extension("yml"), Some(Format::Yaml));
370        }
371
372        assert_eq!(Format::from_extension("unknown"), None);
373    }
374
375    #[test]
376    fn test_format_from_path() {
377        #[cfg(feature = "toml")]
378        assert_eq!(
379            Format::from_path(std::path::Path::new("config.toml")),
380            Some(Format::Toml)
381        );
382
383        #[cfg(feature = "json")]
384        assert_eq!(
385            Format::from_path(std::path::Path::new("data.json")),
386            Some(Format::Json)
387        );
388
389        assert_eq!(Format::from_path(std::path::Path::new("README")), None);
390    }
391
392    #[test]
393    fn test_format_as_str() {
394        #[cfg(feature = "toml")]
395        assert_eq!(Format::Toml.as_str(), "toml");
396
397        assert_eq!(Format::Unknown.as_str(), "unknown");
398    }
399
400    #[test]
401    fn test_format_display() {
402        #[cfg(feature = "toml")]
403        assert_eq!(format!("{}", Format::Toml), "toml");
404
405        assert_eq!(format!("{}", Format::Unknown), "unknown");
406    }
407
408    #[test]
409    fn test_format_is_known() {
410        #[cfg(feature = "toml")]
411        assert!(Format::Toml.is_known());
412
413        assert!(!Format::Unknown.is_known());
414    }
415
416    #[test]
417    #[cfg(feature = "toml")]
418    fn test_format_parse_toml() {
419        let content = r#"
420            [server]
421            host = "localhost"
422            port = 8080
423        "#;
424
425        let result = Format::Toml.parse(content);
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    #[cfg(feature = "json")]
431    fn test_format_parse_json() {
432        let content = r#"{"server": {"host": "localhost", "port": 8080}}"#;
433
434        let result = Format::Json.parse(content);
435        assert!(result.is_ok());
436    }
437
438    #[test]
439    #[cfg(feature = "yaml")]
440    fn test_format_parse_yaml() {
441        let content = r#"
442            server:
443              host: localhost
444              port: 8080
445        "#;
446
447        let result = Format::Yaml.parse(content);
448        assert!(result.is_ok());
449    }
450
451    #[test]
452    fn test_format_parse_unknown() {
453        let result = Format::Unknown.parse("some content");
454        assert!(result.is_err());
455    }
456
457    #[test]
458    #[cfg(feature = "toml")]
459    fn test_format_parse_as_toml() {
460        use serde::Deserialize;
461
462        #[derive(Debug, Deserialize, PartialEq)]
463        struct Server {
464            host: String,
465            port: u16,
466        }
467
468        #[derive(Debug, Deserialize)]
469        struct Config {
470            server: Server,
471        }
472
473        let content = r#"
474            [server]
475            host = "localhost"
476            port = 8080
477        "#;
478
479        let config: Config = Format::Toml.parse_as(content).unwrap();
480        assert_eq!(config.server.host, "localhost");
481        assert_eq!(config.server.port, 8080);
482    }
483
484    #[test]
485    #[cfg(feature = "json")]
486    fn test_format_from_content_json() {
487        let content = r#"{"key": "value"}"#;
488        let format = Format::from_content(content);
489        assert_eq!(format, Some(Format::Json));
490    }
491
492    #[test]
493    fn test_format_mime_type() {
494        #[cfg(feature = "json")]
495        assert_eq!(Format::Json.mime_type(), "application/json");
496
497        #[cfg(feature = "toml")]
498        assert_eq!(Format::Toml.mime_type(), "application/toml");
499
500        assert_eq!(Format::Unknown.mime_type(), "application/octet-stream");
501    }
502
503    #[test]
504    fn test_format_serialization() {
505        #[cfg(feature = "toml")]
506        {
507            let format = Format::Toml;
508            let json = serde_json::to_string(&format).unwrap();
509            assert_eq!(json, "\"toml\"");
510        }
511    }
512}