fallow_api/audit_run/
base_files.rs1use std::io::Write as _;
5use std::path::{Path, PathBuf};
6
7use rustc_hash::{FxHashMap, FxHashSet};
8
9#[must_use]
17#[expect(
18 clippy::implicit_hasher,
19 reason = "fallow standardizes on FxHashSet across the workspace"
20)]
21pub fn can_reuse_current_as_base(
22 root: &Path,
23 cache_dir: Option<&Path>,
24 base_ref: &str,
25 changed_files: &FxHashSet<PathBuf>,
26) -> bool {
27 let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
28 return false;
29 };
30 let canonical_cache_dir = cache_dir.and_then(|dir| dunce::canonicalize(dir).ok());
31 let mut reader: Option<BaseFileReader> = None;
34 for path in changed_files {
35 if cache_dir
36 .is_some_and(|dir| is_fallow_cache_artifact(path, dir, canonical_cache_dir.as_deref()))
37 {
38 continue;
39 }
40 if !is_analysis_input(path) {
41 if is_non_behavioral_doc(path) {
42 continue;
43 }
44 return false;
45 }
46 let Ok(current) = std::fs::read_to_string(path) else {
47 return false;
48 };
49 let Ok(relative) = path.strip_prefix(&git_root) else {
50 return false;
51 };
52 let reader = match reader.as_mut() {
53 Some(reader) => reader,
54 None => {
55 let Some(spawned) = BaseFileReader::spawn(root) else {
56 return false;
57 };
58 reader.insert(spawned)
59 }
60 };
61 let base = match reader.read(base_ref, relative) {
62 BaseRead::Content(base) => base,
63 BaseRead::Missing | BaseRead::Error => return false,
64 };
65 if current == base {
66 continue;
67 }
68 if !js_ts_tokens_equivalent(path, ¤t, &base) {
69 return false;
70 }
71 }
72 true
73}
74
75#[must_use]
85pub fn export_names_at_base(
86 root: &Path,
87 base_ref: &str,
88 files: &[(String, String)],
89) -> FxHashMap<String, FxHashSet<String>> {
90 let mut names = FxHashMap::default();
91 if files.is_empty() {
92 return names;
93 }
94 let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
95 return names;
96 };
97 let git_root = dunce::canonicalize(&git_root).unwrap_or(git_root);
98 let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
99 let Some(mut reader) = BaseFileReader::spawn(&root) else {
100 return names;
101 };
102 let commit = crate::diff_risk::merge_base(&root, base_ref);
103 for (head, base) in files {
104 let base_path = root.join(base);
105 let Ok(relative) = base_path.strip_prefix(&git_root) else {
106 continue;
107 };
108 let exports = match reader.read(&commit, relative) {
109 BaseRead::Content(source) => {
110 fallow_engine::module_graph::export_names_in_source(&base_path, &source)
111 }
112 BaseRead::Missing => FxHashSet::default(),
113 BaseRead::Error => break,
114 };
115 names.insert(head.clone(), exports);
116 }
117 names
118}
119
120pub struct BaseFileReader {
131 child: Option<fallow_process::ScopedChild>,
135 stdin: Option<std::process::ChildStdin>,
138 stdout: std::io::BufReader<std::process::ChildStdout>,
139}
140
141impl BaseFileReader {
142 #[must_use]
147 pub fn spawn(root: &Path) -> Option<Self> {
148 let mut command = fallow_engine::changed_files::git_command_in(root);
149 command
150 .args(["cat-file", "--batch"])
151 .stdin(std::process::Stdio::piped())
152 .stdout(std::process::Stdio::piped())
153 .stderr(std::process::Stdio::null());
154 let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
155 let stdin = child.take_stdin()?;
156 let stdout = child.take_stdout()?;
157 Some(Self {
158 child: Some(child),
159 stdin: Some(stdin),
160 stdout: std::io::BufReader::new(stdout),
161 })
162 }
163
164 pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
172 use std::io::{BufRead, Read};
173
174 let relative = relative.to_string_lossy().replace('\\', "/");
175 if relative.contains('\n') {
176 return BaseRead::Error;
177 }
178
179 let Some(stdin) = self.stdin.as_mut() else {
180 return BaseRead::Error;
181 };
182 if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
183 return BaseRead::Error;
184 }
185
186 let mut header = String::new();
187 if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
188 return BaseRead::Error;
189 }
190 if header.trim_end().ends_with(" missing") {
192 return BaseRead::Missing;
193 }
194 let Some(size) = header
196 .trim_end()
197 .rsplit(' ')
198 .next()
199 .and_then(|raw| raw.parse::<usize>().ok())
200 else {
201 return BaseRead::Error;
202 };
203 let mut buf = vec![0u8; size];
204 if self.stdout.read_exact(&mut buf).is_err() {
205 return BaseRead::Error;
206 }
207 let mut newline = [0u8; 1];
210 if self.stdout.read_exact(&mut newline).is_err() {
211 return BaseRead::Error;
212 }
213
214 BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
215 }
216}
217
218#[derive(Debug, PartialEq, Eq)]
222pub enum BaseRead {
223 Content(String),
225 Missing,
228 Error,
231}
232
233impl Drop for BaseFileReader {
234 fn drop(&mut self) {
235 self.stdin.take();
238 if let Some(child) = self.child.take() {
239 let _ = child.wait();
240 }
241 }
242}
243
244fn is_fallow_cache_artifact(
245 path: &Path,
246 cache_dir: &Path,
247 canonical_cache_dir: Option<&Path>,
248) -> bool {
249 path.starts_with(cache_dir)
250 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
251}
252
253pub(super) fn is_analysis_input(path: &Path) -> bool {
254 matches!(
255 path.extension().and_then(|ext| ext.to_str()),
256 Some(
257 "js" | "jsx"
258 | "ts"
259 | "tsx"
260 | "mjs"
261 | "mts"
262 | "cjs"
263 | "cts"
264 | "vue"
265 | "svelte"
266 | "astro"
267 | "mdx"
268 | "css"
269 | "scss"
270 )
271 )
272}
273
274pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
275 matches!(
276 path.extension().and_then(|ext| ext.to_str()),
277 Some("md" | "markdown" | "txt" | "rst" | "adoc")
278 )
279}
280
281const REUSE_BLOCKING_MARKERS: &[&str] = &[
287 "fallow-ignore",
288 "@expected-unused",
289 "@public",
290 "@internal",
291 "@beta",
292 "@alpha",
293 "@api",
294 "import(",
295];
296
297fn has_reuse_blocking_marker(source: &str) -> bool {
298 REUSE_BLOCKING_MARKERS
299 .iter()
300 .any(|marker| source.contains(marker))
301}
302
303pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
304 if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
305 return false;
306 }
307 if !matches!(
308 path.extension().and_then(|ext| ext.to_str()),
309 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
310 ) {
311 return false;
312 }
313 fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
324 fn a_severed_request_pipe_is_an_error() {
325 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
326 let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
327
328 reader.stdin.take();
329
330 assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
331 }
332}