use crate::Format;
use crate::error::Result;
use crate::file::{ConfigFile, ConfigFiles};
use cfgmatic_paths::{ConfigTier, PathFinder, PathsBuilder};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct FileFinder {
app_name: String,
formats: Vec<Format>,
base_name: Option<String>,
require_existence: bool,
follow_symlinks: bool,
search_depth: usize,
}
impl FileFinder {
pub fn new(app_name: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
formats: vec![Format::Toml, Format::Json],
base_name: Some("config".to_string()),
require_existence: true,
follow_symlinks: false,
search_depth: 3,
}
}
#[must_use]
pub fn formats(mut self, formats: &[Format]) -> Self {
self.formats = formats.to_vec();
self
}
#[must_use]
pub fn base_name(mut self, name: impl Into<String>) -> Self {
self.base_name = Some(name.into());
self
}
#[must_use]
pub fn any_name(mut self) -> Self {
self.base_name = None;
self
}
#[must_use]
pub const fn require_existence(mut self, require: bool) -> Self {
self.require_existence = require;
self
}
#[must_use]
pub const fn follow_symlinks(mut self, follow: bool) -> Self {
self.follow_symlinks = follow;
self
}
#[must_use]
pub const fn search_depth(mut self, depth: usize) -> Self {
self.search_depth = depth;
self
}
#[must_use]
pub fn build(self) -> FileFinderState {
let path_finder = PathsBuilder::new(&self.app_name).build();
FileFinderState {
config: self,
path_finder,
}
}
pub fn find(self) -> Result<ConfigFiles> {
self.build().find()
}
pub fn find_first(self) -> Result<Option<ConfigFile>> {
let files = self.find()?;
Ok(files.first().cloned())
}
}
pub struct FileFinderState {
config: FileFinder,
path_finder: PathFinder,
}
impl FileFinderState {
pub fn find(&self) -> Result<ConfigFiles> {
let mut files = ConfigFiles::new();
self.search_in_tier(&mut files, ConfigTier::User, self.path_finder.user_dirs())?;
self.search_in_tier(&mut files, ConfigTier::Local, self.path_finder.local_dirs())?;
self.search_in_tier(
&mut files,
ConfigTier::System,
self.path_finder.system_dirs(),
)?;
Ok(files)
}
fn search_in_tier(
&self,
files: &mut ConfigFiles,
tier: ConfigTier,
dirs: Vec<PathBuf>,
) -> Result<()> {
for dir in dirs {
if !dir.exists() {
continue;
}
if self.config.base_name.is_some() {
self.search_named_files(files, &dir, tier);
} else {
self.search_any_files(files, &dir, tier, 0)?;
}
}
Ok(())
}
fn search_named_files(&self, files: &mut ConfigFiles, dir: &Path, tier: ConfigTier) {
let Some(base_name) = self.config.base_name.as_ref() else {
return;
};
for format in &self.config.formats {
let file_name = format!("{}.{}", base_name, format.extension());
let path = dir.join(&file_name);
if self.should_include_file(&path)
&& let Some(file) = ConfigFile::new(path, tier)
{
files.push(file);
}
}
}
fn search_any_files(
&self,
files: &mut ConfigFiles,
dir: &Path,
tier: ConfigTier,
depth: usize,
) -> Result<()> {
if depth > self.config.search_depth {
return Ok(());
}
let Ok(entries) = fs::read_dir(dir) else {
return Ok(());
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(format) = Format::from_path(&path)
&& self.config.formats.contains(&format)
&& self.should_include_file(&path)
&& let Some(file) = ConfigFile::new(path, tier)
{
files.push(file);
}
} else if path.is_dir()
&& self.config.follow_symlinks
&& depth < self.config.search_depth
{
self.search_any_files(files, &path, tier, depth + 1)?;
}
}
Ok(())
}
fn should_include_file(&self, path: &Path) -> bool {
if !self.config.require_existence {
return true;
}
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
if metadata.is_symlink() && !self.config.follow_symlinks {
return false;
}
path.exists()
}
}
pub fn find_files(app_name: impl Into<String>) -> Result<ConfigFiles> {
FileFinder::new(app_name).find()
}
pub fn find_first_file(app_name: impl Into<String>) -> Result<Option<ConfigFile>> {
FileFinder::new(app_name).find_first()
}
pub fn load_first<T: serde::de::DeserializeOwned>(
app_name: impl Into<String>,
) -> Result<Option<T>> {
match find_first_file(app_name)? {
Some(mut file) => Ok(Some(file.parse()?)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_finder_builder() {
let finder = FileFinder::new("myapp")
.formats(&[Format::Toml])
.base_name("app")
.require_existence(false);
assert_eq!(finder.formats.len(), 1);
assert_eq!(finder.base_name, Some("app".to_string()));
assert!(!finder.require_existence);
}
#[test]
fn test_find_first_nonexistent() -> Result<()> {
let result = FileFinder::new("nonexistent_app_12345").find_first()?;
assert!(result.is_none());
Ok(())
}
}