config-finder 0.1.2

Easily find config files and directories for your CLI application.
Documentation
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
#![warn(
    missing_docs,
    clippy::missing_errors_doc,
    clippy::missing_panics_doc,
    rustdoc::missing_crate_level_docs
)]
#![allow(clippy::bool_comparison)]

use std::borrow::Cow;
use std::ffi::{OsStr, OsString};
use std::iter::FusedIterator;
use std::path::{Path, PathBuf};
use std::slice::Iter;

/// Common ground for the three way to look for configuration paths.
#[derive(Debug, Clone, Default)]
pub struct ConfigDirs {
    /// Paths *without* the added app directory/file
    paths: Vec<PathBuf>,

    /// If `true`, the current working directory has already been added
    added_cwd: bool,
    /// If `true`, `$XDG_CONFIG_HOME` (defaulting to `~/.config/`) has already been added
    ///
    /// On Windows this is the AppData/Roaming directory.
    added_platform: bool,
    /// If `true`, `/etc` has already been added
    #[cfg(unix)]
    added_etc: bool,
}

impl ConfigDirs {
    /// Empty list of paths to search configs in.
    ///
    /// ```
    /// use config_finder::ConfigDirs;
    ///
    /// assert!(ConfigDirs::empty().paths().is_empty());
    /// ```
    pub const fn empty() -> Self {
        Self {
            paths: Vec::new(),
            added_cwd: false,
            added_platform: false,
            #[cfg(unix)]
            added_etc: false,
        }
    }

    /// Iterator yielding possible config files or directories.
    ///
    /// # Behaviour
    ///
    /// Will search for `app/base.ext` and `app/base.local.ext`. If the extension is empty, it will
    /// search for `app/base` and `app/base.local` instead.
    ///
    /// Giving an empty `app` or `ext`ension is valid, see examples below.
    ///
    /// # Example
    ///
    /// ```
    /// # fn main() { wrapped(); }
    /// # fn wrapped() -> Option<()> {
    /// use std::path::Path;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// let mut app_files = cd.add_path("start")
    ///                       .add_path("second")
    ///                       .add_path("end")
    ///                       .search("my-app", "main", "kdl");
    ///
    /// let wl = app_files.next()?;
    /// assert_eq!(wl.path(), Path::new("start/.config/my-app/main.kdl"));
    /// assert_eq!(wl.local_path(), Path::new("start/.config/my-app/main.local.kdl"));
    ///
    /// let wl = app_files.next_back()?;
    /// assert_eq!(wl.path(), Path::new("end/.config/my-app/main.kdl"));
    /// assert_eq!(wl.local_path(), Path::new("end/.config/my-app/main.local.kdl"));
    ///
    /// let wl = app_files.next()?;
    /// assert_eq!(wl.path(), Path::new("second/.config/my-app/main.kdl"));
    /// assert_eq!(wl.local_path(), Path::new("second/.config/my-app/main.local.kdl"));
    ///
    /// assert_eq!(app_files.next(), None);
    /// # Some(()) }
    /// ```
    ///
    /// Without an app subdirectory:
    ///
    /// ```
    /// # fn main() { wrapped(); }
    /// # fn wrapped() -> Option<()> {
    /// use std::path::Path;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// cd.add_path("start");
    /// let mut app_files =
    ///     cd.add_path("start").search("", "my-app", "kdl");
    ///
    /// let wl = app_files.next()?;
    /// assert_eq!(wl.path(), Path::new("start/.config/my-app.kdl"));
    /// assert_eq!(wl.local_path(), Path::new("start/.config/my-app.local.kdl"));
    ///
    /// assert_eq!(app_files.next(), None);
    /// # Some(()) }
    /// ```
    ///
    /// Without an extension:
    ///
    /// ```
    /// # fn main() { wrapped(); }
    /// # fn wrapped() -> Option<()> {
    /// use std::path::Path;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// let mut app_files =
    ///     cd.add_path("start").search("my-app", "main", "");
    ///
    /// let wl = app_files.next()?;
    /// assert_eq!(wl.path(), Path::new("start/.config/my-app/main"));
    /// assert_eq!(wl.local_path(), Path::new("start/.config/my-app/main.local"));
    ///
    /// assert_eq!(app_files.next(), None);
    /// # Some(()) }
    /// ```
    #[inline]
    pub fn search(&self, app: impl AsRef<Path>, base: impl AsRef<OsStr>, ext: impl AsRef<OsStr>) -> ConfigCandidates {
        ConfigCandidates::new(&self.paths, app, base, ext)
    }
}

/// Accessors
impl ConfigDirs {
    /// Look at the config paths already added.
    ///
    /// ```
    /// use std::path::PathBuf;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// assert!(cd.paths().is_empty());
    /// cd.add_path("my/config/path");
    /// assert_eq!(cd.paths(), &[PathBuf::from("my/config/path/.config")]);
    /// ```
    pub fn paths(&self) -> &[PathBuf] {
        &self.paths
    }
}

/// Adding paths to the list
impl ConfigDirs {
    /// Adds `path` to the list of directories to check, if not previously added.
    ///
    /// This path should **not** contain the config directory (or file) passed during
    /// construction.
    ///
    /// # Behaviour
    ///
    /// This function will add `.config` to the given path if it does not end with that
    /// already. This means you can just pass the workspace for your application (e.g. the root of
    /// a git repository) and this type will look for `workspace/.config/<app>`.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::PathBuf;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// assert!(cd.paths().is_empty());
    /// cd.add_path("my/config/path")
    ///   .add_path("my/other/path/.config"); // .config already present at the end
    /// assert_eq!(cd.paths(), &[
    ///     PathBuf::from("my/config/path/.config"),
    ///     PathBuf::from("my/other/path/.config"), // it has not been added again
    /// ]);
    /// ```
    #[inline]
    pub fn add_path<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
        self._add_path(path, true)
    }

    /// Adds all the paths starting from `start` and going up until a parent is out of `container`.
    ///
    /// This *includes* `container`.
    ///
    /// If `start` does not [starts with][Path::starts_with] `container`, this will do nothing since
    /// `start` is already out of the containing path.
    ///
    /// # Behaviour
    ///
    /// See [`Self::add_path()`]. This behaviour will be applied to each path added by this method.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::PathBuf;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// assert!(cd.paths().is_empty());
    /// cd.add_all_paths_until("look/my/config/path", "look/my");
    /// assert_eq!(cd.paths(), &[
    ///     PathBuf::from("look/my/config/path/.config"),
    ///     PathBuf::from("look/my/config/.config"),
    ///     PathBuf::from("look/my/.config"),
    /// ]);
    /// ```
    ///
    /// `"other"` is not a root of `"my/config/path"`:
    ///
    /// ```
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// assert!(cd.paths().is_empty());
    /// cd.add_all_paths_until("my/config/path", "other");
    /// assert!(cd.paths().is_empty());
    /// ```
    #[inline]
    pub fn add_all_paths_until<P1: AsRef<Path>, P2: AsRef<Path>>(&mut self, start: P1, container: P2) -> &mut Self {
        fn helper(this: &mut ConfigDirs, start: &Path, container: &Path) {
            start
                .ancestors()
                .take_while(|p| p.starts_with(container))
                .for_each(|p| {
                    this._add_path(p, true);
                });
        }

        helper(self, start.as_ref(), container.as_ref());
        self
    }

    /// Adds the platform's config directory to the list of paths to check.
    ///
    /// |Platform | Value                                 | Example                          |
    /// | ------- | ------------------------------------- | -------------------------------- |
    /// | Unix(1) | `$XDG_CONFIG_HOME` or `$HOME/.config` | `/home/alice/.config`            |
    /// | Windows | `{FOLDERID_RoamingAppData}`           | `C:\Users\Alice\AppData\Roaming` |
    ///
    /// (1): *Unix* stand for both Linux and macOS here. Since this crate is primarily intended for
    /// CLI applications & tools, having the macOS files hidden in `$HOME/Library/Application
    /// Support` is not practical.
    ///
    /// # Behaviour
    ///
    /// This method will **not** add `.config`, unlike [`Self::add_path()`].
    ///
    /// ## Examples
    ///
    /// ```
    /// use std::path::PathBuf;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// if cfg!(windows) {
    ///     let mut cd = ConfigDirs::empty();
    ///     cd.add_platform_config_dir()
    ///       .add_platform_config_dir(); // Adding twice does not affect the final list
    ///     assert_eq!(cd.paths().len(), 1);
    ///     assert!(cd.paths()[0].ends_with("AppData/Roaming"));
    /// } else {
    ///     std::env::set_var("HOME", "/home/testuser");
    ///
    ///     // With `XDG_CONFIG_HOME` unset
    ///     std::env::remove_var("XDG_CONFIG_HOME");
    ///     let mut cd = ConfigDirs::empty();
    ///     cd.add_platform_config_dir();
    ///     assert_eq!(cd.paths(), &[PathBuf::from("/home/testuser/.config")]);
    ///
    ///     // With `XDG_CONFIG_HOME` set
    ///     std::env::set_var("XDG_CONFIG_HOME", "/home/.shared_configs");
    ///     let mut cd = ConfigDirs::empty();
    ///     cd.add_platform_config_dir();
    ///     assert_eq!(cd.paths(), &[PathBuf::from("/home/.shared_configs")]); // No `.config` added
    /// }
    /// ```
    pub fn add_platform_config_dir(&mut self) -> &mut Self {
        if self.added_platform {
            return self;
        }

        // We don't set `self.added_platform` unconditionnally because the environment can change
        // between the failing call and the next one (which may succeed and then set to true)

        #[cfg(windows)]
        if let Some(path) = dirs_sys::known_folder_roaming_app_data() {
            self._add_path(path, false);
            self.added_platform = true;
        }

        #[cfg(not(windows))]
        if let Some(path) = std::env::var_os("XDG_CONFIG_HOME").and_then(dirs_sys::is_absolute_path) {
            self._add_path(path, false);
            self.added_platform = true;
        } else if let Some(path) = dirs_sys::home_dir().filter(|p| p.is_absolute()) {
            self._add_path(path, true);
            self.added_platform = true;
        }

        self
    }

    /// Adds the current directory to the list of paths to search in.
    ///
    /// # Errors
    ///
    /// Returns an error if [`std::env::current_dir()`] fails.
    ///
    /// # Behaviour
    ///
    /// See [`Self::add_path()`].
    ///
    /// # Examples
    ///
    /// ```
    /// use config_finder::ConfigDirs;
    ///
    /// let current_dir = std::env::current_dir().unwrap().join(".config");
    ///
    /// let mut cd = ConfigDirs::empty();
    /// cd.add_current_dir();
    /// assert_eq!(cd.paths(), &[current_dir]);
    /// ```
    #[inline]
    pub fn add_current_dir(&mut self) -> std::io::Result<&mut Self> {
        if self.added_cwd == false {
            self._add_path(std::env::current_dir()?, true);
            self.added_cwd = true;
        }
        Ok(self)
    }
}

/// Unix-only methods
#[cfg(unix)]
impl ConfigDirs {
    /// Adds `/etc` to the list of paths to checks if not previously added.
    ///
    /// # Behaviour
    ///
    /// This method will **not** add `.config`, unlike [`Self::add_path()`].
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::PathBuf;
    ///
    /// use config_finder::ConfigDirs;
    ///
    /// let mut cd = ConfigDirs::empty();
    /// cd.add_root_etc();
    /// assert_eq!(cd.paths(), &[PathBuf::from("/etc")]);
    /// ```
    #[inline]
    pub fn add_root_etc(&mut self) -> &mut Self {
        if self.added_etc == false {
            self._add_path("/etc", false);
            self.added_etc = true;
        }
        self
    }
}

/// Private methods
impl ConfigDirs {
    /// Helper that will add the `.config` at the end if asked AND if the given path does *not* end
    /// with `.config` already.
    #[inline]
    pub(crate) fn _add_path<P>(&mut self, path: P, check_for_dot_config: bool) -> &mut Self
    where
        P: AsRef<Path>,
    {
        fn helper(this: &mut ConfigDirs, pr: &Path, check_for_dot_config: bool) {
            let path = if check_for_dot_config == false || pr.ends_with(".config") {
                Cow::Borrowed(pr)
            } else {
                Cow::Owned(pr.join(".config"))
            };

            if this.paths.iter().all(|p| p != &path) {
                this.paths.push(path.into_owned());
            }
        }

        helper(self, path.as_ref(), check_for_dot_config);
        self
    }
}

/// Iterator for [`ConfigDirs::search()`].
pub struct ConfigCandidates<'c> {
    conf: WithLocal,
    paths: Iter<'c, PathBuf>,
}

impl<'c> ConfigCandidates<'c> {
    pub(crate) fn new(
        paths: &'c [PathBuf],
        app: impl AsRef<Path>,
        base: impl AsRef<OsStr>,
        ext: impl AsRef<OsStr>,
    ) -> Self {
        Self {
            conf: WithLocal::new(app.as_ref().join(base.as_ref()), ext),
            paths: paths.iter(),
        }
    }
}

impl Iterator for ConfigCandidates<'_> {
    type Item = WithLocal;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        let dir = self.paths.next()?;
        Some(self.conf.joined_to(dir))
    }

    #[inline]
    fn last(self) -> Option<Self::Item>
    where
        Self: Sized,
    {
        let dir = self.paths.last()?;
        Some(self.conf.joined_to(dir))
    }

    #[inline]
    fn nth(&mut self, n: usize) -> Option<Self::Item> {
        let dir = self.paths.nth(n)?;
        Some(self.conf.joined_to(dir))
    }

    #[inline]
    fn size_hint(&self) -> (usize, Option<usize>) {
        self.paths.size_hint()
    }

    #[inline]
    fn count(self) -> usize
    where
        Self: Sized,
    {
        self.paths.count()
    }
}

impl DoubleEndedIterator for ConfigCandidates<'_> {
    #[inline]
    fn next_back(&mut self) -> Option<Self::Item> {
        let dir = self.paths.next_back()?;
        Some(self.conf.joined_to(dir))
    }
}

impl ExactSizeIterator for ConfigCandidates<'_> {}

impl FusedIterator for ConfigCandidates<'_> {}

/// Stores both the normal and local form a configuration path.
///
/// The local form has `.local` inserted just before the extension: `cli-app.kdl` has the local form
/// `cli-app.local.kdl`.
///
/// While this is mostly intended for file, nothing precludes an application from using it for
/// directories.
///
/// ```
/// use std::path::{Path, PathBuf};
///
/// use config_finder::WithLocal;
///
/// // `.local` is inserted before the extension for the `.local_path()` form
/// let wl = WithLocal::new("cli-app", "kdl");
/// assert_eq!(wl.path(), Path::new("cli-app.kdl"));
/// assert_eq!(wl.local_path(), Path::new("cli-app.local.kdl"));
///
/// // Even if the extension is empty (can notably be used for directories)
/// let wl = WithLocal::new("cli-app", "");
/// assert_eq!(wl.path(), Path::new("cli-app"));
/// assert_eq!(wl.local_path(), Path::new("cli-app.local"));
///
/// // An empty base is valid too
/// let wl = WithLocal::new("", "kdl");
/// assert_eq!(wl.path(), Path::new(".kdl"));
/// assert_eq!(wl.local_path(), Path::new(".local.kdl"));
///
/// // If you need to store a form (local or not),
/// let wl = WithLocal::new("zellij", "kdl");
/// assert_eq!(wl.into_paths(), (PathBuf::from("zellij.kdl"), PathBuf::from("zellij.local.kdl")));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WithLocal {
    /// The normal path
    path: PathBuf,
    /// The local form of the path.
    local_path: PathBuf,
}

impl WithLocal {
    /// Computes both the normal and local forms of the path.
    ///
    /// If the `ext`ension is non-empty, inserts a dot (`.`) between the `base` and the `ext`ension.
    #[inline]
    pub fn new(base: impl Into<OsString>, ext: impl AsRef<OsStr>) -> Self {
        fn helper(mut path: OsString, ext: &OsStr) -> WithLocal {
            let mut local_path = path.clone();
            local_path.push(".local");

            if ext.is_empty() == false {
                path.push(".");
                path.push(ext);

                local_path.push(".");
                local_path.push(ext);
            }

            WithLocal {
                path: path.into(),
                local_path: local_path.into(),
            }
        }

        helper(base.into(), ext.as_ref())
    }

    /// Path without the added `.local` just before the extension.
    #[inline]
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Path with the added `.local` just before the extension.
    #[inline]
    pub fn local_path(&self) -> &Path {
        &self.local_path
    }

    /// Destructure into the inner `(path, local_path)` without allocating.
    #[inline]
    pub fn into_paths(self) -> (PathBuf, PathBuf) {
        (self.path, self.local_path)
    }
}

impl WithLocal {
    // Helper function for the iterator
    fn joined_to(&self, base: &Path) -> Self {
        Self {
            path: base.join(&self.path),
            local_path: base.join(&self.local_path),
        }
    }
}