1mod evict;
4mod glob;
5mod hash;
6mod paths;
7mod remote;
8mod store;
9pub mod warm;
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::{Context, Result};
17use qk_config::Workspace;
18use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
19use qk_taskgraph::{Task, TaskGraph};
20use serde_json::{Value, json};
21
22pub use evict::{Pruned, max_size, parse_size, prune};
23pub use glob::Pattern;
24pub use hash::{Resolved, SourceIgnore, source_files, without_resolution};
25pub use paths::{
26 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
27 worktree_state,
28};
29
30pub struct Cache {
32 pub root: PathBuf,
33 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
34 remote: Option<std::sync::Arc<remote::Remote>>,
35 reuse: bool,
37 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
39 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
41}
42
43pub type Fingerprint = std::result::Result<String, String>;
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum CacheStatus {
51 LocalHit,
52 RemoteHit,
53 Miss,
54 Uncached,
56}
57
58pub struct Execution {
60 pub log: qk_executor::RecordedLog,
61 pub inputs_unchanged: bool,
62}
63
64pub struct TaskResult {
65 pub outcome: Outcome,
66 pub fingerprint: Fingerprint,
67 pub cache: CacheStatus,
68 pub key: Option<(String, Value)>,
70 pub warm: Option<warm::WarmReport>,
72 pub execution: Option<Execution>,
73}
74
75impl TaskResult {
76 pub fn uncached(outcome: Outcome, reason: String) -> Self {
77 Self {
78 outcome,
79 fingerprint: Err(reason),
80 cache: CacheStatus::Uncached,
81 key: None,
82 warm: None,
83 execution: None,
84 }
85 }
86}
87
88pub fn resolve_tasks(
92 workspace: &Workspace,
93 graph: &TaskGraph,
94 extra: &[String],
95) -> Result<BTreeMap<String, Resolved>> {
96 let cache = paths::cache_location(workspace);
97 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
98 let cancelled = AtomicBool::new(false);
99 graph
100 .tasks
101 .iter()
102 .map(|(id, task)| {
103 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
104 .with_context(|| format!("cannot resolve the inputs of {id}"))?;
105 Ok((id.clone(), resolved))
106 })
107 .collect()
108}
109
110pub struct Resolution {
113 pub candidates: std::collections::BTreeSet<String>,
114 pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
115}
116
117pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
118 let cache = paths::cache_location(workspace);
119 let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
120 let cancelled = AtomicBool::new(false);
121 let tasks = graph
122 .tasks
123 .iter()
124 .map(|(id, task)| {
125 let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
126 .map_err(|error| format!("{error:#}"));
127 (id.clone(), resolved)
128 })
129 .collect();
130 Ok(Resolution {
131 candidates: snapshot.files.clone(),
132 tasks,
133 })
134}
135
136impl Cache {
137 pub fn new(root: PathBuf) -> Self {
138 Self {
139 root,
140 snapshot: OnceLock::new(),
141 remote: None,
142 reuse: true,
143 kept: Default::default(),
144 saves: Default::default(),
145 }
146 }
147
148 pub fn for_observation(workspace: &Workspace) -> Self {
150 Self {
151 reuse: false,
152 ..Self::new(paths::cache_location(workspace))
153 }
154 }
155
156 pub fn for_workspace(
161 workspace: &Workspace,
162 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
163 ) -> Self {
164 let root = paths::cache_location(workspace);
165 let remote = match remote::configure(workspace.config.extra.get("s3"), environment) {
166 Ok(remote) => remote.map(std::sync::Arc::new),
167 Err(error) => {
168 qk_executor::status!("qk: remote cache disabled: {error:#}");
169 None
170 }
171 };
172 if let Some(remote) = &remote {
173 remote.read_index(&root);
174 }
175 Self {
176 remote,
177 ..Self::new(root)
178 }
179 }
180
181 pub fn finish(&self, workspace: &Workspace) {
184 if !self.reuse {
185 return;
186 }
187 if let Some(Ok(snapshot)) = self.snapshot.get() {
188 snapshot.save_digests(&workspace.root);
189 }
190 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
192 let _ = save.join();
193 }
194 if let Some(remote) = &self.remote {
195 let failures = remote.finish();
196 for failure in &failures {
197 qk_executor::status!("qk: remote cache upload failed for {failure}");
198 }
199 }
200 }
201
202 fn snapshot(
203 &self,
204 workspace: &Workspace,
205 graph: &TaskGraph,
206 ) -> std::result::Result<&hash::Snapshot, String> {
207 self.snapshot
208 .get_or_init(|| {
209 hash::Snapshot::new(workspace, graph, &self.root)
210 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
211 })
212 .as_ref()
213 .map_err(Clone::clone)
214 }
215
216 pub fn run(
217 &self,
218 workspace: &Workspace,
219 graph: &TaskGraph,
220 task: &Task,
221 prepared: &PreparedTask,
222 dependencies: &BTreeMap<String, Fingerprint>,
223 cancelled: &AtomicBool,
224 ) -> Result<TaskResult> {
225 let result = self.run_task(workspace, graph, task, prepared, dependencies, cancelled);
226 if let Some(Ok(snapshot)) = self.snapshot.get() {
227 snapshot.outputs_written(workspace, task);
228 }
229 result
230 }
231
232 fn run_task(
233 &self,
234 workspace: &Workspace,
235 graph: &TaskGraph,
236 task: &Task,
237 prepared: &PreparedTask,
238 dependencies: &BTreeMap<String, Fingerprint>,
239 cancelled: &AtomicBool,
240 ) -> Result<TaskResult> {
241 let cacheable = task.definition.cache == Some(true);
242 let _kept = self
244 .reuse
245 .then(|| warm::keep_across(workspace, graph, task));
246 let warm = if self.reuse {
247 match warm::config(workspace, task) {
248 Ok(warm) => warm,
249 Err(error) => {
250 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
251 None
252 }
253 }
254 } else {
255 None
256 };
257 let mut running = prepared.clone();
260 if let Some(warm) = &warm {
261 for (name, value) in &warm.env {
262 running.execution.insert(name.into(), value.into());
263 }
264 }
265 let before = |running: &PreparedTask| -> (Option<warm::Restored>, Vec<String>) {
267 let Some(warm) = warm.as_ref() else {
268 return (None, Vec::new());
269 };
270 match self
271 .initialize()
272 .and_then(|()| self.restore_warm(workspace, task, warm, running))
273 {
274 Ok((restored, present)) => {
275 ((!restored.groups.is_empty()).then_some(restored), present)
276 }
277 Err(error) => {
278 qk_executor::status!("qk: {}: warm state not restored ({error:#})", task.id);
279 (None, Vec::new())
280 }
281 }
282 };
283 let after = |outcome: Outcome,
284 (restored, present): (Option<warm::Restored>, Vec<String>),
285 running: &PreparedTask|
286 -> Option<warm::WarmReport> {
287 let warm = warm.as_ref()?;
288 let started = std::time::Instant::now();
289 let mut background = false;
290 let save_ms = if outcome != Outcome::Success {
291 None
292 } else if warm.background {
293 match self.save_warm_in_background(workspace, task, warm, running) {
294 Ok(()) => background = true,
295 Err(error) => {
296 qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
297 }
298 }
299 None
300 } else {
301 match self.save_warm(workspace, task, warm, running) {
302 Ok(()) => Some(started.elapsed().as_millis() as u64),
303 Err(error) => {
304 qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
305 None
306 }
307 }
308 };
309 Some(warm::WarmReport {
310 restored,
311 present,
312 save_ms,
313 background,
314 })
315 };
316 let fallback = |reason: String| {
317 execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
318 };
319 let bypass = |reason: String| {
321 if cacheable {
322 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
323 }
324 fallback(format!("{}: {reason}", task.id))
325 };
326 let dependencies = match dependencies
327 .iter()
328 .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
329 .collect::<std::result::Result<BTreeMap<_, _>, _>>()
330 {
331 Ok(dependencies) => dependencies,
332 Err(root) => {
333 if cacheable {
335 qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
336 }
337 return fallback(root);
338 }
339 };
340 let snapshot = match self.snapshot(workspace, graph) {
341 Ok(snapshot) => snapshot,
342 Err(reason) => return bypass(reason),
343 };
344 let dependencies =
345 match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
346 {
347 Ok(dependencies) => dependencies,
348 Err(error) => return bypass(format!("{error:#}")),
349 };
350 let (key, inputs) = match hash::inputs(
351 snapshot,
352 workspace,
353 task,
354 prepared,
355 &dependencies,
356 cancelled,
357 )
358 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
359 {
360 Ok(key) => key,
361 Err(error) => return bypass(format!("{error:#}")),
362 };
363 let keyed = Some((key.clone(), inputs));
364 running
366 .execution
367 .insert("NX_TASK_HASH".into(), key.clone().into());
368 let running = &running;
369 let outputs = match paths::Outputs::new(workspace, task) {
370 Ok(outputs) => outputs,
371 Err(error) => return bypass(format!("{error:#}")),
372 };
373 if !cacheable {
374 let restored = before(running);
375 let outcome = execute(running, cancelled)?;
376 let warm_report = after(outcome, restored, running);
377 let fingerprint = if self.unchanged(
378 snapshot,
379 workspace,
380 task,
381 prepared,
382 &dependencies,
383 &key,
384 outcome,
385 cancelled,
386 ) {
387 outputs_fingerprint(task, &workspace.root, &outputs, &key)
388 } else {
389 Err(format!("{}: did not complete unchanged", task.id))
390 };
391 return Ok(TaskResult {
392 outcome,
393 fingerprint,
394 cache: CacheStatus::Uncached,
395 key: keyed,
396 warm: warm_report,
397 execution: None,
398 });
399 }
400 if !self.reuse {
401 let capture = Capture::new(None, prepared.display.clone()).retain_log();
402 let outcome = execute_captured(running, cancelled, Some(&capture))?;
403 let inputs_unchanged = self.inputs_match(
404 snapshot,
405 workspace,
406 task,
407 prepared,
408 &dependencies,
409 &key,
410 cancelled,
411 );
412 let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
413 outputs_fingerprint(task, &workspace.root, &outputs, &key)
414 } else {
415 Err(format!("{}: did not complete unchanged", task.id))
416 };
417 return Ok(TaskResult {
418 outcome,
419 fingerprint,
420 cache: CacheStatus::Uncached,
421 key: keyed,
422 warm: None,
423 execution: capture.take_log().map(|log| Execution {
424 log,
425 inputs_unchanged,
426 }),
427 });
428 }
429 if let Err(error) = self.initialize() {
430 return bypass(format!("cache unavailable: {error}"));
431 }
432 let (_lock, waited) = match self.lock(&key, cancelled) {
433 Ok(Some(lock)) => lock,
434 Ok(None) => {
435 return Ok(TaskResult::uncached(
436 Outcome::Cancelled,
437 format!("{}: cancelled", task.id),
438 ));
439 }
440 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
441 };
442 if waited
444 && hash::fingerprint(
445 snapshot,
446 workspace,
447 task,
448 prepared,
449 &dependencies,
450 cancelled,
451 )
452 .ok()
453 .as_ref()
454 != Some(&key)
455 {
456 return bypass("inputs changed while waiting for another run".into());
457 }
458 match self.restore(
459 &workspace.root,
460 &task.id,
461 &task.dependencies,
462 &key,
463 &outputs,
464 &prepared.display,
465 qk_executor::Shown::LocalCache,
466 ) {
467 Ok(Some(fingerprint)) => {
468 if let Some(remote) = &self.remote {
469 remote.held(&key);
470 }
471 qk_executor::report::event(qk_executor::report::Event::Cache {
472 id: task.id.clone(),
473 cached: qk_executor::report::Cached::Hit,
474 });
475 return Ok(TaskResult {
476 outcome: Outcome::Success,
477 fingerprint: Ok(fingerprint),
478 cache: CacheStatus::LocalHit,
479 key: keyed,
480 warm: None,
481 execution: None,
482 });
483 }
484 Ok(None) => {}
485 Err(error) => {
486 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
487 }
488 }
489 if let Some(remote) = &self.remote {
490 let fetched = remote.fetch(&self.root, &key).and_then(|found| {
491 if !found {
492 return Ok(None);
493 }
494 self.restore(
495 &workspace.root,
496 &task.id,
497 &task.dependencies,
498 &key,
499 &outputs,
500 &prepared.display,
501 qk_executor::Shown::RemoteCache,
502 )
503 });
504 match fetched {
505 Ok(Some(fingerprint)) => {
506 qk_executor::report::event(qk_executor::report::Event::Cache {
507 id: task.id.clone(),
508 cached: qk_executor::report::Cached::RemoteHit,
509 });
510 return Ok(TaskResult {
511 outcome: Outcome::Success,
512 fingerprint: Ok(fingerprint),
513 cache: CacheStatus::RemoteHit,
514 key: keyed,
515 warm: None,
516 execution: None,
517 });
518 }
519 Ok(None) => {}
520 Err(error) => {
521 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
522 }
523 }
524 }
525 qk_executor::report::event(qk_executor::report::Event::Cache {
526 id: task.id.clone(),
527 cached: qk_executor::report::Cached::Miss,
528 });
529 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
530 Ok(log) => log,
531 Err(error) => return bypass(format!("cache log unavailable: {error}")),
532 };
533 let capture =
534 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
535 let restored = before(running);
536 let outcome = execute_captured(running, cancelled, Some(&capture))?;
537 let warm_report = after(outcome, restored, running);
538 let inputs_unchanged = self.inputs_match(
539 snapshot,
540 workspace,
541 task,
542 prepared,
543 &dependencies,
544 &key,
545 cancelled,
546 );
547 let execution = capture.take_log().map(|log| Execution {
548 log,
549 inputs_unchanged,
550 });
551 let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
552 Err(format!("{}: did not complete unchanged", task.id))
553 } else if !capture.healthy() {
554 outputs_fingerprint(task, &workspace.root, &outputs, &key)
555 } else {
556 match self.publish(&workspace.root, &key, &outputs, log.path()) {
558 Ok(fingerprint) => {
559 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
560 if let Some(remote) = &self.remote {
561 remote.upload(&self.root, &key);
562 }
563 Ok(fingerprint)
564 }
565 Err(error) => {
566 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
567 outputs_fingerprint(task, &workspace.root, &outputs, &key)
568 }
569 }
570 };
571 Ok(TaskResult {
572 outcome,
573 fingerprint,
574 cache: CacheStatus::Miss,
575 key: keyed,
576 warm: warm_report,
577 execution,
578 })
579 }
580
581 #[allow(clippy::too_many_arguments)]
583 fn unchanged(
584 &self,
585 snapshot: &hash::Snapshot,
586 workspace: &Workspace,
587 task: &Task,
588 prepared: &PreparedTask,
589 dependencies: &BTreeMap<String, String>,
590 before: &str,
591 outcome: Outcome,
592 cancelled: &AtomicBool,
593 ) -> bool {
594 if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
595 return false;
596 }
597 self.inputs_match(
598 snapshot,
599 workspace,
600 task,
601 prepared,
602 dependencies,
603 before,
604 cancelled,
605 )
606 }
607
608 #[allow(clippy::too_many_arguments)]
610 fn inputs_match(
611 &self,
612 snapshot: &hash::Snapshot,
613 workspace: &Workspace,
614 task: &Task,
615 prepared: &PreparedTask,
616 dependencies: &BTreeMap<String, String>,
617 before: &str,
618 cancelled: &AtomicBool,
619 ) -> bool {
620 if cancelled.load(Ordering::SeqCst) {
621 return false;
622 }
623 let after = hash::fingerprint(snapshot, workspace, task, prepared, dependencies, cancelled);
624 if after.as_deref().ok() != Some(before) {
625 if task.definition.cache == Some(true) {
626 if self.reuse {
627 qk_executor::status!(
628 "qk: {}: not caching because inputs changed during execution",
629 task.id
630 );
631 } else {
632 qk_executor::status!(
633 "qk: {}: excluding execution from flaky results because inputs changed during execution",
634 task.id
635 );
636 }
637 }
638 return false;
639 }
640 true
641 }
642}
643
644pub(crate) fn file_output(content: &str, mode: u32) -> Value {
647 json!({"content": content, "mode": mode})
648}
649
650pub(crate) fn link_output(target: &str) -> Value {
651 json!({"link": target})
652}
653
654pub(crate) fn directory_output() -> Value {
655 json!("directory")
656}
657
658pub(crate) fn combine_outputs(
664 input: &str,
665 files: &BTreeMap<String, Value>,
666 declared: bool,
667) -> Result<String> {
668 let identity = if declared { "declared outputs" } else { input };
669 Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
670 .to_hex()
671 .to_string())
672}
673
674fn outputs_fingerprint(
675 task: &Task,
676 root: &Path,
677 outputs: &paths::Outputs,
678 input: &str,
679) -> Fingerprint {
680 output_fingerprint(root, outputs, input)
681 .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
682}
683
684fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
685 let mut files = BTreeMap::new();
686 for entry in outputs.entries(root)? {
687 let (path, metadata) = entry?;
688 paths::safe_parents(root, &path)?;
689 let absolute = root.join(&path);
690 let value = if metadata.file_type().is_symlink() {
691 link_output(
692 std::fs::read_link(absolute)?
693 .to_str()
694 .context("symlink target must be UTF-8")?,
695 )
696 } else if metadata.is_file() {
697 file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
698 } else {
699 directory_output()
700 };
701 files.insert(path, value);
702 }
703 combine_outputs(input, &files, outputs.declared())
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[cfg(unix)]
712 #[test]
713 fn a_blob_the_store_holds_is_not_written_again() {
714 use std::os::unix::fs::MetadataExt;
715 let workspace = tempfile::tempdir().unwrap();
716 let root = workspace.path();
717 let cache = Cache::new(root.join(".qk/cache"));
718 cache.initialize().unwrap();
719 std::fs::write(root.join("first"), "same").unwrap();
720 std::fs::write(root.join("second"), "same").unwrap();
721 std::fs::write(root.join("other"), "different").unwrap();
722 let first = cache.put_blob(&root.join("first")).unwrap();
723 let blob = cache.root.join("blobs").join(&first);
724 let stored = std::fs::metadata(&blob).unwrap().ino();
725 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
726 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
727 std::fs::write(&blob, "corrupt").unwrap();
729 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
730 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
731 {
733 use std::os::unix::fs::PermissionsExt;
734 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
735 }
736 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
737 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
738 let other = cache.put_blob(&root.join("other")).unwrap();
739 assert_ne!(other, first);
740 assert_eq!(
741 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
742 b"different"
743 );
744 }
745
746 #[test]
749 fn manifest_and_disk_output_fingerprints_agree() {
750 let workspace = tempfile::tempdir().unwrap();
751 let root = workspace.path();
752 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
753 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
754 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
755 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
756 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
757 #[cfg(unix)]
758 {
759 use std::os::unix::fs::PermissionsExt;
760 let script = root.join("dist/run.sh");
761 std::fs::write(&script, "#!/bin/sh\n").unwrap();
762 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
763 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
764 std::fs::set_permissions(
765 root.join("dist/nested"),
766 std::fs::Permissions::from_mode(0o500),
767 )
768 .unwrap();
769 std::fs::set_permissions(
770 root.join("bundle/readonly"),
771 std::fs::Permissions::from_mode(0o444),
772 )
773 .unwrap();
774 }
775 let log = root.join("log");
776 std::fs::write(&log, "").unwrap();
777
778 let cache = Cache::new(root.join(".qk/cache"));
779 cache.initialize().unwrap();
780 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
781 let key = "0".repeat(64);
782 let saved = cache.publish(root, &key, &outputs, &log).unwrap();
783 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
784
785 #[cfg(unix)]
786 {
787 use std::os::unix::fs::PermissionsExt;
788 std::fs::set_permissions(
791 root.join("dist/nested"),
792 std::fs::Permissions::from_mode(0o755),
793 )
794 .unwrap();
795 }
796 std::fs::remove_dir_all(root.join("dist")).unwrap();
797 std::fs::remove_dir_all(root.join("bundle")).unwrap();
798 let before = std::time::SystemTime::now();
799 let restored = cache
800 .restore(
801 root,
802 "app:build",
803 &Default::default(),
804 &key,
805 &outputs,
806 &qk_executor::Display::Stream,
807 qk_executor::Shown::LocalCache,
808 )
809 .unwrap();
810 assert_eq!(restored, Some(saved.clone()));
811 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
812 assert!(root.join("dist/nested/empty").is_dir());
813 assert!(root.join("bundle/empty").is_dir());
814 assert_eq!(
815 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
816 "readonly"
817 );
818 #[cfg(unix)]
819 {
820 assert_eq!(
821 std::fs::read_link(root.join("dist/link")).unwrap(),
822 Path::new("nested/a.txt")
823 );
824 assert_eq!(
825 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
826 0o755
827 );
828 assert_eq!(
829 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
830 0o500
831 );
832 assert_eq!(
833 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
834 0o444
835 );
836 }
837 let path = root.join("dist/nested/a.txt");
838 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
839 assert!(modified >= before);
840 let kept = cache
841 .restore(
842 root,
843 "app:build",
844 &Default::default(),
845 &key,
846 &outputs,
847 &qk_executor::Display::Hidden,
848 qk_executor::Shown::LocalCache,
849 )
850 .unwrap();
851 assert_eq!(kept, Some(saved));
852 assert_eq!(
853 std::fs::metadata(&path).unwrap().modified().unwrap(),
854 modified
855 );
856 #[cfg(unix)]
857 {
858 use std::os::unix::fs::PermissionsExt;
859 std::fs::set_permissions(
860 root.join("dist/nested"),
861 std::fs::Permissions::from_mode(0o755),
862 )
863 .unwrap();
864 }
865 }
866}