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<Changed<'_>> {
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 if let Some(path) = file {
211 return Some(Changed { path, how: None });
212 }
213 self.listings.iter().find_map(|directory| {
214 self.listing_changed(root, directory, started, unlisted, before)
215 .map(|how| Changed {
216 path: directory,
217 how,
218 })
219 })
220 }
221
222 fn listing_changed(
225 &self,
226 root: &Path,
227 directory: &str,
228 started: SystemTime,
229 unlisted: &Unlisted,
230 before: &Before,
231 ) -> Option<Option<String>> {
232 if directory != "." && unlisted.matches(directory) {
235 return None;
236 }
237 let absolute = if directory == "." {
238 root.to_path_buf()
239 } else {
240 root.join(directory)
241 };
242 let skipped = |path: &str| self.written.contains(path) || unlisted.matches(path);
243 if let Some(earlier) = before.0.get(directory) {
244 let earlier: BTreeSet<_> = earlier
245 .iter()
246 .filter(|(name, _)| !self.written.contains(&child(directory, name)))
247 .cloned()
248 .collect();
249 let Some(now) = entries(root, directory, skipped) else {
250 return Some(Some("it could no longer be read".to_owned()));
251 };
252 if let Some((name, _)) = now.difference(&earlier).next() {
253 return Some(Some(format!("{name} added")));
254 }
255 if let Some((name, _)) = earlier.difference(&now).next() {
256 return Some(Some(format!("{name} removed")));
257 }
258 return None;
259 }
260 let Ok(entries) = std::fs::read_dir(&absolute) else {
261 return Some(Some("it was removed or could no longer be read".to_owned()));
262 };
263 let entries: Vec<(std::fs::DirEntry, bool, bool)> = entries
266 .flatten()
267 .map(|entry| {
268 let path = child(directory, &entry.file_name().to_string_lossy());
269 let own = self.written.contains(&path) || unlisted.own(&path);
270 (entry, skipped(&path), own)
271 })
272 .collect();
273 let new = |entry: &std::fs::DirEntry| {
277 std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| {
278 if metadata.is_dir() {
279 metadata.created().is_ok_and(|born| born >= started)
280 } else {
281 changed(&metadata) >= started
282 }
283 })
284 };
285 let unskipped = || entries.iter().filter(|(_, skipped, _)| !skipped);
286 let name = |entry: &std::fs::DirEntry| entry.file_name().to_string_lossy().into_owned();
287 let own = self.written.iter().any(|path| parent(path) == directory)
288 || entries.iter().any(|(_, _, own)| *own);
289 if !own {
290 let moved = std::fs::symlink_metadata(&absolute)
291 .and_then(|metadata| metadata.modified())
292 .is_ok_and(|modified| modified >= started);
293 return moved.then(|| {
294 Some(unskipped().find(|(entry, _, _)| new(entry)).map_or_else(
295 || "an entry removed or renamed".to_owned(),
296 |(entry, _, _)| format!("{} added or changed", name(entry)),
297 ))
298 });
299 }
300 unskipped()
301 .find(|(entry, _, _)| new(entry))
302 .map(|(entry, _, _)| Some(format!("{} added or changed", name(entry))))
303 }
304
305 pub fn digest(&self) -> Result<String> {
306 key(&serde_json::to_value(self)?)
307 }
308
309 pub(crate) fn values(
313 &self,
314 snapshot: &Snapshot,
315 root: &Path,
316 unlisted: &Unlisted,
317 ) -> Result<Value> {
318 let value = |path: &str| match kind(&root.join(path)) {
320 "file" | "symlink" => snapshot
321 .recorded_value(root, path)
322 .unwrap_or_else(|_| json!({"unreadable": true})),
323 other => json!(other),
324 };
325 let reads: Map<String, Value> = self
326 .reads
327 .iter()
328 .map(|path| (path.clone(), value(path)))
329 .collect();
330 let mut listings = Map::new();
331 for directory in &self.listings {
332 listings.insert(directory.clone(), self.listing(root, directory, unlisted)?);
333 }
334 let existence: Map<String, Value> = self
335 .present
336 .iter()
337 .chain(&self.absent)
338 .map(|path| (path.clone(), value(path)))
339 .collect();
340 Ok(json!({"reads": reads, "listings": listings, "existence": existence}))
341 }
342
343 fn listing(&self, root: &Path, directory: &str, unlisted: &Unlisted) -> Result<Value> {
345 let absolute = if directory == "." {
346 root.to_path_buf()
347 } else {
348 root.join(directory)
349 };
350 let entries = match std::fs::read_dir(&absolute) {
351 Ok(entries) => entries,
352 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
353 return Ok(json!("absent"));
354 }
355 Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
356 return Ok(json!(kind(&absolute)));
357 }
358 Err(error) => return Err(error.into()),
359 };
360 let mut listed = Map::new();
361 for entry in entries {
362 let entry = entry?;
363 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
364 continue;
365 };
366 let path = if directory == "." {
367 name.clone()
368 } else {
369 format!("{directory}/{name}")
370 };
371 if self.written.contains(&path) || unlisted.matches(&path) {
372 continue;
373 }
374 listed.insert(name, json!(kind(&entry.path())));
375 }
376 Ok(Value::Object(listed))
377 }
378}
379
380fn parent(path: &str) -> &str {
382 path.rsplit_once('/').map_or(".", |(parent, _)| parent)
383}
384
385fn child(directory: &str, name: &str) -> String {
387 if directory == "." {
388 name.to_owned()
389 } else {
390 format!("{directory}/{name}")
391 }
392}
393
394fn entries(
397 root: &Path,
398 directory: &str,
399 skipped: impl Fn(&str) -> bool,
400) -> Option<BTreeSet<(String, &'static str)>> {
401 let absolute = if directory == "." {
402 root.to_path_buf()
403 } else {
404 root.join(directory)
405 };
406 let mut found = BTreeSet::new();
407 for entry in std::fs::read_dir(&absolute).ok()? {
408 let entry = entry.ok()?;
409 let name = entry.file_name().to_string_lossy().into_owned();
410 if !skipped(&child(directory, &name)) {
411 found.insert((name, file_kind(entry.file_type().ok()?)));
412 }
413 }
414 Some(found)
415}
416
417fn kind(path: &Path) -> &'static str {
419 std::fs::symlink_metadata(path).map_or("absent", |metadata| file_kind(metadata.file_type()))
420}
421
422fn file_kind(file_type: std::fs::FileType) -> &'static str {
423 if file_type.is_symlink() {
424 "symlink"
425 } else if file_type.is_dir() {
426 "directory"
427 } else if file_type.is_file() {
428 "file"
429 } else {
430 "other"
431 }
432}
433
434fn relative_root(root: &str) -> &str {
438 let mut root = root.trim_end_matches('/');
439 while let Some(rest) = root.strip_prefix("./") {
440 root = rest;
441 }
442 if root == "." { "" } else { root }
443}
444
445#[derive(Debug, PartialEq, Eq)]
448pub(crate) struct Changed<'a> {
449 pub path: &'a str,
450 pub how: Option<String>,
451}
452
453impl std::fmt::Display for Changed<'_> {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 match &self.how {
456 Some(how) => write!(f, "{} changed while it ran ({how})", self.path),
457 None => write!(f, "{} changed while it ran", self.path),
458 }
459 }
460}
461
462#[derive(Default)]
468pub(crate) struct WorkspaceOutputs {
469 outputs: Vec<(TargetOf, Outputs)>,
470 by_anchor: BTreeMap<String, Vec<usize>>,
471 folders: Vec<Vec<String>>,
474 by_folder: BTreeMap<String, Vec<usize>>,
475}
476
477impl WorkspaceOutputs {
478 pub(crate) fn matches(&self, path: &str) -> bool {
481 self.outputs
482 .iter()
483 .any(|(_, outputs)| outputs.matches(path))
484 }
485}
486
487impl WorkspaceOutputs {
488 pub(crate) fn new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
490 let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
491 let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
492 let mut folders = Vec::new();
493 let projects: BTreeSet<&str> = roots
496 .values()
497 .map(|root| relative_root(root))
498 .filter(|root| !root.is_empty())
499 .flat_map(|root| {
500 std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
501 })
502 .collect();
503 for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
504 let root = roots.get(project).map_or("", |root| relative_root(root));
505 let mut held = Vec::new();
506 for anchor in declared.anchors() {
507 by_anchor.entry(anchor.to_owned()).or_default().push(index);
508 let rest = if root.is_empty() {
509 Some(anchor)
510 } else {
511 anchor
512 .strip_prefix(root)
513 .and_then(|rest| rest.strip_prefix('/'))
514 };
515 let Some(rest) = rest else {
516 continue;
517 };
518 for (at, _) in rest.match_indices('/') {
520 let folder = if root.is_empty() {
521 rest[..at].to_owned()
522 } else {
523 format!("{root}/{}", &rest[..at])
524 };
525 if projects.contains(folder.as_str()) {
526 continue;
527 }
528 by_folder.entry(folder.clone()).or_default().push(index);
529 held.push(folder);
530 }
531 }
532 folders.push(held);
533 }
534 Self {
535 outputs,
536 by_anchor,
537 folders,
538 by_folder,
539 }
540 }
541
542 fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
544 std::iter::once(path)
545 .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
546 .filter_map(|anchor| self.by_anchor.get(anchor))
547 .flatten()
548 .map(|&index| &self.outputs[index])
549 }
550}
551
552pub(crate) type TargetOf = (String, String, Option<String>);
555
556pub(crate) struct Others<'a> {
560 pub outputs: &'a WorkspaceOutputs,
561 pub upstream: BTreeSet<TargetOf>,
563}
564
565impl Others<'_> {
566 pub(crate) fn matches(&self, path: &str) -> bool {
573 let mut other = false;
574 let holding = self
575 .outputs
576 .holding(path)
577 .filter(|(_, outputs)| outputs.matches(path))
578 .map(|(target, _)| target);
579 let folders = self
580 .outputs
581 .by_folder
582 .get(path)
583 .into_iter()
584 .flatten()
585 .map(|&index| &self.outputs.outputs[index].0);
586 for target in holding.chain(folders) {
587 if self.upstream.contains(target) {
588 return false;
589 }
590 other = true;
591 }
592 other
593 }
594}
595
596pub(crate) struct Unlisted<'a> {
604 pub own: Vec<&'a Outputs>,
605 pub others: Option<Others<'a>>,
606 pub ignore: &'a [Pattern],
607}
608
609impl Unlisted<'_> {
610 fn matches(&self, path: &str) -> bool {
611 self.own(path)
612 || self
613 .others
614 .as_ref()
615 .is_some_and(|others| others.matches(path))
616 || self.ignore.iter().any(|pattern| pattern.is_match(path))
617 }
618
619 fn own(&self, path: &str) -> bool {
620 self.own.iter().any(|outputs| outputs.matches(path))
621 }
622
623 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
629 let project_root = match relative_root(project_root) {
630 "" => ".",
631 root => root,
632 };
633 let mut directories = BTreeSet::from([project_root.to_owned()]);
634 if let Some(others) = &self.others {
635 let all = &others.outputs;
636 for (index, (target, outputs)) in all.outputs.iter().enumerate() {
637 if others.upstream.contains(target) {
638 continue;
639 }
640 for anchor in outputs.anchors() {
641 directories.insert(anchor.to_owned());
642 directories.insert(parent(anchor).to_owned());
643 }
644 for folder in &all.folders[index] {
647 directories.insert(folder.clone());
648 directories.insert(parent(folder).to_owned());
649 }
650 }
651 }
652 directories
653 }
654}
655
656#[derive(Default)]
660pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
661
662impl Before {
663 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
664 Self(
665 directories
666 .into_iter()
667 .filter_map(|directory| {
668 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
669 Some((directory, entries))
670 })
671 .collect(),
672 )
673 }
674}
675
676#[derive(Serialize, Deserialize)]
678struct Stored {
679 version: u32,
680 weak: String,
681 sets: Vec<PathSet>,
682}
683
684fn location(cache: &Path, weak: &str) -> PathBuf {
685 cache.join("pathsets").join(format!("{weak}.json"))
686}
687
688pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
691 std::fs::read(location(cache, weak))
692 .ok()
693 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
694 .filter(|stored| stored.version == 1 && stored.weak == weak)
695 .map(|stored| stored.sets)
696 .unwrap_or_default()
697}
698
699pub(crate) fn touch(cache: &Path, weak: &str) {
702 if let Ok(file) = std::fs::File::options()
703 .append(true)
704 .open(location(cache, weak))
705 {
706 let _ = file.set_modified(std::time::SystemTime::now());
707 }
708}
709
710pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
712 let mut sets = load(cache, weak);
713 sets.retain(|kept| kept != &set);
714 sets.insert(0, set);
715 keep(cache, weak, sets)
716}
717
718pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
721 std::fs::read(location(cache, weak)).ok()
722}
723
724pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
729 let Some(fetched) = serde_json::from_slice::<Stored>(record)
730 .ok()
731 .filter(|stored| stored.version == 1 && stored.weak == weak)
732 else {
733 return Ok(Vec::new());
734 };
735 let mut sets = load(cache, weak);
736 let mut added = Vec::new();
737 for set in fetched.sets {
738 if sets.len() + added.len() < KEPT
739 && set.is_relative()
740 && !set.is_empty()
741 && !sets.contains(&set)
742 && !added.contains(&set)
743 {
744 added.push(set);
745 }
746 }
747 if !added.is_empty() {
748 sets.extend(added.iter().cloned());
749 keep(cache, weak, sets)?;
750 }
751 Ok(added)
752}
753
754fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
755 sets.truncate(KEPT);
756 std::fs::create_dir_all(cache.join("pathsets"))?;
757 crate::record::publish(
758 cache,
759 &location(cache, weak),
760 &Stored {
761 version: 1,
762 weak: weak.to_owned(),
763 sets,
764 },
765 )
766}
767
768pub(crate) fn strong_key(
770 weak: &str,
771 set: &PathSet,
772 snapshot: &Snapshot,
773 root: &Path,
774 unlisted: &Unlisted,
775) -> Result<String> {
776 key(&json!({
777 "schema": "qk-recorded-v1",
778 "weak": weak,
779 "set": set.digest()?,
780 "values": set.values(snapshot, root, unlisted)?,
781 }))
782}
783
784pub(crate) fn now(directory: &Path) -> SystemTime {
789 tempfile::NamedTempFile::new_in(directory)
790 .and_then(|file| file.as_file().metadata())
791 .map(|metadata| changed(&metadata))
792 .unwrap_or_else(|_| SystemTime::now())
793}
794
795fn changed(metadata: &std::fs::Metadata) -> SystemTime {
798 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
799 #[cfg(unix)]
800 {
801 use std::os::unix::fs::MetadataExt;
802 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
803 SystemTime::UNIX_EPOCH
804 + std::time::Duration::new(
805 seconds,
806 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
807 )
808 });
809 if let Some(ctime) = ctime {
810 return modified.max(ctime);
811 }
812 }
813 modified
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819
820 const NOTHING: Unlisted<'static> = Unlisted {
822 own: Vec::new(),
823 others: None,
824 ignore: &[],
825 };
826 use qk_input_analysis::Category;
827
828 fn access(path: &str, operation: Operation, result: &str) -> Access {
829 Access {
830 process: 1,
831 path: path.into(),
832 resolved_path: None,
833 keyed_path: None,
834 package: None,
835 operation,
836 result: result.into(),
837 category: Category::Uncovered,
838 }
839 }
840
841 #[test]
842 fn keeps_what_the_task_read_listed_and_looked_up() {
843 let temp = tempfile::tempdir().unwrap();
844 let root = temp.path().canonicalize().unwrap();
845 for directory in ["src", "icons", "dist"] {
846 std::fs::create_dir(root.join(directory)).unwrap();
847 }
848 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
849 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
850 let exclusions = Exclusions {
851 roots: [&root, &root],
852 outputs: &[&outputs],
853 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
854 state: vec![".qk".into()],
855 };
856 let accesses = [
857 access(&path("src/a.ts"), Operation::OpenRead, "success"),
858 access(&path("src"), Operation::OpenRead, "success"),
859 access(&path("icons"), Operation::ListDirectory, "success"),
860 access(&path("config.local"), Operation::Metadata, "missing"),
861 access(&path("src/b.ts"), Operation::Metadata, "success"),
862 access(&path("icons/sprite.svg"), Operation::Write, "success"),
864 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
865 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
867 access(
868 &path("node_modules/x/index.js"),
869 Operation::OpenRead,
870 "success",
871 ),
872 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
873 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
874 access(&path("dist/out.js"), Operation::OpenRead, "success"),
875 access(&path("src/debug.log"), Operation::OpenRead, "success"),
876 access(&path("src/a.ts"), Operation::Metadata, "success"),
878 ];
879 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
880 assert_eq!(
881 set,
882 PathSet {
883 reads: ["src/a.ts".into()].into(),
884 listings: ["icons".into()].into(),
885 present: ["src/b.ts".into()].into(),
886 absent: ["config.local".into()].into(),
887 written: ["icons/sprite.svg".into()].into(),
888 }
889 );
890 }
891
892 #[test]
893 fn values_change_with_contents_listings_and_existence() {
894 let temp = tempfile::tempdir().unwrap();
895 let root = temp.path().canonicalize().unwrap();
896 std::fs::create_dir_all(root.join("icons")).unwrap();
897 std::fs::write(root.join("a.txt"), "one").unwrap();
898 std::fs::write(root.join("icons/a.svg"), "").unwrap();
899 std::fs::write(root.join("nx.json"), "{}").unwrap();
900 std::fs::write(
901 root.join("project.json"),
902 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
903 )
904 .unwrap();
905 let workspace = qk_config::Workspace::load(&root).unwrap();
906 let graph = qk_taskgraph::TaskGraph::build(
907 &workspace,
908 &[qk_taskgraph::Request::parse("app:build").unwrap()],
909 )
910 .unwrap();
911 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
912 std::fs::write(root.join("checked.txt"), "one").unwrap();
913 let set = PathSet {
914 reads: ["a.txt".into()].into(),
915 listings: ["icons".into()].into(),
916 present: ["checked.txt".into()].into(),
917 absent: ["optional.txt".into()].into(),
918 written: ["icons/sprite.svg".into()].into(),
919 };
920 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
921 let unlisted = Unlisted {
922 own: vec![&outputs],
923 others: None,
924 ignore: &[],
925 };
926 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
927 let first = strong();
928 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
931 std::fs::create_dir(root.join("icons/out")).unwrap();
932 std::fs::write(root.join("b.txt"), "unread").unwrap();
933 assert_eq!(strong(), first);
934 for change in [
935 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
936 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
937 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
938 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
940 ] {
941 let before = strong();
942 change(&root);
943 assert_ne!(strong(), before);
944 }
945 }
946
947 #[test]
948 fn keeps_the_newest_sets_first() {
949 let temp = tempfile::tempdir().unwrap();
950 std::fs::create_dir(temp.path().join("tmp")).unwrap();
951 let set = |read: &str| PathSet {
952 reads: [read.to_owned()].into(),
953 ..PathSet::default()
954 };
955 for index in 0..10 {
956 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
957 }
958 save(temp.path(), "weak", set("5")).unwrap();
959 let kept: Vec<String> = load(temp.path(), "weak")
960 .into_iter()
961 .map(|set| set.reads.into_iter().next().unwrap())
962 .collect();
963 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
964 assert!(load(temp.path(), "other").is_empty());
965 }
966
967 #[test]
968 fn merges_the_sets_another_machine_recorded_behind_its_own() {
969 let temp = tempfile::tempdir().unwrap();
970 std::fs::create_dir(temp.path().join("tmp")).unwrap();
971 let set = |read: &str| PathSet {
972 reads: [read.to_owned()].into(),
973 ..PathSet::default()
974 };
975 save(temp.path(), "weak", set("a")).unwrap();
976 let record = |weak: &str, sets: Vec<PathSet>| {
977 serde_json::to_vec(&Stored {
978 version: 1,
979 weak: weak.into(),
980 sets,
981 })
982 .unwrap()
983 };
984 let fetched = record(
985 "weak",
986 vec![set("b"), set("a"), set("../escape"), set("/abs")],
987 );
988 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
989 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
990 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
991 assert!(
992 merge(temp.path(), "weak", &record("other", vec![set("c")]))
993 .unwrap()
994 .is_empty()
995 );
996 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
997 assert_eq!(
998 merge(temp.path(), "weak", &record("weak", many))
999 .unwrap()
1000 .len(),
1001 6
1002 );
1003 assert_eq!(load(temp.path(), "weak").len(), KEPT);
1004 }
1005
1006 #[test]
1007 fn finds_what_changed_while_the_task_ran() {
1008 let temp = tempfile::tempdir().unwrap();
1009 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1010 std::fs::write(temp.path().join(path), "before").unwrap();
1011 }
1012 for directory in ["listed", "written"] {
1013 std::fs::create_dir(temp.path().join(directory)).unwrap();
1014 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1015 }
1016 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1017 let set = PathSet {
1018 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1019 present: ["found.txt".to_owned(), ".".to_owned()].into(),
1020 listings: ["listed".to_owned(), "written".to_owned()].into(),
1021 written: ["written/new.txt".to_owned()].into(),
1022 ..PathSet::default()
1023 };
1024 std::thread::sleep(std::time::Duration::from_millis(20));
1025 let started = now(temp.path());
1026 let changed = |set: &PathSet| {
1027 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1028 .map(|changed| changed.path.to_owned())
1029 };
1030 assert_eq!(changed(&set), None);
1031 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1033 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1034 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1035 let unlisted = PathSet {
1037 listings: ["written".to_owned()].into(),
1038 ..set.clone()
1039 };
1040 assert_eq!(changed(&unlisted), None);
1041 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1042 assert_eq!(changed(&set), Some("found.txt".into()));
1043 let listings = PathSet {
1044 reads: [].into(),
1045 present: [].into(),
1046 ..set.clone()
1047 };
1048 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1051 assert_eq!(changed(&listings), None);
1052 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1054 if std::fs::metadata(temp.path().join("written/other.txt"))
1055 .and_then(|metadata| metadata.created())
1056 .is_ok()
1057 {
1058 assert_eq!(changed(&listings), Some("written".into()));
1059 }
1060 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1061 #[cfg(unix)]
1064 {
1065 std::fs::rename(
1066 temp.path().join("before.txt"),
1067 temp.path().join("written/moved.txt"),
1068 )
1069 .unwrap();
1070 assert_eq!(changed(&listings), Some("written".into()));
1071 std::fs::rename(
1072 temp.path().join("written/moved.txt"),
1073 temp.path().join("before.txt"),
1074 )
1075 .unwrap();
1076 }
1077 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1079 assert_eq!(changed(&listings), Some("listed".into()));
1080 let removed = tempfile::tempdir().unwrap();
1082 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1083 std::thread::sleep(std::time::Duration::from_millis(20));
1084 let started = now(temp.path());
1087 let root = PathSet {
1088 listings: [".".to_owned()].into(),
1089 ..PathSet::default()
1090 };
1091 assert_eq!(
1092 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1093 .map(|changed| changed.path),
1094 None
1095 );
1096 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1097 assert_eq!(
1098 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1099 .map(|changed| changed.path),
1100 Some(".")
1101 );
1102 }
1103
1104 #[test]
1105 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1106 let temp = tempfile::tempdir().unwrap();
1107 let root = &temp.path().canonicalize().unwrap();
1108 std::fs::create_dir_all(root.join("app/src")).unwrap();
1109 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1110 std::fs::write(root.join("nx.json"), "{}").unwrap();
1111 std::fs::write(
1112 root.join("project.json"),
1113 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1114 )
1115 .unwrap();
1116 let workspace = qk_config::Workspace::load(root).unwrap();
1117 let graph = qk_taskgraph::TaskGraph::build(
1118 &workspace,
1119 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1120 )
1121 .unwrap();
1122 let set = PathSet {
1123 listings: ["app".to_owned()].into(),
1124 ..PathSet::default()
1125 };
1126 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1127 std::fs::create_dir(root.join("app/out")).unwrap();
1128 let roots = [("app".to_owned(), "app".to_owned())].into();
1129 let outputs = WorkspaceOutputs::new(
1130 vec![
1131 (
1132 ("app".into(), "tsc".into(), None),
1133 Outputs::from_patterns(&["app/.tsc"]),
1134 ),
1135 (
1136 ("app".into(), "codegen".into(), None),
1137 Outputs::from_patterns(&["app/generated"]),
1138 ),
1139 (
1141 ("app".into(), "bundle".into(), None),
1142 Outputs::from_patterns(&["app/out/*.js"]),
1143 ),
1144 (
1146 ("app".into(), "package".into(), None),
1147 Outputs::from_patterns(&["app/generated"]),
1148 ),
1149 (
1151 ("app".into(), "deploy".into(), None),
1152 Outputs::from_patterns(&["app/.cache/deploy"]),
1153 ),
1154 ],
1155 &roots,
1156 );
1157 let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1158 let unlisted = Unlisted {
1159 own: Vec::new(),
1160 others: Some(Others {
1161 outputs: &outputs,
1162 upstream: [
1164 ("app".into(), "build".into(), None),
1165 ("app".into(), "codegen".into(), None),
1166 ]
1167 .into(),
1168 }),
1169 ignore: &ignore,
1170 };
1171 assert_eq!(
1172 unlisted.directories("app"),
1173 [
1174 "app".to_owned(),
1175 "app/.cache".to_owned(),
1176 "app/.cache/deploy".to_owned(),
1177 "app/.tsc".to_owned(),
1178 "app/generated".to_owned(),
1179 "app/out".to_owned()
1180 ]
1181 .into()
1182 );
1183 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1184 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1185 let before = key();
1186 std::thread::sleep(std::time::Duration::from_millis(20));
1187 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1188 let started = now(root);
1189 let changed = |taken: &Before| {
1190 set.changed_since(root, started, &unlisted, taken)
1191 .map(|changed| changed.path)
1192 };
1193 std::fs::create_dir(root.join("app/.tsc")).unwrap();
1196 std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
1197 std::fs::create_dir(root.join("app/.fallow")).unwrap();
1198 assert_eq!(key(), before);
1199 assert_eq!(changed(&taken), None);
1200 std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
1203 let output = PathSet {
1204 listings: ["app/.tsc".to_owned()].into(),
1205 ..PathSet::default()
1206 };
1207 assert_eq!(
1208 output.changed_since(root, started, &unlisted, &Before::default()),
1209 None
1210 );
1211 assert!(!unlisted.matches("app"));
1213 std::fs::create_dir(root.join("app/gone")).unwrap();
1215 let gone = PathSet {
1216 listings: ["app/gone".to_owned()].into(),
1217 ..PathSet::default()
1218 };
1219 std::fs::remove_dir(root.join("app/gone")).unwrap();
1220 assert_eq!(
1221 gone.changed_since(root, started, &unlisted, &Before::default())
1222 .map(|changed| changed.path),
1223 Some("app/gone")
1224 );
1225 std::fs::write(root.join("app/out/new.js"), "").unwrap();
1227 let out = PathSet {
1228 listings: ["app/out".to_owned()].into(),
1229 ..PathSet::default()
1230 };
1231 assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1232 assert_eq!(
1233 out.changed_since(root, started, &unlisted, &Before::default())
1234 .map(|changed| changed.path),
1235 Some("app/out")
1236 );
1237 assert!(!unlisted.matches("app/generated"));
1239 assert!(unlisted.matches("app/.tsc/index.js"));
1240 assert_eq!(changed(&Before::default()), Some("app"));
1243 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1245 assert_ne!(key(), before);
1246 assert_eq!(
1247 set.changed_since(root, started, &unlisted, &taken)
1248 .and_then(|changed| changed.how),
1249 Some("kept.txt removed".to_owned())
1250 );
1251 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1252 assert_eq!(changed(&taken), None);
1253 std::fs::write(root.join("app/README.md"), "").unwrap();
1255 assert_ne!(key(), before);
1256 assert_eq!(
1257 set.changed_since(root, started, &unlisted, &taken)
1258 .map(|changed| changed.to_string()),
1259 Some("app changed while it ran (README.md added)".to_owned())
1260 );
1261 }
1262}