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
396pub(crate) struct Unlisted<'a> {
403 pub own: Vec<&'a Outputs>,
404 pub siblings: &'a [Outputs],
405 pub ignore: &'a [Pattern],
406}
407
408impl Unlisted<'_> {
409 fn matches(&self, path: &str) -> bool {
410 self.own(path)
411 || self.siblings.iter().any(|outputs| outputs.matches(path))
412 || self.ignore.iter().any(|pattern| pattern.is_match(path))
413 }
414
415 fn own(&self, path: &str) -> bool {
416 self.own.iter().any(|outputs| outputs.matches(path))
417 }
418
419 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
423 let project_root = if project_root.is_empty() {
424 "."
425 } else {
426 project_root
427 };
428 self.siblings
429 .iter()
430 .flat_map(Outputs::anchors)
431 .map(|anchor| parent(anchor).to_owned())
432 .chain([project_root.to_owned()])
433 .collect()
434 }
435}
436
437#[derive(Default)]
441pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
442
443impl Before {
444 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
445 Self(
446 directories
447 .into_iter()
448 .filter_map(|directory| {
449 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
450 Some((directory, entries))
451 })
452 .collect(),
453 )
454 }
455}
456
457#[derive(Serialize, Deserialize)]
459struct Stored {
460 version: u32,
461 weak: String,
462 sets: Vec<PathSet>,
463}
464
465fn location(cache: &Path, weak: &str) -> PathBuf {
466 cache.join("pathsets").join(format!("{weak}.json"))
467}
468
469pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
472 std::fs::read(location(cache, weak))
473 .ok()
474 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
475 .filter(|stored| stored.version == 1 && stored.weak == weak)
476 .map(|stored| stored.sets)
477 .unwrap_or_default()
478}
479
480pub(crate) fn touch(cache: &Path, weak: &str) {
483 if let Ok(file) = std::fs::File::options()
484 .append(true)
485 .open(location(cache, weak))
486 {
487 let _ = file.set_modified(std::time::SystemTime::now());
488 }
489}
490
491pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
493 let mut sets = load(cache, weak);
494 sets.retain(|kept| kept != &set);
495 sets.insert(0, set);
496 keep(cache, weak, sets)
497}
498
499pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
502 std::fs::read(location(cache, weak)).ok()
503}
504
505pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
510 let Some(fetched) = serde_json::from_slice::<Stored>(record)
511 .ok()
512 .filter(|stored| stored.version == 1 && stored.weak == weak)
513 else {
514 return Ok(Vec::new());
515 };
516 let mut sets = load(cache, weak);
517 let mut added = Vec::new();
518 for set in fetched.sets {
519 if sets.len() + added.len() < KEPT
520 && set.is_relative()
521 && !set.is_empty()
522 && !sets.contains(&set)
523 && !added.contains(&set)
524 {
525 added.push(set);
526 }
527 }
528 if !added.is_empty() {
529 sets.extend(added.iter().cloned());
530 keep(cache, weak, sets)?;
531 }
532 Ok(added)
533}
534
535fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
536 sets.truncate(KEPT);
537 std::fs::create_dir_all(cache.join("pathsets"))?;
538 crate::record::publish(
539 cache,
540 &location(cache, weak),
541 &Stored {
542 version: 1,
543 weak: weak.to_owned(),
544 sets,
545 },
546 )
547}
548
549pub(crate) fn strong_key(
551 weak: &str,
552 set: &PathSet,
553 snapshot: &Snapshot,
554 root: &Path,
555 unlisted: &Unlisted,
556) -> Result<String> {
557 key(&json!({
558 "schema": "qk-recorded-v1",
559 "weak": weak,
560 "set": set.digest()?,
561 "values": set.values(snapshot, root, unlisted)?,
562 }))
563}
564
565pub(crate) fn now(directory: &Path) -> SystemTime {
570 tempfile::NamedTempFile::new_in(directory)
571 .and_then(|file| file.as_file().metadata())
572 .map(|metadata| changed(&metadata))
573 .unwrap_or_else(|_| SystemTime::now())
574}
575
576fn changed(metadata: &std::fs::Metadata) -> SystemTime {
579 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
580 #[cfg(unix)]
581 {
582 use std::os::unix::fs::MetadataExt;
583 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
584 SystemTime::UNIX_EPOCH
585 + std::time::Duration::new(
586 seconds,
587 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
588 )
589 });
590 if let Some(ctime) = ctime {
591 return modified.max(ctime);
592 }
593 }
594 modified
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 const NOTHING: Unlisted<'static> = Unlisted {
603 own: Vec::new(),
604 siblings: &[],
605 ignore: &[],
606 };
607 use qk_input_analysis::Category;
608
609 fn access(path: &str, operation: Operation, result: &str) -> Access {
610 Access {
611 process: 1,
612 path: path.into(),
613 resolved_path: None,
614 keyed_path: None,
615 package: None,
616 operation,
617 result: result.into(),
618 category: Category::Uncovered,
619 }
620 }
621
622 #[test]
623 fn keeps_what_the_task_read_listed_and_looked_up() {
624 let temp = tempfile::tempdir().unwrap();
625 let root = temp.path().canonicalize().unwrap();
626 for directory in ["src", "icons", "dist"] {
627 std::fs::create_dir(root.join(directory)).unwrap();
628 }
629 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
630 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
631 let exclusions = Exclusions {
632 roots: [&root, &root],
633 outputs: &[&outputs],
634 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
635 state: vec![".qk".into()],
636 };
637 let accesses = [
638 access(&path("src/a.ts"), Operation::OpenRead, "success"),
639 access(&path("src"), Operation::OpenRead, "success"),
640 access(&path("icons"), Operation::ListDirectory, "success"),
641 access(&path("config.local"), Operation::Metadata, "missing"),
642 access(&path("src/b.ts"), Operation::Metadata, "success"),
643 access(&path("icons/sprite.svg"), Operation::Write, "success"),
645 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
646 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
648 access(
649 &path("node_modules/x/index.js"),
650 Operation::OpenRead,
651 "success",
652 ),
653 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
654 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
655 access(&path("dist/out.js"), Operation::OpenRead, "success"),
656 access(&path("src/debug.log"), Operation::OpenRead, "success"),
657 access(&path("src/a.ts"), Operation::Metadata, "success"),
659 ];
660 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
661 assert_eq!(
662 set,
663 PathSet {
664 reads: ["src/a.ts".into()].into(),
665 listings: ["icons".into()].into(),
666 present: ["src/b.ts".into()].into(),
667 absent: ["config.local".into()].into(),
668 written: ["icons/sprite.svg".into()].into(),
669 }
670 );
671 }
672
673 #[test]
674 fn values_change_with_contents_listings_and_existence() {
675 let temp = tempfile::tempdir().unwrap();
676 let root = temp.path().canonicalize().unwrap();
677 std::fs::create_dir_all(root.join("icons")).unwrap();
678 std::fs::write(root.join("a.txt"), "one").unwrap();
679 std::fs::write(root.join("icons/a.svg"), "").unwrap();
680 std::fs::write(root.join("nx.json"), "{}").unwrap();
681 std::fs::write(
682 root.join("project.json"),
683 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
684 )
685 .unwrap();
686 let workspace = qk_config::Workspace::load(&root).unwrap();
687 let graph = qk_taskgraph::TaskGraph::build(
688 &workspace,
689 &[qk_taskgraph::Request::parse("app:build").unwrap()],
690 )
691 .unwrap();
692 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
693 std::fs::write(root.join("checked.txt"), "one").unwrap();
694 let set = PathSet {
695 reads: ["a.txt".into()].into(),
696 listings: ["icons".into()].into(),
697 present: ["checked.txt".into()].into(),
698 absent: ["optional.txt".into()].into(),
699 written: ["icons/sprite.svg".into()].into(),
700 };
701 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
702 let unlisted = Unlisted {
703 own: vec![&outputs],
704 siblings: &[],
705 ignore: &[],
706 };
707 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
708 let first = strong();
709 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
712 std::fs::create_dir(root.join("icons/out")).unwrap();
713 std::fs::write(root.join("b.txt"), "unread").unwrap();
714 assert_eq!(strong(), first);
715 for change in [
716 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
717 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
718 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
719 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
721 ] {
722 let before = strong();
723 change(&root);
724 assert_ne!(strong(), before);
725 }
726 }
727
728 #[test]
729 fn keeps_the_newest_sets_first() {
730 let temp = tempfile::tempdir().unwrap();
731 std::fs::create_dir(temp.path().join("tmp")).unwrap();
732 let set = |read: &str| PathSet {
733 reads: [read.to_owned()].into(),
734 ..PathSet::default()
735 };
736 for index in 0..10 {
737 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
738 }
739 save(temp.path(), "weak", set("5")).unwrap();
740 let kept: Vec<String> = load(temp.path(), "weak")
741 .into_iter()
742 .map(|set| set.reads.into_iter().next().unwrap())
743 .collect();
744 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
745 assert!(load(temp.path(), "other").is_empty());
746 }
747
748 #[test]
749 fn merges_the_sets_another_machine_recorded_behind_its_own() {
750 let temp = tempfile::tempdir().unwrap();
751 std::fs::create_dir(temp.path().join("tmp")).unwrap();
752 let set = |read: &str| PathSet {
753 reads: [read.to_owned()].into(),
754 ..PathSet::default()
755 };
756 save(temp.path(), "weak", set("a")).unwrap();
757 let record = |weak: &str, sets: Vec<PathSet>| {
758 serde_json::to_vec(&Stored {
759 version: 1,
760 weak: weak.into(),
761 sets,
762 })
763 .unwrap()
764 };
765 let fetched = record(
766 "weak",
767 vec![set("b"), set("a"), set("../escape"), set("/abs")],
768 );
769 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
770 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
771 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
772 assert!(
773 merge(temp.path(), "weak", &record("other", vec![set("c")]))
774 .unwrap()
775 .is_empty()
776 );
777 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
778 assert_eq!(
779 merge(temp.path(), "weak", &record("weak", many))
780 .unwrap()
781 .len(),
782 6
783 );
784 assert_eq!(load(temp.path(), "weak").len(), KEPT);
785 }
786
787 #[test]
788 fn finds_what_changed_while_the_task_ran() {
789 let temp = tempfile::tempdir().unwrap();
790 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
791 std::fs::write(temp.path().join(path), "before").unwrap();
792 }
793 for directory in ["listed", "written"] {
794 std::fs::create_dir(temp.path().join(directory)).unwrap();
795 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
796 }
797 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
798 let set = PathSet {
799 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
800 present: ["found.txt".to_owned(), ".".to_owned()].into(),
801 listings: ["listed".to_owned(), "written".to_owned()].into(),
802 written: ["written/new.txt".to_owned()].into(),
803 ..PathSet::default()
804 };
805 std::thread::sleep(std::time::Duration::from_millis(20));
806 let started = now(temp.path());
807 let changed = |set: &PathSet| {
808 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
809 .map(str::to_owned)
810 };
811 assert_eq!(changed(&set), None);
812 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
814 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
815 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
816 let unlisted = PathSet {
818 listings: ["written".to_owned()].into(),
819 ..set.clone()
820 };
821 assert_eq!(changed(&unlisted), None);
822 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
823 assert_eq!(changed(&set), Some("found.txt".into()));
824 let listings = PathSet {
825 reads: [].into(),
826 present: [].into(),
827 ..set.clone()
828 };
829 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
832 assert_eq!(changed(&listings), None);
833 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
835 if std::fs::metadata(temp.path().join("written/other.txt"))
836 .and_then(|metadata| metadata.created())
837 .is_ok()
838 {
839 assert_eq!(changed(&listings), Some("written".into()));
840 }
841 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
842 #[cfg(unix)]
845 {
846 std::fs::rename(
847 temp.path().join("before.txt"),
848 temp.path().join("written/moved.txt"),
849 )
850 .unwrap();
851 assert_eq!(changed(&listings), Some("written".into()));
852 std::fs::rename(
853 temp.path().join("written/moved.txt"),
854 temp.path().join("before.txt"),
855 )
856 .unwrap();
857 }
858 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
860 assert_eq!(changed(&listings), Some("listed".into()));
861 let removed = tempfile::tempdir().unwrap();
863 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
864 std::thread::sleep(std::time::Duration::from_millis(20));
865 let started = now(temp.path());
868 let root = PathSet {
869 listings: [".".to_owned()].into(),
870 ..PathSet::default()
871 };
872 assert_eq!(
873 root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
874 None
875 );
876 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
877 assert_eq!(
878 root.changed_since(removed.path(), started, &NOTHING, &Before::default()),
879 Some(".")
880 );
881 }
882
883 #[test]
884 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
885 let temp = tempfile::tempdir().unwrap();
886 let root = &temp.path().canonicalize().unwrap();
887 std::fs::create_dir_all(root.join("app/src")).unwrap();
888 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
889 std::fs::write(root.join("nx.json"), "{}").unwrap();
890 std::fs::write(
891 root.join("project.json"),
892 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
893 )
894 .unwrap();
895 let workspace = qk_config::Workspace::load(root).unwrap();
896 let graph = qk_taskgraph::TaskGraph::build(
897 &workspace,
898 &[qk_taskgraph::Request::parse("app:build").unwrap()],
899 )
900 .unwrap();
901 let set = PathSet {
902 listings: ["app".to_owned()].into(),
903 ..PathSet::default()
904 };
905 std::fs::write(root.join("app/kept.txt"), "").unwrap();
906 let build = [Outputs::from_patterns(&["app/.syb-ts"])];
907 let ignore = [Pattern::new("**/.fallow", false).unwrap()];
908 let unlisted = Unlisted {
909 own: Vec::new(),
910 siblings: &build,
911 ignore: &ignore,
912 };
913 assert_eq!(unlisted.directories("app"), ["app".to_owned()].into());
914 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
915 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
916 let before = key();
917 std::thread::sleep(std::time::Duration::from_millis(20));
918 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
919 let started = now(root);
920 let changed = |taken: &Before| set.changed_since(root, started, &unlisted, taken);
921 std::fs::create_dir(root.join("app/.syb-ts")).unwrap();
923 std::fs::create_dir(root.join("app/.fallow")).unwrap();
924 assert_eq!(key(), before);
925 assert_eq!(changed(&taken), None);
926 assert_eq!(changed(&Before::default()), Some("app"));
929 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
931 assert_ne!(key(), before);
932 assert_eq!(changed(&taken), Some("app"));
933 std::fs::write(root.join("app/kept.txt"), "").unwrap();
934 assert_eq!(changed(&taken), None);
935 std::fs::write(root.join("app/README.md"), "").unwrap();
937 assert_ne!(key(), before);
938 assert_eq!(changed(&taken), Some("app"));
939 }
940}