Skip to main content

fallow_engine/
diff_source.rs

1//! Read a unified diff and place it under the directory its paths are relative
2//! to, or say why the run cannot.
3//!
4//! The CLI (`--diff-file`, `--diff-stdin`, `$FALLOW_DIFF_FILE`) and the
5//! programmatic API (a diff inherited from `FALLOW_DIFF_FILE`) both call these
6//! functions. A diff that one surface stands down on therefore stands down on
7//! the other with the same reason token and the same sentence, which is what
8//! `request_outcomes["diff-filter"]` publishes on both.
9
10use std::io::Read as _;
11use std::path::{Path, PathBuf};
12
13use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
14
15/// Why a supplied diff could not be applied, plus the sentence that says so.
16///
17/// The reason token is what `request_outcomes["diff-filter"].reason` publishes
18/// and the message is what both the stderr line and that entry's `message`
19/// render, so the log a human read and the envelope a script read cannot state
20/// different things. Every stand-down returns one of these instead of printing
21/// where it happens, so the caller decides whether to print and always records
22/// (issue #2688).
23#[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    /// The kebab-case reason token.
35    #[must_use]
36    pub const fn reason(&self) -> &'static str {
37        self.reason
38    }
39
40    /// The sentence that names the problem and the next step.
41    #[must_use]
42    pub fn message(&self) -> &str {
43        &self.message
44    }
45
46    /// The reason token and the sentence, by value.
47    #[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    /// The diff's paths resolve equally well under two different directories,
83    /// so existence alone cannot place its base. Rather than filter against a
84    /// guess (whose wrong half drops every source-anchored finding), the run
85    /// discards the diff and reports at full scope, so the message names the
86    /// ambiguity and says so rather than letting silence imply the report was
87    /// scoped.
88    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    /// A diff whose paths name no file under any candidate base was almost
103    /// certainly generated relative to some other directory. fallow cannot
104    /// place it, so it discards the diff and reports at full scope. Say so,
105    /// once, rather than let the unscoped report imply the diff was applied.
106    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
134/// Name a candidate base without putting the machine's checkout path in it.
135///
136/// This sentence is the `message` of a wire member, and every other
137/// path-bearing member of a fallow envelope is project-root-relative, so an
138/// absolute base here would make one input's output differ between checkouts.
139/// The two candidates a run offers are the analysis root and the git toplevel
140/// above it ([`diff_base_candidates`]), and naming them by their relation to
141/// the root tells the user which directory to regenerate the diff from at least
142/// as well as the absolute path did: what they need is the path prefix their
143/// diff is missing, which is exactly the offset reported here.
144fn 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
158/// Read a diff from `reader`, up to `limit` bytes, as UTF-8 text.
159///
160/// # Errors
161///
162/// Returns the `unreadable`, `oversize` or `not-utf8` stand-down.
163pub 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
178/// Read a diff file as UTF-8 text, within [`MAX_DIFF_BYTES`].
179///
180/// # Errors
181///
182/// Returns the `unreadable`, `oversize` or `not-utf8` stand-down.
183pub 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
195/// Place a parsed diff under the directory its paths are relative to.
196///
197/// A diff that parsed but names no analyzable head-side file (empty,
198/// deletion-only, or binary-only) changed nothing a finding can be attributed
199/// to. That is a real, EMPTY scope, not an unplaceable base: the empty index is
200/// kept so every source-anchored finding filters out. Only a diff that cannot
201/// be placed (foreign or ambiguous base) stands down, and the run then reports
202/// at full scope.
203///
204/// # Errors
205///
206/// Returns the `foreign-namespace` or `ambiguous-base` stand-down.
207pub 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    // The diff names files, but none under any candidate base (foreign), or
217    // equally under two at once (ambiguous). Either way the findings cannot be
218    // expressed in its namespace. An unfilterable path is RETAINED, never
219    // silently dropped, so drop the diff instead of the findings and report at
220    // full scope.
221    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
238/// Where the analysis root sits below `base`, forward-slashed, empty when they
239/// are the same directory.
240fn 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
246/// The base a diff's paths were written relative to, plus whether the evidence
247/// actually distinguished it from the runner-up.
248struct ChosenBase {
249    base: PathBuf,
250    ambiguous: bool,
251}
252
253/// Decide which directory the diff's paths are relative to.
254///
255/// A unified diff carries no statement of its own base. `git diff` writes paths
256/// relative to the repository toplevel, but `git diff --relative` writes them
257/// relative to the invoking directory, and both reach fallow. Assuming either
258/// one silently drops every source-anchored finding for users of the other.
259///
260/// The paths themselves settle it: they name files that exist on disk. Score
261/// each candidate by how many of the diff's paths resolve under it and take the
262/// best. `candidate_bases` is ordered most-preferred first, so an exact tie
263/// keeps the caller's precedence.
264///
265/// A tie is not a decision. A repo with both `<toplevel>/src/a.ts` and
266/// `<root>/src/a.ts` resolves the diff path `src/a.ts` under either candidate,
267/// and existence alone cannot say which the diff meant. Picking the preferred
268/// one and staying silent would reproduce the empty-report-looks-clean failure
269/// this whole mechanism exists to prevent, so the tie is reported.
270/// `None` means the diff names nothing under any candidate.
271fn 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    // Stable sort by score, descending: equal scores keep caller precedence.
285    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/// Directories a supplied unified diff's paths might be relative to, most
298/// preferred first: the git toplevel above `root`, then `root` itself. Only
299/// `root` outside a git repository or when `root` is the toplevel.
300///
301/// `git diff` writes paths relative to the repository toplevel, while
302/// `git diff --relative` writes them relative to the invoking directory. A
303/// unified diff does not say which one it is, so the caller offers both and the
304/// paths decide (see [`place_diff`]). The two coincide for a single-package
305/// repo, and differ when the root addresses a package inside a monorepo.
306///
307/// The toplevel is only used to measure how far `root` sits below it; the
308/// returned base is that many components popped off `root` itself, so it keeps
309/// `root`'s spelling. Finding paths are built from `root`, and a canonicalized
310/// base would fail to prefix them wherever the two disagree (`/tmp` vs
311/// `/private/tmp` on macOS).
312#[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
323/// `root` with its offset below the git toplevel popped off, preserving
324/// `root`'s spelling. `None` outside a git repo.
325fn 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    /// The stand-down message is a wire field, so it names the candidate bases
369    /// by their relation to the project root rather than by absolute path.
370    #[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    /// A single-candidate run (analysis root at the repository toplevel) names
405    /// the one base it had, and still names no path.
406    #[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    /// The cap has no override, so the remedy cannot suggest raising it.
421    #[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}