Skip to main content

cfgmatic_files/
file.rs

1//! Configuration file representation.
2
3use crate::Format;
4use crate::error::Result;
5use cfgmatic_merge::{Merge, MergeBehavior, MergeOptions};
6use cfgmatic_paths::ConfigTier;
7use serde::de::DeserializeOwned;
8use std::fs;
9use std::path::PathBuf;
10
11/// A discovered configuration file.
12#[derive(Debug, Clone)]
13pub struct ConfigFile {
14    /// Full path to the file.
15    pub path: PathBuf,
16
17    /// Configuration tier (determines priority).
18    pub tier: ConfigTier,
19
20    /// File format.
21    pub format: Format,
22
23    /// Cached content (loaded on demand).
24    pub content: Option<String>,
25}
26
27impl ConfigFile {
28    /// Create a new config file reference.
29    pub(crate) fn new(path: PathBuf, tier: ConfigTier) -> Option<Self> {
30        let format = Format::from_path(&path)?;
31        Some(Self {
32            path,
33            tier,
34            format,
35            content: None,
36        })
37    }
38
39    /// Get the file name.
40    #[must_use]
41    pub fn name(&self) -> Option<&str> {
42        self.path.file_name()?.to_str()
43    }
44
45    /// Read the file content.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if the file cannot be read.
50    ///
51    /// # Panics
52    ///
53    /// Panics if the content was not set after reading (this should never happen in practice).
54    pub fn read(&mut self) -> Result<&str> {
55        if let Some(ref content) = self.content {
56            return Ok(content);
57        }
58        let content = fs::read_to_string(&self.path)?;
59        self.content = Some(content);
60        Ok(self.content.as_ref().unwrap()) // Safe: content was just set
61    }
62
63    /// Parse the file into a type T.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the file cannot be read or parsed.
68    pub fn parse<T: DeserializeOwned>(&mut self) -> Result<T> {
69        if self.content.is_none() {
70            let content = fs::read_to_string(&self.path)?;
71            self.content = Some(content);
72        }
73        // Safe: content was set above or was already cached
74        let content = self.content.as_ref().unwrap();
75        self.format.parse(content, &self.path)
76    }
77
78    /// Parse without caching content.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the file cannot be read or parsed.
83    pub fn parse_uncached<T: DeserializeOwned>(&self) -> Result<T> {
84        let content = fs::read_to_string(&self.path)?;
85        self.format.parse(&content, &self.path)
86    }
87
88    /// Check if the file exists.
89    #[must_use]
90    pub fn exists(&self) -> bool {
91        self.path.exists()
92    }
93
94    /// Get the file modification time.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if file metadata cannot be retrieved.
99    pub fn modified(&self) -> Result<std::time::SystemTime> {
100        Ok(fs::metadata(&self.path)?.modified()?)
101    }
102}
103
104impl PartialEq for ConfigFile {
105    fn eq(&self, other: &Self) -> bool {
106        self.path == other.path
107    }
108}
109
110impl Eq for ConfigFile {}
111
112impl PartialOrd for ConfigFile {
113    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
114        Some(self.cmp(other))
115    }
116}
117
118impl Ord for ConfigFile {
119    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
120        // Higher tier = higher priority = "greater" for sorting
121        u8::from(self.tier).cmp(&u8::from(other.tier))
122    }
123}
124
125impl std::fmt::Display for ConfigFile {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "{} ({:?}, {})",
130            self.path.display(),
131            self.tier,
132            self.format
133        )
134    }
135}
136
137/// A collection of configuration files sorted by priority.
138#[derive(Debug, Clone, Default)]
139pub struct ConfigFiles {
140    files: Vec<ConfigFile>,
141}
142
143impl ConfigFiles {
144    /// Create a new empty collection.
145    #[must_use]
146    pub const fn new() -> Self {
147        Self { files: Vec::new() }
148    }
149
150    /// Add a file to the collection.
151    pub fn push(&mut self, file: ConfigFile) {
152        self.files.push(file);
153        self.sort();
154    }
155
156    /// Sort files by priority (highest first).
157    fn sort(&mut self) {
158        self.files.sort_by(|a, b| b.cmp(a)); // Reverse order for highest priority first
159    }
160
161    /// Get the number of files.
162    #[must_use]
163    pub const fn len(&self) -> usize {
164        self.files.len()
165    }
166
167    /// Check if collection is empty.
168    #[must_use]
169    pub const fn is_empty(&self) -> bool {
170        self.files.is_empty()
171    }
172
173    /// Get the highest priority file.
174    #[must_use]
175    pub fn first(&self) -> Option<&ConfigFile> {
176        self.files.first()
177    }
178
179    /// Get the highest priority file (mutable).
180    pub fn first_mut(&mut self) -> Option<&mut ConfigFile> {
181        self.files.first_mut()
182    }
183
184    /// Iterate over files in priority order.
185    pub fn iter(&self) -> impl Iterator<Item = &ConfigFile> {
186        self.files.iter()
187    }
188
189    /// Merge all files into a single value.
190    ///
191    /// Files are merged in priority order (highest priority wins).
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if any file cannot be read or parsed.
196    pub fn merge<T>(&mut self) -> Result<T>
197    where
198        T: DeserializeOwned + Mergeable + Default,
199    {
200        let mut result = T::default();
201        for file in self.files.iter_mut().rev() {
202            let value: T = file.parse()?;
203            result = result.merge(value);
204        }
205        Ok(result)
206    }
207}
208
209impl IntoIterator for ConfigFiles {
210    type Item = ConfigFile;
211    type IntoIter = std::vec::IntoIter<ConfigFile>;
212
213    fn into_iter(self) -> Self::IntoIter {
214        self.files.into_iter()
215    }
216}
217
218impl<'a> IntoIterator for &'a ConfigFiles {
219    type Item = &'a ConfigFile;
220    type IntoIter = std::slice::Iter<'a, ConfigFile>;
221
222    fn into_iter(self) -> Self::IntoIter {
223        self.files.iter()
224    }
225}
226
227/// Trait for types that can be merged.
228///
229/// Provides a simple merge interface. For advanced merge options,
230/// use the `Merge` trait from cfgmatic-merge directly.
231pub trait Mergeable {
232    /// Merge another value into self using deep merge strategy.
233    #[must_use]
234    fn merge(self, other: Self) -> Self;
235}
236
237impl Mergeable for serde_json::Value {
238    fn merge(self, other: Self) -> Self {
239        let opts = MergeOptions::new().behavior(MergeBehavior::Deep);
240        // On merge error, fall back to replacing with other value.
241        // This is a simplification for the ergonomic Mergeable trait.
242        Merge::merge(self, other.clone(), &opts).unwrap_or(other)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::io::Write;
250    use tempfile::NamedTempFile;
251
252    #[test]
253    fn test_config_file_sorting() {
254        let file1 = ConfigFile {
255            path: PathBuf::from("/etc/config.toml"),
256            tier: ConfigTier::System,
257            format: Format::Toml,
258            content: None,
259        };
260        let file2 = ConfigFile {
261            path: PathBuf::from("~/.config.toml"),
262            tier: ConfigTier::User,
263            format: Format::Toml,
264            content: None,
265        };
266
267        // User tier has higher priority
268        assert!(file2 > file1);
269    }
270
271    #[test]
272    fn test_parse_toml() -> Result<()> {
273        #[derive(Debug, serde::Deserialize, PartialEq)]
274        struct Config {
275            timeout: u32,
276            host: String,
277        }
278
279        let mut temp = NamedTempFile::with_suffix(".toml")?;
280        write!(temp, "timeout = 30\nhost = \"localhost\"")?;
281
282        let mut file =
283            ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid toml file");
284
285        let config: Config = file.parse()?;
286        assert_eq!(config.timeout, 30);
287        assert_eq!(config.host, "localhost");
288
289        Ok(())
290    }
291
292    #[test]
293    fn test_parse_json() -> Result<()> {
294        let mut temp = NamedTempFile::with_suffix(".json")?;
295        write!(temp, "{{\"port\": 8080, \"enabled\": true}}")?;
296
297        let mut file =
298            ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid json file");
299
300        let value: serde_json::Value = file.parse()?;
301        assert_eq!(value["port"], 8080);
302        assert_eq!(value["enabled"], true);
303
304        Ok(())
305    }
306
307    #[test]
308    fn test_config_files_collection() {
309        let mut files = ConfigFiles::new();
310        assert!(files.is_empty());
311
312        let file = ConfigFile {
313            path: PathBuf::from("config.toml"),
314            tier: ConfigTier::User,
315            format: Format::Toml,
316            content: None,
317        };
318
319        files.push(file);
320        assert_eq!(files.len(), 1);
321        assert!(files.first().is_some());
322    }
323}