use regex::Regex;
use std::{
collections::{BTreeMap, HashMap},
ffi::{OsStr, OsString},
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
const DEFAULT_IGNORE_FILES: [&str; 2] = [".p4ignore", "p4ignore.txt"];
const P4IGNORE_MAX_BYTES: u64 = 1024 * 1024;
const P4IGNORE_MAX_RULES: usize = 10_000;
const P4IGNORE_MAX_LINE_BYTES: usize = 16 * 1024;
const P4CONFIG_MAX_BYTES: u64 = 64 * 1024;
#[derive(Debug, Clone, Default)]
pub(super) struct P4Environment {
vars: BTreeMap<OsString, OsString>,
case_insensitive: bool,
}
impl P4Environment {
pub(super) fn process() -> Self {
Self::from_vars(std::env::vars_os(), cfg!(windows))
}
#[cfg(test)]
pub(super) fn with_vars(vars: impl IntoIterator<Item = (String, String)>) -> Self {
Self::from_vars(
vars.into_iter()
.map(|(key, value)| (key.into(), value.into())),
false,
)
}
#[cfg(test)]
fn with_windows_vars(vars: impl IntoIterator<Item = (String, String)>) -> Self {
Self::from_vars(
vars.into_iter()
.map(|(key, value)| (key.into(), value.into())),
true,
)
}
fn from_vars(
vars: impl IntoIterator<Item = (OsString, OsString)>,
case_insensitive: bool,
) -> Self {
Self {
vars: vars
.into_iter()
.map(|(key, value)| (environment_key(key, case_insensitive), value))
.collect(),
case_insensitive,
}
}
fn value(&self, key: &str) -> Option<&OsStr> {
let key = environment_key(OsString::from(key), self.case_insensitive);
self.vars.get(&key).map(OsString::as_os_str)
}
fn string(&self, key: &str) -> Option<String> {
self.value(key)
.map(|value| value.to_string_lossy().into_owned())
.filter(|value| !value.is_empty())
}
}
fn environment_key(key: OsString, case_insensitive: bool) -> OsString {
if case_insensitive {
key.to_string_lossy().to_ascii_uppercase().into()
} else {
key
}
}
#[derive(Debug, Clone)]
enum P4IgnoreSource {
Relative(PathBuf),
Absolute(PathBuf),
}
#[derive(Debug, Clone)]
struct P4Rule {
matcher: Regex,
ignored: bool,
directory_only: bool,
}
#[derive(Debug, Clone)]
struct P4RuleFile {
root: PathBuf,
rules: Vec<P4Rule>,
}
impl P4RuleFile {
fn matched(&self, path: &Path, is_dir: bool) -> Option<bool> {
let relative = relative_path(path, &self.root)?;
self.rules.iter().fold(None, |matched, rule| {
let direct_match = (!rule.directory_only || is_dir) && rule.matcher.is_match(&relative);
let parent_match = path
.ancestors()
.skip(1)
.take_while(|parent| *parent != self.root)
.any(|parent| {
relative_path(parent, &self.root)
.is_some_and(|relative| rule.matcher.is_match(&relative))
});
if direct_match || parent_match {
Some(rule.ignored)
} else {
matched
}
})
}
fn has_whitelist(&self) -> bool {
self.rules.iter().any(|rule| !rule.ignored)
}
}
fn relative_path(path: &Path, root: &Path) -> Option<String> {
Some(
path.strip_prefix(root)
.ok()?
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)
}
#[derive(Debug, Default)]
struct P4MatcherState {
child_rules: HashMap<PathBuf, Vec<P4RuleFile>>,
error: Option<String>,
}
#[derive(Debug)]
struct P4MatcherInner {
root: PathBuf,
sources: Vec<P4IgnoreSource>,
base_rules: Vec<P4RuleFile>,
built_in_names: Vec<OsString>,
state: Mutex<P4MatcherState>,
}
#[derive(Debug, Clone)]
pub(super) struct P4Matcher {
inner: Arc<P4MatcherInner>,
}
impl P4Matcher {
pub(super) fn from_root(root: &Path, environment: &P4Environment) -> anyhow::Result<Self> {
let sources = ignore_sources(root, environment)?;
let built_in_names = built_in_ignore_names(root, environment)?;
let mut base_rules = Vec::new();
let mut ancestors = root.ancestors().collect::<Vec<_>>();
ancestors.reverse();
for directory in ancestors {
append_rules_for_directory(
&mut base_rules,
directory,
root,
&sources,
directory == root,
)?;
}
Ok(Self {
inner: Arc::new(P4MatcherInner {
root: root.to_path_buf(),
sources,
base_rules,
built_in_names,
state: Mutex::new(P4MatcherState::default()),
}),
})
}
pub(super) fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
if path.components().any(|component| {
self.inner
.built_in_names
.iter()
.any(|name| same_file_name(component.as_os_str(), name))
}) {
return true;
}
let mut ignored = false;
apply_rules(&self.inner.base_rules, path, is_dir, &mut ignored);
let Some(parent) = path.parent() else {
return ignored;
};
let Ok(relative_parent) = parent.strip_prefix(&self.inner.root) else {
return ignored;
};
let mut directory = self.inner.root.clone();
for component in relative_parent.components() {
directory.push(component.as_os_str());
let rules = self.rules_for_child_directory(&directory);
apply_rules(&rules, path, is_dir, &mut ignored);
}
ignored
}
pub(super) fn should_prune(&self, path: &Path) -> bool {
if !self.is_ignored(path, true) {
return false;
}
if self.inner.base_rules.iter().any(P4RuleFile::has_whitelist) {
return false;
}
let Some(parent) = path.parent() else {
return true;
};
let Ok(relative_parent) = parent.strip_prefix(&self.inner.root) else {
return true;
};
let mut directory = self.inner.root.clone();
for component in relative_parent.components() {
directory.push(component.as_os_str());
if self
.rules_for_child_directory(&directory)
.iter()
.any(P4RuleFile::has_whitelist)
{
return false;
}
}
true
}
pub(super) fn take_error(&self) -> Option<anyhow::Error> {
self.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.error
.take()
.map(anyhow::Error::msg)
}
fn rules_for_child_directory(&self, directory: &Path) -> Vec<P4RuleFile> {
let mut state = self
.inner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(rules) = state.child_rules.get(directory) {
return rules.clone();
}
let mut rules = Vec::new();
if let Err(error) = append_rules_for_directory(
&mut rules,
directory,
&self.inner.root,
&self.inner.sources,
false,
) {
state.error.get_or_insert_with(|| error.to_string());
return Vec::new();
}
state
.child_rules
.insert(directory.to_path_buf(), rules.clone());
rules
}
}
fn same_file_name(left: &OsStr, right: &OsStr) -> bool {
if cfg!(windows) {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
} else {
left == right
}
}
fn apply_rules(rules: &[P4RuleFile], path: &Path, is_dir: bool, ignored: &mut bool) {
for rule_file in rules {
if let Some(rule_ignored) = rule_file.matched(path, is_dir) {
*ignored = rule_ignored;
}
}
}
fn append_rules_for_directory(
rules: &mut Vec<P4RuleFile>,
directory: &Path,
walk_root: &Path,
sources: &[P4IgnoreSource],
include_absolute: bool,
) -> anyhow::Result<()> {
for source in sources {
match source {
P4IgnoreSource::Relative(relative) => {
let path = directory.join(relative);
let match_root = path.parent().unwrap_or(directory);
if let Some(rule) = build_rule_file_if_present(&path, match_root, false)? {
rules.push(rule);
}
}
P4IgnoreSource::Absolute(path) if include_absolute => {
let rule = build_rule_file_if_present(path, walk_root, true)?.ok_or_else(|| {
anyhow::anyhow!("cannot read explicit P4IGNORE file {}", path.display())
})?;
rules.push(rule);
}
P4IgnoreSource::Absolute(_) => {}
}
}
Ok(())
}
fn build_rule_file_if_present(
path: &Path,
match_root: &Path,
required: bool,
) -> anyhow::Result<Option<P4RuleFile>> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound && !required => return Ok(None),
Err(error) => {
return Err(anyhow::anyhow!(
"cannot read P4IGNORE file {}: {error}",
path.display()
));
}
};
if !metadata.is_file() {
anyhow::bail!("P4IGNORE path is not a file: {}", path.display());
}
if metadata.len() > P4IGNORE_MAX_BYTES {
anyhow::bail!(
"P4IGNORE file exceeds {P4IGNORE_MAX_BYTES} byte limit: {}",
path.display()
);
}
let text = fs::read_to_string(path).map_err(|error| {
anyhow::anyhow!("cannot read P4IGNORE file {}: {error}", path.display())
})?;
let mut rules = Vec::new();
for (index, line) in text.lines().enumerate() {
if index >= P4IGNORE_MAX_RULES {
anyhow::bail!(
"P4IGNORE file exceeds {P4IGNORE_MAX_RULES} rule limit: {}",
path.display()
);
}
if line.len() > P4IGNORE_MAX_LINE_BYTES {
anyhow::bail!(
"P4IGNORE rule exceeds {P4IGNORE_MAX_LINE_BYTES} byte limit at {}:{}",
path.display(),
index + 1
);
}
if let Some(rule) = parse_rule(line, cfg!(windows)).map_err(|error| {
anyhow::anyhow!(
"invalid P4IGNORE rule {}:{}: {error}",
path.display(),
index + 1
)
})? {
rules.push(rule);
}
}
Ok(Some(P4RuleFile {
root: match_root.to_path_buf(),
rules,
}))
}
fn parse_rule(line: &str, windows: bool) -> anyhow::Result<Option<P4Rule>> {
let mut line = normalize_rule(line.trim(), windows);
if line.starts_with('#') {
return Ok(None);
}
line = line.trim_end().to_string();
if line.is_empty() {
return Ok(None);
}
let escaped_leader = line.starts_with("\\#") || line.starts_with("\\!");
if escaped_leader {
line.remove(0);
}
let ignored = escaped_leader || !line.starts_with('!');
if !ignored {
line.remove(0);
line = line.trim_start().to_string();
}
if line.is_empty() {
return Ok(None);
}
let anchored = line.starts_with('/');
if anchored {
line.remove(0);
}
let directory_only = line.ends_with('/');
if directory_only {
line.pop();
}
if line.is_empty() {
return Ok(None);
}
let mut expression = String::new();
let mut chars = line.chars().peekable();
while let Some(character) = chars.next() {
if character == '*' {
if chars.peek() == Some(&'*') {
chars.next();
expression.push_str(".*");
} else {
expression.push_str("[^/]*");
}
} else {
expression.push_str(®ex::escape(&character.to_string()));
}
}
let expression = if anchored {
format!("^{expression}$")
} else {
format!("(?:^|.*/){expression}$")
};
Ok(Some(P4Rule {
matcher: Regex::new(&expression)?,
ignored,
directory_only,
}))
}
fn normalize_rule(line: &str, windows: bool) -> String {
if !windows {
return line.to_string();
}
line.char_indices()
.map(|(index, character)| {
if character == '\\'
&& !(index == 0
&& line
.as_bytes()
.get(1)
.is_some_and(|next| *next == b'#' || *next == b'!'))
{
'/'
} else {
character
}
})
.collect()
}
fn ignore_sources(root: &Path, environment: &P4Environment) -> anyhow::Result<Vec<P4IgnoreSource>> {
let Some(setting) = resolve_setting(root, environment, "P4IGNORE")? else {
return Ok(DEFAULT_IGNORE_FILES
.iter()
.map(|name| P4IgnoreSource::Relative(PathBuf::from(name)))
.collect());
};
Ok(setting
.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(|entry| PathBuf::from(expand_path(entry, environment)))
.map(|path| {
if path.is_absolute() {
P4IgnoreSource::Absolute(path)
} else {
P4IgnoreSource::Relative(path)
}
})
.collect())
}
fn built_in_ignore_names(
_root: &Path,
environment: &P4Environment,
) -> anyhow::Result<Vec<OsString>> {
let mut names = vec![OsString::from(".p4root")];
if let Some(config) = p4config_name(environment)? {
names.push(OsString::from(".p4config"));
if let Some(name) = Path::new(&config).file_name()
&& !names.iter().any(|existing| same_file_name(existing, name))
{
names.push(name.to_os_string());
}
}
Ok(names)
}
fn resolve_setting(
root: &Path,
environment: &P4Environment,
key: &str,
) -> anyhow::Result<Option<String>> {
if let Some(config_name) = p4config_name(environment)? {
for directory in root.ancestors() {
let path = directory.join(&config_name);
if path.is_file()
&& let Some(value) = read_key(&path, key)?
{
return Ok(Some(value));
}
}
}
if let Some(path) = p4enviro_path(environment)?
&& path.is_file()
&& let Some(value) = read_key(&path, key)?
{
return Ok(Some(value));
}
if let Some(value) = environment.string(key) {
return Ok(Some(value));
}
Ok(registry_value(key))
}
fn p4config_name(environment: &P4Environment) -> anyhow::Result<Option<String>> {
if let Some(path) = p4enviro_path(environment)?
&& path.is_file()
&& let Some(value) = read_key(&path, "P4CONFIG")?
{
return Ok(Some(value));
}
if let Some(value) = environment.string("P4CONFIG") {
return Ok(Some(value));
}
Ok(registry_value("P4CONFIG"))
}
fn p4enviro_path(environment: &P4Environment) -> anyhow::Result<Option<PathBuf>> {
if let Some(value) = environment.string("P4ENVIRO") {
let path = PathBuf::from(expand_path(&value, environment));
if !path.is_file() {
anyhow::bail!("cannot read explicit P4ENVIRO file {}", path.display());
}
return Ok(Some(path));
}
if let Some(value) = registry_value("P4ENVIRO") {
let path = PathBuf::from(expand_path(&value, environment));
if !path.is_file() {
anyhow::bail!("cannot read configured P4ENVIRO file {}", path.display());
}
return Ok(Some(path));
}
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(home) = environment.string("HOME") {
return Ok(Some(PathBuf::from(home).join(".p4enviro")));
}
Ok(None)
}
fn read_key(path: &Path, key: &str) -> anyhow::Result<Option<String>> {
let metadata = fs::metadata(path)
.map_err(|error| anyhow::anyhow!("cannot read P4 config {}: {error}", path.display()))?;
if metadata.len() > P4CONFIG_MAX_BYTES {
anyhow::bail!(
"P4 config exceeds {P4CONFIG_MAX_BYTES} byte limit: {}",
path.display()
);
}
let text = fs::read_to_string(path)
.map_err(|error| anyhow::anyhow!("cannot read P4 config {}: {error}", path.display()))?;
for line in text.lines() {
let Some((name, value)) = line.split_once('=') else {
continue;
};
if name.trim() != key {
continue;
}
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(value)
.trim();
return Ok((!value.is_empty()).then(|| value.to_string()));
}
Ok(None)
}
fn expand_path(value: &str, environment: &P4Environment) -> String {
let home = environment
.string("HOME")
.or_else(|| environment.string("USERPROFILE"))
.unwrap_or_default();
let user_profile = environment
.string("USERPROFILE")
.unwrap_or_else(|| home.clone());
replace_ascii_case_insensitive(
&value.replace("$home", &home).replace("$HOME", &home),
"%USERPROFILE%",
&user_profile,
)
}
fn replace_ascii_case_insensitive(value: &str, needle: &str, replacement: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut remaining = value;
loop {
let lowercase = remaining.to_ascii_lowercase();
let Some(index) = lowercase.find(&needle.to_ascii_lowercase()) else {
output.push_str(remaining);
return output;
};
output.push_str(&remaining[..index]);
output.push_str(replacement);
remaining = &remaining[index + needle.len()..];
}
}
#[cfg(windows)]
fn registry_value(key: &str) -> Option<String> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ},
};
for hive in [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] {
let Ok(environment) = RegKey::predef(hive)
.open_subkey_with_flags("Software\\Perforce\\Environment", KEY_READ)
else {
continue;
};
if let Ok(value) = environment.get_value::<String, _>(key)
&& !value.is_empty()
{
return Some(value);
}
}
None
}
#[cfg(not(windows))]
fn registry_value(_: &str) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn ignored(matcher: &P4Matcher, root: &Path, relative: &str, is_dir: bool) -> bool {
matcher.is_ignored(&root.join(relative), is_dir)
}
#[test]
fn windows_rule_normalization_preserves_leading_escapes() {
assert_eq!(normalize_rule(r"\#file\name", true), r"\#file/name");
assert_eq!(normalize_rule(r"\!literal", true), r"\!literal");
assert_eq!(normalize_rule(r"\root\build\", true), "/root/build/");
assert_eq!(normalize_rule(r"folder\*.obj", true), "folder/*.obj");
assert_eq!(normalize_rule(r"folder\*.obj", false), r"folder\*.obj");
}
#[test]
fn windows_environment_names_and_user_profile_expansion_are_case_insensitive() {
let environment = P4Environment::with_windows_vars([
("p4ignore".into(), "%UserProfile%\\rules.ignore".into()),
("userprofile".into(), "C:\\Users\\tester".into()),
]);
assert_eq!(
environment.string("P4IGNORE"),
Some("%UserProfile%\\rules.ignore".into())
);
assert_eq!(
expand_path("%userprofile%/rules.ignore", &environment),
"C:\\Users\\tester/rules.ignore"
);
}
#[test]
fn effective_p4config_and_p4root_names_are_always_ignored() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join("custom.p4config"), "P4ROOT=.p4root\n");
let environment = P4Environment::with_vars([("P4CONFIG".into(), "custom.p4config".into())]);
let matcher = P4Matcher::from_root(temp.path(), &environment).unwrap();
assert!(ignored(&matcher, temp.path(), "custom.p4config", false));
assert!(ignored(&matcher, temp.path(), ".p4root", true));
assert!(ignored(&matcher, temp.path(), ".p4root/database.db", false));
assert!(!ignored(&matcher, temp.path(), "visible.txt", false));
}
#[test]
fn default_files_preserve_order_and_perforce_syntax() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4ignore"), "*.html\n\\#private\n");
write(
&temp.path().join("p4ignore.txt"),
"!readme.html\nlogs/\ntest/**.txt\n",
);
let matcher = P4Matcher::from_root(temp.path(), &P4Environment::with_vars([])).unwrap();
assert!(ignored(&matcher, temp.path(), "page.html", false));
assert!(!ignored(&matcher, temp.path(), "readme.html", false));
assert!(ignored(&matcher, temp.path(), "#private", false));
assert!(ignored(&matcher, temp.path(), "logs", true));
assert!(ignored(&matcher, temp.path(), "test/deep/file.txt", false));
}
#[test]
fn descendant_negation_prevents_pruning_and_reincludes_only_match() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4ignore"), "cache/\n! cache/keep.rs\n");
let matcher = P4Matcher::from_root(temp.path(), &P4Environment::with_vars([])).unwrap();
assert!(ignored(&matcher, temp.path(), "cache", true));
assert!(!matcher.should_prune(&temp.path().join("cache")));
assert!(ignored(&matcher, temp.path(), "cache/generated.rs", false));
assert!(!ignored(&matcher, temp.path(), "cache/keep.rs", false));
}
#[test]
fn rule_without_trailing_slash_ignores_directory_descendants() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4ignore"), "builds\n!readme.txt\n");
let matcher = P4Matcher::from_root(temp.path(), &P4Environment::with_vars([])).unwrap();
assert!(!matcher.should_prune(&temp.path().join("builds")));
assert!(ignored(&matcher, temp.path(), "builds/secret.txt", false));
assert!(!ignored(&matcher, temp.path(), "readme.txt", false));
}
#[test]
fn relative_ignore_path_rules_are_rooted_at_ignore_file_directory() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join("rules/project.ignore"), "/ignored.txt\n");
let environment =
P4Environment::with_vars([("P4IGNORE".into(), "rules/project.ignore".into())]);
let matcher = P4Matcher::from_root(temp.path(), &environment).unwrap();
assert!(ignored(&matcher, temp.path(), "rules/ignored.txt", false));
assert!(!ignored(&matcher, temp.path(), "ignored.txt", false));
}
#[test]
fn surrounding_whitespace_is_ignored_before_rule_parsing() {
let rule = parse_rule(" *.obj ", false).unwrap().unwrap();
assert!(rule.matcher.is_match("nested/file.obj"));
assert!(parse_rule(" # comment ", false).unwrap().is_none());
}
#[test]
fn mixed_absolute_and_relative_sources_preserve_list_order() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("workspace");
fs::create_dir(&root).unwrap();
let absolute = temp.path().join("absolute.ignore");
write(&root.join("relative.ignore"), "item.txt\n");
write(&absolute, "!item.txt\n");
let relative_then_absolute = P4Environment::with_vars([(
"P4IGNORE".into(),
format!("relative.ignore;{}", absolute.display()),
)]);
let matcher = P4Matcher::from_root(&root, &relative_then_absolute).unwrap();
assert!(!ignored(&matcher, &root, "item.txt", false));
let absolute_then_relative = P4Environment::with_vars([(
"P4IGNORE".into(),
format!("{};relative.ignore", absolute.display()),
)]);
let matcher = P4Matcher::from_root(&root, &absolute_then_relative).unwrap();
assert!(ignored(&matcher, &root, "item.txt", false));
}
#[test]
fn nested_rule_overrides_parent_rule() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4ignore"), "*.html\n");
write(&temp.path().join("nested/.p4ignore"), "!readme.html\n");
let matcher = P4Matcher::from_root(temp.path(), &P4Environment::with_vars([])).unwrap();
assert!(ignored(&matcher, temp.path(), "nested/page.html", false));
assert!(!ignored(&matcher, temp.path(), "nested/readme.html", false));
}
#[test]
fn enviro_p4config_name_overrides_process_environment_name() {
let temp = tempfile::TempDir::new().unwrap();
let enviro = temp.path().join("p4enviro");
write(&enviro, "P4CONFIG=enviro.config\n");
write(
&temp.path().join("enviro.config"),
"P4IGNORE=from-enviro.ignore\n",
);
write(
&temp.path().join("process.config"),
"P4IGNORE=from-process.ignore\n",
);
let environment = P4Environment::with_vars([
("P4ENVIRO".into(), enviro.display().to_string()),
("P4CONFIG".into(), "process.config".into()),
]);
assert_eq!(
resolve_setting(temp.path(), &environment, "P4IGNORE").unwrap(),
Some("from-enviro.ignore".into())
);
}
#[test]
fn p4config_overrides_enviro_and_process_environment() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("workspace/nested");
fs::create_dir_all(&root).unwrap();
let enviro = temp.path().join("p4enviro");
write(&enviro, "P4IGNORE=enviro.ignore\n");
write(
&temp.path().join("workspace/.p4config"),
"P4IGNORE=config.ignore\nP4IGNORE=duplicate.ignore\n",
);
let environment = P4Environment::with_vars([
("P4CONFIG".into(), ".p4config".into()),
("P4ENVIRO".into(), enviro.display().to_string()),
("P4IGNORE".into(), "environment.ignore".into()),
]);
assert_eq!(
resolve_setting(&root, &environment, "P4IGNORE").unwrap(),
Some("config.ignore".into())
);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn default_posix_enviro_precedes_process_environment() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4enviro"), "P4IGNORE=enviro.ignore\n");
let environment = P4Environment::with_vars([
("HOME".into(), temp.path().display().to_string()),
("P4IGNORE".into(), "environment.ignore".into()),
]);
assert_eq!(
resolve_setting(temp.path(), &environment, "P4IGNORE").unwrap(),
Some("enviro.ignore".into())
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_does_not_load_default_home_enviro() {
let temp = tempfile::TempDir::new().unwrap();
write(&temp.path().join(".p4enviro"), "P4IGNORE=enviro.ignore\n");
let environment = P4Environment::with_vars([
("HOME".into(), temp.path().display().to_string()),
("P4IGNORE".into(), "environment.ignore".into()),
]);
assert_eq!(
resolve_setting(temp.path(), &environment, "P4IGNORE").unwrap(),
Some("environment.ignore".into())
);
}
#[test]
fn empty_values_fall_through_to_defaults() {
let environment = P4Environment::with_vars([("P4IGNORE".into(), "".into())]);
assert_eq!(
ignore_sources(Path::new("/tmp"), &environment)
.unwrap()
.len(),
2
);
}
#[test]
fn unreadable_absolute_file_returns_actionable_error() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("workspace");
fs::create_dir(&root).unwrap();
let rules = temp.path().join("rules.ignore");
fs::write(&rules, [0xff]).unwrap();
let environment =
P4Environment::with_vars([("P4IGNORE".into(), rules.display().to_string())]);
let error = P4Matcher::from_root(&root, &environment)
.unwrap_err()
.to_string();
assert!(error.contains("cannot read P4IGNORE file"), "{error}");
assert!(error.contains("rules.ignore"), "{error}");
}
}