use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ScopePath {
pub absolute: PathBuf,
pub is_dir: bool,
}
impl ScopePath {
#[must_use]
pub fn covers_everything(&self, root: &Path) -> bool {
let root = dunce::simplified(root);
dunce::simplified(&self.absolute) == root
|| dunce::canonicalize(root).is_ok_and(|canon| self.absolute == canon)
}
}
pub fn resolve_scope_path(root: &Path, raw: &Path) -> Result<ScopePath, String> {
let raw_display = raw.display().to_string();
if raw.as_os_str().is_empty() {
return Err("PATH must not be empty".to_string());
}
if let Some(raw_str) = raw.to_str()
&& let Err(detail) = fallow_engine::validate::validate_no_control_chars(raw_str, "PATH")
{
return Err(detail);
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let explicit_cwd_claim = matches!(
raw.components().next(),
Some(std::path::Component::CurDir | std::path::Component::ParentDir)
);
let candidates: Vec<PathBuf> = if raw.is_absolute() {
vec![raw.to_path_buf()]
} else if explicit_cwd_claim {
vec![cwd.join(raw)]
} else {
vec![root.join(raw), cwd.join(raw)]
};
let canonical_root = dunce::canonicalize(root)
.map_err(|err| format!("invalid project root '{}': {err}", root.display()))?;
let mut outside: Option<PathBuf> = None;
for candidate in &candidates {
if candidate.symlink_metadata().is_err() {
continue;
}
let Ok(canonical) = dunce::canonicalize(candidate) else {
continue;
};
if canonical != canonical_root && !canonical.starts_with(&canonical_root) {
outside.get_or_insert_with(|| candidate.clone());
continue;
}
let relative = match canonical.strip_prefix(&canonical_root) {
Ok(relative) => relative.to_path_buf(),
Err(_) => PathBuf::new(),
};
let absolute = if let Ok(lexical) = candidate.strip_prefix(root) {
dunce::simplified(&root.join(lexical)).to_path_buf()
} else {
dunce::simplified(&canonical_root.join(relative)).to_path_buf()
};
return Ok(ScopePath {
is_dir: canonical.is_dir(),
absolute,
});
}
if let Some(escaped) = outside {
return Err(format!(
"PATH '{raw_display}' (resolved to '{}') is outside the project root ('{}'). Pass --root <dir> for another project, or a path inside this root",
escaped.display(),
root.display()
));
}
let searched = if dunce::simplified(&cwd) == dunce::simplified(root) {
format!("'{}'", root.display())
} else {
format!(
"the current directory and the project root '{}'",
root.display()
)
};
Err(format!(
"PATH '{raw_display}' does not exist (looked relative to {searched})"
))
}
#[must_use]
pub fn scope_covers(scope: &Path, path: &Path) -> bool {
let simplified = dunce::simplified(path);
simplified == scope || simplified.starts_with(scope)
}
pub fn resolve_command_scope(
root: &Path,
output: fallow_config::OutputFormat,
raw: Option<std::path::PathBuf>,
) -> Result<Option<ScopePath>, std::process::ExitCode> {
let Some(raw) = raw else {
return Ok(None);
};
match resolve_scope_path(root, &raw) {
Ok(scope) if scope.covers_everything(root) => Ok(None),
Ok(scope) => Ok(Some(scope)),
Err(message) => Err(crate::error::emit_error(&message, 2, output)),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn fixture_root() -> PathBuf {
dunce::canonicalize(std::env::current_dir().expect("cwd")).expect("canonical cwd")
}
#[test]
fn resolves_file_inside_root() {
let root = fixture_root();
let scope = resolve_scope_path(&root, Path::new("Cargo.toml")).expect("Cargo.toml exists");
assert!(!scope.is_dir);
assert!(scope.absolute.starts_with(&root));
}
#[test]
fn rejects_missing_path() {
let root = fixture_root();
let err = resolve_scope_path(&root, Path::new("no-such-path-xyz-123")).unwrap_err();
assert!(err.contains("does not exist"), "{err}");
}
#[test]
fn rejects_outside_root() {
let root = fixture_root();
let outside = root.parent().expect("root has a parent").to_path_buf();
if outside == root {
return;
}
let err = resolve_scope_path(&root, &outside).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
}
#[test]
fn root_scope_covers_everything() {
let root = fixture_root();
let scope = resolve_scope_path(&root, &root).expect("root resolves");
assert!(scope.covers_everything(&root));
}
#[test]
fn dot_slash_prefix_claims_current_directory() {
let root = fixture_root();
let scope =
resolve_scope_path(&root, Path::new("./Cargo.toml")).expect("./Cargo.toml exists");
assert!(!scope.is_dir);
assert!(scope.absolute.starts_with(&root));
}
#[test]
fn bare_relative_prefers_root_over_cwd() {
let root = tempfile::tempdir().expect("temporary scope root");
let cwd_file = fixture_root().join("Cargo.toml");
assert!(cwd_file.is_file(), "test cwd must hold Cargo.toml");
fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("scope file");
let scope =
resolve_scope_path(root.path(), Path::new("Cargo.toml")).expect("root file wins");
assert!(scope.absolute.starts_with(dunce::simplified(root.path())));
assert!(!scope.absolute.starts_with(fixture_root()));
}
#[test]
fn scope_covers_file_and_children_only() {
let scope = Path::new("/repo/src");
assert!(scope_covers(scope, Path::new("/repo/src")));
assert!(scope_covers(scope, Path::new("/repo/src/a.ts")));
assert!(!scope_covers(scope, Path::new("/repo/src-extra/a.ts")));
assert!(!scope_covers(scope, Path::new("/repo/other/a.ts")));
}
}