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, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct PathSet {
37 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
39 pub reads: BTreeSet<String>,
40 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
42 pub listings: BTreeSet<String>,
43 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
47 pub present: BTreeSet<String>,
48 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
50 pub absent: BTreeSet<String>,
51 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
54 pub written: BTreeSet<String>,
55}
56
57pub struct Exclusions<'a> {
59 pub roots: [&'a Path; 2],
61 pub outputs: &'a [&'a Outputs],
63 pub ignore: Vec<Pattern>,
65 pub state: Vec<String>,
67}
68
69impl Exclusions<'_> {
70 fn relative(&self, path: &str) -> Option<String> {
72 let path = qk_input_analysis::strace::normalize(Path::new(path));
73 let relative = self
74 .roots
75 .iter()
76 .find_map(|root| path.strip_prefix(root).ok())?;
77 let relative = relative.to_str()?.replace('\\', "/");
78 Some(if relative.is_empty() {
79 ".".into()
80 } else {
81 relative
82 })
83 }
84
85 fn excluded(&self, path: &str) -> bool {
86 let mut parts = path.split('/');
87 parts.clone().next() == Some(".git")
88 || parts.any(|part| part == "node_modules")
89 || self.state.iter().any(|state| {
90 path == state
91 || path
92 .strip_prefix(state.as_str())
93 .is_some_and(|rest| rest.starts_with('/'))
94 })
95 || self.outputs.iter().any(|outputs| outputs.matches(path))
96 || self.ignore.iter().any(|pattern| pattern.is_match(path))
97 }
98}
99
100impl PathSet {
101 pub fn from_accesses(accesses: &[Access], exclusions: &Exclusions, root: &Path) -> Self {
105 let mut set = Self::default();
106 let mut written = BTreeSet::new();
107 for access in accesses {
108 let Some(path) = exclusions.relative(&access.path) else {
109 continue;
110 };
111 if access.operation.writes() {
112 written.insert(path);
113 continue;
114 }
115 if written.contains(&path) || exclusions.excluded(&path) {
117 continue;
118 }
119 let found = matches!(access.result.as_str(), "success" | "unknown");
120 let missing = matches!(access.result.as_str(), "missing" | "ENOTDIR");
121 match access.operation {
122 Operation::OpenRead | Operation::ReadData | Operation::Execute if found => {
123 if !root.join(&path).is_dir() {
126 set.reads.insert(path);
127 }
128 }
129 Operation::ListDirectory | Operation::ReadDirectory if found => {
130 set.listings.insert(path);
131 }
132 Operation::Metadata | Operation::ReadLink if found => {
133 set.present.insert(path);
134 }
135 _ if missing => {
136 set.absent.insert(path);
137 }
138 _ => {}
139 }
140 }
141 set.present.retain(|path| !set.reads.contains(path));
142 set.absent
143 .retain(|path| !set.reads.contains(path) && !set.present.contains(path));
144 set.written = written
145 .into_iter()
146 .filter(|path| set.listings.contains(parent(path)))
147 .collect();
148 set
149 }
150
151 pub fn is_relative(&self) -> bool {
154 [
155 &self.reads,
156 &self.listings,
157 &self.present,
158 &self.absent,
159 &self.written,
160 ]
161 .into_iter()
162 .flatten()
163 .all(|path| {
164 !path.is_empty()
165 && Path::new(path).components().all(|component| {
166 matches!(
167 component,
168 std::path::Component::Normal(_) | std::path::Component::CurDir
169 )
170 })
171 })
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.reads.is_empty()
176 && self.listings.is_empty()
177 && self.present.is_empty()
178 && self.absent.is_empty()
179 }
180
181 pub(crate) fn changed_since(
200 &self,
201 root: &Path,
202 started: SystemTime,
203 unlisted: &Unlisted,
204 before: &Before,
205 ) -> Option<&str> {
206 let file = self.reads.iter().chain(&self.present).find(|path| {
207 std::fs::symlink_metadata(root.join(path))
208 .is_ok_and(|metadata| !metadata.is_dir() && changed(&metadata) >= started)
209 });
210 file.or_else(|| {
211 self.listings.iter().find(|directory| {
212 let absolute = if *directory == "." {
213 root.to_path_buf()
214 } else {
215 root.join(directory)
216 };
217 let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
218 if let Some(earlier) = before.0.get(*directory) {
219 let earlier: BTreeSet<_> = earlier
220 .iter()
221 .filter(|(name, _)| !self.written.contains(&child(directory, name)))
222 .cloned()
223 .collect();
224 return entries(root, directory, skipped).is_none_or(|now| now != earlier);
225 }
226 let Ok(entries) = std::fs::read_dir(&absolute) else {
227 return false;
228 };
229 let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
232 .flatten()
233 .map(|entry| {
234 let path = child(directory, &entry.file_name().to_string_lossy());
235 let own = self.written.contains(&path) || unlisted.own(&path);
236 (entry, skipped(&path), own)
237 })
238 .collect();
239 let own = self.written.iter().any(|path| parent(path) == *directory)
240 || entries.iter().any(|(_, _, own)| *own);
241 if !own {
242 return std::fs::symlink_metadata(&absolute)
243 .and_then(|metadata| metadata.modified())
244 .is_ok_and(|modified| modified >= started);
245 }
246 entries
247 .iter()
248 .filter(|(_, skipped, _)| !skipped)
249 .any(|(entry, _, _)| {
250 std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
251 if metadata.is_dir() {
256 metadata.created().is_ok_and(|born| born >= started)
257 } else {
258 changed(&metadata) >= started
259 }
260 })
261 })
262 })
263 })
264 .map(String::as_str)
265 }
266
267 pub fn digest(&self) -> Result<String> {
268 key(&serde_json::to_value(self)?)
269 }
270
271 pub(crate) fn values(
275 &self,
276 snapshot: &Snapshot,
277 root: &Path,
278 unlisted: &Unlisted,
279 ) -> Result<Value> {
280 let value = |path: &str| match kind(&root.join(path)) {
282 "file" | "symlink" => snapshot
283 .recorded_value(root, path)
284 .unwrap_or_else(|_| json!({"unreadable": true})),
285 other => json!(other),
286 };
287 let reads: Map<String, Value> = self
288 .reads
289 .iter()
290 .map(|path| (path.clone(), value(path)))
291 .collect();
292 let mut listings = Map::new();
293 for directory in &self.listings {
294 listings.insert(directory.clone(), self.listing(root, directory, unlisted)?);
295 }
296 let existence: Map<String, Value> = self
297 .present
298 .iter()
299 .chain(&self.absent)
300 .map(|path| (path.clone(), value(path)))
301 .collect();
302 Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
303 }
304
305 fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
307 let absolute = if directory == "." {
308 root.to_path_buf()
309 } else {
310 root.join(directory)
311 };
312 let entries = match std::fs::read_dir(&absolute) {
313 Ok(entries) => entries,
314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
315 return Ok(json!("absent"));
316 }
317 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
318 return Ok(json!(kind(&absolute)));
319 }
320 Err(error) => return Err(error.into()),
321 };
322 let mut listed = Map::new();
323 for entry in entries {
324 let entry = entry?;
325 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
326 continue;
327 };
328 let path = if directory == "." {
329 name.clone()
330 } else {
331 format!("{directory}/{name}")
332 };
333 if self.written.contains(&path) || unlisted.matches(&path) {
334 continue;
335 }
336 listed.insert(name, json!(kind(&entry.path())));
337 }
338 Ok(Value::Object(listed))
339 }
340}
341
342fn parent(path: &str) -> &str {
344 path.rsplit_once('/').map_or(".", |(parent, _)| parent)
345}
346
347fn child(directory: &str, name: &str) -> String {
349 if directory == "." {
350 name.to_owned()
351 } else {
352 format!("{directory}/{name}")
353 }
354}
355
356fn entries(
359 root: &Path,
360 directory: &str,
361 skipped: impl Fn(&str) -> bool,
362) -> Option<BTreeSet<(String, &'static str)>> {
363 let absolute = if directory == "." {
364 root.to_path_buf()
365 } else {
366 root.join(directory)
367 };
368 let mut found = BTreeSet::new();
369 for entry in std::fs::read_dir(&absolute).ok()? {
370 let entry = entry.ok()?;
371 let name = entry.file_name().to_string_lossy().into_owned();
372 if !skipped(&child(directory, &name)) {
373 found.insert((name, file_kind(entry.file_type().ok()?)));
374 }
375 }
376 Some(found)
377}
378
379fn kind(path: &Path) -> &'static str {
381 std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
382}
383
384fn file_kind(file_type: std::fs::FileType) -> &'static str {
385 if file_type.is_symlink() {
386 "symlink"
387 } else if file_type.is_dir() {
388 "directory"
389 } else if file_type.is_file() {
390 "file"
391 } else {
392 "other"
393 }
394}
395
396#[derive(Default)]
402pub(crate) struct WorkspaceOutputs {
403 outputs: Vec<(TargetOf, Outputs)>,
404 by_anchor: BTreeMap<String, Vec<usize>>,
405}
406
407impl WorkspaceOutputs {
408 pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>) -> Self {
409 let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
410 for (index, (_, declared)) in outputs.iter().enumerate() {
411 for anchor in declared.anchors() {
412 by_anchor.entry(anchor.to_owned()).or_default().push(index);
413 }
414 }
415 Self { outputs, by_anchor }
416 }
417
418 fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
420 std::iter::once(path)
421 .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
422 .filter_map(|anchor| self.by_anchor.get(anchor))
423 .flatten()
424 .map(|&index| &self.outputs[index])
425 }
426}
427
428pub(crate) type TargetOf = (String, String, Option<String>);
431
432pub(crate) struct Others<'a> {
436 pub outputs: &'a WorkspaceOutputs,
437 pub upstream: BTreeSet<TargetOf>,
439}
440
441impl Others<'_> {
442 pub(crate) fn matches(&self, path: &str) -> bool {
445 let mut other = false;
446 for (target, outputs) in self.outputs.holding(path) {
447 if outputs.matches(path) {
448 if self.upstream.contains(target) {
449 return false;
450 }
451 other = true;
452 }
453 }
454 other
455 }
456}
457
458pub(crate) struct Unlisted<'a> {
466 pub own: Vec<&'a Outputs>,
467 pub others: Option<Others<'a>>,
468 pub ignore: &'a [Pattern],
469}
470
471impl Unlisted<'_> {
472 fn matches(&self, path: &str) -> bool {
473 self.own(path)
474 || self
475 .others
476 .as_ref()
477 .is_some_and(|others| others.matches(path))
478 || self.ignore.iter().any(|pattern| pattern.is_match(path))
479 }
480
481 fn own(&self, path: &str) -> bool {
482 self.own.iter().any(|outputs| outputs.matches(path))
483 }
484
485 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
490 let project_root = if project_root.is_empty() {
491 "."
492 } else {
493 project_root
494 };
495 self.others
496 .iter()
497 .flat_map(|others| {
498 others
499 .outputs
500 .outputs
501 .iter()
502 .filter(|(target, _)| !others.upstream.contains(target))
503 .flat_map(|(_, outputs)| outputs.anchors())
504 })
505 .flat_map(|anchor| [anchor.to_owned(), parent(anchor).to_owned()])
506 .chain([project_root.to_owned()])
507 .collect()
508 }
509}
510
511#[derive(Default)]
515pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
516
517impl Before {
518 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
519 Self(
520 directories
521 .into_iter()
522 .filter_map(|directory| {
523 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
524 Some((directory, entries))
525 })
526 .collect(),
527 )
528 }
529}
530
531#[derive(Serialize, Deserialize)]
533struct Stored {
534 version: u32,
535 weak: String,
536 sets: Vec<PathSet>,
537}
538
539fn location(cache: &Path, weak: &str) -> PathBuf {
540 cache.join("pathsets").join(format!("{weak}.json"))
541}
542
543pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
546 std::fs::read(location(cache, weak))
547 .ok()
548 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
549 .filter(|stored| stored.version == 1 && stored.weak == weak)
550 .map(|stored| stored.sets)
551 .unwrap_or_default()
552}
553
554pub(crate) fn touch(cache: &Path, weak: &str) {
557 if let Ok(file) = std::fs::File::options()
558 .append(true)
559 .open(location(cache, weak))
560 {
561 let _ = file.set_modified(std::time::SystemTime::now());
562 }
563}
564
565pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
567 let mut sets = load(cache, weak);
568 sets.retain(|kept| kept != &set);
569 sets.insert(0, set);
570 keep(cache, weak, sets)
571}
572
573pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
576 std::fs::read(location(cache, weak)).ok()
577}
578
579pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
584 let Some(fetched) = serde_json::from_slice::<Stored>(record)
585 .ok()
586 .filter(|stored| stored.version == 1 && stored.weak == weak)
587 else {
588 return Ok(Vec::new());
589 };
590 let mut sets = load(cache, weak);
591 let mut added = Vec::new();
592 for set in fetched.sets {
593 if sets.len() + added.len() < KEPT
594 && set.is_relative()
595 && !set.is_empty()
596 && !sets.contains(&set)
597 && !added.contains(&set)
598 {
599 added.push(set);
600 }
601 }
602 if !added.is_empty() {
603 sets.extend(added.iter().cloned());
604 keep(cache, weak, sets)?;
605 }
606 Ok(added)
607}
608
609fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
610 sets.truncate(KEPT);
611 std::fs::create_dir_all(cache.join("pathsets"))?;
612 crate::record::publish(
613 cache,
614 &location(cache, weak),
615 &Stored {
616 version: 1,
617 weak: weak.to_owned(),
618 sets,
619 },
620 )
621}
622
623pub(crate) fn strong_key(
625 weak: &str,
626 set: &PathSet,
627 snapshot: &Snapshot,
628 root: &Path,
629 unlisted: &Unlisted,
630) -> Result<String> {
631 key(&json!({
632 "schema": "qk-recorded-v1",
633 "weak": weak,
634 "set": set.digest()?,
635 "values": set.values(snapshot, root, unlisted)?,
636 }))
637}
638
639pub(crate) fn now(directory: &Path) -> SystemTime {
644 tempfile::NamedTempFile::new_in(directory)
645 .and_then(|file| file.as_file().metadata())
646 .map(|metadata| changed(&metadata))
647 .unwrap_or_else(|_| SystemTime::now())
648}
649
650fn changed(metadata: &std::fs::Metadata) -> SystemTime {
653 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
654 #[cfg(unix)]
655 {
656 use std::os::unix::fs::MetadataExt;
657 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
658 SystemTime::UNIX_EPOCH
659 + std::time::Duration::new(
660 seconds,
661 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
662 )
663 });
664 if let Some(ctime) = ctime {
665 return modified.max(ctime);
666 }
667 }
668 modified
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 const NOTHING: Unlisted<'static> = Unlisted {
677 own: Vec::new(),
678 others: None,
679 ignore: &[],
680 };
681 use qk_input_analysis::Category;
682
683 fn access(path: &str, operation: Operation, result: &str) -> Access {
684 Access {
685 process: 1,
686 path: path.into(),
687 resolved_path: None,
688 keyed_path: None,
689 package: None,
690 operation,
691 result: result.into(),
692 category: Category::Uncovered,
693 }
694 }
695
696 #[test]
697 fn keeps_what_the_task_read_listed_and_looked_up() {
698 let temp = tempfile::tempdir().unwrap();
699 let root = temp.path().canonicalize().unwrap();
700 for directory in ["src", "icons", "dist"] {
701 std::fs::create_dir(root.join(directory)).unwrap();
702 }
703 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
704 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
705 let exclusions = Exclusions {
706 roots: [&root, &root],
707 outputs: &[&outputs],
708 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
709 state: vec![".qk".into()],
710 };
711 let accesses = [
712 access(&path("src/a.ts"), Operation::OpenRead, "success"),
713 access(&path("src"), Operation::OpenRead, "success"),
714 access(&path("icons"), Operation::ListDirectory, "success"),
715 access(&path("config.local"), Operation::Metadata, "missing"),
716 access(&path("src/b.ts"), Operation::Metadata, "success"),
717 access(&path("icons/sprite.svg"), Operation::Write, "success"),
719 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
720 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
722 access(
723 &path("node_modules/x/index.js"),
724 Operation::OpenRead,
725 "success",
726 ),
727 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
728 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
729 access(&path("dist/out.js"), Operation::OpenRead, "success"),
730 access(&path("src/debug.log"), Operation::OpenRead, "success"),
731 access(&path("src/a.ts"), Operation::Metadata, "success"),
733 ];
734 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
735 assert_eq!(
736 set,
737 PathSet {
738 reads: ["src/a.ts".into()].into(),
739 listings: ["icons".into()].into(),
740 present: ["src/b.ts".into()].into(),
741 absent: ["config.local".into()].into(),
742 written: ["icons/sprite.svg".into()].into(),
743 }
744 );
745 }
746
747 #[test]
748 fn values_change_with_contents_listings_and_existence() {
749 let temp = tempfile::tempdir().unwrap();
750 let root = temp.path().canonicalize().unwrap();
751 std::fs::create_dir_all(root.join("icons")).unwrap();
752 std::fs::write(root.join("a.txt"), "one").unwrap();
753 std::fs::write(root.join("icons/a.svg"), "").unwrap();
754 std::fs::write(root.join("nx.json"), "{}").unwrap();
755 std::fs::write(
756 root.join("project.json"),
757 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
758 )
759 .unwrap();
760 let workspace = qk_config::Workspace::load(&root).unwrap();
761 let graph = qk_taskgraph::TaskGraph::build(
762 &workspace,
763 &[qk_taskgraph::Request::parse("app:build").unwrap()],
764 )
765 .unwrap();
766 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
767 std::fs::write(root.join("checked.txt"), "one").unwrap();
768 let set = PathSet {
769 reads: ["a.txt".into()].into(),
770 listings: ["icons".into()].into(),
771 present: ["checked.txt".into()].into(),
772 absent: ["optional.txt".into()].into(),
773 written: ["icons/sprite.svg".into()].into(),
774 };
775 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
776 let unlisted = Unlisted {
777 own: vec![&outputs],
778 others: None,
779 ignore: &[],
780 };
781 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
782 let first = strong();
783 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
786 std::fs::create_dir(root.join("icons/out")).unwrap();
787 std::fs::write(root.join("b.txt"), "unread").unwrap();
788 assert_eq!(strong(), first);
789 for change in [
790 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
791 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
792 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
793 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
795 ] {
796 let before = strong();
797 change(&root);
798 assert_ne!(strong(), before);
799 }
800 }
801
802 #[test]
803 fn keeps_the_newest_sets_first() {
804 let temp = tempfile::tempdir().unwrap();
805 std::fs::create_dir(temp.path().join("tmp")).unwrap();
806 let set = |read: &str| PathSet {
807 reads: [read.to_owned()].into(),
808 ..PathSet::default()
809 };
810 for index in 0..10 {
811 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
812 }
813 save(temp.path(), "weak", set("5")).unwrap();
814 let kept: Vec<String> = load(temp.path(), "weak")
815 .into_iter()
816 .map(|set| set.reads.into_iter().next().unwrap())
817 .collect();
818 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
819 assert!(load(temp.path(), "other").is_empty());
820 }
821
822 #[test]
823 fn merges_the_sets_another_machine_recorded_behind_its_own() {
824 let temp = tempfile::tempdir().unwrap();
825 std::fs::create_dir(temp.path().join("tmp")).unwrap();
826 let set = |read: &str| PathSet {
827 reads: [read.to_owned()].into(),
828 ..PathSet::default()
829 };
830 save(temp.path(), "weak", set("a")).unwrap();
831 let record = |weak: &str, sets: Vec<PathSet>| {
832 serde_json::to_vec(&Stored {
833 version: 1,
834 weak: weak.into(),
835 sets,
836 })
837 .unwrap()
838 };
839 let fetched = record(
840 "weak",
841 vec![set("b"), set("a"), set("../escape"), set("/abs")],
842 );
843 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
844 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
845 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
846 assert!(
847 merge(temp.path(), "weak", &record("other", vec![set("c")]))
848 .unwrap()
849 .is_empty()
850 );
851 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
852 assert_eq!(
853 merge(temp.path(), "weak", &record("weak", many))
854 .unwrap()
855 .len(),
856 6
857 );
858 assert_eq!(load(temp.path(), "weak").len(), KEPT);
859 }
860
861 #[test]
862 fn finds_what_changed_while_the_task_ran() {
863 let temp = tempfile::tempdir().unwrap();
864 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
865 std::fs::write(temp.path().join(path), "before").unwrap();
866 }
867 for directory in ["listed", "written"] {
868 std::fs::create_dir(temp.path().join(directory)).unwrap();
869 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
870 }
871 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
872 let set = PathSet {
873 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
874 present: ["found.txt".to_owned(), ".".to_owned()].into(),
875 listings: ["listed".to_owned(), "written".to_owned()].into(),
876 written: ["written/new.txt".to_owned()].into(),
877 ..PathSet::default()
878 };
879 std::thread::sleep(std::time::Duration::from_millis(20));
880 let started = now(temp.path());
881 let changed = |set: &PathSet| {
882 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
883 .map(str::to_owned)
884 };
885 assert_eq!(changed(&set), None);
886 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
888 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
889 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
890 let unlisted = PathSet {
892 listings: ["written".to_owned()].into(),
893 ..set.clone()
894 };
895 assert_eq!(changed(&unlisted), None);
896 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
897 assert_eq!(changed(&set), Some("found.txt".into()));
898 let listings = PathSet {
899 reads: [].into(),
900 present: [].into(),
901 ..set.clone()
902 };
903 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
906 assert_eq!(changed(&listings), None);
907 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
909 if std::fs::metadata(temp.path().join("written/other.txt"))
910 .and_then(|metadata| metadata.created())
911 .is_ok()
912 {
913 assert_eq!(changed(&listings), Some("written".into()));
914 }
915 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
916 #[cfg(unix)]
919 {
920 std::fs::rename(
921 temp.path().join("before.txt"),
922 temp.path().join("written/moved.txt"),
923 )
924 .unwrap();
925 assert_eq!(changed(&listings), Some("written".into()));
926 std::fs::rename(
927 temp.path().join("written/moved.txt"),
928 temp.path().join("before.txt"),
929 )
930 .unwrap();
931 }
932 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
934 assert_eq!(changed(&listings), Some("listed".into()));
935 let removed = tempfile::tempdir().unwrap();
937 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
938 std::thread::sleep(std::time::Duration::from_millis(20));
939 let started = now(temp.path());
942 let root = PathSet {
943 listings: [".".to_owned()].into(),
944 ..PathSet::default()
945 };
946 assert_eq!(
947 root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
948 None
949 );
950 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
951 assert_eq!(
952 root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
953 Some(".")
954 );
955 }
956
957 #[test]
958 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
959 let temp = tempfile::tempdir().unwrap();
960 let root = &temp.path().canonicalize().unwrap();
961 std::fs::create_dir_all(root.join("app/src")).unwrap();
962 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
963 std::fs::write(root.join("nx.json"), "{}").unwrap();
964 std::fs::write(
965 root.join("project.json"),
966 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
967 )
968 .unwrap();
969 let workspace = qk_config::Workspace::load(root).unwrap();
970 let graph = qk_taskgraph::TaskGraph::build(
971 &workspace,
972 &[qk_taskgraph::Request::parse("app:build").unwrap()],
973 )
974 .unwrap();
975 let set = PathSet {
976 listings: ["app".to_owned()].into(),
977 ..PathSet::default()
978 };
979 std::fs::write(root.join("app/kept.txt"), "").unwrap();
980 std::fs::create_dir(root.join("app/out")).unwrap();
981 let outputs = WorkspaceOutputs::new(vec![
982 (
983 ("app".into(), "tsc".into(), None),
984 Outputs::from_patterns(&["app/.tsc"]),
985 ),
986 (
987 ("app".into(), "codegen".into(), None),
988 Outputs::from_patterns(&["app/generated"]),
989 ),
990 (
992 ("app".into(), "bundle".into(), None),
993 Outputs::from_patterns(&["app/out/*.js"]),
994 ),
995 (
997 ("app".into(), "package".into(), None),
998 Outputs::from_patterns(&["app/generated"]),
999 ),
1000 ]);
1001 let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1002 let unlisted = Unlisted {
1003 own: Vec::new(),
1004 others: Some(Others {
1005 outputs: &outputs,
1006 upstream: [
1008 ("app".into(), "build".into(), None),
1009 ("app".into(), "codegen".into(), None),
1010 ]
1011 .into(),
1012 }),
1013 ignore: &ignore,
1014 };
1015 assert_eq!(
1016 unlisted.directories("app"),
1017 [
1018 "app".to_owned(),
1019 "app/.tsc".to_owned(),
1020 "app/generated".to_owned(),
1021 "app/out".to_owned()
1022 ]
1023 .into()
1024 );
1025 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1026 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1027 let before = key();
1028 std::thread::sleep(std::time::Duration::from_millis(20));
1029 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1030 let started = now(root);
1031 let changed = |taken: &Before| set.changed_since(root, started, &unlisted, taken);
1032 std::fs::create_dir(root.join("app/.tsc")).unwrap();
1034 std::fs::create_dir(root.join("app/.fallow")).unwrap();
1035 assert_eq!(key(), before);
1036 assert_eq!(changed(&taken), None);
1037 std::fs::write(root.join("app/out/new.js"), "").unwrap();
1039 let out = PathSet {
1040 listings: ["app/out".to_owned()].into(),
1041 ..PathSet::default()
1042 };
1043 assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1044 assert_eq!(
1045 out.changed_since(root, started, &unlisted, &Before::default()),
1046 Some("app/out")
1047 );
1048 assert!(!unlisted.matches("app/generated"));
1050 assert!(unlisted.matches("app/.tsc/index.js"));
1051 assert_eq!(changed(&Before::default()), Some("app"));
1054 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1056 assert_ne!(key(), before);
1057 assert_eq!(changed(&taken), Some("app"));
1058 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1059 assert_eq!(changed(&taken), None);
1060 std::fs::write(root.join("app/README.md"), "").unwrap();
1062 assert_ne!(key(), before);
1063 assert_eq!(changed(&taken), Some("app"));
1064 }
1065}