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