Skip to main content

cfgmatic_paths/
builder.rs

1//! Builder for creating path finders and directory scanners.
2
3mod discovery;
4mod rules;
5mod scan;
6
7use crate::core::{AppType, ConfigTier};
8use crate::env::StdEnv;
9use crate::platform::{DirectoryFinder, DirectoryInfo};
10use crate::{Fs, StdFs};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14/// Builder for creating platform-specific path finders.
15///
16/// # Examples
17///
18/// ```
19/// use cfgmatic_paths::{PathsBuilder, AppType};
20///
21/// // CLI application with XDG directories
22/// let finder = PathsBuilder::new("myapp")
23///     .app_type(AppType::Cli)
24///     .build();
25///
26/// let user_dirs = finder.user_dirs();
27/// ```
28#[derive(Debug, Clone)]
29pub struct PathsBuilder {
30    /// Application name used in paths.
31    app_name: String,
32    /// Type of application (CLI, GUI, Service).
33    app_type: AppType,
34    /// Windows-specific company name.
35    #[cfg(windows)]
36    company_name: Option<String>,
37    /// macOS GUI-specific bundle ID.
38    #[cfg(all(target_os = "macos", feature = "macos-gui"))]
39    bundle_id: Option<String>,
40    /// Whether to include legacy `~/.apprc` fallback.
41    legacy_rc: bool,
42}
43
44impl PathsBuilder {
45    /// Create a new builder.
46    pub fn new(app_name: impl Into<String>) -> Self {
47        Self {
48            app_name: app_name.into(),
49            app_type: AppType::default(),
50            #[cfg(windows)]
51            company_name: None,
52            #[cfg(all(target_os = "macos", feature = "macos-gui"))]
53            bundle_id: None,
54            legacy_rc: true,
55        }
56    }
57
58    /// Set the application type.
59    #[must_use]
60    pub const fn app_type(mut self, app_type: AppType) -> Self {
61        self.app_type = app_type;
62        self
63    }
64
65    /// Enable/disable legacy `~/.apprc` fallback (Unix only).
66    #[must_use]
67    pub const fn legacy_rc(mut self, enabled: bool) -> Self {
68        self.legacy_rc = enabled;
69        self
70    }
71
72    /// Windows: set the company name.
73    #[cfg(windows)]
74    pub fn company_name(mut self, name: impl Into<String>) -> Self {
75        self.company_name = Some(name.into());
76        self
77    }
78
79    /// macOS GUI: set the bundle ID.
80    #[cfg(all(target_os = "macos", feature = "macos-gui"))]
81    #[must_use]
82    pub fn bundle_id(mut self, id: impl Into<String>) -> Self {
83        self.bundle_id = Some(id.into());
84        self
85    }
86
87    /// Build the platform-specific directory finder.
88    #[must_use]
89    pub fn build(self) -> PathFinder {
90        let preferred_fallback = PathBuf::from(".config").join(&self.app_name);
91        let dir_finder = self.build_directory_finder();
92        PathFinder {
93            dir_finder,
94            fs: Arc::new(StdFs),
95            preferred_fallback,
96        }
97    }
98
99    /// Build the directory finder (internal).
100    fn build_directory_finder(self) -> Box<dyn DirectoryFinder> {
101        cfg_if::cfg_if! {
102            if #[cfg(all(target_os = "macos", feature = "macos-gui"))] {
103                use crate::platform::MacOSGuiDirectoryFinder;
104                use crate::platform::UnixDirectoryFinder;
105                if self.app_type == AppType::Gui {
106                    let bundle_id = self.bundle_id.unwrap_or_else(|| {
107                        format!("com.example.{}", self.app_name)
108                    });
109                    Box::new(MacOSGuiDirectoryFinder::new(bundle_id))
110                } else {
111                    // CLI on macOS uses XDG
112                    Box::new(UnixDirectoryFinder::new(self.app_name).legacy_rc(self.legacy_rc))
113                }
114            } else if #[cfg(windows)] {
115                use crate::platform::WindowsDirectoryFinder;
116                let company_name = self.company_name.unwrap_or_else(|| {
117                    self.app_name.clone()
118                });
119                Box::new(WindowsDirectoryFinder::new(self.app_name, company_name))
120            } else {
121                use crate::platform::UnixDirectoryFinder;
122                // Unix/Linux or macOS CLI
123                Box::new(UnixDirectoryFinder::new(self.app_name).legacy_rc(self.legacy_rc))
124            }
125        }
126    }
127}
128
129/// Path finder for discovering configuration directories.
130///
131/// Provides methods to find configuration directories following
132/// platform conventions (XDG on Unix, Known Folders on Windows,
133/// Application Support on macOS).
134pub struct PathFinder {
135    /// Platform-specific directory finder.
136    dir_finder: Box<dyn DirectoryFinder>,
137    /// Filesystem abstraction.
138    fs: Arc<dyn Fs>,
139    /// Fallback path used when no user directory can be discovered.
140    preferred_fallback: PathBuf,
141}
142
143impl PathFinder {
144    /// Get all user directories.
145    #[must_use]
146    pub fn user_dirs(&self) -> Vec<PathBuf> {
147        self.dir_finder.user_dirs(&StdEnv)
148    }
149
150    /// Get all local directories.
151    #[must_use]
152    pub fn local_dirs(&self) -> Vec<PathBuf> {
153        self.dir_finder.local_dirs(&StdEnv)
154    }
155
156    /// Get all system directories.
157    #[must_use]
158    pub fn system_dirs(&self) -> Vec<PathBuf> {
159        self.dir_finder.system_dirs(&StdEnv)
160    }
161
162    /// Get all directories with their tiers.
163    #[must_use]
164    pub fn all_dirs(&self) -> Vec<DirectoryInfo> {
165        [
166            self.dirs_with_tier(self.user_dirs(), ConfigTier::User),
167            self.dirs_with_tier(self.local_dirs(), ConfigTier::Local),
168            self.dirs_with_tier(self.system_dirs(), ConfigTier::System),
169        ]
170        .into_iter()
171        .flatten()
172        .collect()
173    }
174
175    /// Get the primary user config directory (first user directory).
176    #[must_use]
177    pub fn user_config_dir(&self) -> Option<PathBuf> {
178        self.user_dirs().into_iter().next()
179    }
180
181    /// Ensure the user config directory exists, creating it if necessary.
182    ///
183    /// Returns the path to the directory, or an error if creation fails.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if no user config directory is found or if creation fails.
188    pub fn ensure_user_config_dir(&self) -> std::io::Result<PathBuf> {
189        let path = self.user_config_dir().ok_or_else(|| {
190            std::io::Error::new(
191                std::io::ErrorKind::NotFound,
192                "No user config directory found",
193            )
194        })?;
195        self.fs.create_dir_all(&path)?;
196        Ok(path)
197    }
198
199    /// Get the preferred user config path (without checking existence).
200    ///
201    /// Returns the first user config directory path, regardless of whether
202    /// it exists. This is useful for determining where to create a new
203    /// configuration file.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use cfgmatic_paths::PathsBuilder;
209    ///
210    /// let finder = PathsBuilder::new("myapp").build();
211    /// let path = finder.preferred_config_path();
212    /// println!("Config would be at: {}", path.display());
213    /// ```
214    #[must_use]
215    pub fn preferred_config_path(&self) -> PathBuf {
216        self.user_dirs()
217            .into_iter()
218            .next()
219            .unwrap_or_else(|| self.preferred_fallback.clone())
220    }
221
222    /// Get the preferred config path with a filename appended.
223    ///
224    /// # Examples
225    ///
226    /// ```
227    /// use cfgmatic_paths::PathsBuilder;
228    ///
229    /// let finder = PathsBuilder::new("myapp").build();
230    /// let path = finder.preferred_config_file("config.toml");
231    /// println!("Config file would be at: {}", path.display());
232    /// ```
233    #[must_use]
234    pub fn preferred_config_file(&self, filename: impl AsRef<Path>) -> PathBuf {
235        self.preferred_config_path().join(filename)
236    }
237
238    /// Convert paths to directory info with tier.
239    fn dirs_with_tier(&self, paths: Vec<PathBuf>, tier: ConfigTier) -> Vec<DirectoryInfo> {
240        paths
241            .into_iter()
242            .map(|path| DirectoryInfo {
243                exists: self.fs.is_dir(&path),
244                path,
245                tier,
246            })
247            .collect()
248    }
249}
250
251impl std::fmt::Debug for PathFinder {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("PathFinder")
254            .field("user_dirs", &self.user_dirs())
255            .field("local_dirs", &self.local_dirs())
256            .field("system_dirs", &self.system_dirs())
257            .finish()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::FilePattern;
265    use std::collections::{BTreeMap, BTreeSet};
266    use std::iter;
267    use std::path::Path;
268
269    struct EmptyDirectoryFinder;
270
271    impl DirectoryFinder for EmptyDirectoryFinder {
272        fn user_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
273            Vec::new()
274        }
275
276        fn local_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
277            Vec::new()
278        }
279
280        fn system_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
281            Vec::new()
282        }
283    }
284
285    #[derive(Default)]
286    struct MemoryFs {
287        files: BTreeSet<PathBuf>,
288        dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
289    }
290
291    impl Fs for MemoryFs {
292        fn exists(&self, path: &Path) -> bool {
293            self.files.contains(path) || self.dirs.contains_key(path)
294        }
295
296        fn is_file(&self, path: &Path) -> bool {
297            self.files.contains(path)
298        }
299
300        fn is_dir(&self, path: &Path) -> bool {
301            self.dirs.contains_key(path)
302        }
303
304        fn create_dir_all(&self, _path: &Path) -> std::io::Result<()> {
305            Ok(())
306        }
307
308        fn read_dir(&self, path: &Path) -> Vec<PathBuf> {
309            self.dirs.get(path).cloned().unwrap_or_default()
310        }
311    }
312
313    #[test]
314    fn test_preferred_config_path_uses_app_specific_fallback() {
315        let finder = PathFinder {
316            dir_finder: Box::new(EmptyDirectoryFinder),
317            fs: Arc::new(MemoryFs::default()),
318            preferred_fallback: PathBuf::from(".config").join("custom-app"),
319        };
320
321        assert_eq!(
322            finder.preferred_config_path(),
323            PathBuf::from(".config").join("custom-app")
324        );
325    }
326
327    #[test]
328    fn test_find_files_in_dirs_uses_shared_scanner() {
329        let config_dir = PathBuf::from("/config");
330        let config_file = config_dir.join("app.toml");
331
332        let fs = MemoryFs {
333            files: iter::once(config_file.clone()).collect(),
334            dirs: iter::once((config_dir.clone(), vec![config_file.clone()])).collect(),
335        };
336
337        let finder = PathFinder {
338            dir_finder: Box::new(EmptyDirectoryFinder),
339            fs: Arc::new(fs),
340            preferred_fallback: PathBuf::from(".config").join("app"),
341        };
342
343        let mut candidates = Vec::new();
344        finder.find_files_in_dirs(
345            &[config_dir],
346            ConfigTier::User,
347            &FilePattern::glob("*.toml"),
348            &mut candidates,
349        );
350
351        assert_eq!(candidates.len(), 1);
352        assert!(
353            candidates
354                .iter()
355                .all(|candidate| candidate.path == config_file)
356        );
357    }
358}