1use std::collections::{BTreeMap, BTreeSet};
18use std::path::{Path, PathBuf};
19use std::time::SystemTime;
20
21use anyhow::Result;
22use qk_input_analysis::{Access, Operation};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map, Value, json};
25
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: crate::ultracache::IgnoredReads,
121 pub state: Vec<String>,
123 pub produced: &'a Produced,
125}
126
127#[derive(Default)]
135pub struct Produced {
136 pub upstream: Vec<Outputs>,
138 pub roots: Vec<String>,
140 pub declared: Vec<Vec<String>>,
142}
143
144impl Produced {
145 pub fn new(upstream: Vec<Outputs>, include_ignored: Vec<String>) -> Self {
149 let mut roots: Vec<String> = upstream
150 .iter()
151 .flat_map(|outputs| outputs.anchors().map(str::to_owned))
152 .chain(include_ignored)
153 .filter(|root| in_node_modules(root))
154 .collect();
155 roots.sort_unstable();
156 roots.dedup();
157 Self {
158 upstream,
159 roots,
160 declared: Vec::new(),
161 }
162 }
163
164 pub fn declared(mut self, mut declared: Vec<Vec<String>>) -> Self {
166 declared.sort_unstable();
167 self.declared = declared;
168 self
169 }
170
171 pub fn holds(&self, path: &str) -> bool {
174 self.roots.iter().any(|root| {
175 path == root || under(path, root) || (under(root, path) && inside_node_modules(path))
176 }) || self.upstream.iter().any(|outputs| outputs.matches(path))
177 }
178
179 pub fn lists(&self, path: &str) -> bool {
186 self.holds(path)
187 || self.roots.iter().any(|root| under(root, path))
188 || self
189 .upstream
190 .iter()
191 .flat_map(Outputs::anchors)
192 .any(|anchor| under(path, anchor))
193 }
194
195 pub fn traced(&self, path: &str, operation: &Operation) -> bool {
201 let listed = matches!(
202 operation,
203 Operation::ListDirectory | Operation::ReadDirectory
204 ) || operation.writes();
205 self.holds(path) || listed && self.lists(path)
206 }
207
208 pub fn weak(&self) -> Option<Value> {
212 (!self.roots.is_empty() || !self.declared.is_empty())
213 .then(|| json!({"roots": self.roots, "upstream": self.declared}))
214 }
215}
216
217pub fn in_node_modules(path: &str) -> bool {
219 path.split('/').any(|part| part == "node_modules")
220}
221
222fn inside_node_modules(path: &str) -> bool {
225 path.rsplit_once('/')
226 .is_some_and(|(directory, _)| in_node_modules(directory))
227}
228
229impl Exclusions<'_> {
230 fn relative(&self, path: &str) -> Option<String> {
232 let path = qk_input_analysis::strace::normalize(Path::new(path));
233 let relative = self
234 .roots
235 .iter()
236 .find_map(|root| path.strip_prefix(root).ok())?;
237 let relative = relative.to_str()?.replace('\\', "/");
238 Some(if relative.is_empty() {
239 ".".into()
240 } else {
241 relative
242 })
243 }
244
245 fn excluded(&self, path: &str) -> bool {
246 self.excluded_unless(path, |path| self.produced.holds(path))
247 }
248
249 fn excluded_listing(&self, path: &str) -> bool {
252 self.excluded_unless(path, |path| self.produced.lists(path))
253 }
254
255 fn excluded_unless(&self, path: &str, produced: impl Fn(&str) -> bool) -> bool {
256 path.split('/').next() == Some(".git")
257 || in_node_modules(path) && !produced(path)
258 || self.state.iter().any(|state| {
259 path == state
260 || path
261 .strip_prefix(state.as_str())
262 .is_some_and(|rest| rest.starts_with('/'))
263 })
264 || self.outputs.iter().any(|outputs| outputs.matches(path))
265 || self.ignore.is_match(path)
266 }
267}
268
269impl PathSet {
270 pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
274 let mut set = Self::default();
275 let mut written = BTreeSet::new();
276 for access in accesses {
277 let Some(path) = exclusions.relative(&access.path) else {
278 continue;
279 };
280 if access.operation.writes() {
281 written.insert(path);
282 continue;
283 }
284 let listing = matches!(
285 access.operation,
286 Operation::ListDirectory | Operation::ReadDirectory
287 );
288 if written.contains(&path)
290 || if listing {
291 exclusions.excluded_listing(&path)
292 } else {
293 exclusions.excluded(&path)
294 }
295 {
296 continue;
297 }
298 let found = matches!(access.result.as_str(), "success" | "unknown");
299 let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
300 match access.operation {
301 Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
302 if !root.join(&path).is_dir() {
305 set.reads.insert(path);
306 }
307 }
308 Operation::ListDirectory | Operation::ReadDirectory if found => {
309 set.listings.insert(path);
310 }
311 Operation::Metadata | Operation::ReadLink if found => {
312 set.present.insert(path);
313 }
314 _ if missing => {
315 set.absent.insert(path);
316 }
317 _ => {}
318 }
319 }
320 set.present.retain(|path| !set.reads.contains(path));
321 set.absent
322 .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
323 set.written = written
324 .into_iter()
325 .filter(|path| set.listings.contains(parent(path)))
326 .collect();
327 set
328 }
329
330 pub fn is_relative(&self) -> bool {
333 [
334 &self.reads,
335 &self.listings,
336 &self.present,
337 &self.absent,
338 &self.written,
339 ]
340 .into_iter()
341 .flatten()
342 .all(|path| {
343 !path.is_empty()
344 && Path::new(path).components().all(|component| {
345 matches!(
346 component,
347 std::path::Component::Normal(_) | std::path::Component::CurDir
348 )
349 })
350 })
351 }
352
353 pub fn is_empty(&self) -> bool {
354 self.reads.is_empty()
355 && self.listings.is_empty()
356 && self.present.is_empty()
357 && self.absent.is_empty()
358 }
359
360 pub(crate) fn changed_since(
379 &self,
380 root: &Path,
381 started: SystemTime,
382 unlisted: &Unlisted,
383 before: &Before,
384 ) -> Option<Changed<'_>> {
385 let file = self.reads.iter().chain(&self.present).find(|path| {
386 std::fs::symlink_metadata(root.join(path))
387 .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
388 });
389 if let Some(path) = file {
390 return Some(Changed { path, how: None });
391 }
392 self.listings.iter().find_map(|directory| {
393 self.listing_changed(root, directory, started, unlisted, before)
394 .map(|how| Changed {
395 path: directory,
396 how,
397 })
398 })
399 }
400
401 fn listing_changed(
404 &self,
405 root: &Path,
406 directory: &str,
407 started: SystemTime,
408 unlisted: &Unlisted,
409 before: &Before,
410 ) -> Option<Option<String>> {
411 if directory != "." && unlisted.matches(directory) && !unlisted.own_folder(directory) {
415 return None;
416 }
417 let absolute = if directory == "." {
418 root.to_path_buf()
419 } else {
420 root.join(directory)
421 };
422 let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
423 if let Some(earlier) = before.0.get(directory) {
424 let earlier: BTreeSet<_> = earlier
425 .iter()
426 .filter(|(name, _)| !self.written.contains(&child(directory, name)))
427 .cloned()
428 .collect();
429 let Some(now) = entries(root, directory, skipped) else {
430 return Some(Some("it could no longer be read".to_owned()));
431 };
432 if let Some((name, _)) = now.difference(&earlier).next() {
433 return Some(Some(format!("{name} added")));
434 }
435 if let Some((name, _)) = earlier.difference(&now).next() {
436 return Some(Some(format!("{name} removed")));
437 }
438 return None;
439 }
440 let Ok(entries) = std::fs::read_dir(&absolute) else {
441 return Some(Some("it was removed or could no longer be read".to_owned()));
442 };
443 let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
446 .flatten()
447 .map(|entry| {
448 let path = child(directory, &entry.file_name().to_string_lossy());
449 let own = self.written.contains(&path) || unlisted.own(&path);
450 (entry, skipped(&path), own)
451 })
452 .collect();
453 let new = |entry: &std::fs::DirEntry| {
457 std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
458 if metadata.is_dir() {
459 metadata.created().is_ok_and(|born| born >= started)
460 } else {
461 changed(&metadata) >= started
462 }
463 })
464 };
465 let unskipped = || entries.iter().filter(|(_, skipped, _)| !skipped);
466 let name = |entry: &std::fs::DirEntry| entry.file_name().to_string_lossy().into_owned();
467 let own = self.written.iter().any(|path| parent(path) == directory)
468 || entries.iter().any(|(_, _, own)| *own);
469 if !own {
470 let moved = std::fs::symlink_metadata(&absolute)
471 .and_then(|metadata| metadata.modified())
472 .is_ok_and(|modified| modified >= started);
473 return moved.then(|| {
474 Some(unskipped().find(|(entry, _, _)| new(entry)).map_or_else(
475 || "an entry removed or renamed".to_owned(),
476 |(entry, _, _)| format!("{} added or changed", name(entry)),
477 ))
478 });
479 }
480 unskipped()
481 .find(|(entry, _, _)| new(entry))
482 .map(|(entry, _, _)| Some(format!("{} added or changed", name(entry))))
483 }
484
485 pub fn digest(&self) -> Result<String> {
487 let paths = Self {
488 recorded: None,
489 ..self.clone()
490 };
491 key(&serde_json::to_value(paths)?)
492 }
493
494 pub(crate) fn changed(
498 &self,
499 snapshot: &Snapshot,
500 root: &Path,
501 unlisted: &Unlisted,
502 ) -> Result<Vec<(&str, &'static str)>> {
503 let Some(recorded) = &self.recorded else {
504 return Ok(Vec::new());
505 };
506 let now = Values::of(&self.values(snapshot, root, unlisted)?)?;
507 let differs = |then: &BTreeMap<String, String>,
508 now: &BTreeMap<String, String>,
509 path: &str| { then.get(path) != now.get(path) };
510 let mut changed = Vec::new();
511 for path in &self.reads {
512 if differs(&recorded.reads, &now.reads, path) {
513 changed.push((path.as_str(), "read"));
514 }
515 }
516 for path in &self.listings {
517 if differs(&recorded.listings, &now.listings, path) {
518 changed.push((path.as_str(), "listed"));
519 }
520 }
521 for (paths, how) in [(&self.present, "found"), (&self.absent, "looked for")] {
522 for path in paths {
523 if differs(&recorded.existence, &now.existence, path) {
524 changed.push((path.as_str(), how));
525 }
526 }
527 }
528 Ok(changed)
529 }
530
531 pub(crate) fn unkeyable(&self, snapshot: &Snapshot, root: &Path) -> Option<String> {
538 self.reads
539 .iter()
540 .chain(&self.present)
541 .chain(&self.absent)
542 .find_map(|path| match kind(&root.join(path)) {
543 "file" | "symlink" => snapshot
544 .recorded_value(root, path)
545 .err()
546 .map(|error| format!("{path} cannot be keyed by its content ({error:#})")),
547 _ => None,
548 })
549 }
550
551 pub(crate) fn values(
552 &self,
553 snapshot: &Snapshot,
554 root: &Path,
555 unlisted: &Unlisted,
556 ) -> Result<Value> {
557 let value = |path: &str| match kind(&root.join(path)) {
559 "file" | "symlink" => snapshot
560 .recorded_value(root, path)
561 .unwrap_or_else(|_| json!({"unreadable": true})),
562 other => json!(other),
563 };
564 let reads: Map<String, Value> = self
565 .reads
566 .iter()
567 .map(|path| (path.clone(), value(path)))
568 .collect();
569 let absent = json!("absent");
573 let mut listings = Map::new();
574 for directory in &self.listings {
575 let listing = match self.listing(root, directory, unlisted)? {
576 listing if listing == absent && unlisted.own_folder(directory) => json!({}),
577 listing => listing,
578 };
579 listings.insert(directory.clone(), listing);
580 }
581 let existence: Map<String, Value> = self
582 .present
583 .iter()
584 .chain(&self.absent)
585 .map(|path| {
586 let value = match value(path) {
587 value
588 if unlisted.own_folder(path)
589 && (value == absent || value == json!("directory")) =>
590 {
591 json!("folder")
592 }
593 value => value,
594 };
595 (path.clone(), value)
596 })
597 .collect();
598 Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
599 }
600
601 fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
603 let absolute = if directory == "." {
604 root.to_path_buf()
605 } else {
606 root.join(directory)
607 };
608 let entries = match std::fs::read_dir(&absolute) {
609 Ok(entries) => entries,
610 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
611 return Ok(json!("absent"));
612 }
613 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
614 return Ok(json!(kind(&absolute)));
615 }
616 Err(error) => return Err(error.into()),
617 };
618 let mut listed = Map::new();
619 for entry in entries {
620 let entry = entry?;
621 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
622 continue;
623 };
624 let path = if directory == "." {
625 name.clone()
626 } else {
627 format!("{directory}/{name}")
628 };
629 if self.written.contains(&path) || unlisted.matches(&path) {
630 continue;
631 }
632 listed.insert(name, json!(kind(&entry.path())));
633 }
634 Ok(Value::Object(listed))
635 }
636}
637
638fn parent(path: &str) -> &str {
640 path.rsplit_once('/').map_or(".", |(parent, _)| parent)
641}
642
643fn under(path: &str, directory: &str) -> bool {
645 path.strip_prefix(directory)
646 .is_some_and(|rest| rest.starts_with('/'))
647}
648
649fn child(directory: &str, name: &str) -> String {
651 if directory == "." {
652 name.to_owned()
653 } else {
654 format!("{directory}/{name}")
655 }
656}
657
658fn entries(
661 root: &Path,
662 directory: &str,
663 skipped: impl Fn(&str) -> bool,
664) -> Option<BTreeSet<(String, &'static str)>> {
665 let absolute = if directory == "." {
666 root.to_path_buf()
667 } else {
668 root.join(directory)
669 };
670 let mut found = BTreeSet::new();
671 for entry in std::fs::read_dir(&absolute).ok()? {
672 let entry = entry.ok()?;
673 let name = entry.file_name().to_string_lossy().into_owned();
674 if !skipped(&child(directory, &name)) {
675 found.insert((name, file_kind(entry.file_type().ok()?)));
676 }
677 }
678 Some(found)
679}
680
681fn kind(path: &Path) -> &'static str {
683 std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
684}
685
686fn file_kind(file_type: std::fs::FileType) -> &'static str {
687 if file_type.is_symlink() {
688 "symlink"
689 } else if file_type.is_dir() {
690 "directory"
691 } else if file_type.is_file() {
692 "file"
693 } else {
694 "other"
695 }
696}
697
698fn relative_root(root: &str) -> &str {
702 let mut root = root.trim_end_matches('/');
703 while let Some(rest) = root.strip_prefix("./") {
704 root = rest;
705 }
706 if root == "." { "" } else { root }
707}
708
709#[derive(Debug, PartialEq, Eq)]
712pub(crate) struct Changed<'a> {
713 pub path: &'a str,
714 pub how: Option<String>,
715}
716
717impl std::fmt::Display for Changed<'_> {
718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719 match &self.how {
720 Some(how) => write!(f, "{} changed while it ran ({how})", self.path),
721 None => write!(f, "{} changed while it ran", self.path),
722 }
723 }
724}
725
726#[derive(Default)]
732pub(crate) struct WorkspaceOutputs {
733 outputs: Vec<(TargetOf, Outputs)>,
734 by_anchor: BTreeMap<String, Vec<usize>>,
735 folders: Vec<Vec<String>>,
738 by_folder: BTreeMap<String, Vec<usize>>,
739 projects: BTreeSet<String>,
741}
742
743impl WorkspaceOutputs {
744 pub(crate) fn matches(&self, path: &str) -> bool {
747 self.outputs
748 .iter()
749 .any(|(_, outputs)| outputs.matches(path))
750 }
751}
752
753impl WorkspaceOutputs {
754 pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
756 let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
757 let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
758 let mut folders = Vec::new();
759 let projects: BTreeSet<&str> = roots
762 .values()
763 .map(|root| relative_root(root))
764 .filter(|root| !root.is_empty())
765 .flat_map(|root| {
766 std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
767 })
768 .collect();
769 for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
770 let root = roots.get(project).map_or("", |root| relative_root(root));
771 let mut held = Vec::new();
772 for anchor in declared.anchors() {
773 by_anchor.entry(anchor.to_owned()).or_default().push(index);
774 let rest = if root.is_empty() {
775 Some(anchor)
776 } else {
777 anchor
778 .strip_prefix(root)
779 .and_then(|rest| rest.strip_prefix('/'))
780 };
781 let Some(rest) = rest else {
782 continue;
783 };
784 for (at, _) in rest.match_indices('/') {
786 let folder = if root.is_empty() {
787 rest[..at].to_owned()
788 } else {
789 format!("{root}/{}", &rest[..at])
790 };
791 if projects.contains(folder.as_str()) {
792 continue;
793 }
794 by_folder.entry(folder.clone()).or_default().push(index);
795 held.push(folder);
796 }
797 }
798 folders.push(held);
799 }
800 let projects = projects.into_iter().map(str::to_owned).collect();
801 Self {
802 outputs,
803 by_anchor,
804 folders,
805 by_folder,
806 projects,
807 }
808 }
809
810 fn own_folders(&self, own: &[&Outputs], upstream: &BTreeSet<TargetOf>) -> BTreeSet<String> {
816 let inputs: Vec<&str> = self
817 .outputs
818 .iter()
819 .filter(|(target, _)| upstream.contains(target))
820 .flat_map(|(_, outputs)| outputs.anchors())
821 .collect();
822 let related = |a: &str, b: &str| a == b || under(a, b) || under(b, a);
823 own.iter()
824 .flat_map(|outputs| outputs.anchors())
825 .flat_map(|anchor| anchor.match_indices('/').map(move |(at, _)| &anchor[..at]))
826 .filter(|folder| !self.projects.contains(*folder))
827 .filter(|folder| !inputs.iter().any(|input| related(input, folder)))
828 .map(str::to_owned)
829 .collect()
830 }
831
832 fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
834 std::iter::once(path)
835 .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
836 .filter_map(|anchor| self.by_anchor.get(anchor))
837 .flatten()
838 .map(|&index| &self.outputs[index])
839 }
840}
841
842pub(crate) type TargetOf = (String, String, Option<String>);
845
846pub(crate) struct Others<'a> {
850 pub outputs: &'a WorkspaceOutputs,
851 pub upstream: BTreeSet<TargetOf>,
853 pub own_folders: BTreeSet<String>,
857}
858
859impl<'a> Others<'a> {
860 pub(crate) fn new(
862 outputs: &'a WorkspaceOutputs,
863 upstream: BTreeSet<TargetOf>,
864 own: &[&Outputs],
865 ) -> Self {
866 let own_folders = outputs.own_folders(own, &upstream);
867 Self {
868 outputs,
869 upstream,
870 own_folders,
871 }
872 }
873
874 pub(crate) fn own_folder(&self, path: &str) -> bool {
876 self.own_folders.contains(path)
877 }
878}
879
880impl Others<'_> {
881 pub(crate) fn matches(&self, path: &str) -> bool {
888 let mut other = false;
889 let holding = self
890 .outputs
891 .holding(path)
892 .filter(|(_, outputs)| outputs.matches(path))
893 .map(|(target, _)| target);
894 let folders = self
895 .outputs
896 .by_folder
897 .get(path)
898 .into_iter()
899 .flatten()
900 .map(|&index| &self.outputs.outputs[index].0);
901 for target in holding.chain(folders) {
902 if self.upstream.contains(target) {
903 return false;
904 }
905 other = true;
906 }
907 other
908 }
909}
910
911pub(crate) struct Unlisted<'a> {
920 pub own: Vec<&'a Outputs>,
921 pub others: Option<Others<'a>>,
922 pub ignore: &'a crate::ultracache::IgnoredReads,
923 pub produced: Option<&'a Produced>,
925}
926
927impl Unlisted<'_> {
928 fn matches(&self, path: &str) -> bool {
929 self.own(path)
930 || self
931 .others
932 .as_ref()
933 .is_some_and(|others| others.matches(path))
934 || self.ignore.is_match(path)
935 || inside_node_modules(path)
936 && self.produced.is_some_and(|produced| !produced.lists(path))
937 }
938
939 fn own(&self, path: &str) -> bool {
942 self.own.iter().any(|outputs| outputs.matches(path)) || self.own_folder(path)
943 }
944
945 fn own_folder(&self, path: &str) -> bool {
947 self.others
948 .as_ref()
949 .is_some_and(|others| others.own_folder(path))
950 }
951
952 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
958 let project_root = match relative_root(project_root) {
959 "" => ".",
960 root => root,
961 };
962 let mut directories = BTreeSet::from([project_root.to_owned()]);
963 if let Some(others) = &self.others {
964 let all = &others.outputs;
965 for (index, (target, outputs)) in all.outputs.iter().enumerate() {
966 if others.upstream.contains(target) {
967 continue;
968 }
969 for anchor in outputs.anchors() {
970 directories.insert(anchor.to_owned());
971 directories.insert(parent(anchor).to_owned());
972 }
973 for folder in &all.folders[index] {
976 directories.insert(folder.clone());
977 directories.insert(parent(folder).to_owned());
978 }
979 }
980 directories.extend(others.own_folders.iter().cloned());
982 }
983 directories
984 }
985}
986
987#[derive(Default)]
991pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
992
993impl Before {
994 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
995 Self(
996 directories
997 .into_iter()
998 .filter_map(|directory| {
999 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
1000 Some((directory, entries))
1001 })
1002 .collect(),
1003 )
1004 }
1005}
1006
1007#[derive(Serialize, Deserialize)]
1009struct Stored {
1010 version: u32,
1011 weak: String,
1012 sets: Vec<PathSet>,
1013}
1014
1015fn location(cache: &Path, weak: &str) -> PathBuf {
1016 cache.join("pathsets").join(format!("{weak}.json"))
1017}
1018
1019pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
1022 std::fs::read(location(cache, weak))
1023 .ok()
1024 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
1025 .filter(|stored| stored.version == 1 && stored.weak == weak)
1026 .map(|stored| stored.sets)
1027 .unwrap_or_default()
1028}
1029
1030pub(crate) fn touch(cache: &Path, weak: &str) {
1033 if let Ok(file) = std::fs::File::options()
1034 .append(true)
1035 .open(location(cache, weak))
1036 {
1037 let _ = file.set_modified(std::time::SystemTime::now());
1038 }
1039}
1040
1041pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
1043 let mut sets = load(cache, weak);
1044 sets.retain(|kept| kept != &set);
1045 sets.insert(0, set);
1046 keep(cache, weak, sets)
1047}
1048
1049pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
1052 std::fs::read(location(cache, weak)).ok()
1053}
1054
1055pub(crate) fn within(record: &[u8], largest: usize) -> Result<Option<Vec<u8>>> {
1059 if record.len() <= largest {
1060 return Ok(Some(record.to_vec()));
1061 }
1062 let mut stored: Stored = serde_json::from_slice(record)?;
1063 while stored.sets.len() > 1 {
1064 stored.sets.pop();
1065 let shorter = serde_json::to_vec(&stored)?;
1066 if shorter.len() <= largest {
1067 return Ok(Some(shorter));
1068 }
1069 }
1070 Ok(None)
1071}
1072
1073pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
1078 let Some(fetched) = serde_json::from_slice::<Stored>(record)
1079 .ok()
1080 .filter(|stored| stored.version == 1 && stored.weak == weak)
1081 else {
1082 return Ok(Vec::new());
1083 };
1084 let mut sets = load(cache, weak);
1085 let mut added = Vec::new();
1086 for set in fetched.sets {
1087 if sets.len() + added.len() < KEPT
1088 && set.is_relative()
1089 && !set.is_empty()
1090 && !sets.contains(&set)
1091 && !added.contains(&set)
1092 {
1093 added.push(set);
1094 }
1095 }
1096 if !added.is_empty() {
1097 sets.extend(added.iter().cloned());
1098 keep(cache, weak, sets)?;
1099 }
1100 Ok(added)
1101}
1102
1103fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
1104 sets.truncate(KEPT);
1105 std::fs::create_dir_all(cache.join("pathsets"))?;
1106 crate::record::publish(
1107 cache,
1108 &location(cache, weak),
1109 &Stored {
1110 version: 1,
1111 weak: weak.to_owned(),
1112 sets,
1113 },
1114 )
1115}
1116
1117pub(crate) fn strong_key(
1119 weak: &str,
1120 set: &PathSet,
1121 snapshot: &Snapshot,
1122 root: &Path,
1123 unlisted: &Unlisted,
1124) -> Result<String> {
1125 keyed(weak, set, &set.values(snapshot, root, unlisted)?)
1126}
1127
1128fn keyed(weak: &str, set: &PathSet, values: &Value) -> Result<String> {
1129 key(&json!({
1130 "schema": "qk-recorded-v1",
1131 "weak": weak,
1132 "set": set.digest()?,
1133 "values": values,
1134 }))
1135}
1136
1137pub(crate) fn with_values(
1140 weak: &str,
1141 mut set: PathSet,
1142 snapshot: &Snapshot,
1143 root: &Path,
1144 unlisted: &Unlisted,
1145) -> Result<(String, PathSet)> {
1146 let values = set.values(snapshot, root, unlisted)?;
1147 let strong = keyed(weak, &set, &values)?;
1148 set.recorded = Some(Values::of(&values)?);
1149 Ok((strong, set))
1150}
1151
1152pub(crate) fn now(directory: &Path) -> SystemTime {
1157 tempfile::NamedTempFile::new_in(directory)
1158 .and_then(|file| file.as_file().metadata())
1159 .map(|metadata| changed(&metadata))
1160 .unwrap_or_else(|_| SystemTime::now())
1161}
1162
1163fn changed(metadata: &std::fs::Metadata) -> SystemTime {
1166 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
1167 #[cfg(unix)]
1168 {
1169 use std::os::unix::fs::MetadataExt;
1170 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
1171 SystemTime::UNIX_EPOCH
1172 + std::time::Duration::new(
1173 seconds,
1174 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
1175 )
1176 });
1177 if let Some(ctime) = ctime {
1178 return modified.max(ctime);
1179 }
1180 }
1181 modified
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use crate::ultracache::IgnoredReads;
1188
1189 static IGNORE_NOTHING: IgnoredReads = IgnoredReads::none();
1190
1191 const NOTHING: Unlisted<'static> = Unlisted {
1193 own: Vec::new(),
1194 others: None,
1195 ignore: &IGNORE_NOTHING,
1196 produced: None,
1197 };
1198 use qk_input_analysis::Category;
1199
1200 fn access(path: &str, operation: Operation, result: &str) -> Access {
1201 Access {
1202 process: 1,
1203 path: path.into(),
1204 resolved_path: None,
1205 keyed_path: None,
1206 package: None,
1207 operation,
1208 result: result.into(),
1209 category: Category::Uncovered,
1210 }
1211 }
1212
1213 #[test]
1214 fn keeps_what_the_task_read_listed_and_looked_up() {
1215 let temp = tempfile::tempdir().unwrap();
1216 let root = temp.path().canonicalize().unwrap();
1217 for directory in ["src", "icons", "dist"] {
1218 std::fs::create_dir(root.join(directory)).unwrap();
1219 }
1220 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
1221 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
1222 let exclusions = Exclusions {
1223 roots: [&root, &root],
1224 outputs: &[&outputs],
1225 ignore: IgnoredReads::of_globs(&["src/*.log".to_owned()]).unwrap(),
1226 state: vec![".qk".into()],
1227 produced: &Produced::default(),
1228 };
1229 let accesses = [
1230 access(&path("src/a.ts"), Operation::OpenRead, "success"),
1231 access(&path("src"), Operation::OpenRead, "success"),
1232 access(&path("icons"), Operation::ListDirectory, "success"),
1233 access(&path("config.local"), Operation::Metadata, "missing"),
1234 access(&path("src/b.ts"), Operation::Metadata, "success"),
1235 access(&path("icons/sprite.svg"), Operation::Write, "success"),
1237 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
1238 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
1240 access(
1241 &path("node_modules/x/index.js"),
1242 Operation::OpenRead,
1243 "success",
1244 ),
1245 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
1246 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
1247 access(&path("dist/out.js"), Operation::OpenRead, "success"),
1248 access(&path("src/debug.log"), Operation::OpenRead, "success"),
1249 access(&path("src/a.ts"), Operation::Metadata, "success"),
1251 ];
1252 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
1253 assert_eq!(
1254 set,
1255 PathSet {
1256 reads: ["src/a.ts".into()].into(),
1257 listings: ["icons".into()].into(),
1258 present: ["src/b.ts".into()].into(),
1259 absent: ["config.local".into()].into(),
1260 written: ["icons/sprite.svg".into()].into(),
1261 recorded: None,
1262 }
1263 );
1264 }
1265
1266 #[test]
1270 fn keeps_what_is_produced_in_node_modules() {
1271 let temp = tempfile::tempdir().unwrap();
1272 let root = temp.path().canonicalize().unwrap();
1273 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
1274 let produced = Produced::new(
1275 vec![
1276 Outputs::from_paths(&["node_modules/.gen/app".into()]).unwrap(),
1277 Outputs::from_paths(&["dist/lib".into()]).unwrap(),
1278 Outputs::from_paths(&["libs/types/**/*.d.ts".into()]).unwrap(),
1281 ],
1282 vec![
1283 "libs/e2e/node_modules/.cache/fixtures".into(),
1284 "libs/e2e".into(),
1287 ],
1288 );
1289 assert_eq!(
1290 produced.roots,
1291 [
1292 "libs/e2e/node_modules/.cache/fixtures",
1293 "node_modules/.gen/app"
1294 ]
1295 );
1296 let exclusions = Exclusions {
1297 roots: [&root, &root],
1298 outputs: &[],
1299 ignore: IgnoredReads::none(),
1300 state: Vec::new(),
1301 produced: &produced,
1302 };
1303 let read = |relative: &str| access(&path(relative), Operation::OpenRead, "success");
1304 let accesses = [
1305 read("node_modules/.gen/app/index.js"),
1306 access(
1307 &path("node_modules/.gen/app"),
1308 Operation::ListDirectory,
1309 "success",
1310 ),
1311 read("node_modules/.gen/other/index.js"),
1312 read("node_modules/foo/index.js"),
1313 read("dist/lib/node_modules/dep/index.js"),
1314 read("libs/types/node_modules/@types/x/index.d.ts"),
1315 read("libs/types/node_modules/x/index.js"),
1316 read("libs/e2e/node_modules/.cache/fixtures/a.json"),
1317 read("libs/e2e/node_modules/foo/index.js"),
1318 access(
1321 &path("node_modules/.gen"),
1322 Operation::ListDirectory,
1323 "success",
1324 ),
1325 access(
1326 &path("libs/e2e/node_modules/.cache"),
1327 Operation::ListDirectory,
1328 "success",
1329 ),
1330 access(&path("node_modules"), Operation::ListDirectory, "success"),
1331 access(
1333 &path("libs/types/node_modules/@types"),
1334 Operation::ListDirectory,
1335 "success",
1336 ),
1337 access(
1338 &path("node_modules/foo"),
1339 Operation::ListDirectory,
1340 "success",
1341 ),
1342 access(
1343 &path("node_modules/.gen/app/index.d.ts"),
1344 Operation::Metadata,
1345 "success",
1346 ),
1347 access(
1348 &path("node_modules/.gen/app/extra.js"),
1349 Operation::Metadata,
1350 "missing",
1351 ),
1352 access(
1353 &path("node_modules/foo/package.json"),
1354 Operation::Metadata,
1355 "success",
1356 ),
1357 access(
1358 &path("node_modules/foo/extra.js"),
1359 Operation::Metadata,
1360 "missing",
1361 ),
1362 ];
1363 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
1364 assert_eq!(
1365 set.reads,
1366 [
1367 "dist/lib/node_modules/dep/index.js",
1368 "libs/e2e/node_modules/.cache/fixtures/a.json",
1369 "libs/types/node_modules/@types/x/index.d.ts",
1370 "node_modules/.gen/app/index.js",
1371 ]
1372 .map(String::from)
1373 .into()
1374 );
1375 assert_eq!(
1376 set.listings,
1377 [
1378 "libs/e2e/node_modules/.cache",
1379 "libs/types/node_modules/@types",
1380 "node_modules",
1381 "node_modules/.gen",
1382 "node_modules/.gen/app",
1383 ]
1384 .map(String::from)
1385 .into()
1386 );
1387 assert_eq!(
1388 set.present,
1389 ["node_modules/.gen/app/index.d.ts".to_owned()].into()
1390 );
1391 assert_eq!(
1392 set.absent,
1393 ["node_modules/.gen/app/extra.js".to_owned()].into()
1394 );
1395 std::fs::write(root.join("nx.json"), "{}").unwrap();
1397 std::fs::write(
1398 root.join("project.json"),
1399 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1400 )
1401 .unwrap();
1402 let workspace = qk_config::Workspace::load(&root).unwrap();
1403 let graph = qk_taskgraph::TaskGraph::build(
1404 &workspace,
1405 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1406 )
1407 .unwrap();
1408 let cache = tempfile::tempdir().unwrap();
1409 let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1410 std::fs::create_dir_all(root.join("node_modules/.gen/app")).unwrap();
1411 std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1412 let unlisted = Unlisted {
1413 produced: Some(&produced),
1414 ..NOTHING
1415 };
1416 let key = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1417 let generated = key();
1418 std::fs::create_dir_all(root.join("node_modules/bar")).unwrap();
1420 assert_eq!(key(), generated);
1421 std::fs::rename(root.join("node_modules/.gen"), root.join(".gen")).unwrap();
1423 assert_ne!(key(), generated);
1424 std::fs::rename(root.join(".gen"), root.join("node_modules/.gen")).unwrap();
1425 assert_eq!(key(), generated);
1426 let listing = PathSet {
1428 listings: ["node_modules".to_owned()].into(),
1429 ..PathSet::default()
1430 };
1431 let listed = || strong_key("weak", &listing, &snapshot, &root, &unlisted).unwrap();
1432 let before = listed();
1433 std::fs::create_dir_all(root.join("node_modules/baz")).unwrap();
1434 assert_eq!(listed(), before);
1435 std::fs::rename(root.join("node_modules/.gen"), root.join(".gen")).unwrap();
1436 assert_ne!(listed(), before);
1437 std::fs::rename(root.join(".gen"), root.join("node_modules/.gen")).unwrap();
1438 assert_eq!(listed(), before);
1439 let keyed = |set: &PathSet| strong_key("weak", set, &snapshot, &root, &unlisted).unwrap();
1441 let found = PathSet {
1442 present: set.present.clone(),
1443 ..PathSet::default()
1444 };
1445 let before = keyed(&found);
1446 std::fs::remove_file(root.join("node_modules/.gen/app/index.d.ts")).unwrap();
1447 assert_ne!(keyed(&found), before);
1448 std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1449 assert_eq!(keyed(&found), before);
1450 let missing = PathSet {
1451 absent: set.absent.clone(),
1452 ..PathSet::default()
1453 };
1454 let before = keyed(&missing);
1455 std::fs::write(root.join("node_modules/.gen/app/extra.js"), "").unwrap();
1456 assert_ne!(keyed(&missing), before);
1457 std::fs::remove_file(root.join("node_modules/.gen/app/extra.js")).unwrap();
1458 assert_eq!(keyed(&missing), before);
1459 std::fs::remove_file(root.join("node_modules/.gen/app/index.d.ts")).unwrap();
1460 assert_ne!(key(), generated);
1461 std::fs::write(root.join("node_modules/.gen/app/index.d.ts"), "").unwrap();
1462 assert_eq!(key(), generated);
1463 std::fs::write(root.join("node_modules/.gen/app/extra.js"), "").unwrap();
1464 assert_ne!(key(), generated);
1465 }
1466
1467 #[test]
1471 fn a_trace_keeps_in_node_modules_only_what_a_path_set_may() {
1472 let produced = Produced::new(
1473 vec![
1474 Outputs::from_paths(&["node_modules/.gen/app".into()]).unwrap(),
1475 Outputs::from_paths(&["libs/types/**/*.d.ts".into()]).unwrap(),
1476 ],
1477 Vec::new(),
1478 );
1479 let traced = |path: &str, operation: Operation| produced.traced(path, &operation);
1480 assert!(traced(
1482 "node_modules/.gen/app/index.js",
1483 Operation::OpenRead
1484 ));
1485 assert!(traced(
1486 "libs/types/node_modules/@types/x/index.d.ts",
1487 Operation::OpenRead
1488 ));
1489 assert!(traced("node_modules", Operation::ListDirectory));
1491 assert!(traced(
1492 "libs/types/node_modules/@types",
1493 Operation::ListDirectory
1494 ));
1495 assert!(traced(
1496 "libs/types/node_modules/x/cache.json",
1497 Operation::Write
1498 ));
1499 assert!(!traced(
1500 "libs/types/node_modules/x/index.js",
1501 Operation::OpenRead
1502 ));
1503 assert!(!traced("libs/types/node_modules/x", Operation::Metadata));
1504 assert!(!traced("node_modules/foo/index.js", Operation::OpenRead));
1506 assert!(!traced("node_modules/foo", Operation::ListDirectory));
1507 }
1508
1509 #[test]
1512 fn what_keeps_paths_in_node_modules_is_keyed_once() {
1513 assert_eq!(Produced::default().weak(), None);
1514 let produced = |declared: &[&[&str]]| {
1515 Produced::default()
1516 .declared(
1517 declared
1518 .iter()
1519 .map(|outputs| outputs.iter().map(|output| (*output).to_owned()).collect())
1520 .collect(),
1521 )
1522 .weak()
1523 };
1524 let both = produced(&[&["dist/lib"], &["node_modules/.gen/app"]]);
1525 assert!(both.is_some());
1526 assert_eq!(produced(&[&["node_modules/.gen/app"], &["dist/lib"]]), both);
1527 assert_ne!(produced(&[&["dist/lib"]]), both);
1528 }
1529
1530 #[cfg(unix)]
1533 #[test]
1534 fn a_file_read_through_a_linked_directory_is_unkeyable() {
1535 let temp = tempfile::tempdir().unwrap();
1536 let root = temp.path().canonicalize().unwrap();
1537 std::fs::write(root.join("nx.json"), "{}").unwrap();
1538 std::fs::write(
1539 root.join("project.json"),
1540 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1541 )
1542 .unwrap();
1543 std::fs::create_dir(root.join("package")).unwrap();
1544 std::fs::write(root.join("package/index.js"), "").unwrap();
1545 std::os::unix::fs::symlink("package", root.join("linked")).unwrap();
1546 let workspace = qk_config::Workspace::load(&root).unwrap();
1547 let graph = qk_taskgraph::TaskGraph::build(
1548 &workspace,
1549 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1550 )
1551 .unwrap();
1552 let cache = tempfile::tempdir().unwrap();
1553 let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1554 let reading = |path: &str| PathSet {
1555 reads: [path.to_owned()].into(),
1556 ..PathSet::default()
1557 };
1558 assert_eq!(
1559 reading("package/index.js").unkeyable(&snapshot, &root),
1560 None
1561 );
1562 let linked = reading("linked/index.js").unkeyable(&snapshot, &root);
1563 assert!(
1564 linked
1565 .as_deref()
1566 .is_some_and(|reason| reason.starts_with("linked/index.js cannot be keyed")),
1567 "{linked:?}"
1568 );
1569 assert_eq!(
1571 reading("linked/missing.js").unkeyable(&snapshot, &root),
1572 None
1573 );
1574 }
1575
1576 #[test]
1577 fn values_change_with_contents_listings_and_existence() {
1578 let temp = tempfile::tempdir().unwrap();
1579 let root = temp.path().canonicalize().unwrap();
1580 std::fs::create_dir_all(root.join("icons")).unwrap();
1581 std::fs::write(root.join("a.txt"), "one").unwrap();
1582 std::fs::write(root.join("icons/a.svg"), "").unwrap();
1583 std::fs::write(root.join("nx.json"), "{}").unwrap();
1584 std::fs::write(
1585 root.join("project.json"),
1586 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1587 )
1588 .unwrap();
1589 let workspace = qk_config::Workspace::load(&root).unwrap();
1590 let graph = qk_taskgraph::TaskGraph::build(
1591 &workspace,
1592 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1593 )
1594 .unwrap();
1595 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1596 std::fs::write(root.join("checked.txt"), "one").unwrap();
1597 let set = PathSet {
1598 reads: ["a.txt".into()].into(),
1599 listings: ["icons".into()].into(),
1600 present: ["checked.txt".into()].into(),
1601 absent: ["optional.txt".into()].into(),
1602 written: ["icons/sprite.svg".into()].into(),
1603 recorded: None,
1604 };
1605 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
1606 let unlisted = Unlisted {
1607 own: vec![&outputs],
1608 others: None,
1609 ignore: &IGNORE_NOTHING,
1610 produced: None,
1611 };
1612 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
1613 let first = strong();
1614 let (kept_key, kept) =
1617 with_values("weak", set.clone(), &snapshot, &root, &unlisted).unwrap();
1618 assert_eq!(kept_key, first);
1619 assert_eq!(kept, set);
1620 assert_eq!(kept.digest().unwrap(), set.digest().unwrap());
1621 assert!(
1622 kept.changed(&snapshot, &root, &unlisted)
1623 .unwrap()
1624 .is_empty()
1625 );
1626 std::fs::write(root.join("a.txt"), "changed").unwrap();
1627 std::fs::write(root.join("optional.txt"), "").unwrap();
1628 assert_eq!(
1629 kept.changed(&snapshot, &root, &unlisted).unwrap(),
1630 [("a.txt", "read"), ("optional.txt", "looked for")]
1631 );
1632 assert!(set.changed(&snapshot, &root, &unlisted).unwrap().is_empty());
1633 std::fs::write(root.join("a.txt"), "one").unwrap();
1634 std::fs::remove_file(root.join("optional.txt")).unwrap();
1635 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
1638 std::fs::create_dir(root.join("icons/out")).unwrap();
1639 std::fs::write(root.join("b.txt"), "unread").unwrap();
1640 assert_eq!(strong(), first);
1641 for change in [
1642 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
1643 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
1644 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
1645 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
1647 ] {
1648 let before = strong();
1649 change(&root);
1650 assert_ne!(strong(), before);
1651 }
1652 }
1653
1654 #[test]
1655 fn keeps_the_newest_sets_first() {
1656 let temp = tempfile::tempdir().unwrap();
1657 std::fs::create_dir(temp.path().join("tmp")).unwrap();
1658 let set = |read: &str| PathSet {
1659 reads: [read.to_owned()].into(),
1660 ..PathSet::default()
1661 };
1662 for index in 0..10 {
1663 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
1664 }
1665 save(temp.path(), "weak", set("5")).unwrap();
1666 let kept: Vec<String> = load(temp.path(), "weak")
1667 .into_iter()
1668 .map(|set| set.reads.into_iter().next().unwrap())
1669 .collect();
1670 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
1671 assert!(load(temp.path(), "other").is_empty());
1672 }
1673
1674 #[test]
1675 fn merges_the_sets_another_machine_recorded_behind_its_own() {
1676 let temp = tempfile::tempdir().unwrap();
1677 std::fs::create_dir(temp.path().join("tmp")).unwrap();
1678 let set = |read: &str| PathSet {
1679 reads: [read.to_owned()].into(),
1680 ..PathSet::default()
1681 };
1682 save(temp.path(), "weak", set("a")).unwrap();
1683 let record = |weak: &str, sets: Vec<PathSet>| {
1684 serde_json::to_vec(&Stored {
1685 version: 1,
1686 weak: weak.into(),
1687 sets,
1688 })
1689 .unwrap()
1690 };
1691 let fetched = record(
1692 "weak",
1693 vec![set("b"), set("a"), set("../escape"), set("/abs")],
1694 );
1695 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
1696 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
1697 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
1698 assert!(
1699 merge(temp.path(), "weak", &record("other", vec![set("c")]))
1700 .unwrap()
1701 .is_empty()
1702 );
1703 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
1704 assert_eq!(
1705 merge(temp.path(), "weak", &record("weak", many))
1706 .unwrap()
1707 .len(),
1708 6
1709 );
1710 assert_eq!(load(temp.path(), "weak").len(), KEPT);
1711 }
1712
1713 #[test]
1714 fn a_record_too_large_leaves_out_its_oldest_sets() {
1715 let set = |read: &str| PathSet {
1716 reads: [read.to_owned()].into(),
1717 ..PathSet::default()
1718 };
1719 let record = |sets: Vec<PathSet>| {
1720 serde_json::to_vec(&Stored {
1721 version: 1,
1722 weak: "weak".into(),
1723 sets,
1724 })
1725 .unwrap()
1726 };
1727 let newest = "n".repeat(100);
1728 let full = record(vec![
1729 set(&newest),
1730 set(&"o".repeat(100)),
1731 set(&"p".repeat(100)),
1732 ]);
1733 assert_eq!(within(&full, full.len()).unwrap(), Some(full.clone()));
1734 let one = record(vec![set(&newest)]);
1735 assert_eq!(within(&full, one.len() + 50).unwrap(), Some(one.clone()));
1736 assert_eq!(within(&full, one.len() - 1).unwrap(), None);
1737 }
1738
1739 #[test]
1740 fn finds_what_changed_while_the_task_ran() {
1741 let temp = tempfile::tempdir().unwrap();
1742 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1743 std::fs::write(temp.path().join(path), "before").unwrap();
1744 }
1745 for directory in ["listed", "written"] {
1746 std::fs::create_dir(temp.path().join(directory)).unwrap();
1747 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1748 }
1749 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1750 let set = PathSet {
1751 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1752 present: ["found.txt".to_owned(), ".".to_owned()].into(),
1753 listings: ["listed".to_owned(), "written".to_owned()].into(),
1754 written: ["written/new.txt".to_owned()].into(),
1755 ..PathSet::default()
1756 };
1757 std::thread::sleep(std::time::Duration::from_millis(20));
1758 let started = now(temp.path());
1759 let changed = |set: &PathSet| {
1760 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1761 .map(|changed| changed.path.to_owned())
1762 };
1763 assert_eq!(changed(&set), None);
1764 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1766 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1767 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1768 let unlisted = PathSet {
1770 listings: ["written".to_owned()].into(),
1771 ..set.clone()
1772 };
1773 assert_eq!(changed(&unlisted), None);
1774 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1775 assert_eq!(changed(&set), Some("found.txt".into()));
1776 let listings = PathSet {
1777 reads: [].into(),
1778 present: [].into(),
1779 ..set.clone()
1780 };
1781 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1784 assert_eq!(changed(&listings), None);
1785 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1787 if std::fs::metadata(temp.path().join("written/other.txt"))
1788 .and_then(|metadata| metadata.created())
1789 .is_ok()
1790 {
1791 assert_eq!(changed(&listings), Some("written".into()));
1792 }
1793 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1794 #[cfg(unix)]
1797 {
1798 std::fs::rename(
1799 temp.path().join("before.txt"),
1800 temp.path().join("written/moved.txt"),
1801 )
1802 .unwrap();
1803 assert_eq!(changed(&listings), Some("written".into()));
1804 std::fs::rename(
1805 temp.path().join("written/moved.txt"),
1806 temp.path().join("before.txt"),
1807 )
1808 .unwrap();
1809 }
1810 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1812 assert_eq!(changed(&listings), Some("listed".into()));
1813 let removed = tempfile::tempdir().unwrap();
1815 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1816 std::thread::sleep(std::time::Duration::from_millis(20));
1817 let started = now(temp.path());
1820 let root = PathSet {
1821 listings: [".".to_owned()].into(),
1822 ..PathSet::default()
1823 };
1824 assert_eq!(
1825 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1826 .map(|changed| changed.path),
1827 None
1828 );
1829 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1830 assert_eq!(
1831 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1832 .map(|changed| changed.path),
1833 Some(".")
1834 );
1835 }
1836
1837 #[test]
1841 fn directories_only_holding_its_own_outputs_key_the_same_made_or_not() {
1842 let roots = [
1843 ("web".to_owned(), "apps/web".to_owned()),
1844 ("ui".to_owned(), "libs/ui".to_owned()),
1845 ]
1846 .into();
1847 let declared = |upstream: Option<&str>| {
1848 let mut outputs = vec![(
1849 ("web".into(), "build".into(), None),
1850 Outputs::from_patterns(&["dist/apps/web"]),
1851 )];
1852 outputs.extend(upstream.map(|anchor| {
1853 (
1854 ("ui".into(), "build".into(), None),
1855 Outputs::from_patterns(&[anchor]),
1856 )
1857 }));
1858 WorkspaceOutputs::new(outputs, &roots)
1859 };
1860 let build = Outputs::from_patterns(&["dist/apps/web"]);
1861 let coverage = Outputs::from_patterns(&["apps/web/coverage/unit"]);
1862 let upstream: BTreeSet<TargetOf> = [("ui".into(), "build".into(), None)].into();
1863 let folders = |outputs: &WorkspaceOutputs| {
1864 outputs
1865 .own_folders(&[&build, &coverage], &upstream)
1866 .into_iter()
1867 .collect::<Vec<_>>()
1868 };
1869 assert_eq!(
1872 folders(&declared(None)),
1873 ["apps/web/coverage", "dist", "dist/apps"]
1874 );
1875 assert_eq!(
1878 folders(&declared(Some("dist/libs/ui"))),
1879 ["apps/web/coverage", "dist/apps"]
1880 );
1881 assert_eq!(folders(&declared(Some("dist"))), ["apps/web/coverage"]);
1882 let outputs = declared(None);
1883
1884 let temp = tempfile::tempdir().unwrap();
1885 let root = &temp.path().canonicalize().unwrap();
1886 std::fs::write(root.join("nx.json"), "{}").unwrap();
1887 std::fs::write(
1888 root.join("project.json"),
1889 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1890 )
1891 .unwrap();
1892 let workspace = qk_config::Workspace::load(root).unwrap();
1893 let graph = qk_taskgraph::TaskGraph::build(
1894 &workspace,
1895 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1896 )
1897 .unwrap();
1898 let cache = tempfile::tempdir().unwrap();
1900 let snapshot = Snapshot::new(&workspace, &graph, cache.path()).unwrap();
1901 let own = [&build, &coverage];
1902 let unlisted = Unlisted {
1903 own: own.to_vec(),
1904 others: Some(Others::new(&outputs, BTreeSet::new(), &own)),
1905 ignore: &IGNORE_NOTHING,
1906 produced: None,
1907 };
1908 let set = PathSet {
1909 listings: [".", "dist", "apps/web/coverage"].map(String::from).into(),
1910 present: ["dist/apps".to_owned()].into(),
1911 ..PathSet::default()
1912 };
1913 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1914 std::fs::create_dir_all(root.join("dist/apps")).unwrap();
1916 std::fs::create_dir_all(root.join("apps/web/coverage")).unwrap();
1917 let made = key();
1918 std::fs::remove_dir_all(root.join("dist")).unwrap();
1919 std::fs::remove_dir(root.join("apps/web/coverage")).unwrap();
1920 assert_eq!(key(), made);
1921 std::fs::create_dir(root.join("apps/web/coverage")).unwrap();
1923 assert_eq!(key(), made);
1924 std::fs::write(root.join("apps/web/coverage/README.md"), "").unwrap();
1925 assert_ne!(key(), made);
1926 std::fs::remove_file(root.join("apps/web/coverage/README.md")).unwrap();
1927 std::fs::create_dir(root.join("build")).unwrap();
1929 assert_ne!(key(), made);
1930 std::fs::create_dir_all(root.join("dist/apps")).unwrap();
1933 let directories = unlisted.directories(".");
1934 assert!(directories.contains("apps/web/coverage"));
1935 let taken = Before::take(root, directories, &unlisted);
1936 std::thread::sleep(std::time::Duration::from_millis(20));
1937 let started = now(root);
1938 std::fs::write(root.join("apps/web/coverage/added.md"), "").unwrap();
1939 assert_eq!(
1940 set.changed_since(root, started, &unlisted, &taken)
1941 .map(|changed| changed.path),
1942 Some("apps/web/coverage")
1943 );
1944 }
1945
1946 #[test]
1947 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1948 let temp = tempfile::tempdir().unwrap();
1949 let root = &temp.path().canonicalize().unwrap();
1950 std::fs::create_dir_all(root.join("app/src")).unwrap();
1951 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1952 std::fs::write(root.join("nx.json"), "{}").unwrap();
1953 std::fs::write(
1954 root.join("project.json"),
1955 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1956 )
1957 .unwrap();
1958 let workspace = qk_config::Workspace::load(root).unwrap();
1959 let graph = qk_taskgraph::TaskGraph::build(
1960 &workspace,
1961 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1962 )
1963 .unwrap();
1964 let set = PathSet {
1965 listings: ["app".to_owned()].into(),
1966 ..PathSet::default()
1967 };
1968 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1969 std::fs::create_dir(root.join("app/out")).unwrap();
1970 let roots = [("app".to_owned(), "app".to_owned())].into();
1971 let outputs = WorkspaceOutputs::new(
1972 vec![
1973 (
1974 ("app".into(), "tsc".into(), None),
1975 Outputs::from_patterns(&["app/.tsc"]),
1976 ),
1977 (
1978 ("app".into(), "codegen".into(), None),
1979 Outputs::from_patterns(&["app/generated"]),
1980 ),
1981 (
1983 ("app".into(), "bundle".into(), None),
1984 Outputs::from_patterns(&["app/out/*.js"]),
1985 ),
1986 (
1988 ("app".into(), "package".into(), None),
1989 Outputs::from_patterns(&["app/generated"]),
1990 ),
1991 (
1993 ("app".into(), "deploy".into(), None),
1994 Outputs::from_patterns(&["app/.cache/deploy"]),
1995 ),
1996 ],
1997 &roots,
1998 );
1999 let ignore = IgnoredReads::of_globs(&["**/.fallow".to_owned()]).unwrap();
2000 let unlisted = Unlisted {
2001 own: Vec::new(),
2002 others: Some(Others::new(
2003 &outputs,
2004 [
2006 ("app".into(), "build".into(), None),
2007 ("app".into(), "codegen".into(), None),
2008 ]
2009 .into(),
2010 &[],
2011 )),
2012 ignore: &ignore,
2013 produced: None,
2014 };
2015 assert_eq!(
2016 unlisted.directories("app"),
2017 [
2018 "app".to_owned(),
2019 "app/.cache".to_owned(),
2020 "app/.cache/deploy".to_owned(),
2021 "app/.tsc".to_owned(),
2022 "app/generated".to_owned(),
2023 "app/out".to_owned()
2024 ]
2025 .into()
2026 );
2027 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
2028 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
2029 let before = key();
2030 std::thread::sleep(std::time::Duration::from_millis(20));
2031 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
2032 let started = now(root);
2033 let changed = |taken: &Before| {
2034 set.changed_since(root, started, &unlisted, taken)
2035 .map(|changed| changed.path)
2036 };
2037 std::fs::create_dir(root.join("app/.tsc")).unwrap();
2040 std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
2041 std::fs::create_dir(root.join("app/.fallow")).unwrap();
2042 assert_eq!(key(), before);
2043 assert_eq!(changed(&taken), None);
2044 std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
2047 let output = PathSet {
2048 listings: ["app/.tsc".to_owned()].into(),
2049 ..PathSet::default()
2050 };
2051 assert_eq!(
2052 output.changed_since(root, started, &unlisted, &Before::default()),
2053 None
2054 );
2055 assert!(!unlisted.matches("app"));
2057 std::fs::create_dir(root.join("app/gone")).unwrap();
2059 let gone = PathSet {
2060 listings: ["app/gone".to_owned()].into(),
2061 ..PathSet::default()
2062 };
2063 std::fs::remove_dir(root.join("app/gone")).unwrap();
2064 assert_eq!(
2065 gone.changed_since(root, started, &unlisted, &Before::default())
2066 .map(|changed| changed.path),
2067 Some("app/gone")
2068 );
2069 std::fs::write(root.join("app/out/new.js"), "").unwrap();
2071 let out = PathSet {
2072 listings: ["app/out".to_owned()].into(),
2073 ..PathSet::default()
2074 };
2075 assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
2076 assert_eq!(
2077 out.changed_since(root, started, &unlisted, &Before::default())
2078 .map(|changed| changed.path),
2079 Some("app/out")
2080 );
2081 assert!(!unlisted.matches("app/generated"));
2083 assert!(unlisted.matches("app/.tsc/index.js"));
2084 assert_eq!(changed(&Before::default()), Some("app"));
2087 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
2089 assert_ne!(key(), before);
2090 assert_eq!(
2091 set.changed_since(root, started, &unlisted, &taken)
2092 .and_then(|changed| changed.how),
2093 Some("kept.txt removed".to_owned())
2094 );
2095 std::fs::write(root.join("app/kept.txt"), "").unwrap();
2096 assert_eq!(changed(&taken), None);
2097 std::fs::write(root.join("app/README.md"), "").unwrap();
2099 assert_ne!(key(), before);
2100 assert_eq!(
2101 set.changed_since(root, started, &unlisted, &taken)
2102 .map(|changed| changed.to_string()),
2103 Some("app changed while it ran (README.md added)".to_owned())
2104 );
2105 }
2106}