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 new(outputs: Vec<(TargetOf, Outputs)>, roots: &BTreeMap<String, String>) -> Self {
480 let mut by_anchor: BTreeMap<String, Vec<usize>> = BTreeMap::new();
481 let mut by_folder: BTreeMap<String, Vec<usize>> = BTreeMap::new();
482 let mut folders = Vec::new();
483 let projects: BTreeSet<&str> = roots
486 .values()
487 .map(|root| relative_root(root))
488 .filter(|root| !root.is_empty())
489 .flat_map(|root| {
490 std::iter::once(root).chain(root.match_indices('/').map(|(at, _)| &root[..at]))
491 })
492 .collect();
493 for (index, ((project, _, _), declared)) in outputs.iter().enumerate() {
494 let root = roots.get(project).map_or("", |root| relative_root(root));
495 let mut held = Vec::new();
496 for anchor in declared.anchors() {
497 by_anchor.entry(anchor.to_owned()).or_default().push(index);
498 let rest = if root.is_empty() {
499 Some(anchor)
500 } else {
501 anchor
502 .strip_prefix(root)
503 .and_then(|rest| rest.strip_prefix('/'))
504 };
505 let Some(rest) = rest else {
506 continue;
507 };
508 for (at, _) in rest.match_indices('/') {
510 let folder = if root.is_empty() {
511 rest[..at].to_owned()
512 } else {
513 format!("{root}/{}", &rest[..at])
514 };
515 if projects.contains(folder.as_str()) {
516 continue;
517 }
518 by_folder.entry(folder.clone()).or_default().push(index);
519 held.push(folder);
520 }
521 }
522 folders.push(held);
523 }
524 Self {
525 outputs,
526 by_anchor,
527 folders,
528 by_folder,
529 }
530 }
531
532 fn holding<'a>(&'a self, path: &'a str) -> impl Iterator<Item = &'a (TargetOf, Outputs)> {
534 std::iter::once(path)
535 .chain(path.match_indices('/').map(|(at, _)| &path[..at]))
536 .filter_map(|anchor| self.by_anchor.get(anchor))
537 .flatten()
538 .map(|&index| &self.outputs[index])
539 }
540}
541
542pub(crate) type TargetOf = (String, String, Option<String>);
545
546pub(crate) struct Others<'a> {
550 pub outputs: &'a WorkspaceOutputs,
551 pub upstream: BTreeSet<TargetOf>,
553}
554
555impl Others<'_> {
556 pub(crate) fn matches(&self, path: &str) -> bool {
563 let mut other = false;
564 let holding = self
565 .outputs
566 .holding(path)
567 .filter(|(_, outputs)| outputs.matches(path))
568 .map(|(target, _)| target);
569 let folders = self
570 .outputs
571 .by_folder
572 .get(path)
573 .into_iter()
574 .flatten()
575 .map(|&index| &self.outputs.outputs[index].0);
576 for target in holding.chain(folders) {
577 if self.upstream.contains(target) {
578 return false;
579 }
580 other = true;
581 }
582 other
583 }
584}
585
586pub(crate) struct Unlisted<'a> {
594 pub own: Vec<&'a Outputs>,
595 pub others: Option<Others<'a>>,
596 pub ignore: &'a [Pattern],
597}
598
599impl Unlisted<'_> {
600 fn matches(&self, path: &str) -> bool {
601 self.own(path)
602 || self
603 .others
604 .as_ref()
605 .is_some_and(|others| others.matches(path))
606 || self.ignore.iter().any(|pattern| pattern.is_match(path))
607 }
608
609 fn own(&self, path: &str) -> bool {
610 self.own.iter().any(|outputs| outputs.matches(path))
611 }
612
613 pub(crate) fn directories(&self, project_root: &str) -> BTreeSet<String> {
619 let project_root = match relative_root(project_root) {
620 "" => ".",
621 root => root,
622 };
623 let mut directories = BTreeSet::from([project_root.to_owned()]);
624 if let Some(others) = &self.others {
625 let all = &others.outputs;
626 for (index, (target, outputs)) in all.outputs.iter().enumerate() {
627 if others.upstream.contains(target) {
628 continue;
629 }
630 for anchor in outputs.anchors() {
631 directories.insert(anchor.to_owned());
632 directories.insert(parent(anchor).to_owned());
633 }
634 for folder in &all.folders[index] {
637 directories.insert(folder.clone());
638 directories.insert(parent(folder).to_owned());
639 }
640 }
641 }
642 directories
643 }
644}
645
646#[derive(Default)]
650pub(crate) struct Before(BTreeMap<String, BTreeSet<(String, &'static str)>>);
651
652impl Before {
653 pub(crate) fn take(root: &Path, directories: BTreeSet<String>, unlisted: &Unlisted) -> Self {
654 Self(
655 directories
656 .into_iter()
657 .filter_map(|directory| {
658 let entries = entries(root, &directory, |path| unlisted.matches(path))?;
659 Some((directory, entries))
660 })
661 .collect(),
662 )
663 }
664}
665
666#[derive(Serialize, Deserialize)]
668struct Stored {
669 version: u32,
670 weak: String,
671 sets: Vec<PathSet>,
672}
673
674fn location(cache: &Path, weak: &str) -> PathBuf {
675 cache.join("pathsets").join(format!("{weak}.json"))
676}
677
678pub(crate) fn load(cache: &Path, weak: &str) -> Vec<PathSet> {
681 std::fs::read(location(cache, weak))
682 .ok()
683 .and_then(|bytes| serde_json::from_slice::<Stored>(&bytes).ok())
684 .filter(|stored| stored.version == 1 && stored.weak == weak)
685 .map(|stored| stored.sets)
686 .unwrap_or_default()
687}
688
689pub(crate) fn touch(cache: &Path, weak: &str) {
692 if let Ok(file) = std::fs::File::options()
693 .append(true)
694 .open(location(cache, weak))
695 {
696 let _ = file.set_modified(std::time::SystemTime::now());
697 }
698}
699
700pub(crate) fn save(cache: &Path, weak: &str, set: PathSet) -> Result<()> {
702 let mut sets = load(cache, weak);
703 sets.retain(|kept| kept != &set);
704 sets.insert(0, set);
705 keep(cache, weak, sets)
706}
707
708pub(crate) fn record(cache: &Path, weak: &str) -> Option<Vec<u8>> {
711 std::fs::read(location(cache, weak)).ok()
712}
713
714pub(crate) fn merge(cache: &Path, weak: &str, record: &[u8]) -> Result<Vec<PathSet>> {
719 let Some(fetched) = serde_json::from_slice::<Stored>(record)
720 .ok()
721 .filter(|stored| stored.version == 1 && stored.weak == weak)
722 else {
723 return Ok(Vec::new());
724 };
725 let mut sets = load(cache, weak);
726 let mut added = Vec::new();
727 for set in fetched.sets {
728 if sets.len() + added.len() < KEPT
729 && set.is_relative()
730 && !set.is_empty()
731 && !sets.contains(&set)
732 && !added.contains(&set)
733 {
734 added.push(set);
735 }
736 }
737 if !added.is_empty() {
738 sets.extend(added.iter().cloned());
739 keep(cache, weak, sets)?;
740 }
741 Ok(added)
742}
743
744fn keep(cache: &Path, weak: &str, mut sets: Vec<PathSet>) -> Result<()> {
745 sets.truncate(KEPT);
746 std::fs::create_dir_all(cache.join("pathsets"))?;
747 crate::record::publish(
748 cache,
749 &location(cache, weak),
750 &Stored {
751 version: 1,
752 weak: weak.to_owned(),
753 sets,
754 },
755 )
756}
757
758pub(crate) fn strong_key(
760 weak: &str,
761 set: &PathSet,
762 snapshot: &Snapshot,
763 root: &Path,
764 unlisted: &Unlisted,
765) -> Result<String> {
766 key(&json!({
767 "schema": "qk-recorded-v1",
768 "weak": weak,
769 "set": set.digest()?,
770 "values": set.values(snapshot, root, unlisted)?,
771 }))
772}
773
774pub(crate) fn now(directory: &Path) -> SystemTime {
779 tempfile::NamedTempFile::new_in(directory)
780 .and_then(|file| file.as_file().metadata())
781 .map(|metadata| changed(&metadata))
782 .unwrap_or_else(|_| SystemTime::now())
783}
784
785fn changed(metadata: &std::fs::Metadata) -> SystemTime {
788 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
789 #[cfg(unix)]
790 {
791 use std::os::unix::fs::MetadataExt;
792 let ctime = u64::try_from(metadata.ctime()).ok().map(|seconds| {
793 SystemTime::UNIX_EPOCH
794 + std::time::Duration::new(
795 seconds,
796 u32::try_from(metadata.ctime_nsec()).unwrap_or(0),
797 )
798 });
799 if let Some(ctime) = ctime {
800 return modified.max(ctime);
801 }
802 }
803 modified
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 const NOTHING: Unlisted<'static> = Unlisted {
812 own: Vec::new(),
813 others: None,
814 ignore: &[],
815 };
816 use qk_input_analysis::Category;
817
818 fn access(path: &str, operation: Operation, result: &str) -> Access {
819 Access {
820 process: 1,
821 path: path.into(),
822 resolved_path: None,
823 keyed_path: None,
824 package: None,
825 operation,
826 result: result.into(),
827 category: Category::Uncovered,
828 }
829 }
830
831 #[test]
832 fn keeps_what_the_task_read_listed_and_looked_up() {
833 let temp = tempfile::tempdir().unwrap();
834 let root = temp.path().canonicalize().unwrap();
835 for directory in ["src", "icons", "dist"] {
836 std::fs::create_dir(root.join(directory)).unwrap();
837 }
838 let path = |relative: &str| root.join(relative).to_str().unwrap().to_owned();
839 let outputs = Outputs::from_paths(&["dist".into()]).unwrap();
840 let exclusions = Exclusions {
841 roots: [&root, &root],
842 outputs: &[&outputs],
843 ignore: vec![Pattern::new("src/*.log", false).unwrap()],
844 state: vec![".qk".into()],
845 };
846 let accesses = [
847 access(&path("src/a.ts"), Operation::OpenRead, "success"),
848 access(&path("src"), Operation::OpenRead, "success"),
849 access(&path("icons"), Operation::ListDirectory, "success"),
850 access(&path("config.local"), Operation::Metadata, "missing"),
851 access(&path("src/b.ts"), Operation::Metadata, "success"),
852 access(&path("icons/sprite.svg"), Operation::Write, "success"),
854 access(&path("icons/sprite.svg"), Operation::OpenRead, "success"),
855 access("/usr/lib/libc.so", Operation::OpenRead, "success"),
857 access(
858 &path("node_modules/x/index.js"),
859 Operation::OpenRead,
860 "success",
861 ),
862 access(&path(".git/HEAD"), Operation::OpenRead, "success"),
863 access(&path(".qk/state.json"), Operation::OpenRead, "success"),
864 access(&path("dist/out.js"), Operation::OpenRead, "success"),
865 access(&path("src/debug.log"), Operation::OpenRead, "success"),
866 access(&path("src/a.ts"), Operation::Metadata, "success"),
868 ];
869 let set = PathSet::from_accesses(&accesses, &exclusions, &root);
870 assert_eq!(
871 set,
872 PathSet {
873 reads: ["src/a.ts".into()].into(),
874 listings: ["icons".into()].into(),
875 present: ["src/b.ts".into()].into(),
876 absent: ["config.local".into()].into(),
877 written: ["icons/sprite.svg".into()].into(),
878 }
879 );
880 }
881
882 #[test]
883 fn values_change_with_contents_listings_and_existence() {
884 let temp = tempfile::tempdir().unwrap();
885 let root = temp.path().canonicalize().unwrap();
886 std::fs::create_dir_all(root.join("icons")).unwrap();
887 std::fs::write(root.join("a.txt"), "one").unwrap();
888 std::fs::write(root.join("icons/a.svg"), "").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 snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
902 std::fs::write(root.join("checked.txt"), "one").unwrap();
903 let set = PathSet {
904 reads: ["a.txt".into()].into(),
905 listings: ["icons".into()].into(),
906 present: ["checked.txt".into()].into(),
907 absent: ["optional.txt".into()].into(),
908 written: ["icons/sprite.svg".into()].into(),
909 };
910 let outputs = Outputs::from_paths(&["icons/out".into()]).unwrap();
911 let unlisted = Unlisted {
912 own: vec![&outputs],
913 others: None,
914 ignore: &[],
915 };
916 let strong = || strong_key("weak", &set, &snapshot, &root, &unlisted).unwrap();
917 let first = strong();
918 std::fs::write(root.join("icons/sprite.svg"), "").unwrap();
921 std::fs::create_dir(root.join("icons/out")).unwrap();
922 std::fs::write(root.join("b.txt"), "unread").unwrap();
923 assert_eq!(strong(), first);
924 for change in [
925 |root: &Path| std::fs::write(root.join("icons/b.svg"), "").unwrap(),
926 |root: &Path| std::fs::write(root.join("optional.txt"), "").unwrap(),
927 |root: &Path| std::fs::write(root.join("a.txt"), "two").unwrap(),
928 |root: &Path| std::fs::write(root.join("checked.txt"), "two").unwrap(),
930 ] {
931 let before = strong();
932 change(&root);
933 assert_ne!(strong(), before);
934 }
935 }
936
937 #[test]
938 fn keeps_the_newest_sets_first() {
939 let temp = tempfile::tempdir().unwrap();
940 std::fs::create_dir(temp.path().join("tmp")).unwrap();
941 let set = |read: &str| PathSet {
942 reads: [read.to_owned()].into(),
943 ..PathSet::default()
944 };
945 for index in 0..10 {
946 save(temp.path(), "weak", set(&format!("{index}"))).unwrap();
947 }
948 save(temp.path(), "weak", set("5")).unwrap();
949 let kept: Vec<String> = load(temp.path(), "weak")
950 .into_iter()
951 .map(|set| set.reads.into_iter().next().unwrap())
952 .collect();
953 assert_eq!(kept, ["5", "9", "8", "7", "6", "4", "3", "2"]);
954 assert!(load(temp.path(), "other").is_empty());
955 }
956
957 #[test]
958 fn merges_the_sets_another_machine_recorded_behind_its_own() {
959 let temp = tempfile::tempdir().unwrap();
960 std::fs::create_dir(temp.path().join("tmp")).unwrap();
961 let set = |read: &str| PathSet {
962 reads: [read.to_owned()].into(),
963 ..PathSet::default()
964 };
965 save(temp.path(), "weak", set("a")).unwrap();
966 let record = |weak: &str, sets: Vec<PathSet>| {
967 serde_json::to_vec(&Stored {
968 version: 1,
969 weak: weak.into(),
970 sets,
971 })
972 .unwrap()
973 };
974 let fetched = record(
975 "weak",
976 vec![set("b"), set("a"), set("../escape"), set("/abs")],
977 );
978 assert_eq!(merge(temp.path(), "weak", &fetched).unwrap(), [set("b")]);
979 assert_eq!(load(temp.path(), "weak"), [set("a"), set("b")]);
980 assert!(merge(temp.path(), "weak", &fetched).unwrap().is_empty());
981 assert!(
982 merge(temp.path(), "weak", &record("other", vec![set("c")]))
983 .unwrap()
984 .is_empty()
985 );
986 let many = (0..10).map(|index| set(&format!("{index}"))).collect();
987 assert_eq!(
988 merge(temp.path(), "weak", &record("weak", many))
989 .unwrap()
990 .len(),
991 6
992 );
993 assert_eq!(load(temp.path(), "weak").len(), KEPT);
994 }
995
996 #[test]
997 fn finds_what_changed_while_the_task_ran() {
998 let temp = tempfile::tempdir().unwrap();
999 for path in ["read.txt", "found.txt", "listed.txt", "before.txt"] {
1000 std::fs::write(temp.path().join(path), "before").unwrap();
1001 }
1002 for directory in ["listed", "written"] {
1003 std::fs::create_dir(temp.path().join(directory)).unwrap();
1004 std::fs::write(temp.path().join(directory).join("kept.txt"), "").unwrap();
1005 }
1006 std::fs::create_dir(temp.path().join("listed/sub")).unwrap();
1007 let set = PathSet {
1008 reads: ["read.txt".to_owned(), "gone.txt".to_owned()].into(),
1009 present: ["found.txt".to_owned(), ".".to_owned()].into(),
1010 listings: ["listed".to_owned(), "written".to_owned()].into(),
1011 written: ["written/new.txt".to_owned()].into(),
1012 ..PathSet::default()
1013 };
1014 std::thread::sleep(std::time::Duration::from_millis(20));
1015 let started = now(temp.path());
1016 let changed = |set: &PathSet| {
1017 set.changed_since(temp.path(), started, &NOTHING, &Before::default())
1018 .map(|changed| changed.path.to_owned())
1019 };
1020 assert_eq!(changed(&set), None);
1021 std::fs::write(temp.path().join("written/new.txt"), "").unwrap();
1023 std::fs::write(temp.path().join("listed/kept.txt"), "after").unwrap();
1024 std::fs::write(temp.path().join("listed.txt"), "after").unwrap();
1025 let unlisted = PathSet {
1027 listings: ["written".to_owned()].into(),
1028 ..set.clone()
1029 };
1030 assert_eq!(changed(&unlisted), None);
1031 std::fs::write(temp.path().join("found.txt"), "after").unwrap();
1032 assert_eq!(changed(&set), Some("found.txt".into()));
1033 let listings = PathSet {
1034 reads: [].into(),
1035 present: [].into(),
1036 ..set.clone()
1037 };
1038 std::fs::write(temp.path().join("listed/sub/made.txt"), "").unwrap();
1041 assert_eq!(changed(&listings), None);
1042 std::fs::write(temp.path().join("written/other.txt"), "").unwrap();
1044 if std::fs::metadata(temp.path().join("written/other.txt"))
1045 .and_then(|metadata| metadata.created())
1046 .is_ok()
1047 {
1048 assert_eq!(changed(&listings), Some("written".into()));
1049 }
1050 std::fs::remove_file(temp.path().join("written/other.txt")).unwrap();
1051 #[cfg(unix)]
1054 {
1055 std::fs::rename(
1056 temp.path().join("before.txt"),
1057 temp.path().join("written/moved.txt"),
1058 )
1059 .unwrap();
1060 assert_eq!(changed(&listings), Some("written".into()));
1061 std::fs::rename(
1062 temp.path().join("written/moved.txt"),
1063 temp.path().join("before.txt"),
1064 )
1065 .unwrap();
1066 }
1067 std::fs::write(temp.path().join("listed/new.txt"), "").unwrap();
1069 assert_eq!(changed(&listings), Some("listed".into()));
1070 let removed = tempfile::tempdir().unwrap();
1072 std::fs::write(removed.path().join("gone.txt"), "").unwrap();
1073 std::thread::sleep(std::time::Duration::from_millis(20));
1074 let started = now(temp.path());
1077 let root = PathSet {
1078 listings: [".".to_owned()].into(),
1079 ..PathSet::default()
1080 };
1081 assert_eq!(
1082 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1083 .map(|changed| changed.path),
1084 None
1085 );
1086 std::fs::remove_file(removed.path().join("gone.txt")).unwrap();
1087 assert_eq!(
1088 root.changed_since(removed.path(), started, &NOTHING, &Before::default())
1089 .map(|changed| changed.path),
1090 Some(".")
1091 );
1092 }
1093
1094 #[test]
1095 fn listings_leave_out_other_targets_outputs_and_ignored_reads() {
1096 let temp = tempfile::tempdir().unwrap();
1097 let root = &temp.path().canonicalize().unwrap();
1098 std::fs::create_dir_all(root.join("app/src")).unwrap();
1099 std::fs::write(root.join("app/src/main.ts"), "").unwrap();
1100 std::fs::write(root.join("nx.json"), "{}").unwrap();
1101 std::fs::write(
1102 root.join("project.json"),
1103 r#"{"name":"app","targets":{"build":{"command":"true"}}}"#,
1104 )
1105 .unwrap();
1106 let workspace = qk_config::Workspace::load(root).unwrap();
1107 let graph = qk_taskgraph::TaskGraph::build(
1108 &workspace,
1109 &[qk_taskgraph::Request::parse("app:build").unwrap()],
1110 )
1111 .unwrap();
1112 let set = PathSet {
1113 listings: ["app".to_owned()].into(),
1114 ..PathSet::default()
1115 };
1116 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1117 std::fs::create_dir(root.join("app/out")).unwrap();
1118 let roots = [("app".to_owned(), "app".to_owned())].into();
1119 let outputs = WorkspaceOutputs::new(
1120 vec![
1121 (
1122 ("app".into(), "tsc".into(), None),
1123 Outputs::from_patterns(&["app/.tsc"]),
1124 ),
1125 (
1126 ("app".into(), "codegen".into(), None),
1127 Outputs::from_patterns(&["app/generated"]),
1128 ),
1129 (
1131 ("app".into(), "bundle".into(), None),
1132 Outputs::from_patterns(&["app/out/*.js"]),
1133 ),
1134 (
1136 ("app".into(), "package".into(), None),
1137 Outputs::from_patterns(&["app/generated"]),
1138 ),
1139 (
1141 ("app".into(), "deploy".into(), None),
1142 Outputs::from_patterns(&["app/.cache/deploy"]),
1143 ),
1144 ],
1145 &roots,
1146 );
1147 let ignore = [Pattern::new("**/.fallow", false).unwrap()];
1148 let unlisted = Unlisted {
1149 own: Vec::new(),
1150 others: Some(Others {
1151 outputs: &outputs,
1152 upstream: [
1154 ("app".into(), "build".into(), None),
1155 ("app".into(), "codegen".into(), None),
1156 ]
1157 .into(),
1158 }),
1159 ignore: &ignore,
1160 };
1161 assert_eq!(
1162 unlisted.directories("app"),
1163 [
1164 "app".to_owned(),
1165 "app/.cache".to_owned(),
1166 "app/.cache/deploy".to_owned(),
1167 "app/.tsc".to_owned(),
1168 "app/generated".to_owned(),
1169 "app/out".to_owned()
1170 ]
1171 .into()
1172 );
1173 let snapshot = Snapshot::new(&workspace, &graph, &root.join("cache")).unwrap();
1174 let key = || strong_key("weak", &set, &snapshot, root, &unlisted).unwrap();
1175 let before = key();
1176 std::thread::sleep(std::time::Duration::from_millis(20));
1177 let taken = Before::take(root, unlisted.directories("app"), &unlisted);
1178 let started = now(root);
1179 let changed = |taken: &Before| {
1180 set.changed_since(root, started, &unlisted, taken)
1181 .map(|changed| changed.path)
1182 };
1183 std::fs::create_dir(root.join("app/.tsc")).unwrap();
1186 std::fs::create_dir_all(root.join("app/.cache/deploy")).unwrap();
1187 std::fs::create_dir(root.join("app/.fallow")).unwrap();
1188 assert_eq!(key(), before);
1189 assert_eq!(changed(&taken), None);
1190 std::fs::write(root.join("app/.tsc/index.js"), "").unwrap();
1193 let output = PathSet {
1194 listings: ["app/.tsc".to_owned()].into(),
1195 ..PathSet::default()
1196 };
1197 assert_eq!(
1198 output.changed_since(root, started, &unlisted, &Before::default()),
1199 None
1200 );
1201 assert!(!unlisted.matches("app"));
1203 std::fs::create_dir(root.join("app/gone")).unwrap();
1205 let gone = PathSet {
1206 listings: ["app/gone".to_owned()].into(),
1207 ..PathSet::default()
1208 };
1209 std::fs::remove_dir(root.join("app/gone")).unwrap();
1210 assert_eq!(
1211 gone.changed_since(root, started, &unlisted, &Before::default())
1212 .map(|changed| changed.path),
1213 Some("app/gone")
1214 );
1215 std::fs::write(root.join("app/out/new.js"), "").unwrap();
1217 let out = PathSet {
1218 listings: ["app/out".to_owned()].into(),
1219 ..PathSet::default()
1220 };
1221 assert_eq!(out.changed_since(root, started, &unlisted, &taken), None);
1222 assert_eq!(
1223 out.changed_since(root, started, &unlisted, &Before::default())
1224 .map(|changed| changed.path),
1225 Some("app/out")
1226 );
1227 assert!(!unlisted.matches("app/generated"));
1229 assert!(unlisted.matches("app/.tsc/index.js"));
1230 assert_eq!(changed(&Before::default()), Some("app"));
1233 std::fs::remove_file(root.join("app/kept.txt")).unwrap();
1235 assert_ne!(key(), before);
1236 assert_eq!(
1237 set.changed_since(root, started, &unlisted, &taken)
1238 .and_then(|changed| changed.how),
1239 Some("kept.txt removed".to_owned())
1240 );
1241 std::fs::write(root.join("app/kept.txt"), "").unwrap();
1242 assert_eq!(changed(&taken), None);
1243 std::fs::write(root.join("app/README.md"), "").unwrap();
1245 assert_ne!(key(), before);
1246 assert_eq!(
1247 set.changed_since(root, started, &unlisted, &taken)
1248 .map(|changed| changed.to_string()),
1249 Some("app changed while it ran (README.md added)".to_owned())
1250 );
1251 }
1252}