use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, OnceLock, mpsc};
use std::thread;
use anyhow::{Context, Result};
use clap::Args;
use ignore::overrides::OverrideBuilder;
use ignore::{DirEntry, WalkBuilder};
use rayon::iter::{ParallelBridge, ParallelIterator};
use veloci::agent::AgentPolicy;
use veloci::config::Config;
use veloci::detect::DETECTOR_NAMES;
use veloci::files::{FileGlobs, FileKeyPaths};
use veloci::{Allow, Redactor};
pub const CONFIG_ENV: &str = "VELOCI_CONFIG";
pub const CONFIG_FILE_NAMES: [&str; 2] = ["veloci.yml", "VELOCI.yml"];
pub const SKIPPED_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"vendor",
".venv",
"venv",
"__pycache__",
"dist",
"build",
];
#[derive(Debug, Clone, Args)]
pub struct ConfigArg {
#[arg(short, long, value_name = "FILE", env = CONFIG_ENV)]
pub config: Option<PathBuf>,
#[command(flatten)]
pub enable: DetectorArg,
}
#[derive(Debug, Clone, Default, Args)]
pub struct DetectorArg {
#[arg(long = "detector", value_name = "NAME")]
pub detectors: Vec<String>,
}
impl DetectorArg {
pub fn apply(&self, config: &mut Config) -> Result<()> {
for name in config.enable_detectors(&self.detectors) {
if DETECTOR_NAMES.contains(&name) {
eprintln!(
"warning: --detector {name}: the configuration does not list it, \
so it does not run"
);
} else {
anyhow::bail!(
"--detector {name}: no detector in the configuration is labelled {name:?}, \
and no detector is called that (expected a label or one of {})",
DETECTOR_NAMES.join(", ")
);
}
}
Ok(())
}
}
impl ConfigArg {
pub fn explicit_path(&self) -> Option<&Path> {
self.config.as_deref().filter(|p| !p.as_os_str().is_empty())
}
pub fn resolved_path(&self) -> Result<Option<PathBuf>> {
if let Some(path) = self.explicit_path() {
return Ok(Some(path.to_owned()));
}
discover_config()
}
pub fn resolved_path_from(&self, start: &Path) -> Option<PathBuf> {
if let Some(path) = self.explicit_path() {
return Some(path.to_owned());
}
let home = std::env::var_os("HOME").map(PathBuf::from);
discover_from(start, home.as_deref())
}
pub fn resolved_path_cached(
&self,
start: &Path,
discoveries: &mut Discoveries,
) -> Option<PathBuf> {
match self.explicit_path() {
Some(path) => Some(path.to_owned()),
None => discoveries.discover(start),
}
}
pub fn load(&self) -> Result<Config> {
let mut config = match self.resolved_path()? {
Some(path) => Config::from_path(path)?,
None => Config::builtin().clone(),
};
self.enable.apply(&mut config)?;
Ok(config)
}
}
fn discover_config() -> Result<Option<PathBuf>> {
let cwd = std::env::current_dir().context("determining the current directory")?;
let home = std::env::var_os("HOME").map(PathBuf::from);
Ok(discover_from(&cwd, home.as_deref()))
}
fn discover_from(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
let mut dir = start;
loop {
match discover_in(dir, home) {
Discovery::Found(path) => return Some(path),
Discovery::Stop => return None,
Discovery::Parent => dir = dir.parent()?,
}
}
}
enum Discovery {
Found(PathBuf),
Stop,
Parent,
}
fn discover_in(dir: &Path, home: Option<&Path>) -> Discovery {
for name in CONFIG_FILE_NAMES {
let candidate = dir.join(name);
if candidate.is_file() {
return Discovery::Found(candidate);
}
}
let at_git_root = dir.join(".git").exists();
let at_home_root = home.is_some_and(|home| dir == home);
if at_git_root || at_home_root || dir.parent().is_none() {
Discovery::Stop
} else {
Discovery::Parent
}
}
pub struct Discoveries {
home: Option<PathBuf>,
found: HashMap<PathBuf, Option<PathBuf>>,
}
impl Discoveries {
pub fn new() -> Self {
Discoveries {
home: std::env::var_os("HOME").map(PathBuf::from),
found: HashMap::new(),
}
}
pub fn discover(&mut self, start: &Path) -> Option<PathBuf> {
let mut passed = Vec::new();
let mut dir = start;
let found = loop {
if let Some(found) = self.found.get(dir) {
break found.clone();
}
passed.push(dir.to_path_buf());
match discover_in(dir, self.home.as_deref()) {
Discovery::Found(path) => break Some(path),
Discovery::Stop => break None,
Discovery::Parent => match dir.parent() {
Some(parent) => dir = parent,
None => break None,
},
}
};
for dir in passed {
self.found.insert(dir, found.clone());
}
found
}
}
pub fn git_root(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| dir.join(".git").exists())
.map(Path::to_path_buf)
}
pub fn anchored_policy(config: &Config, path: &Path) -> Result<Option<AgentPolicy>> {
let Some(agent) = &config.agent else {
return Ok(None);
};
let base =
std::path::absolute(path).with_context(|| format!("resolving {}", path.display()))?;
let base = base.parent().unwrap_or(Path::new("/"));
Ok(Some(AgentPolicy::new(agent, base)))
}
pub fn build_redactor(config: &Config) -> Result<Redactor> {
let (redactor, warnings) = config.redactor()?;
for warning in warnings {
eprintln!("warning: {warning}");
}
Ok(redactor)
}
pub struct Rules {
pub redactor: Redactor,
pub allow: Allow,
pub allowed_files: FileGlobs,
pub allowed_file_paths: FileKeyPaths,
pub agent: Option<AgentPolicy>,
}
impl Rules {
pub fn redactor_for(&self, path: Option<&Path>) -> Cow<'_, Redactor> {
let key_paths = path.map_or_else(Vec::new, |path| self.allowed_file_paths.for_file(path));
if key_paths.is_empty() {
Cow::Borrowed(&self.redactor)
} else {
Cow::Owned(self.redactor.with_allow_paths(key_paths))
}
}
pub fn allow_for(&self, path: Option<&Path>) -> &Allow {
static ALL: LazyLock<Allow> = LazyLock::new(Allow::all);
match path {
Some(path) if self.allowed_files.matches(path) => &ALL,
_ => &self.allow,
}
}
}
fn config_base(path: Option<&Path>) -> Result<PathBuf> {
let Some(path) = path else {
return Ok(PathBuf::new());
};
let absolute =
std::path::absolute(path).with_context(|| format!("resolving {}", path.display()))?;
Ok(absolute.parent().unwrap_or(Path::new("/")).to_owned())
}
pub fn allowed_files(config: &Config, path: Option<&Path>) -> Result<FileGlobs> {
Ok(config.allowed_files(config_base(path)?))
}
pub fn allowed_file_paths(config: &Config, path: Option<&Path>) -> Result<FileKeyPaths> {
Ok(config.allowed_file_paths(config_base(path)?)?)
}
fn load_rules(path: Option<&Path>, enable: &DetectorArg) -> Result<Rules> {
let load = || -> Result<Rules> {
let mut config = match path {
Some(path) => Config::from_path(path)?,
None => Config::builtin().clone(),
};
enable.apply(&mut config)?;
let agent = match path {
Some(path) => anchored_policy(&config, path)?,
None => None,
};
Ok(Rules {
redactor: build_redactor(&config)?,
allow: config.allow()?,
allowed_files: allowed_files(&config, path)?,
allowed_file_paths: allowed_file_paths(&config, path)?,
agent,
})
};
match path {
Some(path) => load().with_context(|| format!("loading {}", path.display())),
None => load(),
}
}
type LoadedRules = Arc<OnceLock<Result<Arc<Rules>, String>>>;
pub struct RulesCache {
config: ConfigArg,
discoveries: Mutex<Discoveries>,
rules: Mutex<HashMap<Option<PathBuf>, LoadedRules>>,
}
impl RulesCache {
pub fn new(config: ConfigArg) -> Self {
RulesCache {
config,
discoveries: Mutex::new(Discoveries::new()),
rules: Mutex::new(HashMap::new()),
}
}
pub fn for_directory(&self, directory: Option<&Path>) -> Result<Arc<Rules>> {
let path = match directory {
None => self.config.resolved_path()?,
Some(directory) => {
let mut discoveries = self.discoveries.lock().unwrap_or_else(|e| e.into_inner());
self.config
.resolved_path_cached(directory, &mut discoveries)
}
};
let mut rules = self.rules.lock().unwrap_or_else(|e| e.into_inner());
let loaded = rules.entry(path.clone()).or_default().clone();
drop(rules);
let loaded = loaded.get_or_init(|| {
load_rules(path.as_deref(), &self.config.enable)
.map(Arc::new)
.map_err(|err| format!("{err:#}"))
});
match loaded {
Ok(rules) => Ok(rules.clone()),
Err(err) => Err(Fatal(err.clone()).into()),
}
}
pub fn for_file(&self, path: &Path) -> Result<Arc<Rules>> {
let absolute =
std::path::absolute(path).with_context(|| format!("resolving {}", path.display()))?;
self.for_directory(Some(absolute.parent().unwrap_or(Path::new("/"))))
}
}
#[derive(Debug)]
pub struct Fatal(pub String);
impl std::fmt::Display for Fatal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Fatal {}
pub struct WalkOptions<'a> {
pub globs: &'a [String],
pub hidden: bool,
pub ignored: bool,
pub follow: bool,
pub max_depth: Option<usize>,
pub skipped_dirs: &'static [&'static str],
}
pub fn walk_builder(root: &Path, options: &WalkOptions<'_>) -> Result<WalkBuilder> {
let cwd = std::env::current_dir().context("determining the current directory")?;
let mut overrides = OverrideBuilder::new(&cwd);
for glob in options.globs {
overrides
.add(glob)
.with_context(|| format!("parsing the glob {glob:?}"))?;
}
let skipped_dirs = options.skipped_dirs;
let mut builder = WalkBuilder::new(root);
builder
.hidden(!options.hidden)
.ignore(!options.ignored)
.git_ignore(!options.ignored)
.git_global(!options.ignored)
.git_exclude(!options.ignored)
.parents(!options.ignored)
.follow_links(options.follow)
.max_depth(options.max_depth)
.overrides(overrides.build().context("parsing --glob")?)
.sort_by_file_name(|a, b| a.cmp(b))
.filter_entry(move |entry| {
let name = entry.file_name().to_string_lossy();
let skipped = entry.file_name() == ".git"
|| (entry.file_type().is_some_and(|t| t.is_dir())
&& skipped_dirs.contains(&name.as_ref()));
entry.depth() == 0 || !skipped
});
Ok(builder)
}
pub fn is_file(entry: &DirEntry) -> bool {
entry.file_type().is_some_and(|t| t.is_file()) || (entry.depth() == 0 && entry.path().is_file())
}
pub fn display_path(path: &Path, implicit_root: bool) -> &Path {
match path.strip_prefix("./") {
Ok(stripped) if implicit_root => stripped,
_ => path,
}
}
pub fn for_each_ordered<S, T, W>(
items: impl Iterator<Item = S> + Send,
stop: &AtomicBool,
init: impl Fn() -> W + Send + Sync,
work: impl Fn(&mut W, S) -> T + Send + Sync,
mut report: impl FnMut(T) -> Result<bool>,
) -> Result<Vec<T>>
where
S: Send,
T: Send,
{
let (sender, receiver) = mpsc::channel::<(usize, T)>();
thread::scope(|scope| {
let (init, work) = (&init, &work);
scope.spawn(move || {
items
.take_while(|_| !stop.load(Ordering::Relaxed))
.enumerate()
.par_bridge()
.for_each_init(
|| (sender.clone(), init()),
|(sender, worker), (index, item)| {
if stop.load(Ordering::Relaxed) {
return;
}
let _ = sender.send((index, work(worker, item)));
},
);
});
let mut early = BTreeMap::new();
let mut next = 0;
for (index, result) in &receiver {
early.insert(index, result);
while let Some(result) = early.remove(&next) {
next += 1;
match report(result) {
Ok(true) => {}
Ok(false) => {
stop.store(true, Ordering::Relaxed);
return Ok(Vec::new());
}
Err(err) => {
stop.store(true, Ordering::Relaxed);
return Err(err);
}
}
}
}
Ok(early.into_values().collect())
})
}
pub fn is_broken_pipe(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<io::Error>()
.is_some_and(|e| e.kind() == io::ErrorKind::BrokenPipe)
})
}
pub fn shell_quote(text: &str) -> String {
let plain = |c: char| c.is_ascii_alphanumeric() || "/._-+=:@%,".contains(c);
if !text.is_empty() && text.chars().all(plain) {
text.to_owned()
} else {
format!("'{}'", text.replace('\'', r"'\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn write_file(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, "found\n").unwrap();
path
}
#[test]
fn finds_a_config_in_the_starting_directory() {
let root = tempfile::tempdir().unwrap();
let want = write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(root.path(), Some(root.path())), Some(want));
}
#[test]
fn finds_an_uppercase_config_name() {
let root = tempfile::tempdir().unwrap();
write_file(root.path(), "VELOCI.yml");
let found =
discover_from(root.path(), Some(root.path())).expect("uppercase name is accepted");
assert!(
CONFIG_FILE_NAMES
.iter()
.any(|name| found.file_name().is_some_and(|n| n == *name)),
"{found:?}"
);
assert_eq!(fs::read_to_string(&found).unwrap(), "found\n");
}
#[test]
fn prefers_the_lowercase_name_when_both_exist() {
let root = tempfile::tempdir().unwrap();
let lower = write_file(root.path(), "veloci.yml");
let upper = root.path().join("VELOCI.yml");
if upper != lower {
fs::write(&upper, "other\n").unwrap();
}
assert_eq!(discover_from(root.path(), Some(root.path())), Some(lower));
}
#[test]
fn walks_up_to_a_parent() {
let root = tempfile::tempdir().unwrap();
let child = root.path().join("src");
fs::create_dir(&child).unwrap();
let want = write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(&child, Some(root.path())), Some(want));
}
#[test]
fn prefers_the_closest_file() {
let root = tempfile::tempdir().unwrap();
let child = root.path().join("src");
fs::create_dir(&child).unwrap();
write_file(root.path(), "veloci.yml");
let want = write_file(&child, "veloci.yml");
assert_eq!(discover_from(&child, Some(root.path())), Some(want));
}
#[test]
fn ignores_a_directory_with_the_config_name() {
let root = tempfile::tempdir().unwrap();
let child = root.path().join("src");
fs::create_dir(&child).unwrap();
fs::create_dir(child.join("veloci.yml")).unwrap();
let want = write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(&child, Some(root.path())), Some(want));
}
#[test]
fn finds_a_config_in_a_git_root() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let src = repo.join("src");
fs::create_dir_all(&src).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
let want = write_file(&repo, "veloci.yml");
write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(&src, Some(root.path())), Some(want));
}
#[test]
fn stops_at_a_git_directory() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let src = repo.join("src");
fs::create_dir_all(&src).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(&src, Some(root.path())), None);
}
#[test]
fn stops_at_a_git_file() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let src = repo.join("src");
fs::create_dir_all(&src).unwrap();
fs::write(repo.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
write_file(root.path(), "veloci.yml");
assert_eq!(discover_from(&src, Some(root.path())), None);
}
#[test]
fn finds_a_config_in_home() {
let outer = tempfile::tempdir().unwrap();
let home = outer.path().join("home");
let project = home.join("project");
fs::create_dir_all(&project).unwrap();
let want = write_file(&home, "veloci.yml");
write_file(outer.path(), "veloci.yml");
assert_eq!(discover_from(&project, Some(&home)), Some(want));
}
#[test]
fn stops_at_home_when_starting_inside_it() {
let outer = tempfile::tempdir().unwrap();
let home = outer.path().join("home");
let project = home.join("project");
fs::create_dir_all(&project).unwrap();
write_file(outer.path(), "veloci.yml");
assert_eq!(discover_from(&project, Some(&home)), None);
}
#[test]
fn walks_past_home_when_starting_outside_it() {
let outer = tempfile::tempdir().unwrap();
let home = outer.path().join("home");
let other = outer.path().join("other");
fs::create_dir_all(&home).unwrap();
fs::create_dir(&other).unwrap();
let want = write_file(outer.path(), "veloci.yml");
assert_eq!(discover_from(&other, Some(&home)), Some(want));
}
#[test]
fn cached_discovery_agrees_with_discover_from() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let nested = repo.join("a/b/c");
let configured = repo.join("a/configured/d");
fs::create_dir_all(&nested).unwrap();
fs::create_dir_all(&configured).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
write_file(&repo.join("a/configured"), "veloci.yml");
write_file(root.path(), "veloci.yml");
let mut discoveries = Discoveries {
home: Some(root.path().to_owned()),
found: Default::default(),
};
for dir in [
&nested,
&configured,
&repo.join("a/b"),
&repo,
&repo.join("a/configured"),
] {
assert_eq!(
discoveries.discover(dir),
discover_from(dir, Some(root.path())),
"{dir:?}"
);
}
}
#[test]
fn returns_none_when_nothing_is_in_reach() {
let root = tempfile::tempdir().unwrap();
let child = root.path().join("src");
fs::create_dir(&child).unwrap();
assert_eq!(discover_from(&child, Some(root.path())), None);
}
}