use std::{collections::HashMap, path::PathBuf, sync::RwLock};
use crate::{
constant,
evaluator::{self, utils},
};
#[derive(Debug)]
pub struct ConfigFile {
#[allow(dead_code)]
pub path: PathBuf,
pub exclude_file_path: Option<PathBuf>,
pub checksum: Vec<u8>,
}
#[derive(Debug, Default)]
pub struct ConfigHandler {
git_config_paths: RwLock<HashMap<PathBuf, ConfigFile>>,
}
impl ConfigHandler {
pub fn get_global_git_exclude_file_path(&self) -> evaluator::Result<Option<PathBuf>> {
for config_path in [
xdir::config().map(|p| p.join(constant::GLOBAL_GIT_CONFIG_PATH)),
xdir::home().map(|p| p.join(constant::LEGACY_GLOBAL_GIT_CONFIG_PATH)),
] {
log::debug!("Attempting read of git config file potentially in: {config_path:?}");
if let Some(path) = config_path.as_ref() {
let guard = self
.git_config_paths
.read()
.map_err(|_| evaluator::Error::CachePoisoned(path.clone()))?;
let config_file = guard.get(path);
let (target_checksum, file) = match crate::utils::compute_checksum(path) {
Ok((checksum, file)) => (checksum, file),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
continue;
}
Err(e) => {
return Err(evaluator::Error::FileError {
file: path.clone(),
source: e,
});
}
};
if config_file.is_some_and(|config_file| target_checksum == config_file.checksum) {
let config_file = config_file.expect("Git config file cannot ever have a matching checksum when the config file is not present.");
log::debug!(
"Using existing cached core.excludesfile set as: {:?}",
config_file.exclude_file_path
);
return Ok(config_file.exclude_file_path.clone());
}
drop(guard);
if let Ok(config_file) = utils::read_git_config(path, file, &target_checksum) {
let exclude_file_path = config_file
.exclude_file_path
.as_ref()
.map(std::borrow::ToOwned::to_owned);
self.git_config_paths
.write()
.map_err(|_| evaluator::Error::CachePoisoned(path.clone()))?
.insert(path.clone(), config_file);
if exclude_file_path.is_some() {
log::debug!(
"Git config file set core.excludesfile as: {exclude_file_path:?}"
);
return Ok(exclude_file_path);
}
}
}
}
let default = xdir::config().map(|p| p.join(constant::DEFAULT_GLOBAL_GIT_EXCLUDE_PATH));
log::debug!(
"No valid core.excludesfile config found in any git config file. Using default path: {default:?}"
);
Ok(default)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::process::Stdio;
use std::sync::RwLock;
use std::{path::Path, path::PathBuf};
use temp_env::with_vars;
use tempfile::TempDir;
use crate::constant;
use crate::evaluator::git_config::{ConfigFile, ConfigHandler};
fn write_git_config(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test_log::test(rstest::rstest)]
#[case(
Some("[core]\n\texcludesfile = /home/excludes\n"),
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", false),
],
Some(PathBuf::from("/home/excludes"))
)]
#[case(
None,
Some("[core]\n\texcludesfile = /xdg/excludes\n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
Some(PathBuf::from("/xdg/excludes"))
)]
#[case(
Some("[core]\n\texcludesfile = /home/excludes\n"),
Some("[core]\n\texcludesfile = /xdg/excludes\n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
Some(PathBuf::from("/xdg/excludes"))
)]
#[case(
Some("[core]\n\texcludesfile = /home/excludes\n"),
Some("[core]\n\texcludesfile = /xdg/excludes\n"),
vec![
("HOME", true),
("XDG_CONFIG_HOME", true),
("USERPROFILE", false),
],
Some(PathBuf::from("/xdg/excludes")) )]
#[case(
Some("[CORE]\n# comment\n excludesfile = \"/home/excludes\" \n"),
Some("[core]\n\texcludesfile=/xdg/excludes\n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
Some(PathBuf::from("/xdg/excludes")) )]
#[case(
None,
Some("[core]\n excludesfile = /xdg/excludes \n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
Some(PathBuf::from("/xdg/excludes"))
)]
#[case(
Some("[core]\nexcludesfile = /first/path\n[core]\nexcludesfile = \"/second/path\"\n"),
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", false),
],
Some(PathBuf::from("/second/path"))
)]
#[case(
Some("[core]\n\texcludesfile\t=\t/path/with/spaces\n"),
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", false),
],
Some(PathBuf::from("/path/with/spaces"))
)]
fn test_parsing_excludes_file_from_config(
#[case] home_contents: Option<&str>,
#[case] xdg_contents: Option<&str>,
#[case] env_keys: Vec<(&str, bool)>,
#[case] expected_exclude: Option<PathBuf>,
) {
let temp_home = TempDir::new().unwrap();
let temp_xdg = TempDir::new().unwrap();
let env_vars: Vec<(&str, Option<PathBuf>)> = env_keys
.into_iter()
.map(|(key, set)| {
let path = if set {
match key {
"HOME" | "USERPROFILE" => Some(temp_home.path().to_path_buf()),
"XDG_CONFIG_HOME" => Some(temp_xdg.path().to_path_buf()),
_ => None,
}
} else {
None
};
(key, path)
})
.collect();
if let Some(contents) = home_contents {
write_git_config(
&temp_home
.path()
.join(".config")
.join(constant::GLOBAL_GIT_CONFIG_PATH),
contents,
);
}
if let Some(contents) = xdg_contents {
write_git_config(
&temp_xdg.path().join(constant::GLOBAL_GIT_CONFIG_PATH),
contents,
);
}
let env_vec: Vec<(&str, Option<&Path>)> = env_vars
.iter()
.map(|(key, opt_path)| (*key, opt_path.as_ref().map(PathBuf::as_path)))
.collect();
with_vars(env_vec, || {
let config_handler = ConfigHandler::default();
let path = config_handler
.get_global_git_exclude_file_path()
.expect("Should be able to get global git exclude file path");
assert_eq!(
path, expected_exclude,
"{path:?} does not match expected: {expected_exclude:?}"
);
let output = std::process::Command::new("git")
.arg("config")
.arg("--get")
.arg("core.excludesfile")
.stdout(Stdio::piped())
.output()
.expect("failed to run git");
let git_returned_path = String::from_utf8(output.stdout).ok().and_then(|stdout| {
if stdout.is_empty() {
return None;
}
Some(PathBuf::from(stdout.trim_end()))
});
assert_eq!(
path, git_returned_path,
"{path:?} does not match git cli path: {git_returned_path:?}"
);
});
}
#[test_log::test(rstest::rstest)]
#[case(
None,
None,
vec![
("HOME", false),
("USERPROFILE", false),
("XDG_CONFIG_HOME", false),
],
)]
#[case(
None,
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", false),
],
)]
#[case(
None,
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
)]
#[case(
Some("[CORE]\n# comment\n"),
None,
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", false),
],
)]
#[case(
None,
Some("[CORE]\n# comment\n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
)]
#[case(
Some("[CORE]\n# comment\n"),
Some("[CORE]\n# comment\n"),
vec![
("HOME", true),
("USERPROFILE", true),
("XDG_CONFIG_HOME", true),
],
)]
#[case(
None,
None,
vec![
("HOME", false),
("USERPROFILE", false),
("XDG_CONFIG_HOME", true),
],
)]
fn test_handles_defaults_when_excludes_file_is_not_set_in_config(
#[case] home_contents: Option<&str>,
#[case] xdg_contents: Option<&str>,
#[case] env_keys: Vec<(&str, bool)>,
) {
let temp_home = TempDir::new().unwrap();
let temp_xdg = TempDir::new().unwrap();
let env_vars: Vec<(&str, Option<PathBuf>)> = env_keys
.into_iter()
.map(|(key, set)| {
let path = if set {
match key {
"HOME" | "USERPROFILE" => Some(temp_home.path().to_path_buf()),
"XDG_CONFIG_HOME" => Some(temp_xdg.path().to_path_buf()),
_ => None,
}
} else {
None
};
(key, path)
})
.collect();
if let Some(contents) = home_contents {
write_git_config(
&temp_home
.path()
.join(".config")
.join(constant::GLOBAL_GIT_CONFIG_PATH),
contents,
);
}
if let Some(contents) = xdg_contents {
write_git_config(
&temp_xdg.path().join(constant::GLOBAL_GIT_CONFIG_PATH),
contents,
);
}
let env_vec: Vec<(&str, Option<&Path>)> = env_vars
.iter()
.map(|(key, opt_path)| (*key, opt_path.as_ref().map(PathBuf::as_path)))
.collect();
with_vars(env_vec, || {
let config_handler = ConfigHandler::default();
let path = config_handler.get_global_git_exclude_file_path();
assert!(
path.as_ref()
.expect("reading file should succeed")
.as_ref()
.is_some_and(|s| s.ends_with("git/ignore")),
"{path:?} is not the default excludes file path (ending in .config/git/ignore)"
);
let output = std::process::Command::new("git")
.arg("config")
.arg("--get")
.arg("core.excludesfile")
.stdout(Stdio::piped())
.output()
.expect("failed to run git");
let git_returned_path = String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim_end().is_empty())
.or(Some(true));
assert_eq!(Some(true), git_returned_path);
});
}
#[test_log::test(rstest::rstest)]
fn test_doesnt_reparse_config_on_repeated_calls() {
let temp_xdg = TempDir::new().unwrap();
write_git_config(
&temp_xdg.path().join(constant::GLOBAL_GIT_CONFIG_PATH),
"[core]\n\texcludesfile = /some/path/read/from/disk\n",
);
with_vars([("XDG_CONFIG_HOME", Some(temp_xdg.path()))], || {
let config_path = temp_xdg.path().join(constant::GLOBAL_GIT_CONFIG_PATH);
let config_handler = ConfigHandler {
git_config_paths: RwLock::new(
[(
config_path.clone(),
ConfigFile {
path: config_path.clone(),
exclude_file_path: Some(PathBuf::from("/some/path/not/read/from/disk")),
checksum: crate::utils::compute_checksum(&config_path)
.map(|(checksum, _)| checksum)
.expect("Should be able to compute checksum for config file"),
},
)]
.into(),
),
};
assert_eq!(
config_handler
.get_global_git_exclude_file_path()
.expect("Should be able to get global git exclude file path"),
Some(PathBuf::from("/some/path/not/read/from/disk"))
);
});
}
}