1use std::collections::BTreeSet;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use anyhow::Result;
21use qk_input_analysis::{Access, Operation};
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value, json};
24
25use crate::glob::Pattern;
26use crate::hash::{Snapshot, key};
27use crate::paths::Outputs;
28
29const KEPT: usize = 8;
33
34#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct PathSet {
37 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
39 pub reads: BTreeSet<String>,
40 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
42 pub listings: BTreeSet<String>,
43 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
47 pub present: BTreeSet<String>,
48 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
50 pub absent: BTreeSet<String>,
51 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
54 pub written: BTreeSet<String>,
55}
56
57pub struct Exclusions<'a> {
59 pub roots: [&'a Path; 2],
61 pub outputs: &'a [&'a Outputs],
63 pub ignore: Vec<Pattern>,
65 pub state: Vec<String>,
67}
68
69impl Exclusions<'_> {
70 fn relative(&self, path: &str) -> Option<String> {
72 let path = qk_input_analysis::strace::normalize(Path::new(path));
73 let relative = self
74 .roots
75 .iter()
76 .find_map(|root| path.strip_prefix(root).ok())?;
77 let relative = relative.to_str()?.replace('\\', "/");
78 Some(if relative.is_empty() {
79 ".".into()
80 } else {
81 relative
82 })
83 }
84
85 fn excluded(&self, path: &str) -> bool {
86 let mut parts = path.split('/');
87 parts.clone().next() == Some(".git")
88 || parts.any(|part| part == "node_modules")
89 || self.state.iter().any(|state| {
90 path == state
91 || path
92 .strip_prefix(state.as_str())
93 .is_some_and(|rest| rest.starts_with('/'))
94 })
95 || self.outputs.iter().any(|outputs| outputs.matches(path))
96 || self.ignore.iter().any(|pattern| pattern.is_match(path))
97 }
98}
99
100impl PathSet {
101 pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
105 let mut set = Self::default();
106 let mut written = BTreeSet::new();
107 for access in accesses {
108 let Some(path) = exclusions.relative(&access.path) else {
109 continue;
110 };
111 if access.operation.writes() {
112 written.insert(path);
113 continue;
114 }
115 if written.contains(&path) || exclusions.excluded(&path) {
117 continue;
118 }
119 let found = matches!(access.result.as_str(), "success" | "unknown");
120 let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
121 match access.operation {
122 Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
123 if !root.join(&path).is_dir() {
126 set.reads.insert(path);
127 }
128 }
129 Operation::ListDirectory | Operation::ReadDirectory if found => {
130 set.listings.insert(path);
131 }
132 Operation::Metadata | Operation::ReadLink if found => {
133 set.present.insert(path);
134 }
135 _ if missing => {
136 set.absent.insert(path);
137 }
138 _ => {}
139 }
140 }
141 set.present.retain(|path| !set.reads.contains(path));
142 set.absent
143 .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
144 set.written = written
145 .into_iter()
146 .filter(|path| set.listings.contains(parent(path)))
147 .collect();
148 set
149 }
150
151 pub fn is_relative(&self) -> bool {
154 [
155 &self.reads,
156 &self.listings,
157 &self.present,
158 &self.absent,
159 &self.written,
160 ]
161 .into_iter()
162 .flatten()
163 .all(|path| {
164 !path.is_empty()
165 && Path::new(path).components().all(|component| {
166 matches!(
167 component,
168 std::path::Component::Normal(_) | std::path::Component::CurDir
169 )
170 })
171 })
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.reads.is_empty()
176 && self.listings.is_empty()
177 && self.present.is_empty()
178 && self.absent.is_empty()
179 }
180
181 pub fn changed_since(
191 &self,
192 root: &Path,
193 started: SystemTime,
194 outputs: &[&Outputs],
195 ) -> Option<&str> {
196 let since = |path: &Path| {
197 std::fs::symlink_metadata(path).is_ok_and(|metadata| changed(&metadata) >= started)
198 };
199 let file = self.reads.iter().chain(&self.present).find(|path| {
200 std::fs::symlink_metadata(root.join(path))
201 .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
202 });
203 file.or_else(|| {
204 self.listings.iter().find(|directory| {
205 let absolute = if *directory == "." {
206 root.to_path_buf()
207 } else {
208 root.join(directory)
209 };
210 let Ok(entries) = std::fs::read_dir(&absolute) else {
211 return false;
212 };
213 let mut own = self.written.iter().any(|path| parent(path) == *directory);
214 for entry in entries.flatten() {
215 let name = entry.file_name().to_string_lossy().into_owned();
216 let path = if *directory == "." {
217 name
218 } else {
219 format!("{directory}/{name}")
220 };
221 if self.written.contains(&path)
222 || outputs.iter().any(|outputs| outputs.matches(&path))
223 {
224 own = true;
225 } else if since(&entry.path()) {
226 return true;
227 }
228 }
229 !own && since(&absolute)
230 })
231 })
232 .map(String::as_str)
233 }
234
235 pub fn digest(&self) -> Result<String> {
236 key(&serde_json::to_value(self)?)
237 }
238
239 pub(crate) fn values(
243 &self,
244 snapshot: &Snapshot,
245 root: &Path,
246 outputs: &[&Outputs],
247 ) -> Result<Value> {
248 let value = |path: &str| match kind(&root.join(path)) {
250 "file" | "symlink" => snapshot
251 .recorded_value(root, path)
252 .unwrap_or_else(|_| json!({"unreadable": true})),
253 other => json!(other),
254 };
255 let reads: Map<String, Value> = self
256 .reads
257 .iter()
258 .map(|path| (path.clone(), value(path)))
259 .collect();
260 let mut listings = Map::new();
261 for directory in &self.listings {
262 listings.insert(directory.clone(), self.listing(root, directory, outputs)?);
263 }
264 let existence: Map<String, Value> = self
265 .present
266 .iter()
267 .chain(&self.absent)
268 .map(|path| (path.clone(), value(path)))
269 .collect();
270 Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
271 }
272
273 fn listing(&self, root: &Path, directory: &str, outputs: &[&Outputs]) -> Result<Value> {
275 let absolute = if directory == "." {
276 root.to_path_buf()
277 } else {
278 root.join(directory)
279 };
280 let entries = match std::fs::read_dir(&absolute) {
281 Ok(entries) => entries,
282 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
283 return Ok(json!("absent"));
284 }
285 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
286 return Ok(json!(kind(&absolute)));
287 }
288 Err(error) => return Err(error.into()),
289 };
290 let mut listed = Map::new();
291 for entry in entries {
292 let entry = entry?;
293 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
294 continue;
295 };
296 let path = if directory == "." {
297 name.clone()
298 } else {
299 format!("{directory}/{name}")
300 };
301 if self.written.contains(&path) || outputs.iter().any(|outputs| outputs.matches(&path))
302 {
303 continue;
304 }
305 listed.insert(name, json!(kind(&entry.path())));
306 }
307 Ok(Value::Object(listed))
308 }
309}
310
311fn parent(path: &str) -> &str {
313 path.rsplit_once('/').map_or(".", |(parent, _)| parent)
314}
315
316fn kind(path: &Path) -> &'static str {
318 match std::fs::symlink_metadata(path) {
319 Ok(metadata) if metadata.file_type().is_symlink() => "symlink",
320 Ok(metadata) if metadata.is_dir() => "directory",
321 Ok(metadata) if metadata.is_file() => "file",
322 Ok(_) => "other",
323 Err(_) => "absent",
324 }
325}
326
327#[derive(Serialize, Deserialize)]
329struct Stored {
330 version: u32,
331 weak: String,
332 sets: Vec<PathSet>,
333}
334
335fn location(cache: &Path, weak: &str) -> PathBuf {
336 cache.join("pathsets").join(format!("{weak}.json"))
337}
338
339pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
342 std::fs::read(location(cache, weak))
343 .ok()
344 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
345 .filter(|stored| stored.version == 1 && stored.weak == weak)
346 .map(|stored| stored.sets)
347 .unwrap_or_default()
348}
349
350pub(crate) fn touch(cache: &Path, weak: &str) {
353 if let Ok(file) = std::fs::File::options()
354 .append(true)
355 .open(location(cache, weak))
356 {
357 let _ = file.set_modified(std::time::SystemTime::now());
358 }
359}
360
361pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
363 let mut sets = load(cache, weak);
364 sets.retain(|kept| kept != &set);
365 sets.insert(0, set);
366 keep(cache, weak, sets)
367}
368
369pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
372 std::fs::read(location(cache, weak)).ok()
373}
374
375pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
380 let Some(fetched) = serde_json::from_slice::<Stored>(record)
381 .ok()
382 .filter(|stored| stored.version == 1 && stored.weak == weak)
383 else {
384 return Ok(Vec::new());
385 };
386 let mut sets = load(cache, weak);
387 let mut added = Vec::new();
388 for set in fetched.sets {
389 if sets.len() + added.len() < KEPT
390 && set.is_relative()
391 && !set.is_empty()
392 && !sets.contains(&set)
393 && !added.contains(&set)
394 {
395 added.push(set);
396 }
397 }
398 if !added.is_empty() {
399 sets.extend(added.iter().cloned());
400 keep(cache, weak, sets)?;
401 }
402 Ok(added)
403}
404
405fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
406 sets.truncate(KEPT);
407 std::fs::create_dir_all(cache.join("pathsets"))?;
408 crate::record::publish(
409 cache,
410 &location(cache, weak),
411 &Stored {
412 version: 1,
413 weak: weak.to_owned(),
414 sets,
415 },
416 )
417}
418
419pub(crate) fn strong_key(
421 weak: &str,
422 set: &PathSet,
423 snapshot: &Snapshot,
424 root: &Path,
425 outputs: &[&Outputs],
426) -> Result<String> {
427 key(&json!({
428 "schema": "qk-recorded-v1",
429 "weak": weak,
430 "set": set.digest()?,
431 "values": set.values(snapshot, root, outputs)?,
432 }))
433}
434
435pub(crate) fn now(directory: &Path) -> SystemTime {
440 tempfile::NamedTempFile::new_in(directory)
441 .and_then(|file| file.as_file().metadata())
442 .map(|metadata| changed(&metadata))
443 .unwrap_or_else(|_| SystemTime::now())
444}
445
446fn changed(metadata: &std::fs::Metadata) -> SystemTime {
449 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
450 #[cfg(unix)]
451 {
452 use std::os::unix::fs::MetadataExt;
453 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
454 SystemTime::UNIX_EPOCH
455 + std::time::Duration::new(
456 seconds,
457 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
458 )
459 });
460 if let Some(ctime) = ctime {
461 return modified.max(ctime);
462 }
463 }
464 modified
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use qk_input_analysis::Category;
471
472 fn access(path: &str, operation: Operation, result: &str) -> Access {
473 Access {
474 process: 1,
475 path: path.into(),
476 resolved_path: None,
477 keyed_path: None,
478 package: None,
479 operation,
480 result: result.into(),
481 category: Category::Uncovered,
482 }
483 }
484
485 #[test]
486 fn keeps_what_the_task_read_listed_and_looked_up() {
487 let temp = tempfile::tempdir().unwrap();
488 let root = temp.path().canonicalize().unwrap();
489 for directory in ["src", "icons", "dist"] {
490 std::fs::create_dir(root.join(directory)).unwrap();
491 }
492 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
493 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
494 let exclusions = Exclusions {
495 roots: [&root, &root],
496 outputs: &[&outputs],
497 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
498 state: vec![".qk".into()],
499 };
500 let accesses = [
501 access(&path("src/a.ts"), Operation::OpenRead, "success"),
502 access(&path("src"), Operation::OpenRead, "success"),
503 access(&path("icons"), Operation::ListDirectory, "success"),
504 access(&path("config.local"), Operation::Metadata, "missing"),
505 access(&path("src/b.ts"), Operation::Metadata, "success"),
506 access(&path("icons/sprite.svg"), Operation::Write, "success"),
508 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
509 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
511 access(
512 &path("node_modules/x/index.js"),
513 Operation::OpenRead,
514 "success",
515 ),
516 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
517 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
518 access(&path("dist/out.js"), Operation::OpenRead, "success"),
519 access(&path("src/debug.log"), Operation::OpenRead, "success"),
520 access(&path("src/a.ts"), Operation::Metadata, "success"),
522 ];
523 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
524 assert_eq!(
525 set,
526 PathSet {
527 reads: ["src/a.ts".into()].into(),
528 listings: ["icons".into()].into(),
529 present: ["src/b.ts".into()].into(),
530 absent: ["config.local".into()].into(),
531 written: ["icons/sprite.svg".into()].into(),
532 }
533 );
534 }
535
536 #[test]
537 fn values_change_with_contents_listings_and_existence() {
538 let temp = tempfile::tempdir().unwrap();
539 let root = temp.path().canonicalize().unwrap();
540 std::fs::create_dir_all(root.join("icons")).unwrap();
541 std::fs::write(root.join("a.txt"), "one").unwrap();
542 std::fs::write(root.join("icons/a.svg"), "").unwrap();
543 std::fs::write(root.join("nx.json"), "{}").unwrap();
544 std::fs::write(
545 root.join("project.json"),
546 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
547 )
548 .unwrap();
549 let workspace = qk_config::Workspace::load(&root).unwrap();
550 let graph = qk_taskgraph::TaskGraph::build(
551 &workspace,
552 &[qk_taskgraph::Request::parse("app:build").unwrap()],
553 )
554 .unwrap();
555 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
556 std::fs::write(root.join("checked.txt"), "one").unwrap();
557 let set = PathSet {
558 reads: ["a.txt".into()].into(),
559 listings: ["icons".into()].into(),
560 present: ["checked.txt".into()].into(),
561 absent: ["optional.txt".into()].into(),
562 written: ["icons/sprite.svg".into()].into(),
563 };
564 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
565 let strong = || strong_key("weak", &set, &snapshot, &root, &[&outputs]).unwrap();
566 let first = strong();
567 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
570 std::fs::create_dir(root.join("icons/out")).unwrap();
571 std::fs::write(root.join("b.txt"), "unread").unwrap();
572 assert_eq!(strong(), first);
573 for change in [
574 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
575 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
576 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
577 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
579 ] {
580 let before = strong();
581 change(&root);
582 assert_ne!(strong(), before);
583 }
584 }
585
586 #[test]
587 fn keeps_the_newest_sets_first() {
588 let temp = tempfile::tempdir().unwrap();
589 std::fs::create_dir(temp.path().join("tmp")).unwrap();
590 let set = |read: &str| PathSet {
591 reads: [read.to_owned()].into(),
592 ..PathSet::default()
593 };
594 for index in 0..10 {
595 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
596 }
597 save(temp.path(), "weak", set("5")).unwrap();
598 let kept: Vec<String> = load(temp.path(), "weak")
599 .into_iter()
600 .map(|set| set.reads.into_iter().next().unwrap())
601 .collect();
602 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
603 assert!(load(temp.path(), "other").is_empty());
604 }
605
606 #[test]
607 fn merges_the_sets_another_machine_recorded_behind_its_own() {
608 let temp = tempfile::tempdir().unwrap();
609 std::fs::create_dir(temp.path().join("tmp")).unwrap();
610 let set = |read: &str| PathSet {
611 reads: [read.to_owned()].into(),
612 ..PathSet::default()
613 };
614 save(temp.path(), "weak", set("a")).unwrap();
615 let record = |weak: &str, sets: Vec<PathSet>| {
616 serde_json::to_vec(&Stored {
617 version: 1,
618 weak: weak.into(),
619 sets,
620 })
621 .unwrap()
622 };
623 let fetched = record(
624 "weak",
625 vec![set("b"), set("a"), set("../escape"), set("/abs")],
626 );
627 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
628 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
629 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
630 assert!(
631 merge(temp.path(), "weak", &record("other", vec![set("c")]))
632 .unwrap()
633 .is_empty()
634 );
635 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
636 assert_eq!(
637 merge(temp.path(), "weak", &record("weak", many))
638 .unwrap()
639 .len(),
640 6
641 );
642 assert_eq!(load(temp.path(), "weak").len(), KEPT);
643 }
644
645 #[test]
646 fn finds_what_changed_while_the_task_ran() {
647 let temp = tempfile::tempdir().unwrap();
648 for path in ["read.txt", "found.txt", "listed.txt"] {
649 std::fs::write(temp.path().join(path), "before").unwrap();
650 }
651 for directory in ["listed", "written"] {
652 std::fs::create_dir(temp.path().join(directory)).unwrap();
653 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
654 }
655 let set = PathSet {
656 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
657 present: ["found.txt".to_owned(), ".".to_owned()].into(),
658 listings: ["listed".to_owned(), "written".to_owned()].into(),
659 written: ["written/new.txt".to_owned()].into(),
660 ..PathSet::default()
661 };
662 std::thread::sleep(std::time::Duration::from_millis(20));
663 let started = now(temp.path());
664 let changed = |set: &PathSet| {
665 set.changed_since(temp.path(), started, &[])
666 .map(str::to_owned)
667 };
668 assert_eq!(changed(&set), None);
669 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
671 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
672 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
673 let unlisted = PathSet {
675 listings: ["written".to_owned()].into(),
676 ..set.clone()
677 };
678 assert_eq!(changed(&unlisted), None);
679 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
680 assert_eq!(changed(&set), Some("found.txt".into()));
681 let listings = PathSet {
682 reads: [].into(),
683 present: [].into(),
684 ..set.clone()
685 };
686 assert_eq!(changed(&listings), Some("listed".into()));
687 let removed = tempfile::tempdir().unwrap();
689 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
690 std::thread::sleep(std::time::Duration::from_millis(20));
691 let started = now(temp.path());
694 let root = PathSet {
695 listings: [".".to_owned()].into(),
696 ..PathSet::default()
697 };
698 assert_eq!(root.changed_since(removed.path(), started, &[]), None);
699 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
700 assert_eq!(root.changed_since(removed.path(), started, &[]), Some("."));
701 }
702}