1use std::io::Read as _;
11use std::path::{Path, PathBuf};
12
13use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
14
15#[derive(Debug)]
24pub struct DiffStandDown {
25 reason: &'static str,
26 message: String,
27}
28
29impl DiffStandDown {
30 fn new(reason: &'static str, message: String) -> Self {
31 Self { reason, message }
32 }
33
34 #[must_use]
36 pub const fn reason(&self) -> &'static str {
37 self.reason
38 }
39
40 #[must_use]
42 pub fn message(&self) -> &str {
43 &self.message
44 }
45
46 #[must_use]
48 pub fn into_parts(self) -> (&'static str, String) {
49 (self.reason, self.message)
50 }
51
52 fn oversize(label: &str, bytes: u64, cap: u64) -> Self {
53 Self::new(
54 "oversize",
55 format!(
56 "{label} is {bytes} bytes (cap {cap}); line-level filtering disabled, \
57 reporting all findings. Narrow the diff; the cap is fixed."
58 ),
59 )
60 }
61
62 fn unreadable(label: &str, err: &std::io::Error) -> Self {
63 Self::new(
64 "unreadable",
65 format!(
66 "could not read {label}: {err} (line-level filtering disabled, \
67 reporting all findings). Check the path exists and is readable."
68 ),
69 )
70 }
71
72 fn not_utf8(label: &str, err: &std::string::FromUtf8Error) -> Self {
73 Self::new(
74 "not-utf8",
75 format!(
76 "could not read {label} as UTF-8: {err} (line-level filtering disabled, \
77 reporting all findings). Regenerate the diff as UTF-8 text."
78 ),
79 )
80 }
81
82 fn ambiguous_base(candidate_bases: &[PathBuf], root: &Path, label: &str) -> Self {
89 let bases = join_bases(candidate_bases, root, " and ");
90 Self::new(
91 "ambiguous-base",
92 format!(
93 "the paths in {label} name existing files under {bases}, so their base is \
94 ambiguous and fallow cannot tell which one the diff is relative to. It will \
95 not filter against a guess: every finding is reported (full scope, not scoped \
96 to the diff). Generate the diff from the repository root (plain `git diff`, \
97 not `git diff --relative`) to scope the report."
98 ),
99 )
100 }
101
102 fn foreign_namespace(
107 index: &DiffIndex,
108 candidate_bases: &[PathBuf],
109 root: &Path,
110 label: &str,
111 ) -> Self {
112 let total = index.touched_files().count();
113 let bases = join_bases(candidate_bases, root, ", ");
114 Self::new(
115 "foreign-namespace",
116 format!(
117 "none of the {total} file(s) named by {label} exist under {bases}; the diff's \
118 paths look relative to a different directory. fallow cannot place the diff, so \
119 every finding is reported (full scope, not scoped to the diff). Regenerate the \
120 diff from one of those directories to scope the report."
121 ),
122 )
123 }
124}
125
126fn join_bases(candidate_bases: &[PathBuf], root: &Path, separator: &str) -> String {
127 candidate_bases
128 .iter()
129 .map(|base| base_label(base, root))
130 .collect::<Vec<_>>()
131 .join(separator)
132}
133
134fn base_label(base: &Path, root: &Path) -> String {
145 if base == root {
146 return "the project root".to_owned();
147 }
148 if let Ok(offset) = root.strip_prefix(base) {
149 let offset = offset.display().to_string().replace('\\', "/");
150 return format!("the repository root (the project root is {offset} below it)");
151 }
152 if let Ok(inside) = base.strip_prefix(root) {
153 return inside.display().to_string().replace('\\', "/");
154 }
155 "a directory outside the project root".to_owned()
156}
157
158pub fn read_diff_text(
164 reader: impl std::io::Read,
165 label: &str,
166 limit: u64,
167) -> Result<String, DiffStandDown> {
168 let mut bytes = Vec::new();
169 if let Err(err) = reader.take(limit + 1).read_to_end(&mut bytes) {
170 return Err(DiffStandDown::unreadable(label, &err));
171 }
172 if bytes.len() as u64 > limit {
173 return Err(DiffStandDown::oversize(label, bytes.len() as u64, limit));
174 }
175 String::from_utf8(bytes).map_err(|err| DiffStandDown::not_utf8(label, &err))
176}
177
178pub fn read_diff_file(path: &Path, label: &str) -> Result<String, DiffStandDown> {
184 if let Ok(meta) = std::fs::metadata(path)
185 && meta.len() > MAX_DIFF_BYTES
186 {
187 return Err(DiffStandDown::oversize(label, meta.len(), MAX_DIFF_BYTES));
188 }
189 match std::fs::File::open(path) {
190 Ok(file) => read_diff_text(file, label, MAX_DIFF_BYTES),
191 Err(err) => Err(DiffStandDown::unreadable(label, &err)),
192 }
193}
194
195pub fn place_diff(
208 index: DiffIndex,
209 root: &Path,
210 candidate_bases: &[PathBuf],
211 label: &str,
212) -> Result<DiffIndex, DiffStandDown> {
213 if index.touched_files().next().is_none() {
214 return Ok(index);
215 }
216 match choose_diff_base(&index, candidate_bases) {
222 None => Err(DiffStandDown::foreign_namespace(
223 &index,
224 candidate_bases,
225 root,
226 label,
227 )),
228 Some(chosen) if chosen.ambiguous => {
229 Err(DiffStandDown::ambiguous_base(candidate_bases, root, label))
230 }
231 Some(chosen) => {
232 let offset = root_offset_below(&chosen.base, root);
233 Ok(index.with_base(chosen.base).with_root_offset(offset))
234 }
235 }
236}
237
238fn root_offset_below(base: &Path, root: &Path) -> String {
241 root.strip_prefix(base)
242 .map(|offset| offset.display().to_string().replace('\\', "/"))
243 .unwrap_or_default()
244}
245
246struct ChosenBase {
249 base: PathBuf,
250 ambiguous: bool,
251}
252
253fn choose_diff_base(index: &DiffIndex, candidate_bases: &[PathBuf]) -> Option<ChosenBase> {
272 let mut scored: Vec<(usize, &PathBuf)> = candidate_bases
273 .iter()
274 .map(|base| {
275 let resolved = index
276 .touched_files()
277 .filter(|path| base.join(path).exists())
278 .count();
279 (resolved, base)
280 })
281 .filter(|(resolved, _)| *resolved > 0)
282 .collect();
283
284 scored.sort_by(|(a, _), (b, _)| b.cmp(a));
286 let (best_score, best_base) = *scored.first()?;
287 let ambiguous = scored
288 .get(1)
289 .is_some_and(|(runner_up, _)| *runner_up == best_score);
290
291 Some(ChosenBase {
292 base: best_base.clone(),
293 ambiguous,
294 })
295}
296
297#[must_use]
313pub fn diff_base_candidates(root: &Path) -> Vec<PathBuf> {
314 let Some(toplevel) = git_toplevel_base(root) else {
315 return vec![root.to_path_buf()];
316 };
317 if toplevel == root {
318 return vec![root.to_path_buf()];
319 }
320 vec![toplevel, root.to_path_buf()]
321}
322
323fn git_toplevel_base(root: &Path) -> Option<PathBuf> {
326 let toplevel = crate::changed_files::resolve_git_toplevel(root).ok()?;
327 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
328 let offset = canonical_root.strip_prefix(&toplevel).ok()?;
329 let mut base = root.to_path_buf();
330 for _ in offset.components() {
331 if !base.pop() {
332 return None;
333 }
334 }
335 Some(base)
336}
337
338#[cfg(test)]
339mod tests {
340 use std::io::Cursor;
341
342 use super::*;
343
344 #[test]
345 fn the_reader_accepts_the_exact_limit_and_rejects_one_byte_more() {
346 assert_eq!(
347 read_diff_text(Cursor::new(b"12345678"), "test diff", 8).unwrap(),
348 "12345678"
349 );
350 let stand_down = read_diff_text(Cursor::new(b"123456789"), "test diff", 8).unwrap_err();
351 assert_eq!(stand_down.reason(), "oversize");
352 }
353
354 #[test]
355 fn the_reader_rejects_invalid_utf8() {
356 let stand_down = read_diff_text(Cursor::new([0xff, 0xfe]), "test diff", 8).unwrap_err();
357 assert_eq!(stand_down.reason(), "not-utf8");
358 }
359
360 #[test]
361 fn a_missing_diff_file_stands_down_as_unreadable() {
362 let dir = tempfile::tempdir().expect("tempdir");
363 let stand_down = read_diff_file(&dir.path().join("absent.diff"), "label").unwrap_err();
364 assert_eq!(stand_down.reason(), "unreadable");
365 assert!(stand_down.message().starts_with("could not read label: "));
366 }
367
368 #[test]
371 fn a_stand_down_names_its_bases_without_the_checkout_path() {
372 let root = Path::new("/checkout/packages/app");
373 let toplevel = Path::new("/checkout");
374 let index = DiffIndex::from_unified_diff(
375 "diff --git a/src/a.ts b/src/a.ts\n\
376 --- a/src/a.ts\n\
377 +++ b/src/a.ts\n\
378 @@ -0,0 +1,1 @@\n\
379 +export const a = 1;\n",
380 );
381 let bases = vec![toplevel.to_path_buf(), root.to_path_buf()];
382
383 let foreign = DiffStandDown::foreign_namespace(&index, &bases, root, "--diff-file pr.diff");
384 assert_eq!(foreign.reason(), "foreign-namespace");
385 let ambiguous = DiffStandDown::ambiguous_base(&bases, root, "--diff-file pr.diff");
386 assert_eq!(ambiguous.reason(), "ambiguous-base");
387
388 for message in [foreign.message(), ambiguous.message()] {
389 assert!(
390 !message.contains("/checkout"),
391 "no absolute base reaches the wire: {message}"
392 );
393 assert!(
394 message.contains("the project root"),
395 "the analysis root is named: {message}"
396 );
397 assert!(
398 message.contains("the repository root (the project root is packages/app below it)"),
399 "the toplevel is named with the offset the diff is missing: {message}"
400 );
401 }
402 }
403
404 #[test]
407 fn a_single_candidate_base_is_named_as_the_project_root() {
408 let root = Path::new("/checkout");
409 let stand_down = DiffStandDown::ambiguous_base(&[root.to_path_buf()], root, "--diff-stdin");
410 assert!(
411 stand_down
412 .message()
413 .contains("under the project root, so their base is ambiguous"),
414 "{}",
415 stand_down.message()
416 );
417 assert!(!stand_down.message().contains("/checkout"));
418 }
419
420 #[test]
422 fn the_oversize_remedy_asks_only_for_something_the_user_can_do() {
423 let stand_down =
424 DiffStandDown::oversize("--diff-file pr.diff", MAX_DIFF_BYTES + 1, MAX_DIFF_BYTES);
425 assert!(
426 !stand_down.message().contains("raise the cap"),
427 "{}",
428 stand_down.message()
429 );
430 assert!(stand_down.message().contains("Narrow the diff"));
431 }
432}