fallow_api/audit_run/
base_files.rs1use std::io::Write as _;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use fallow_engine::changed_files::clear_ambient_git_env;
9use rustc_hash::FxHashSet;
10
11#[must_use]
19#[expect(
20 clippy::implicit_hasher,
21 reason = "fallow standardizes on FxHashSet across the workspace"
22)]
23pub fn can_reuse_current_as_base(
24 root: &Path,
25 cache_dir: Option<&Path>,
26 base_ref: &str,
27 changed_files: &FxHashSet<PathBuf>,
28) -> bool {
29 let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
30 return false;
31 };
32 let canonical_cache_dir = cache_dir.and_then(|dir| dunce::canonicalize(dir).ok());
33 let mut reader: Option<BaseFileReader> = None;
36 for path in changed_files {
37 if cache_dir
38 .is_some_and(|dir| is_fallow_cache_artifact(path, dir, canonical_cache_dir.as_deref()))
39 {
40 continue;
41 }
42 if !is_analysis_input(path) {
43 if is_non_behavioral_doc(path) {
44 continue;
45 }
46 return false;
47 }
48 let Ok(current) = std::fs::read_to_string(path) else {
49 return false;
50 };
51 let Ok(relative) = path.strip_prefix(&git_root) else {
52 return false;
53 };
54 let reader = match reader.as_mut() {
55 Some(reader) => reader,
56 None => {
57 let Some(spawned) = BaseFileReader::spawn(root) else {
58 return false;
59 };
60 reader.insert(spawned)
61 }
62 };
63 let base = match reader.read(base_ref, relative) {
64 BaseRead::Content(base) => base,
65 BaseRead::Missing | BaseRead::Error => return false,
66 };
67 if current == base {
68 continue;
69 }
70 if !js_ts_tokens_equivalent(path, ¤t, &base) {
71 return false;
72 }
73 }
74 true
75}
76
77pub struct BaseFileReader {
88 child: Option<fallow_process::ScopedChild>,
92 stdin: Option<std::process::ChildStdin>,
95 stdout: std::io::BufReader<std::process::ChildStdout>,
96}
97
98impl BaseFileReader {
99 #[must_use]
104 pub fn spawn(root: &Path) -> Option<Self> {
105 let mut command = Command::new("git");
106 command
107 .args(["cat-file", "--batch"])
108 .current_dir(root)
109 .stdin(std::process::Stdio::piped())
110 .stdout(std::process::Stdio::piped())
111 .stderr(std::process::Stdio::null());
112 clear_ambient_git_env(&mut command);
113 let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
114 let stdin = child.take_stdin()?;
115 let stdout = child.take_stdout()?;
116 Some(Self {
117 child: Some(child),
118 stdin: Some(stdin),
119 stdout: std::io::BufReader::new(stdout),
120 })
121 }
122
123 pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
131 use std::io::{BufRead, Read};
132
133 let relative = relative.to_string_lossy().replace('\\', "/");
134 if relative.contains('\n') {
135 return BaseRead::Error;
136 }
137
138 let Some(stdin) = self.stdin.as_mut() else {
139 return BaseRead::Error;
140 };
141 if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
142 return BaseRead::Error;
143 }
144
145 let mut header = String::new();
146 if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
147 return BaseRead::Error;
148 }
149 if header.trim_end().ends_with(" missing") {
151 return BaseRead::Missing;
152 }
153 let Some(size) = header
155 .trim_end()
156 .rsplit(' ')
157 .next()
158 .and_then(|raw| raw.parse::<usize>().ok())
159 else {
160 return BaseRead::Error;
161 };
162 let mut buf = vec![0u8; size];
163 if self.stdout.read_exact(&mut buf).is_err() {
164 return BaseRead::Error;
165 }
166 let mut newline = [0u8; 1];
169 if self.stdout.read_exact(&mut newline).is_err() {
170 return BaseRead::Error;
171 }
172
173 BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
174 }
175}
176
177#[derive(Debug, PartialEq, Eq)]
181pub enum BaseRead {
182 Content(String),
184 Missing,
187 Error,
190}
191
192impl Drop for BaseFileReader {
193 fn drop(&mut self) {
194 self.stdin.take();
197 if let Some(child) = self.child.take() {
198 let _ = child.wait();
199 }
200 }
201}
202
203fn is_fallow_cache_artifact(
204 path: &Path,
205 cache_dir: &Path,
206 canonical_cache_dir: Option<&Path>,
207) -> bool {
208 path.starts_with(cache_dir)
209 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
210}
211
212pub(super) fn is_analysis_input(path: &Path) -> bool {
213 matches!(
214 path.extension().and_then(|ext| ext.to_str()),
215 Some(
216 "js" | "jsx"
217 | "ts"
218 | "tsx"
219 | "mjs"
220 | "mts"
221 | "cjs"
222 | "cts"
223 | "vue"
224 | "svelte"
225 | "astro"
226 | "mdx"
227 | "css"
228 | "scss"
229 )
230 )
231}
232
233pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
234 matches!(
235 path.extension().and_then(|ext| ext.to_str()),
236 Some("md" | "markdown" | "txt" | "rst" | "adoc")
237 )
238}
239
240const REUSE_BLOCKING_MARKERS: &[&str] = &[
246 "fallow-ignore",
247 "@expected-unused",
248 "@public",
249 "@internal",
250 "@beta",
251 "@alpha",
252 "@api",
253 "import(",
254];
255
256fn has_reuse_blocking_marker(source: &str) -> bool {
257 REUSE_BLOCKING_MARKERS
258 .iter()
259 .any(|marker| source.contains(marker))
260}
261
262pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
263 if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
264 return false;
265 }
266 if !matches!(
267 path.extension().and_then(|ext| ext.to_str()),
268 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
269 ) {
270 return false;
271 }
272 fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
283 fn a_severed_request_pipe_is_an_error() {
284 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
285 let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
286
287 reader.stdin.take();
288
289 assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
290 }
291}