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::{FxHashMap, 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
77#[must_use]
87pub fn export_names_at_base(
88 root: &Path,
89 base_ref: &str,
90 files: &[(String, String)],
91) -> FxHashMap<String, FxHashSet<String>> {
92 let mut names = FxHashMap::default();
93 if files.is_empty() {
94 return names;
95 }
96 let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(root) else {
97 return names;
98 };
99 let git_root = dunce::canonicalize(&git_root).unwrap_or(git_root);
100 let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
101 let Some(mut reader) = BaseFileReader::spawn(&root) else {
102 return names;
103 };
104 let commit = crate::diff_risk::merge_base(&root, base_ref);
105 for (head, base) in files {
106 let base_path = root.join(base);
107 let Ok(relative) = base_path.strip_prefix(&git_root) else {
108 continue;
109 };
110 let exports = match reader.read(&commit, relative) {
111 BaseRead::Content(source) => {
112 fallow_engine::module_graph::export_names_in_source(&base_path, &source)
113 }
114 BaseRead::Missing => FxHashSet::default(),
115 BaseRead::Error => break,
116 };
117 names.insert(head.clone(), exports);
118 }
119 names
120}
121
122pub struct BaseFileReader {
133 child: Option<fallow_process::ScopedChild>,
137 stdin: Option<std::process::ChildStdin>,
140 stdout: std::io::BufReader<std::process::ChildStdout>,
141}
142
143impl BaseFileReader {
144 #[must_use]
149 pub fn spawn(root: &Path) -> Option<Self> {
150 let mut command = Command::new("git");
151 command
152 .args(["cat-file", "--batch"])
153 .current_dir(root)
154 .stdin(std::process::Stdio::piped())
155 .stdout(std::process::Stdio::piped())
156 .stderr(std::process::Stdio::null());
157 clear_ambient_git_env(&mut command);
158 let mut child = fallow_process::ScopedChild::spawn(&mut command).ok()?;
159 let stdin = child.take_stdin()?;
160 let stdout = child.take_stdout()?;
161 Some(Self {
162 child: Some(child),
163 stdin: Some(stdin),
164 stdout: std::io::BufReader::new(stdout),
165 })
166 }
167
168 pub fn read(&mut self, base_ref: &str, relative: &Path) -> BaseRead {
176 use std::io::{BufRead, Read};
177
178 let relative = relative.to_string_lossy().replace('\\', "/");
179 if relative.contains('\n') {
180 return BaseRead::Error;
181 }
182
183 let Some(stdin) = self.stdin.as_mut() else {
184 return BaseRead::Error;
185 };
186 if writeln!(stdin, "{base_ref}:{relative}").is_err() || stdin.flush().is_err() {
187 return BaseRead::Error;
188 }
189
190 let mut header = String::new();
191 if !matches!(self.stdout.read_line(&mut header), Ok(n) if n > 0) {
192 return BaseRead::Error;
193 }
194 if header.trim_end().ends_with(" missing") {
196 return BaseRead::Missing;
197 }
198 let Some(size) = header
200 .trim_end()
201 .rsplit(' ')
202 .next()
203 .and_then(|raw| raw.parse::<usize>().ok())
204 else {
205 return BaseRead::Error;
206 };
207 let mut buf = vec![0u8; size];
208 if self.stdout.read_exact(&mut buf).is_err() {
209 return BaseRead::Error;
210 }
211 let mut newline = [0u8; 1];
214 if self.stdout.read_exact(&mut newline).is_err() {
215 return BaseRead::Error;
216 }
217
218 BaseRead::Content(String::from_utf8_lossy(&buf).into_owned())
219 }
220}
221
222#[derive(Debug, PartialEq, Eq)]
226pub enum BaseRead {
227 Content(String),
229 Missing,
232 Error,
235}
236
237impl Drop for BaseFileReader {
238 fn drop(&mut self) {
239 self.stdin.take();
242 if let Some(child) = self.child.take() {
243 let _ = child.wait();
244 }
245 }
246}
247
248fn is_fallow_cache_artifact(
249 path: &Path,
250 cache_dir: &Path,
251 canonical_cache_dir: Option<&Path>,
252) -> bool {
253 path.starts_with(cache_dir)
254 || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
255}
256
257pub(super) fn is_analysis_input(path: &Path) -> bool {
258 matches!(
259 path.extension().and_then(|ext| ext.to_str()),
260 Some(
261 "js" | "jsx"
262 | "ts"
263 | "tsx"
264 | "mjs"
265 | "mts"
266 | "cjs"
267 | "cts"
268 | "vue"
269 | "svelte"
270 | "astro"
271 | "mdx"
272 | "css"
273 | "scss"
274 )
275 )
276}
277
278pub(super) fn is_non_behavioral_doc(path: &Path) -> bool {
279 matches!(
280 path.extension().and_then(|ext| ext.to_str()),
281 Some("md" | "markdown" | "txt" | "rst" | "adoc")
282 )
283}
284
285const REUSE_BLOCKING_MARKERS: &[&str] = &[
291 "fallow-ignore",
292 "@expected-unused",
293 "@public",
294 "@internal",
295 "@beta",
296 "@alpha",
297 "@api",
298 "import(",
299];
300
301fn has_reuse_blocking_marker(source: &str) -> bool {
302 REUSE_BLOCKING_MARKERS
303 .iter()
304 .any(|marker| source.contains(marker))
305}
306
307pub(super) fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
308 if has_reuse_blocking_marker(current) || has_reuse_blocking_marker(base) {
309 return false;
310 }
311 if !matches!(
312 path.extension().and_then(|ext| ext.to_str()),
313 Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
314 ) {
315 return false;
316 }
317 fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
328 fn a_severed_request_pipe_is_an_error() {
329 let tmp = tempfile::TempDir::new().expect("temp dir should be created");
330 let mut reader = BaseFileReader::spawn(tmp.path()).expect("reader should spawn");
331
332 reader.stdin.take();
333
334 assert_eq!(reader.read("HEAD", Path::new("README.md")), BaseRead::Error);
335 }
336}