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