Skip to main content

teamy_figue/
config_format.rs

1//! Config file format abstraction for layered configuration.
2//!
3//! This module is under active development and not yet wired into the main API.
4//!
5//! This module provides the [`ConfigFormat`] trait for pluggable config file parsing,
6//! along with a built-in [`JsonFormat`] implementation.
7//!
8//! The [`FormatRegistry`](crate::layers::file::FormatRegistry) that manages multiple
9//! formats is in the [`layers::file`](crate::layers::file) module.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use figue::config_format::{ConfigFormat, JsonFormat};
15//!
16//! let format = JsonFormat;
17//! let config = format.parse(r#"{"port": 8080}"#)?;
18//! ```
19
20use std::string::String;
21
22use facet::Facet;
23
24use crate::config_value::ConfigValue;
25
26/// Error returned when parsing a config file fails.
27#[derive(Facet, Debug)]
28pub struct ConfigFormatError {
29    /// Human-readable error message.
30    pub message: String,
31
32    /// Byte offset in the source where the error occurred, if known.
33    pub offset: Option<usize>,
34}
35
36impl ConfigFormatError {
37    /// Create a new error with just a message.
38    pub fn new(message: impl Into<String>) -> Self {
39        Self {
40            message: message.into(),
41            offset: None,
42        }
43    }
44
45    /// Create a new error with a message and source offset.
46    pub fn with_offset(message: impl Into<String>, offset: usize) -> Self {
47        Self {
48            message: message.into(),
49            offset: Some(offset),
50        }
51    }
52}
53
54impl core::fmt::Display for ConfigFormatError {
55    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
56        if let Some(offset) = self.offset {
57            write!(f, "at byte {}: {}", offset, self.message)
58        } else {
59            write!(f, "{}", self.message)
60        }
61    }
62}
63
64impl core::error::Error for ConfigFormatError {}
65
66/// Trait for config file format parsers.
67///
68/// Implementations of this trait can parse configuration files into [`ConfigValue`],
69/// preserving source span information for rich error messages.
70///
71/// # Built-in Formats
72///
73/// - [`JsonFormat`] - JSON files (`.json`)
74///
75/// # Custom Formats
76///
77/// To support additional formats (TOML, YAML, etc.), implement this trait:
78///
79/// ```rust,ignore
80/// use figue::config_format::{ConfigFormat, ConfigFormatError};
81/// use figue::config_value::ConfigValue;
82///
83/// pub struct TomlFormat;
84///
85/// impl ConfigFormat for TomlFormat {
86///     fn extensions(&self) -> &[&str] {
87///         &["toml"]
88///     }
89///
90///     fn parse(&self, contents: &str) -> Result<ConfigValue, ConfigFormatError> {
91///         // Parse TOML and convert to ConfigValue with spans...
92///         todo!()
93///     }
94/// }
95/// ```
96pub trait ConfigFormat: Send + Sync {
97    /// File extensions this format handles (without the leading dot).
98    ///
99    /// For example, `["json"]` or `["yaml", "yml"]`.
100    fn extensions(&self) -> &[&str];
101
102    /// Parse file contents into a [`ConfigValue`] with span tracking.
103    ///
104    /// The implementation should preserve source locations in the returned
105    /// `ConfigValue` tree so that error messages can point to the exact
106    /// location in the config file.
107    fn parse(&self, contents: &str) -> Result<ConfigValue, ConfigFormatError>;
108}
109
110/// JSON config file format.
111///
112/// Parses `.json` files using `facet-json`, preserving span information
113/// for error reporting.
114#[derive(Debug, Clone, Copy, Default)]
115pub struct JsonFormat;
116
117impl ConfigFormat for JsonFormat {
118    fn extensions(&self) -> &[&str] {
119        &["json"]
120    }
121
122    fn parse(&self, contents: &str) -> Result<ConfigValue, ConfigFormatError> {
123        facet_json::from_str(contents).map_err(|e| ConfigFormatError::new(e.to_string()))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_json_format_extensions() {
133        let format = JsonFormat;
134        assert_eq!(format.extensions(), &["json"]);
135    }
136
137    #[test]
138    fn test_json_format_parse_object() {
139        let format = JsonFormat;
140        let result = format.parse(r#"{"port": 8080, "host": "localhost"}"#);
141        assert!(result.is_ok(), "parse failed: {:?}", result.err());
142        let value = result.unwrap();
143        assert!(matches!(value, ConfigValue::Object(_)));
144    }
145
146    #[test]
147    fn test_json_format_parse_nested() {
148        let format = JsonFormat;
149        let result = format.parse(r#"{"smtp": {"host": "mail.example.com", "port": 587}}"#);
150        assert!(result.is_ok(), "parse failed: {:?}", result.err());
151    }
152
153    #[test]
154    fn test_json_format_parse_array() {
155        let format = JsonFormat;
156        let result = format.parse(r#"["one", "two", "three"]"#);
157        assert!(result.is_ok(), "parse failed: {:?}", result.err());
158        let value = result.unwrap();
159        assert!(matches!(value, ConfigValue::Array(_)));
160    }
161
162    #[test]
163    fn test_json_format_parse_error() {
164        let format = JsonFormat;
165        let result = format.parse(r#"{"port": invalid}"#);
166        assert!(result.is_err());
167        let err = result.unwrap_err();
168        assert!(!err.message.is_empty());
169    }
170
171    #[test]
172    fn test_config_format_error_display() {
173        let err = ConfigFormatError::new("something went wrong");
174        assert_eq!(err.to_string(), "something went wrong");
175
176        let err = ConfigFormatError::with_offset("unexpected token", 42);
177        assert_eq!(err.to_string(), "at byte 42: unexpected token");
178    }
179}