use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::settings;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FoundConfig {
pub display: String,
pub path: PathBuf,
}
pub fn discover(paths: &[PathBuf], cwd: &Path) -> Result<Vec<FoundConfig>, String> {
let mut found = Vec::new();
let mut seen = HashSet::new();
let implicit = [default_root(cwd)];
let roots = if paths.is_empty() {
&implicit[..]
} else {
paths
};
for given in roots {
let absolute = cwd.join(given);
let given = if paths.is_empty() {
Path::new(".")
} else {
given.as_path()
};
if std::fs::metadata(&absolute).is_err() {
return Err(format!("path not found: {}", given.display()));
}
let candidates: Vec<(String, PathBuf)> = if absolute.is_dir() {
configs_in_tree(&absolute)
.into_iter()
.map(|rel| {
let display = if given == Path::new(".") {
rel.clone()
} else {
format!(
"{}/{rel}",
given.display().to_string().trim_end_matches('/')
)
};
(display, absolute.join(&rel))
})
.collect()
} else {
vec![(given.display().to_string(), absolute)]
};
for (display, path) in candidates {
let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if seen.insert(key) {
found.push(FoundConfig { display, path });
}
}
}
Ok(found)
}
#[must_use]
pub fn default_root(cwd: &Path) -> PathBuf {
git(cwd, &["rev-parse", "--show-toplevel"])
.map(|out| {
String::from_utf8_lossy(&out)
.trim_end_matches(['\n', '\r'])
.to_string()
})
.filter(|top| !top.is_empty())
.map_or_else(|| cwd.to_path_buf(), PathBuf::from)
}
fn git(dir: &Path, args: &[&str]) -> Option<Vec<u8>> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
output.status.success().then_some(output.stdout)
}
fn configs_in_tree(root: &Path) -> Vec<String> {
let files = git_visible_files(root).unwrap_or_else(|| walk_without_git(root));
let mut by_dir: BTreeMap<String, Vec<String>> = BTreeMap::new();
for rel in files {
let (dir, name) = match rel.rsplit_once('/') {
Some((dir, name)) => (dir.to_string(), name.to_string()),
None => (String::new(), rel.clone()),
};
if settings::CONFIG_NAMES.contains(&name.as_str()) && root.join(&rel).is_file() {
by_dir.entry(dir).or_default().push(name);
}
}
by_dir
.into_iter()
.filter_map(|(dir, names)| {
let name = settings::CONFIG_NAMES
.iter()
.find(|candidate| names.iter().any(|n| n == *candidate))?;
Some(if dir.is_empty() {
(*name).to_string()
} else {
format!("{dir}/{name}")
})
})
.collect()
}
fn git_visible_files(root: &Path) -> Option<Vec<String>> {
let stdout = git(
root,
&[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
)?;
let text = String::from_utf8_lossy(&stdout);
Some(
text.split('\0')
.filter(|rel| !rel.is_empty())
.map(str::to_string)
.collect(),
)
}
fn walk_without_git(root: &Path) -> Vec<String> {
fn rec(base: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
if entry.file_name() != ".git" {
rec(base, &path, out);
}
} else if kind.is_file() {
if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
}
let mut out = Vec::new();
rec(root, root, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(root: &Path, rel: &str) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "spec: ./a.yml\n").unwrap();
}
fn displays(found: &[FoundConfig]) -> Vec<&str> {
found.iter().map(|f| f.display.as_str()).collect()
}
#[test]
fn no_paths_search_the_git_top_level_or_else_the_working_directory() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(default_root(&root), root);
let status = Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.status()
.unwrap();
assert!(status.success());
touch(&root, "other/crozier.yml");
std::fs::create_dir_all(root.join("sub")).unwrap();
let sub = root.join("sub");
assert_eq!(
std::fs::canonicalize(default_root(&sub)).unwrap(),
root,
"a subdirectory searches its repository's top level"
);
assert_eq!(
displays(&discover(&[], &sub).unwrap()),
["other/crozier.yml"]
);
assert!(discover(&[PathBuf::from(".")], &sub).unwrap().is_empty());
}
#[test]
fn a_plain_tree_finds_one_config_per_directory_by_priority() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
touch(root, "crozier.yml");
touch(root, ".crozier.yml");
touch(root, "a/.crozier.yaml");
touch(root, "b/c/crozier.yaml");
touch(root, "b/c/.crozier.yml");
touch(root, ".git/crozier.yml");
touch(root, "d/not-a-config.yml");
let found = discover(&[], root).unwrap();
assert_eq!(
displays(&found),
["crozier.yml", "a/.crozier.yaml", "b/c/crozier.yaml"]
);
assert_eq!(found[1].path, root.join("a/.crozier.yaml"));
}
#[test]
fn a_git_tree_skips_ignored_paths_but_finds_hidden_configs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
let status = Command::new("git")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.arg("-C")
.arg(root)
.args(args)
.stdout(Stdio::null())
.status()
.unwrap();
assert!(status.success());
};
git(&["init", "-q"]);
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
std::fs::create_dir_all(root.join(".git/info")).unwrap();
std::fs::write(root.join(".git/info/exclude"), "excluded/\n").unwrap();
touch(root, "ignored/crozier.yml");
touch(root, "excluded/crozier.yml");
touch(root, "svc/.crozier.yml");
touch(root, "tracked/crozier.yml");
git(&["add", "tracked/crozier.yml"]);
let found = discover(&[PathBuf::from(".")], root).unwrap();
assert_eq!(
displays(&found),
["svc/.crozier.yml", "tracked/crozier.yml"]
);
let found = discover(&[PathBuf::from("svc/")], root).unwrap();
assert_eq!(displays(&found), ["svc/.crozier.yml"]);
}
#[test]
fn a_file_argument_is_a_config_whatever_its_name_and_duplicates_collapse() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
touch(root, "configs/team.yaml");
touch(root, "crozier.yml");
let found = discover(
&[
PathBuf::from("configs/team.yaml"),
PathBuf::from("."),
PathBuf::from("crozier.yml"),
],
root,
)
.unwrap();
assert_eq!(displays(&found), ["configs/team.yaml", "crozier.yml"]);
}
#[test]
fn a_missing_path_is_an_error_naming_it() {
let dir = tempfile::tempdir().unwrap();
let error = discover(&[PathBuf::from("nope")], dir.path()).unwrap_err();
assert_eq!(error, "path not found: nope");
assert!(discover(&[], dir.path()).unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn the_plain_walk_does_not_follow_symbolic_links() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
touch(outside.path(), "crozier.yml");
std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap();
assert!(walk_without_git(dir.path()).is_empty());
assert!(walk_without_git(&dir.path().join("absent")).is_empty());
}
}