1use std::collections::{BTreeMap, 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, 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 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub recorded: Option<Values>,
60}
61
62impl PartialEq for PathSet {
65 fn eq(&self, other: &Self) -> bool {
66 (
67 &self.reads,
68 &self.listings,
69 &self.present,
70 &self.absent,
71 &self.written,
72 ) == (
73 &other.reads,
74 &other.listings,
75 &other.present,
76 &other.absent,
77 &other.written,
78 )
79 }
80}
81
82#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct Values {
86 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 pub reads: BTreeMap<String, String>,
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89 pub listings: BTreeMap<String, String>,
90 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
91 pub existence: BTreeMap<String, String>,
92}
93
94impl Values {
95 fn of(values: &Value) -> Result<Self> {
97 let digests = |part: &str| -> Result<BTreeMap<String, String>> {
98 values[part]
99 .as_object()
100 .into_iter()
101 .flatten()
102 .map(|(path, value)| Ok((path.clone(), key(value)?[..16].to_owned())))
103 .collect()
104 };
105 Ok(Self {
106 reads: digests("reads")?,
107 listings: digests("listings")?,
108 existence: digests("existence")?,
109 })
110 }
111}
112
113pub struct Exclusions<'a> {
115 pub roots: [&'a Path; 2],
117 pub outputs: &'a [&'a Outputs],
119 pub ignore: Vec<Pattern>,
121 pub state: Vec<String>,
123}
124
125impl Exclusions<'_> {
126 fn relative(&self, path: &str) -> Option<String> {
128 let path = qk_input_analysis::strace::normalize(Path::new(path));
129 let relative = self
130 .roots
131 .iter()
132 .find_map(|root| path.strip_prefix(root).ok())?;
133 let relative = relative.to_str()?.replace('\\', "/");
134 Some(if relative.is_empty() {
135 ".".into()
136 } else {
137 relative
138 })
139 }
140
141 fn excluded(&self, path: &str) -> bool {
142 let mut parts = path.split('/');
143 parts.clone().next() == Some(".git")
144 || parts.any(|part| part == "node_modules")
145 || self.state.iter().any(|state| {
146 path == state
147 || path
148 .strip_prefix(state.as_str())
149 .is_some_and(|rest| rest.starts_with('/'))
150 })
151 || self.outputs.iter().any(|outputs| outputs.matches(path))
152 || self.ignore.iter().any(|pattern| pattern.is_match(path))
153 }
154}
155
156impl PathSet {
157 pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
161 let mut set = Self::default();
162 let mut written = BTreeSet::new();
163 for access in accesses {
164 let Some(path) = exclusions.relative(&access.path) else {
165 continue;
166 };
167 if access.operation.writes() {
168 written.insert(path);
169 continue;
170 }
171 if written.contains(&path) || exclusions.excluded(&path) {
173 continue;
174 }
175 let found = matches!(access.result.as_str(), "success" | "unknown");
176 let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
177 match access.operation {
178 Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
179 if !root.join(&path).is_dir() {
182 set.reads.insert(path);
183 }
184 }
185 Operation::ListDirectory | Operation::ReadDirectory if found => {
186 set.listings.insert(path);
187 }
188 Operation::Metadata | Operation::ReadLink if found => {
189 set.present.insert(path);
190 }
191 _ if missing => {
192 set.absent.insert(path);
193 }
194 _ => {}
195 }
196 }
197 set.present.retain(|path| !set.reads.contains(path));
198 set.absent
199 .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
200 set.written = written
201 .into_iter()
202 .filter(|path| set.listings.contains(parent(path)))
203 .collect();
204 set
205 }
206
207 pub fn is_relative(&self) -> bool {
210 [
211 &self.reads,
212 &self.listings,
213 &self.present,
214 &self.absent,
215 &self.written,
216 ]
217 .into_iter()
218 .flatten()
219 .all(|path| {
220 !path.is_empty()
221 && Path::new(path).components().all(|component| {
222 matches!(
223 component,
224 std::path::Component::Normal(_) | std::path::Component::CurDir
225 )
226 })
227 })
228 }
229
230 pub fn is_empty(&self) -> bool {
231 self.reads.is_empty()
232 && self.listings.is_empty()
233 && self.present.is_empty()
234 && self.absent.is_empty()
235 }
236
237 pub(crate) fn changed_since(
256 &self,
257 root: &Path,
258 started: SystemTime,
259 unlisted: &Unlisted,
260 before: &Before,
261 ) -> Option<Changed<'_>> {
262 let file = self.reads.iter().chain(&self.present).find(|path| {
263 std::fs::symlink_metadata(root.join(path))
264 .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
265 });
266 if let Some(path) = file {
267 return Some(Changed { path, how: None });
268 }
269 self.listings.iter().find_map(|directory| {
270 self.listing_changed(root, directory, started, unlisted, before)
271 .map(|how| Changed {
272 path: directory,
273 how,
274 })
275 })
276 }
277
278 fn listing_changed(
281 &self,
282 root: &Path,
283 directory: &str,
284 started: SystemTime,
285 unlisted: &Unlisted,
286 before: &Before,
287 ) -> Option<Option<String>> {
288 if directory != "." && unlisted.matches(directory) {
291 return None;
292 }
293 let absolute = if directory == "." {
294 root.to_path_buf()
295 } else {
296 root.join(directory)
297 };
298 let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
299 if let Some(earlier) = before.0.get(directory) {
300 let earlier: BTreeSet<_> = earlier
301 .iter()
302 .filter(|(name, _)| !self.written.contains(&child(directory, name)))
303 .cloned()
304 .collect();
305 let Some(now) = entries(root, directory, skipped) else {
306 return Some(Some("it could no longer be read".to_owned()));
307 };
308 if let Some((name, _)) = now.difference(&earlier).next() {
309 return Some(Some(format!("{name} added")));
310 }
311 if let Some((name, _)) = earlier.difference(&now).next() {
312 return Some(Some(format!("{name} removed")));
313 }
314 return None;
315 }
316 let Ok(entries) = std::fs::read_dir(&absolute) else {
317 return Some(Some("it was removed or could no longer be read".to_owned()));
318 };
319 let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
322 .flatten()
323 .map(|entry| {
324 let path = child(directory, &entry.file_name().to_string_lossy());
325 let own = self.written.contains(&path) || unlisted.own(&path);
326 (entry, skipped(&path), own)
327 })
328 .collect();
329 let new = |entry: &std::fs::DirEntry| {
333 std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
334 if metadata.is_dir() {
335 metadata.created().is_ok_and(|born| born >= started)
336 } else {
337 changed(&metadata) >= started
338 }
339 })
340 };
341 let unskipped = || entries.iter().filter(|(_, skipped, _)| !skipped);
342 let name = |entry: &std::fs::DirEntry| entry.file_name().to_string_lossy().into_owned();
343 let own = self.written.iter().any(|path| parent(path) == directory)
344 || entries.iter().any(|(_, _, own)| *own);
345 if !own {
346 let moved = std::fs::symlink_metadata(&absolute)
347 .and_then(|metadata| metadata.modified())
348 .is_ok_and(|modified| modified >= started);
349 return moved.then(|| {
350 Some(unskipped().find(|(entry, _, _)| new(entry)).map_or_else(
351 || "an entry removed or renamed".to_owned(),
352 |(entry, _, _)| format!("{} added or changed", name(entry)),
353 ))
354 });
355 }
356 unskipped()
357 .find(|(entry, _, _)| new(entry))
358 .map(|(entry, _, _)| Some(format!("{} added or changed", name(entry))))
359 }
360
361 pub fn digest(&self) -> Result<String> {
363 let paths = Self {
364 recorded: None,
365 ..self.clone()
366 };
367 key(&serde_json::to_value(paths)?)
368 }
369
370 pub(crate) fn changed(
374 &self,
375 snapshot: &Snapshot,
376 root: &Path,
377 unlisted: &Unlisted,
378 ) -> Result<Vec<(&str, &'static str)>> {
379 let Some(recorded) = &self.recorded else {
380 return Ok(Vec::new());
381 };
382 let now = Values::of(&self.values(snapshot, root, unlisted)?)?;
383 let differs = |then: &BTreeMap<String, String>,
384 now: &BTreeMap<String, String>,
385 path: &str| { then.get(path) != now.get(path) };
386 let mut changed = Vec::new();
387 for path in &self.reads {
388 if differs(&recorded.reads, &now.reads, path) {
389 changed.push((path.as_str(), "read"));
390 }
391 }
392 for path in &self.listings {
393 if differs(&recorded.listings, &now.listings, path) {
394 changed.push((path.as_str(), "listed"));
395 }
396 }
397 for (paths, how) in [(&self.present, "found"), (&self.absent, "looked for")] {
398 for path in paths {
399 if differs(&recorded.existence, &now.existence, path) {
400 changed.push((path.as_str(), how));
401 }
402 }
403 }
404 Ok(changed)
405 }
406
407 pub(crate) fn values(
411 &self,
412 snapshot: &Snapshot,
413 root: &Path,
414 unlisted: &Unlisted,
415 ) -> Result<Value> {
416 let value = |path: &str| match kind(&root.join(path)) {
418 "file" | "symlink" => snapshot
419 .recorded_value(root, path)
420 .unwrap_or_else(|_| json!({"unreadable": true})),
421 other => json!(other),
422 };
423 let reads: Map<String, Value> = self
424 .reads
425 .iter()
426 .map(|path| (path.clone(), value(path)))
427 .collect();
428 let mut listings = Map::new();
429 for directory in &self.listings {
430 listings.insert(directory.clone(), self.listing(root, directory, unlisted)?);
431 }
432 let existence: Map<String, Value> = self
433 .present
434 .iter()
435 .chain(&self.absent)
436 .map(|path| (path.clone(), value(path)))
437 .collect();
438 Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
439 }
440
441 fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
443 let absolute = if directory == "." {
444 root.to_path_buf()
445 } else {
446 root.join(directory)
447 };
448 let entries = match std::fs::read_dir(&absolute) {
449 Ok(entries) => entries,
450 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
451 return Ok(json!("absent"));
452 }
453 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
454 return Ok(json!(kind(&absolute)));
455 }
456 Err(error) => return Err(error.into()),
457 };
458 let mut listed = Map::new();
459 for entry in entries {
460 let entry = entry?;
461 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
462 continue;
463 };
464 let path = if directory == "." {
465 name.clone()
466 } else {
467 format!("{directory}/{name}")
468 };
469 if self.written.contains(&path) || unlisted.matches(&path) {
470 continue;
471 }
472 listed.insert(name, json!(kind(&entry.path())));
473 }
474 Ok(Value::Object(listed))
475 }
476}
477
478fn parent(path: &str) -> &str {
480 path.rsplit_once('/').map_or(".", |(parent, _)| parent)
481}
482
483fn child(directory: &str, name: &str) -> String {
485 if directory == "." {
486 name.to_owned()
487 } else {
488 format!("{directory}/{name}")
489 }
490}
491
492fn entries(
495 root: &Path,
496 directory: &str,
497 skipped: impl Fn(&str) -> bool,
498) -> Option<BTreeSet<(String, &'static str)>> {
499 let absolute = if directory == "." {
500 root.to_path_buf()
501 } else {
502 root.join(directory)
503 };
504 let mut found = BTreeSet::new();
505 for entry in std::fs::read_dir(&absolute).ok()? {
506 let entry = entry.ok()?;
507 let name = entry.file_name().to_string_lossy().into_owned();
508 if !skipped(&child(directory, &name)) {
509 found.insert((name, file_kind(entry.file_type().ok()?)));
510 }
511 }
512 Some(found)
513}
514
515fn kind(path: &Path) -> &'static str {
517 std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
518}
519
520fn file_kind(file_type: std::fs::FileType) -> &'static str {
521 if file_type.is_symlink() {
522 "symlink"
523 } else if file_type.is_dir() {
524 "directory"
525 } else if file_type.is_file() {
526 "file"
527 } else {
528 "other"
529 }
530}
531
532fn relative_root(root: &str) -> &str {
536 let mut root = root.trim_end_matches('/');
537 while let Some(rest) = root.strip_prefix("./") {
538 root = rest;
539 }
540 if root == "." { "" } else { root }
541}
542
543#[derive(Debug, PartialEq, Eq)]
546pub(crate) struct Changed<'a> {
547 pub path: &'a str,
548 pub how: Option<String>,
549}
550
551impl std::fmt::Display for Changed<'_> {
552 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553 match &self.how {
554 Some(how) => write!(f, "{} changed while it ran ({how})", self.path),
555 None => write!(f, "{} changed while it ran", self.path),
556 }
557 }
558}
559
560#[derive(Default)]
566pub(crate) struct WorkspaceOutputs {
567 outputs: Vec<(TargetOf, Outputs)>,
568 by_anchor: BTreeMap<String, Vec<usize>>,
569 folders: Vec<Vec<String>>,
572 by_folder: BTreeMap<String, Vec<usize>>,
573}
574
575impl WorkspaceOutputs {
576 pub(crate) fn matches(&self, path: &str) -> bool {
579 self.outputs
580 .iter()
581 .any(|(_, outputs)| outputs.matches(path))
582 }
583}
584
585impl WorkspaceOutputs {
586 pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
588 let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
589 let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
590 let mut folders = Vec::new();
591 let projects: BTreeSet<&str> = roots
594 .values()
595 .map(|root| relative_root(root))
596 .filter(|root| !root.is_empty())
597 .flat_map(|root| {
598 std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
599 })
600 .collect();
601 for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
602 let root = roots.get(project).map_or("", |root| relative_root(root));
603 let mut held = Vec::new();
604 for anchor in declared.anchors() {
605 by_anchor.entry(anchor.to_owned()).or_default().push(index);
606 let rest = if root.is_empty() {
607 Some(anchor)
608 } else {
609 anchor
610 .strip_prefix(root)
611 .and_then(|rest| rest.strip_prefix('/'))
612 };
613 let Some(rest) = rest else {
614 continue;
615 };
616 for (at, _) in rest.match_indices('/') {
618 let folder = if root.is_empty() {
619 rest[..at].to_owned()
620 } else {
621 format!("{root}/{}", &rest[..at])
622 };
623 if projects.contains(folder.as_str()) {
624 continue;
625 }
626 by_folder.entry(folder.clone()).or_default().push(index);
627 held.push(folder);
628 }
629 }
630 folders.push(held);
631 }
632 Self {
633 outputs,
634 by_anchor,
635 folders,
636 by_folder,
637 }
638 }
639
640 fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
642 std::iter::once(path)
643 .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
644 .filter_map(|anchor| self.by_anchor.get(anchor))
645 .flatten()
646 .map(|&index| &self.outputs[index])
647 }
648}
649
650pub(crate) type TargetOf = (String, String, Option<String>);
653
654pub(crate) struct Others<'a> {
658 pub outputs: &'a WorkspaceOutputs,
659 pub upstream: BTreeSet<TargetOf>,
661}
662
663impl Others<'_> {
664 pub(crate) fn matches(&self, path: &str) -> bool {
671 let mut other = false;
672 let holding = self
673 .outputs
674 .holding(path)
675 .filter(|(_, outputs)| outputs.matches(path))
676 .map(|(target, _)| target);
677 let folders = self
678 .outputs
679 .by_folder
680 .get(path)
681 .into_iter()
682 .flatten()
683 .map(|&index| &self.outputs.outputs[index].0);
684 for target in holding.chain(folders) {
685 if self.upstream.contains(target) {
686 return false;
687 }
688 other = true;
689 }
690 other
691 }
692}
693
694pub(crate) struct Unlisted<'a> {
702 pub own: Vec<&'a Outputs>,
703 pub others: Option<Others<'a>>,
704 pub ignore: &'a [Pattern],
705}
706
707impl Unlisted<'_> {
708 fn matches(&self, path: &str) -> bool {
709 self.own(path)
710 || self
711 .others
712 .as_ref()
713 .is_some_and(|others| others.matches(path))
714 || self.ignore.iter().any(|pattern| pattern.is_match(path))
715 }
716
717 fn own(&self, path: &str) -> bool {
718 self.own.iter().any(|outputs| outputs.matches(path))
719 }
720
721 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
727 let project_root = match relative_root(project_root) {
728 "" => ".",
729 root => root,
730 };
731 let mut directories = BTreeSet::from([project_root.to_owned()]);
732 if let Some(others) = &self.others {
733 let all = &others.outputs;
734 for (index, (target, outputs)) in all.outputs.iter().enumerate() {
735 if others.upstream.contains(target) {
736 continue;
737 }
738 for anchor in outputs.anchors() {
739 directories.insert(anchor.to_owned());
740 directories.insert(parent(anchor).to_owned());
741 }
742 for folder in &all.folders[index] {
745 directories.insert(folder.clone());
746 directories.insert(parent(folder).to_owned());
747 }
748 }
749 }
750 directories
751 }
752}
753
754#[derive(Default)]
758pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
759
760impl Before {
761 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
762 Self(
763 directories
764 .into_iter()
765 .filter_map(|directory| {
766 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
767 Some((directory, entries))
768 })
769 .collect(),
770 )
771 }
772}
773
774#[derive(Serialize, Deserialize)]
776struct Stored {
777 version: u32,
778 weak: String,
779 sets: Vec<PathSet>,
780}
781
782fn location(cache: &Path, weak: &str) -> PathBuf {
783 cache.join("pathsets").join(format!("{weak}.json"))
784}
785
786pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
789 std::fs::read(location(cache, weak))
790 .ok()
791 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
792 .filter(|stored| stored.version == 1 && stored.weak == weak)
793 .map(|stored| stored.sets)
794 .unwrap_or_default()
795}
796
797pub(crate) fn touch(cache: &Path, weak: &str) {
800 if let Ok(file) = std::fs::File::options()
801 .append(true)
802 .open(location(cache, weak))
803 {
804 let _ = file.set_modified(std::time::SystemTime::now());
805 }
806}
807
808pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
810 let mut sets = load(cache, weak);
811 sets.retain(|kept| kept != &set);
812 sets.insert(0, set);
813 keep(cache, weak, sets)
814}
815
816pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
819 std::fs::read(location(cache, weak)).ok()
820}
821
822pub(crate) fn within(record: &[u8], largest: usize) -> Result<Option<Vec<u8>>> {
826 if record.len() <= largest {
827 return Ok(Some(record.to_vec()));
828 }
829 let mut stored: Stored = serde_json::from_slice(record)?;
830 while stored.sets.len() > 1 {
831 stored.sets.pop();
832 let shorter = serde_json::to_vec(&stored)?;
833 if shorter.len() <= largest {
834 return Ok(Some(shorter));
835 }
836 }
837 Ok(None)
838}
839
840pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
845 let Some(fetched) = serde_json::from_slice::<Stored>(record)
846 .ok()
847 .filter(|stored| stored.version == 1 && stored.weak == weak)
848 else {
849 return Ok(Vec::new());
850 };
851 let mut sets = load(cache, weak);
852 let mut added = Vec::new();
853 for set in fetched.sets {
854 if sets.len() + added.len() < KEPT
855 && set.is_relative()
856 && !set.is_empty()
857 && !sets.contains(&set)
858 && !added.contains(&set)
859 {
860 added.push(set);
861 }
862 }
863 if !added.is_empty() {
864 sets.extend(added.iter().cloned());
865 keep(cache, weak, sets)?;
866 }
867 Ok(added)
868}
869
870fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
871 sets.truncate(KEPT);
872 std::fs::create_dir_all(cache.join("pathsets"))?;
873 crate::record::publish(
874 cache,
875 &location(cache, weak),
876 &Stored {
877 version: 1,
878 weak: weak.to_owned(),
879 sets,
880 },
881 )
882}
883
884pub(crate) fn strong_key(
886 weak: &str,
887 set: &PathSet,
888 snapshot: &Snapshot,
889 root: &Path,
890 unlisted: &Unlisted,
891) -> Result<String> {
892 keyed(weak, set, &set.values(snapshot, root, unlisted)?)
893}
894
895fn keyed(weak: &str, set: &PathSet, values: &Value) -> Result<String> {
896 key(&json!({
897 "schema": "qk-recorded-v1",
898 "weak": weak,
899 "set": set.digest()?,
900 "values": values,
901 }))
902}
903
904pub(crate) fn with_values(
907 weak: &str,
908 mut set: PathSet,
909 snapshot: &Snapshot,
910 root: &Path,
911 unlisted: &Unlisted,
912) -> Result<(String, PathSet)> {
913 let values = set.values(snapshot, root, unlisted)?;
914 let strong = keyed(weak, &set, &values)?;
915 set.recorded = Some(Values::of(&values)?);
916 Ok((strong, set))
917}
918
919pub(crate) fn now(directory: &Path) -> SystemTime {
924 tempfile::NamedTempFile::new_in(directory)
925 .and_then(|file| file.as_file().metadata())
926 .map(|metadata| changed(&metadata))
927 .unwrap_or_else(|_| SystemTime::now())
928}
929
930fn changed(metadata: &std::fs::Metadata) -> SystemTime {
933 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
934 #[cfg(unix)]
935 {
936 use std::os::unix::fs::MetadataExt;
937 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
938 SystemTime::UNIX_EPOCH
939 + std::time::Duration::new(
940 seconds,
941 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
942 )
943 });
944 if let Some(ctime) = ctime {
945 return modified.max(ctime);
946 }
947 }
948 modified
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 const NOTHING: Unlisted<'static> = Unlisted {
957 own: Vec::new(),
958 others: None,
959 ignore: &[],
960 };
961 use qk_input_analysis::Category;
962
963 fn access(path: &str, operation: Operation, result: &str) -> Access {
964 Access {
965 process: 1,
966 path: path.into(),
967 resolved_path: None,
968 keyed_path: None,
969 package: None,
970 operation,
971 result: result.into(),
972 category: Category::Uncovered,
973 }
974 }
975
976 #[test]
977 fn keeps_what_the_task_read_listed_and_looked_up() {
978 let temp = tempfile::tempdir().unwrap();
979 let root = temp.path().canonicalize().unwrap();
980 for directory in ["src", "icons", "dist"] {
981 std::fs::create_dir(root.join(directory)).unwrap();
982 }
983 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
984 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
985 let exclusions = Exclusions {
986 roots: [&root, &root],
987 outputs: &[&outputs],
988 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
989 state: vec![".qk".into()],
990 };
991 let accesses = [
992 access(&path("src/a.ts"), Operation::OpenRead, "success"),
993 access(&path("src"), Operation::OpenRead, "success"),
994 access(&path("icons"), Operation::ListDirectory, "success"),
995 access(&path("config.local"), Operation::Metadata, "missing"),
996 access(&path("src/b.ts"), Operation::Metadata, "success"),
997 access(&path("icons/sprite.svg"), Operation::Write, "success"),
999 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
1000 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
1002 access(
1003 &path("node_modules/x/index.js"),
1004 Operation::OpenRead,
1005 "success",
1006 ),
1007 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
1008 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
1009 access(&path("dist/out.js"), Operation::OpenRead, "success"),
1010 access(&path("src/debug.log"), Operation::OpenRead, "success"),
1011 access(&path("src/a.ts"), Operation::Metadata, "success"),
1013 ];
1014 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
1015 assert_eq!(
1016 set,
1017 PathSet {
1018 reads: ["src/a.ts".into()].into(),
1019 listings: ["icons".into()].into(),
1020 present: ["src/b.ts".into()].into(),
1021 absent: ["config.local".into()].into(),
1022 written: ["icons/sprite.svg".into()].into(),
1023 recorded: None,
1024 }
1025 );
1026 }
1027
1028 #[test]
1029 fn values_change_with_contents_listings_and_existence() {
1030 let temp = tempfile::tempdir().unwrap();
1031 let root = temp.path().canonicalize().unwrap();
1032 std::fs::create_dir_all(root.join("icons")).unwrap();
1033 std::fs::write(root.join("a.txt"), "one").unwrap();
1034 std::fs::write(root.join("icons/a.svg"), "").unwrap();
1035 std::fs::write(root.join("nx.json"), "{}").unwrap();
1036 std::fs::write(
1037 root.join("project.json"),
1038 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1039 )
1040 .unwrap();
1041 let workspace = qk_config::Workspace::load(&root).unwrap();
1042 let graph = qk_taskgraph::TaskGraph::build(
1043 &workspace,
1044 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1045 )
1046 .unwrap();
1047 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1048 std::fs::write(root.join("checked.txt"), "one").unwrap();
1049 let set = PathSet {
1050 reads: ["a.txt".into()].into(),
1051 listings: ["icons".into()].into(),
1052 present: ["checked.txt".into()].into(),
1053 absent: ["optional.txt".into()].into(),
1054 written: ["icons/sprite.svg".into()].into(),
1055 recorded: None,
1056 };
1057 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
1058 let unlisted = Unlisted {
1059 own: vec![&outputs],
1060 others: None,
1061 ignore: &[],
1062 };
1063 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1064 let first = strong();
1065 let (kept_key, kept) =
1068 with_values("weak", set.clone(), &snapshot, &root, &unlisted).unwrap();
1069 assert_eq!(kept_key, first);
1070 assert_eq!(kept, set);
1071 assert_eq!(kept.digest().unwrap(), set.digest().unwrap());
1072 assert!(
1073 kept.changed(&snapshot, &root, &unlisted)
1074 .unwrap()
1075 .is_empty()
1076 );
1077 std::fs::write(root.join("a.txt"), "changed").unwrap();
1078 std::fs::write(root.join("optional.txt"), "").unwrap();
1079 assert_eq!(
1080 kept.changed(&snapshot, &root, &unlisted).unwrap(),
1081 [("a.txt", "read"), ("optional.txt", "looked for")]
1082 );
1083 assert!(set.changed(&snapshot, &root, &unlisted).unwrap().is_empty());
1084 std::fs::write(root.join("a.txt"), "one").unwrap();
1085 std::fs::remove_file(root.join("optional.txt")).unwrap();
1086 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
1089 std::fs::create_dir(root.join("icons/out")).unwrap();
1090 std::fs::write(root.join("b.txt"), "unread").unwrap();
1091 assert_eq!(strong(), first);
1092 for change in [
1093 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
1094 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
1095 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
1096 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
1098 ] {
1099 let before = strong();
1100 change(&root);
1101 assert_ne!(strong(), before);
1102 }
1103 }
1104
1105 #[test]
1106 fn keeps_the_newest_sets_first() {
1107 let temp = tempfile::tempdir().unwrap();
1108 std::fs::create_dir(temp.path().join("tmp")).unwrap();
1109 let set = |read: &str| PathSet {
1110 reads: [read.to_owned()].into(),
1111 ..PathSet::default()
1112 };
1113 for index in 0..10 {
1114 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
1115 }
1116 save(temp.path(), "weak", set("5")).unwrap();
1117 let kept: Vec<String> = load(temp.path(), "weak")
1118 .into_iter()
1119 .map(|set| set.reads.into_iter().next().unwrap())
1120 .collect();
1121 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
1122 assert!(load(temp.path(), "other").is_empty());
1123 }
1124
1125 #[test]
1126 fn merges_the_sets_another_machine_recorded_behind_its_own() {
1127 let temp = tempfile::tempdir().unwrap();
1128 std::fs::create_dir(temp.path().join("tmp")).unwrap();
1129 let set = |read: &str| PathSet {
1130 reads: [read.to_owned()].into(),
1131 ..PathSet::default()
1132 };
1133 save(temp.path(), "weak", set("a")).unwrap();
1134 let record = |weak: &str, sets: Vec<PathSet>| {
1135 serde_json::to_vec(&Stored {
1136 version: 1,
1137 weak: weak.into(),
1138 sets,
1139 })
1140 .unwrap()
1141 };
1142 let fetched = record(
1143 "weak",
1144 vec![set("b"), set("a"), set("../escape"), set("/abs")],
1145 );
1146 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
1147 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
1148 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
1149 assert!(
1150 merge(temp.path(), "weak", &record("other", vec![set("c")]))
1151 .unwrap()
1152 .is_empty()
1153 );
1154 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
1155 assert_eq!(
1156 merge(temp.path(), "weak", &record("weak", many))
1157 .unwrap()
1158 .len(),
1159 6
1160 );
1161 assert_eq!(load(temp.path(), "weak").len(), KEPT);
1162 }
1163
1164 #[test]
1165 fn a_record_too_large_leaves_out_its_oldest_sets() {
1166 let set = |read: &str| PathSet {
1167 reads: [read.to_owned()].into(),
1168 ..PathSet::default()
1169 };
1170 let record = |sets: Vec<PathSet>| {
1171 serde_json::to_vec(&Stored {
1172 version: 1,
1173 weak: "weak".into(),
1174 sets,
1175 })
1176 .unwrap()
1177 };
1178 let newest = "n".repeat(100);
1179 let full = record(vec![
1180 set(&newest),
1181 set(&"o".repeat(100)),
1182 set(&"p".repeat(100)),
1183 ]);
1184 assert_eq!(within(&full, full.len()).unwrap(), Some(full.clone()));
1185 let one = record(vec![set(&newest)]);
1186 assert_eq!(within(&full, one.len() + 50).unwrap(), Some(one.clone()));
1187 assert_eq!(within(&full, one.len() - 1).unwrap(), None);
1188 }
1189
1190 #[test]
1191 fn finds_what_changed_while_the_task_ran() {
1192 let temp = tempfile::tempdir().unwrap();
1193 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1194 std::fs::write(temp.path().join(path), "before").unwrap();
1195 }
1196 for directory in ["listed", "written"] {
1197 std::fs::create_dir(temp.path().join(directory)).unwrap();
1198 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1199 }
1200 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1201 let set = PathSet {
1202 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1203 present: ["found.txt".to_owned(), ".".to_owned()].into(),
1204 listings: ["listed".to_owned(), "written".to_owned()].into(),
1205 written: ["written/new.txt".to_owned()].into(),
1206 ..PathSet::default()
1207 };
1208 std::thread::sleep(std::time::Duration::from_millis(20));
1209 let started = now(temp.path());
1210 let changed = |set: &PathSet| {
1211 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1212 .map(|changed| changed.path.to_owned())
1213 };
1214 assert_eq!(changed(&set), None);
1215 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1217 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1218 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1219 let unlisted = PathSet {
1221 listings: ["written".to_owned()].into(),
1222 ..set.clone()
1223 };
1224 assert_eq!(changed(&unlisted), None);
1225 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1226 assert_eq!(changed(&set), Some("found.txt".into()));
1227 let listings = PathSet {
1228 reads: [].into(),
1229 present: [].into(),
1230 ..set.clone()
1231 };
1232 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1235 assert_eq!(changed(&listings), None);
1236 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1238 if std::fs::metadata(temp.path().join("written/other.txt"))
1239 .and_then(|metadata| metadata.created())
1240 .is_ok()
1241 {
1242 assert_eq!(changed(&listings), Some("written".into()));
1243 }
1244 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1245 #[cfg(unix)]
1248 {
1249 std::fs::rename(
1250 temp.path().join("before.txt"),
1251 temp.path().join("written/moved.txt"),
1252 )
1253 .unwrap();
1254 assert_eq!(changed(&listings), Some("written".into()));
1255 std::fs::rename(
1256 temp.path().join("written/moved.txt"),
1257 temp.path().join("before.txt"),
1258 )
1259 .unwrap();
1260 }
1261 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1263 assert_eq!(changed(&listings), Some("listed".into()));
1264 let removed = tempfile::tempdir().unwrap();
1266 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1267 std::thread::sleep(std::time::Duration::from_millis(20));
1268 let started = now(temp.path());
1271 let root = PathSet {
1272 listings: [".".to_owned()].into(),
1273 ..PathSet::default()
1274 };
1275 assert_eq!(
1276 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1277 .map(|changed| changed.path),
1278 None
1279 );
1280 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1281 assert_eq!(
1282 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1283 .map(|changed| changed.path),
1284 Some(".")
1285 );
1286 }
1287
1288 #[test]
1289 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1290 let temp = tempfile::tempdir().unwrap();
1291 let root = &temp.path().canonicalize().unwrap();
1292 std::fs::create_dir_all(root.join("app/src")).unwrap();
1293 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1294 std::fs::write(root.join("nx.json"), "{}").unwrap();
1295 std::fs::write(
1296 root.join("project.json"),
1297 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1298 )
1299 .unwrap();
1300 let workspace = qk_config::Workspace::load(root).unwrap();
1301 let graph = qk_taskgraph::TaskGraph::build(
1302 &workspace,
1303 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1304 )
1305 .unwrap();
1306 let set = PathSet {
1307 listings: ["app".to_owned()].into(),
1308 ..PathSet::default()
1309 };
1310 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1311 std::fs::create_dir(root.join("app/out")).unwrap();
1312 let roots = [("app".to_owned(), "app".to_owned())].into();
1313 let outputs = WorkspaceOutputs::new(
1314 vec![
1315 (
1316 ("app".into(), "tsc".into(), None),
1317 Outputs::from_patterns(&["app/.tsc"]),
1318 ),
1319 (
1320 ("app".into(), "codegen".into(), None),
1321 Outputs::from_patterns(&["app/generated"]),
1322 ),
1323 (
1325 ("app".into(), "bundle".into(), None),
1326 Outputs::from_patterns(&["app/out/*.js"]),
1327 ),
1328 (
1330 ("app".into(), "package".into(), None),
1331 Outputs::from_patterns(&["app/generated"]),
1332 ),
1333 (
1335 ("app".into(), "deploy".into(), None),
1336 Outputs::from_patterns(&["app/.cache/deploy"]),
1337 ),
1338 ],
1339 &roots,
1340 );
1341 let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1342 let unlisted = Unlisted {
1343 own: Vec::new(),
1344 others: Some(Others {
1345 outputs: &outputs,
1346 upstream: [
1348 ("app".into(), "build".into(), None),
1349 ("app".into(), "codegen".into(), None),
1350 ]
1351 .into(),
1352 }),
1353 ignore: &ignore,
1354 };
1355 assert_eq!(
1356 unlisted.directories("app"),
1357 [
1358 "app".to_owned(),
1359 "app/.cache".to_owned(),
1360 "app/.cache/deploy".to_owned(),
1361 "app/.tsc".to_owned(),
1362 "app/generated".to_owned(),
1363 "app/out".to_owned()
1364 ]
1365 .into()
1366 );
1367 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1368 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1369 let before = key();
1370 std::thread::sleep(std::time::Duration::from_millis(20));
1371 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1372 let started = now(root);
1373 let changed = |taken: &Before| {
1374 set.changed_since(root, started, &unlisted, taken)
1375 .map(|changed| changed.path)
1376 };
1377 std::fs::create_dir(root.join("app/.tsc")).unwrap();
1380 std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
1381 std::fs::create_dir(root.join("app/.fallow")).unwrap();
1382 assert_eq!(key(), before);
1383 assert_eq!(changed(&taken), None);
1384 std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
1387 let output = PathSet {
1388 listings: ["app/.tsc".to_owned()].into(),
1389 ..PathSet::default()
1390 };
1391 assert_eq!(
1392 output.changed_since(root, started, &unlisted, &Before::default()),
1393 None
1394 );
1395 assert!(!unlisted.matches("app"));
1397 std::fs::create_dir(root.join("app/gone")).unwrap();
1399 let gone = PathSet {
1400 listings: ["app/gone".to_owned()].into(),
1401 ..PathSet::default()
1402 };
1403 std::fs::remove_dir(root.join("app/gone")).unwrap();
1404 assert_eq!(
1405 gone.changed_since(root, started, &unlisted, &Before::default())
1406 .map(|changed| changed.path),
1407 Some("app/gone")
1408 );
1409 std::fs::write(root.join("app/out/new.js"), "").unwrap();
1411 let out = PathSet {
1412 listings: ["app/out".to_owned()].into(),
1413 ..PathSet::default()
1414 };
1415 assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1416 assert_eq!(
1417 out.changed_since(root, started, &unlisted, &Before::default())
1418 .map(|changed| changed.path),
1419 Some("app/out")
1420 );
1421 assert!(!unlisted.matches("app/generated"));
1423 assert!(unlisted.matches("app/.tsc/index.js"));
1424 assert_eq!(changed(&Before::default()), Some("app"));
1427 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1429 assert_ne!(key(), before);
1430 assert_eq!(
1431 set.changed_since(root, started, &unlisted, &taken)
1432 .and_then(|changed| changed.how),
1433 Some("kept.txt removed".to_owned())
1434 );
1435 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1436 assert_eq!(changed(&taken), None);
1437 std::fs::write(root.join("app/README.md"), "").unwrap();
1439 assert_ne!(key(), before);
1440 assert_eq!(
1441 set.changed_since(root, started, &unlisted, &taken)
1442 .map(|changed| changed.to_string()),
1443 Some("app changed while it ran (README.md added)".to_owned())
1444 );
1445 }
1446}