use std::io::Read as _;
use std::path::{Path, PathBuf};
use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
#[derive(Debug)]
pub struct DiffStandDown {
reason: &'static str,
message: String,
}
impl DiffStandDown {
fn new(reason: &'static str, message: String) -> Self {
Self { reason, message }
}
#[must_use]
pub const fn reason(&self) -> &'static str {
self.reason
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn into_parts(self) -> (&'static str, String) {
(self.reason, self.message)
}
fn oversize(label: &str, bytes: u64, cap: u64) -> Self {
Self::new(
"oversize",
format!(
"{label} is {bytes} bytes (cap {cap}); line-level filtering disabled, \
reporting all findings. Narrow the diff; the cap is fixed."
),
)
}
fn unreadable(label: &str, err: &std::io::Error) -> Self {
Self::new(
"unreadable",
format!(
"could not read {label}: {err} (line-level filtering disabled, \
reporting all findings). Check the path exists and is readable."
),
)
}
fn not_utf8(label: &str, err: &std::string::FromUtf8Error) -> Self {
Self::new(
"not-utf8",
format!(
"could not read {label} as UTF-8: {err} (line-level filtering disabled, \
reporting all findings). Regenerate the diff as UTF-8 text."
),
)
}
fn ambiguous_base(candidate_bases: &[PathBuf], root: &Path, label: &str) -> Self {
let bases = join_bases(candidate_bases, root, " and ");
Self::new(
"ambiguous-base",
format!(
"the paths in {label} name existing files under {bases}, so their base is \
ambiguous and fallow cannot tell which one the diff is relative to. It will \
not filter against a guess: every finding is reported (full scope, not scoped \
to the diff). Generate the diff from the repository root (plain `git diff`, \
not `git diff --relative`) to scope the report."
),
)
}
fn foreign_namespace(
index: &DiffIndex,
candidate_bases: &[PathBuf],
root: &Path,
label: &str,
) -> Self {
let total = index.touched_files().count();
let bases = join_bases(candidate_bases, root, ", ");
Self::new(
"foreign-namespace",
format!(
"none of the {total} file(s) named by {label} exist under {bases}; the diff's \
paths look relative to a different directory. fallow cannot place the diff, so \
every finding is reported (full scope, not scoped to the diff). Regenerate the \
diff from one of those directories to scope the report."
),
)
}
}
fn join_bases(candidate_bases: &[PathBuf], root: &Path, separator: &str) -> String {
candidate_bases
.iter()
.map(|base| base_label(base, root))
.collect::<Vec<_>>()
.join(separator)
}
fn base_label(base: &Path, root: &Path) -> String {
if base == root {
return "the project root".to_owned();
}
if let Ok(offset) = root.strip_prefix(base) {
let offset = offset.display().to_string().replace('\\', "/");
return format!("the repository root (the project root is {offset} below it)");
}
if let Ok(inside) = base.strip_prefix(root) {
return inside.display().to_string().replace('\\', "/");
}
"a directory outside the project root".to_owned()
}
pub fn read_diff_text(
reader: impl std::io::Read,
label: &str,
limit: u64,
) -> Result<String, DiffStandDown> {
let mut bytes = Vec::new();
if let Err(err) = reader.take(limit + 1).read_to_end(&mut bytes) {
return Err(DiffStandDown::unreadable(label, &err));
}
if bytes.len() as u64 > limit {
return Err(DiffStandDown::oversize(label, bytes.len() as u64, limit));
}
String::from_utf8(bytes).map_err(|err| DiffStandDown::not_utf8(label, &err))
}
pub fn read_diff_file(path: &Path, label: &str) -> Result<String, DiffStandDown> {
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_DIFF_BYTES
{
return Err(DiffStandDown::oversize(label, meta.len(), MAX_DIFF_BYTES));
}
match std::fs::File::open(path) {
Ok(file) => read_diff_text(file, label, MAX_DIFF_BYTES),
Err(err) => Err(DiffStandDown::unreadable(label, &err)),
}
}
pub fn place_diff(
index: DiffIndex,
root: &Path,
candidate_bases: &[PathBuf],
label: &str,
) -> Result<DiffIndex, DiffStandDown> {
if index.touched_files().next().is_none() {
return Ok(index);
}
match choose_diff_base(&index, candidate_bases) {
None => Err(DiffStandDown::foreign_namespace(
&index,
candidate_bases,
root,
label,
)),
Some(chosen) if chosen.ambiguous => {
Err(DiffStandDown::ambiguous_base(candidate_bases, root, label))
}
Some(chosen) => {
let offset = root_offset_below(&chosen.base, root);
Ok(index.with_base(chosen.base).with_root_offset(offset))
}
}
}
fn root_offset_below(base: &Path, root: &Path) -> String {
root.strip_prefix(base)
.map(|offset| offset.display().to_string().replace('\\', "/"))
.unwrap_or_default()
}
struct ChosenBase {
base: PathBuf,
ambiguous: bool,
}
fn choose_diff_base(index: &DiffIndex, candidate_bases: &[PathBuf]) -> Option<ChosenBase> {
let mut scored: Vec<(usize, &PathBuf)> = candidate_bases
.iter()
.map(|base| {
let resolved = index
.touched_files()
.filter(|path| base.join(path).exists())
.count();
(resolved, base)
})
.filter(|(resolved, _)| *resolved > 0)
.collect();
scored.sort_by(|(a, _), (b, _)| b.cmp(a));
let (best_score, best_base) = *scored.first()?;
let ambiguous = scored
.get(1)
.is_some_and(|(runner_up, _)| *runner_up == best_score);
Some(ChosenBase {
base: best_base.clone(),
ambiguous,
})
}
#[must_use]
pub fn diff_base_candidates(root: &Path) -> Vec<PathBuf> {
let Some(toplevel) = git_toplevel_base(root) else {
return vec![root.to_path_buf()];
};
if toplevel == root {
return vec![root.to_path_buf()];
}
vec![toplevel, root.to_path_buf()]
}
fn git_toplevel_base(root: &Path) -> Option<PathBuf> {
let toplevel = crate::changed_files::resolve_git_toplevel(root).ok()?;
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let offset = canonical_root.strip_prefix(&toplevel).ok()?;
let mut base = root.to_path_buf();
for _ in offset.components() {
if !base.pop() {
return None;
}
}
Some(base)
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
#[test]
fn the_reader_accepts_the_exact_limit_and_rejects_one_byte_more() {
assert_eq!(
read_diff_text(Cursor::new(b"12345678"), "test diff", 8).unwrap(),
"12345678"
);
let stand_down = read_diff_text(Cursor::new(b"123456789"), "test diff", 8).unwrap_err();
assert_eq!(stand_down.reason(), "oversize");
}
#[test]
fn the_reader_rejects_invalid_utf8() {
let stand_down = read_diff_text(Cursor::new([0xff, 0xfe]), "test diff", 8).unwrap_err();
assert_eq!(stand_down.reason(), "not-utf8");
}
#[test]
fn a_missing_diff_file_stands_down_as_unreadable() {
let dir = tempfile::tempdir().expect("tempdir");
let stand_down = read_diff_file(&dir.path().join("absent.diff"), "label").unwrap_err();
assert_eq!(stand_down.reason(), "unreadable");
assert!(stand_down.message().starts_with("could not read label: "));
}
#[test]
fn a_stand_down_names_its_bases_without_the_checkout_path() {
let root = Path::new("/checkout/packages/app");
let toplevel = Path::new("/checkout");
let index = DiffIndex::from_unified_diff(
"diff --git a/src/a.ts b/src/a.ts\n\
--- a/src/a.ts\n\
+++ b/src/a.ts\n\
@@ -0,0 +1,1 @@\n\
+export const a = 1;\n",
);
let bases = vec![toplevel.to_path_buf(), root.to_path_buf()];
let foreign = DiffStandDown::foreign_namespace(&index, &bases, root, "--diff-file pr.diff");
assert_eq!(foreign.reason(), "foreign-namespace");
let ambiguous = DiffStandDown::ambiguous_base(&bases, root, "--diff-file pr.diff");
assert_eq!(ambiguous.reason(), "ambiguous-base");
for message in [foreign.message(), ambiguous.message()] {
assert!(
!message.contains("/checkout"),
"no absolute base reaches the wire: {message}"
);
assert!(
message.contains("the project root"),
"the analysis root is named: {message}"
);
assert!(
message.contains("the repository root (the project root is packages/app below it)"),
"the toplevel is named with the offset the diff is missing: {message}"
);
}
}
#[test]
fn a_single_candidate_base_is_named_as_the_project_root() {
let root = Path::new("/checkout");
let stand_down = DiffStandDown::ambiguous_base(&[root.to_path_buf()], root, "--diff-stdin");
assert!(
stand_down
.message()
.contains("under the project root, so their base is ambiguous"),
"{}",
stand_down.message()
);
assert!(!stand_down.message().contains("/checkout"));
}
#[test]
fn the_oversize_remedy_asks_only_for_something_the_user_can_do() {
let stand_down =
DiffStandDown::oversize("--diff-file pr.diff", MAX_DIFF_BYTES + 1, MAX_DIFF_BYTES);
assert!(
!stand_down.message().contains("raise the cap"),
"{}",
stand_down.message()
);
assert!(stand_down.message().contains("Narrow the diff"));
}
}