cfgmatic-paths 5.0.0

Cross-platform configuration path discovery following XDG and platform conventions
Documentation
//! Builder for creating path finders and directory scanners.

mod discovery;
mod rules;
mod scan;

use crate::core::{AppType, ConfigTier};
use crate::env::StdEnv;
use crate::platform::{DirectoryFinder, DirectoryInfo};
use crate::{Fs, StdFs};
use std::path::{Path, PathBuf};
use std::sync::Arc;

/// Builder for creating platform-specific path finders.
///
/// # Examples
///
/// ```
/// use cfgmatic_paths::{PathsBuilder, AppType};
///
/// // CLI application with XDG directories
/// let finder = PathsBuilder::new("myapp")
///     .app_type(AppType::Cli)
///     .build();
///
/// let user_dirs = finder.user_dirs();
/// ```
#[derive(Debug, Clone)]
pub struct PathsBuilder {
    /// Application name used in paths.
    app_name: String,
    /// Type of application (CLI, GUI, Service).
    app_type: AppType,
    /// Windows-specific company name.
    #[cfg(windows)]
    company_name: Option<String>,
    /// macOS GUI-specific bundle ID.
    #[cfg(all(target_os = "macos", feature = "macos-gui"))]
    bundle_id: Option<String>,
    /// Whether to include legacy `~/.apprc` fallback.
    legacy_rc: bool,
}

impl PathsBuilder {
    /// Create a new builder.
    pub fn new(app_name: impl Into<String>) -> Self {
        Self {
            app_name: app_name.into(),
            app_type: AppType::default(),
            #[cfg(windows)]
            company_name: None,
            #[cfg(all(target_os = "macos", feature = "macos-gui"))]
            bundle_id: None,
            legacy_rc: true,
        }
    }

    /// Set the application type.
    #[must_use]
    pub const fn app_type(mut self, app_type: AppType) -> Self {
        self.app_type = app_type;
        self
    }

    /// Enable/disable legacy `~/.apprc` fallback (Unix only).
    #[must_use]
    pub const fn legacy_rc(mut self, enabled: bool) -> Self {
        self.legacy_rc = enabled;
        self
    }

    /// Windows: set the company name.
    #[cfg(windows)]
    pub fn company_name(mut self, name: impl Into<String>) -> Self {
        self.company_name = Some(name.into());
        self
    }

    /// macOS GUI: set the bundle ID.
    #[cfg(all(target_os = "macos", feature = "macos-gui"))]
    #[must_use]
    pub fn bundle_id(mut self, id: impl Into<String>) -> Self {
        self.bundle_id = Some(id.into());
        self
    }

    /// Build the platform-specific directory finder.
    #[must_use]
    pub fn build(self) -> PathFinder {
        let preferred_fallback = PathBuf::from(".config").join(&self.app_name);
        let dir_finder = self.build_directory_finder();
        PathFinder {
            dir_finder,
            fs: Arc::new(StdFs),
            preferred_fallback,
        }
    }

    /// Build the directory finder (internal).
    fn build_directory_finder(self) -> Box<dyn DirectoryFinder> {
        cfg_if::cfg_if! {
            if #[cfg(all(target_os = "macos", feature = "macos-gui"))] {
                use crate::platform::MacOSGuiDirectoryFinder;
                use crate::platform::UnixDirectoryFinder;
                if self.app_type == AppType::Gui {
                    let bundle_id = self.bundle_id.unwrap_or_else(|| {
                        format!("com.example.{}", self.app_name)
                    });
                    Box::new(MacOSGuiDirectoryFinder::new(bundle_id))
                } else {
                    // CLI on macOS uses XDG
                    Box::new(UnixDirectoryFinder::new(self.app_name).legacy_rc(self.legacy_rc))
                }
            } else if #[cfg(windows)] {
                use crate::platform::WindowsDirectoryFinder;
                let company_name = self.company_name.unwrap_or_else(|| {
                    self.app_name.clone()
                });
                Box::new(WindowsDirectoryFinder::new(self.app_name, company_name))
            } else {
                use crate::platform::UnixDirectoryFinder;
                // Unix/Linux or macOS CLI
                Box::new(UnixDirectoryFinder::new(self.app_name).legacy_rc(self.legacy_rc))
            }
        }
    }
}

/// Path finder for discovering configuration directories.
///
/// Provides methods to find configuration directories following
/// platform conventions (XDG on Unix, Known Folders on Windows,
/// Application Support on macOS).
pub struct PathFinder {
    /// Platform-specific directory finder.
    dir_finder: Box<dyn DirectoryFinder>,
    /// Filesystem abstraction.
    fs: Arc<dyn Fs>,
    /// Fallback path used when no user directory can be discovered.
    preferred_fallback: PathBuf,
}

impl PathFinder {
    /// Get all user directories.
    #[must_use]
    pub fn user_dirs(&self) -> Vec<PathBuf> {
        self.dir_finder.user_dirs(&StdEnv)
    }

    /// Get all local directories.
    #[must_use]
    pub fn local_dirs(&self) -> Vec<PathBuf> {
        self.dir_finder.local_dirs(&StdEnv)
    }

    /// Get all system directories.
    #[must_use]
    pub fn system_dirs(&self) -> Vec<PathBuf> {
        self.dir_finder.system_dirs(&StdEnv)
    }

    /// Get all directories with their tiers.
    #[must_use]
    pub fn all_dirs(&self) -> Vec<DirectoryInfo> {
        [
            self.dirs_with_tier(self.user_dirs(), ConfigTier::User),
            self.dirs_with_tier(self.local_dirs(), ConfigTier::Local),
            self.dirs_with_tier(self.system_dirs(), ConfigTier::System),
        ]
        .into_iter()
        .flatten()
        .collect()
    }

    /// Get the primary user config directory (first user directory).
    #[must_use]
    pub fn user_config_dir(&self) -> Option<PathBuf> {
        self.user_dirs().into_iter().next()
    }

    /// Ensure the user config directory exists, creating it if necessary.
    ///
    /// Returns the path to the directory, or an error if creation fails.
    ///
    /// # Errors
    ///
    /// Returns an error if no user config directory is found or if creation fails.
    pub fn ensure_user_config_dir(&self) -> std::io::Result<PathBuf> {
        let path = self.user_config_dir().ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "No user config directory found",
            )
        })?;
        self.fs.create_dir_all(&path)?;
        Ok(path)
    }

    /// Get the preferred user config path (without checking existence).
    ///
    /// Returns the first user config directory path, regardless of whether
    /// it exists. This is useful for determining where to create a new
    /// configuration file.
    ///
    /// # Examples
    ///
    /// ```
    /// use cfgmatic_paths::PathsBuilder;
    ///
    /// let finder = PathsBuilder::new("myapp").build();
    /// let path = finder.preferred_config_path();
    /// println!("Config would be at: {}", path.display());
    /// ```
    #[must_use]
    pub fn preferred_config_path(&self) -> PathBuf {
        self.user_dirs()
            .into_iter()
            .next()
            .unwrap_or_else(|| self.preferred_fallback.clone())
    }

    /// Get the preferred config path with a filename appended.
    ///
    /// # Examples
    ///
    /// ```
    /// use cfgmatic_paths::PathsBuilder;
    ///
    /// let finder = PathsBuilder::new("myapp").build();
    /// let path = finder.preferred_config_file("config.toml");
    /// println!("Config file would be at: {}", path.display());
    /// ```
    #[must_use]
    pub fn preferred_config_file(&self, filename: impl AsRef<Path>) -> PathBuf {
        self.preferred_config_path().join(filename)
    }

    /// Convert paths to directory info with tier.
    fn dirs_with_tier(&self, paths: Vec<PathBuf>, tier: ConfigTier) -> Vec<DirectoryInfo> {
        paths
            .into_iter()
            .map(|path| DirectoryInfo {
                exists: self.fs.is_dir(&path),
                path,
                tier,
            })
            .collect()
    }
}

impl std::fmt::Debug for PathFinder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PathFinder")
            .field("user_dirs", &self.user_dirs())
            .field("local_dirs", &self.local_dirs())
            .field("system_dirs", &self.system_dirs())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::FilePattern;
    use std::collections::{BTreeMap, BTreeSet};
    use std::iter;
    use std::path::Path;

    struct EmptyDirectoryFinder;

    impl DirectoryFinder for EmptyDirectoryFinder {
        fn user_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
            Vec::new()
        }

        fn local_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
            Vec::new()
        }

        fn system_dirs(&self, _env: &dyn crate::Env) -> Vec<PathBuf> {
            Vec::new()
        }
    }

    #[derive(Default)]
    struct MemoryFs {
        files: BTreeSet<PathBuf>,
        dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
    }

    impl Fs for MemoryFs {
        fn exists(&self, path: &Path) -> bool {
            self.files.contains(path) || self.dirs.contains_key(path)
        }

        fn is_file(&self, path: &Path) -> bool {
            self.files.contains(path)
        }

        fn is_dir(&self, path: &Path) -> bool {
            self.dirs.contains_key(path)
        }

        fn create_dir_all(&self, _path: &Path) -> std::io::Result<()> {
            Ok(())
        }

        fn read_dir(&self, path: &Path) -> Vec<PathBuf> {
            self.dirs.get(path).cloned().unwrap_or_default()
        }
    }

    #[test]
    fn test_preferred_config_path_uses_app_specific_fallback() {
        let finder = PathFinder {
            dir_finder: Box::new(EmptyDirectoryFinder),
            fs: Arc::new(MemoryFs::default()),
            preferred_fallback: PathBuf::from(".config").join("custom-app"),
        };

        assert_eq!(
            finder.preferred_config_path(),
            PathBuf::from(".config").join("custom-app")
        );
    }

    #[test]
    fn test_find_files_in_dirs_uses_shared_scanner() {
        let config_dir = PathBuf::from("/config");
        let config_file = config_dir.join("app.toml");

        let fs = MemoryFs {
            files: iter::once(config_file.clone()).collect(),
            dirs: iter::once((config_dir.clone(), vec![config_file.clone()])).collect(),
        };

        let finder = PathFinder {
            dir_finder: Box::new(EmptyDirectoryFinder),
            fs: Arc::new(fs),
            preferred_fallback: PathBuf::from(".config").join("app"),
        };

        let mut candidates = Vec::new();
        finder.find_files_in_dirs(
            &[config_dir],
            ConfigTier::User,
            &FilePattern::glob("*.toml"),
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert!(
            candidates
                .iter()
                .all(|candidate| candidate.path == config_file)
        );
    }
}