Skip to main content

cfgmatic_files/
finder.rs

1//! Configuration file finder.
2
3use crate::Format;
4use crate::error::Result;
5use crate::file::{ConfigFile, ConfigFiles};
6use cfgmatic_paths::{ConfigTier, PathFinder, PathsBuilder};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Builder for configuring file search.
11///
12/// # Example
13///
14/// ```
15/// use cfgmatic_files::FileFinder;
16///
17/// let finder = FileFinder::new("myapp")
18///     .formats(&[cfgmatic_files::Format::Toml, cfgmatic_files::Format::Json])
19///     .base_name("config")
20///     .require_existence(true);
21/// ```
22#[derive(Debug, Clone)]
23pub struct FileFinder {
24    app_name: String,
25    formats: Vec<Format>,
26    base_name: Option<String>,
27    require_existence: bool,
28    follow_symlinks: bool,
29    search_depth: usize,
30}
31
32impl FileFinder {
33    /// Create a new file finder for the given application.
34    pub fn new(app_name: impl Into<String>) -> Self {
35        Self {
36            app_name: app_name.into(),
37            formats: vec![Format::Toml, Format::Json],
38            base_name: Some("config".to_string()),
39            require_existence: true,
40            follow_symlinks: false,
41            search_depth: 3,
42        }
43    }
44
45    /// Set the file formats to search for.
46    #[must_use]
47    pub fn formats(mut self, formats: &[Format]) -> Self {
48        self.formats = formats.to_vec();
49        self
50    }
51
52    /// Set the base name for config files.
53    ///
54    /// For example, if `base_name` is "app", files like "app.toml", "app.json" will be searched.
55    #[must_use]
56    pub fn base_name(mut self, name: impl Into<String>) -> Self {
57        self.base_name = Some(name.into());
58        self
59    }
60
61    /// Clear the base name, searching for any file with supported extensions.
62    #[must_use]
63    pub fn any_name(mut self) -> Self {
64        self.base_name = None;
65        self
66    }
67
68    /// Require that files exist (default: true).
69    #[must_use]
70    pub const fn require_existence(mut self, require: bool) -> Self {
71        self.require_existence = require;
72        self
73    }
74
75    /// Follow symbolic links when searching.
76    #[must_use]
77    pub const fn follow_symlinks(mut self, follow: bool) -> Self {
78        self.follow_symlinks = follow;
79        self
80    }
81
82    /// Set the maximum search depth for directory traversal.
83    #[must_use]
84    pub const fn search_depth(mut self, depth: usize) -> Self {
85        self.search_depth = depth;
86        self
87    }
88
89    /// Build the finder configuration.
90    #[must_use]
91    pub fn build(self) -> FileFinderState {
92        let path_finder = PathsBuilder::new(&self.app_name).build();
93        FileFinderState {
94            config: self,
95            path_finder,
96        }
97    }
98
99    /// Find all matching configuration files.
100    ///
101    /// This is a convenience method that builds and searches immediately.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if directories cannot be accessed.
106    pub fn find(self) -> Result<ConfigFiles> {
107        self.build().find()
108    }
109
110    /// Find the first (highest priority) configuration file.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if directories cannot be accessed.
115    pub fn find_first(self) -> Result<Option<ConfigFile>> {
116        let files = self.find()?;
117        Ok(files.first().cloned())
118    }
119}
120
121/// Stateful file finder after configuration.
122pub struct FileFinderState {
123    config: FileFinder,
124    path_finder: PathFinder,
125}
126
127impl FileFinderState {
128    /// Find all matching configuration files.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if directories cannot be accessed.
133    pub fn find(&self) -> Result<ConfigFiles> {
134        let mut files = ConfigFiles::new();
135
136        // Search in all tiers
137        self.search_in_tier(&mut files, ConfigTier::User, self.path_finder.user_dirs())?;
138        self.search_in_tier(&mut files, ConfigTier::Local, self.path_finder.local_dirs())?;
139        self.search_in_tier(
140            &mut files,
141            ConfigTier::System,
142            self.path_finder.system_dirs(),
143        )?;
144
145        Ok(files)
146    }
147
148    /// Search for files in directories of a specific tier.
149    fn search_in_tier(
150        &self,
151        files: &mut ConfigFiles,
152        tier: ConfigTier,
153        dirs: Vec<PathBuf>,
154    ) -> Result<()> {
155        for dir in dirs {
156            if !dir.exists() {
157                continue;
158            }
159
160            if self.config.base_name.is_some() {
161                // Search for specific named files
162                self.search_named_files(files, &dir, tier);
163            } else {
164                // Search for any files with supported extensions
165                self.search_any_files(files, &dir, tier, 0)?;
166            }
167        }
168        Ok(())
169    }
170
171    /// Search for files with specific names.
172    fn search_named_files(&self, files: &mut ConfigFiles, dir: &Path, tier: ConfigTier) {
173        let Some(base_name) = self.config.base_name.as_ref() else {
174            return;
175        };
176
177        for format in &self.config.formats {
178            let file_name = format!("{}.{}", base_name, format.extension());
179            let path = dir.join(&file_name);
180
181            if self.should_include_file(&path)
182                && let Some(file) = ConfigFile::new(path, tier)
183            {
184                files.push(file);
185            }
186        }
187    }
188
189    /// Search for any files with supported extensions.
190    fn search_any_files(
191        &self,
192        files: &mut ConfigFiles,
193        dir: &Path,
194        tier: ConfigTier,
195        depth: usize,
196    ) -> Result<()> {
197        if depth > self.config.search_depth {
198            return Ok(());
199        }
200
201        let Ok(entries) = fs::read_dir(dir) else {
202            return Ok(());
203        };
204
205        for entry in entries.flatten() {
206            let path = entry.path();
207
208            if path.is_file() {
209                if let Some(format) = Format::from_path(&path)
210                    && self.config.formats.contains(&format)
211                    && self.should_include_file(&path)
212                    && let Some(file) = ConfigFile::new(path, tier)
213                {
214                    files.push(file);
215                }
216            } else if path.is_dir()
217                && self.config.follow_symlinks
218                && depth < self.config.search_depth
219            {
220                // Recurse into subdirectories
221                self.search_any_files(files, &path, tier, depth + 1)?;
222            }
223        }
224
225        Ok(())
226    }
227
228    /// Check if a file should be included in results.
229    fn should_include_file(&self, path: &Path) -> bool {
230        if !self.config.require_existence {
231            return true;
232        }
233
234        let Ok(metadata) = fs::symlink_metadata(path) else {
235            return false;
236        };
237
238        if metadata.is_symlink() && !self.config.follow_symlinks {
239            return false;
240        }
241
242        path.exists()
243    }
244}
245
246/// Find configuration files using a simple API.
247///
248/// # Example
249///
250/// ```
251/// use cfgmatic_files::find_files;
252///
253/// let files = find_files("myapp").expect("find config files");
254/// for file in files.iter() {
255///     println!("Found: {}", file.path.display());
256/// }
257/// ```
258///
259/// # Errors
260///
261/// Returns an error if directories cannot be accessed.
262pub fn find_files(app_name: impl Into<String>) -> Result<ConfigFiles> {
263    FileFinder::new(app_name).find()
264}
265
266/// Find the first configuration file.
267///
268/// # Errors
269///
270/// Returns an error if directories cannot be accessed.
271pub fn find_first_file(app_name: impl Into<String>) -> Result<Option<ConfigFile>> {
272    FileFinder::new(app_name).find_first()
273}
274
275/// Load configuration from the first found file.
276///
277/// # Errors
278///
279/// Returns an error if directories cannot be accessed or file cannot be parsed.
280pub fn load_first<T: serde::de::DeserializeOwned>(
281    app_name: impl Into<String>,
282) -> Result<Option<T>> {
283    match find_first_file(app_name)? {
284        Some(mut file) => Ok(Some(file.parse()?)),
285        None => Ok(None),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_finder_builder() {
295        let finder = FileFinder::new("myapp")
296            .formats(&[Format::Toml])
297            .base_name("app")
298            .require_existence(false);
299
300        assert_eq!(finder.formats.len(), 1);
301        assert_eq!(finder.base_name, Some("app".to_string()));
302        assert!(!finder.require_existence);
303    }
304
305    #[test]
306    fn test_find_first_nonexistent() -> Result<()> {
307        let result = FileFinder::new("nonexistent_app_12345").find_first()?;
308        assert!(result.is_none());
309        Ok(())
310    }
311}