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 graph,
381 task,
382 prepared,
383 &dependencies,
384 &key,
385 outcome,
386 cancelled,
387 ) {
388 outputs_fingerprint(task, &workspace.root, &outputs, &key)
389 } else {
390 Err(format!("{}: did not complete unchanged", task.id))
391 };
392 return Ok(TaskResult {
393 outcome,
394 fingerprint,
395 cache: CacheStatus::Uncached,
396 key: keyed,
397 warm: warm_report,
398 execution: None,
399 });
400 }
401 if !self.reuse {
402 let capture = Capture::new(None, prepared.display.clone()).retain_log();
403 let outcome = execute_captured(running, cancelled, Some(&capture))?;
404 let inputs_unchanged = self.inputs_match(
405 snapshot,
406 workspace,
407 graph,
408 task,
409 prepared,
410 &dependencies,
411 &key,
412 cancelled,
413 );
414 let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
415 outputs_fingerprint(task, &workspace.root, &outputs, &key)
416 } else {
417 Err(format!("{}: did not complete unchanged", task.id))
418 };
419 return Ok(TaskResult {
420 outcome,
421 fingerprint,
422 cache: CacheStatus::Uncached,
423 key: keyed,
424 warm: None,
425 execution: capture.take_log().map(|log| Execution {
426 log,
427 inputs_unchanged,
428 }),
429 });
430 }
431 if let Err(error) = self.initialize() {
432 return bypass(format!("cache unavailable: {error}"));
433 }
434 let (_lock, waited) = match self.lock(&key, cancelled) {
435 Ok(Some(lock)) => lock,
436 Ok(None) => {
437 return Ok(TaskResult::uncached(
438 Outcome::Cancelled,
439 format!("{}: cancelled", task.id),
440 ));
441 }
442 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
443 };
444 if waited
446 && hash::recheck_dependency_keys(
447 snapshot,
448 workspace,
449 graph,
450 task,
451 &dependencies,
452 cancelled,
453 )
454 .and_then(|dependencies| {
455 hash::fingerprint(
456 snapshot,
457 workspace,
458 task,
459 prepared,
460 &dependencies,
461 cancelled,
462 )
463 })
464 .ok()
465 .as_ref()
466 != Some(&key)
467 {
468 return bypass("inputs changed while waiting for another run".into());
469 }
470 match self.restore(
471 &workspace.root,
472 &task.id,
473 &task.dependencies,
474 &key,
475 &outputs,
476 &prepared.display,
477 qk_executor::Shown::LocalCache,
478 ) {
479 Ok(Some(fingerprint)) => {
480 if let Some(remote) = &self.remote {
481 remote.held(&key);
482 }
483 qk_executor::report::event(qk_executor::report::Event::Cache {
484 id: task.id.clone(),
485 cached: qk_executor::report::Cached::Hit,
486 });
487 return Ok(TaskResult {
488 outcome: Outcome::Success,
489 fingerprint: Ok(fingerprint),
490 cache: CacheStatus::LocalHit,
491 key: keyed,
492 warm: None,
493 execution: None,
494 });
495 }
496 Ok(None) => {}
497 Err(error) => {
498 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
499 }
500 }
501 if let Some(remote) = &self.remote {
502 let fetched = remote.fetch(&self.root, &key).and_then(|found| {
503 if !found {
504 return Ok(None);
505 }
506 self.restore(
507 &workspace.root,
508 &task.id,
509 &task.dependencies,
510 &key,
511 &outputs,
512 &prepared.display,
513 qk_executor::Shown::RemoteCache,
514 )
515 });
516 match fetched {
517 Ok(Some(fingerprint)) => {
518 qk_executor::report::event(qk_executor::report::Event::Cache {
519 id: task.id.clone(),
520 cached: qk_executor::report::Cached::RemoteHit,
521 });
522 return Ok(TaskResult {
523 outcome: Outcome::Success,
524 fingerprint: Ok(fingerprint),
525 cache: CacheStatus::RemoteHit,
526 key: keyed,
527 warm: None,
528 execution: None,
529 });
530 }
531 Ok(None) => {}
532 Err(error) => {
533 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
534 }
535 }
536 }
537 qk_executor::report::event(qk_executor::report::Event::Cache {
538 id: task.id.clone(),
539 cached: qk_executor::report::Cached::Miss,
540 });
541 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
542 Ok(log) => log,
543 Err(error) => return bypass(format!("cache log unavailable: {error}")),
544 };
545 let capture =
546 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
547 let restored = before(running);
548 let outcome = execute_captured(running, cancelled, Some(&capture))?;
549 let warm_report = after(outcome, restored, running);
550 let inputs_unchanged = self.inputs_match(
551 snapshot,
552 workspace,
553 graph,
554 task,
555 prepared,
556 &dependencies,
557 &key,
558 cancelled,
559 );
560 let execution = capture.take_log().map(|log| Execution {
561 log,
562 inputs_unchanged,
563 });
564 let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
565 Err(format!("{}: did not complete unchanged", task.id))
566 } else if !capture.healthy() {
567 outputs_fingerprint(task, &workspace.root, &outputs, &key)
568 } else {
569 match self.publish(&workspace.root, &key, &outputs, log.path()) {
571 Ok(fingerprint) => {
572 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
573 if let Some(remote) = &self.remote {
574 remote.upload(&self.root, &key);
575 }
576 Ok(fingerprint)
577 }
578 Err(error) => {
579 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
580 outputs_fingerprint(task, &workspace.root, &outputs, &key)
581 }
582 }
583 };
584 Ok(TaskResult {
585 outcome,
586 fingerprint,
587 cache: CacheStatus::Miss,
588 key: keyed,
589 warm: warm_report,
590 execution,
591 })
592 }
593
594 #[allow(clippy::too_many_arguments)]
596 fn unchanged(
597 &self,
598 snapshot: &hash::Snapshot,
599 workspace: &Workspace,
600 graph: &TaskGraph,
601 task: &Task,
602 prepared: &PreparedTask,
603 dependencies: &BTreeMap<String, String>,
604 before: &str,
605 outcome: Outcome,
606 cancelled: &AtomicBool,
607 ) -> bool {
608 if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
609 return false;
610 }
611 self.inputs_match(
612 snapshot,
613 workspace,
614 graph,
615 task,
616 prepared,
617 dependencies,
618 before,
619 cancelled,
620 )
621 }
622
623 #[allow(clippy::too_many_arguments)]
625 fn inputs_match(
626 &self,
627 snapshot: &hash::Snapshot,
628 workspace: &Workspace,
629 graph: &TaskGraph,
630 task: &Task,
631 prepared: &PreparedTask,
632 dependencies: &BTreeMap<String, String>,
633 before: &str,
634 cancelled: &AtomicBool,
635 ) -> bool {
636 if cancelled.load(Ordering::SeqCst) {
637 return false;
638 }
639 let after = hash::recheck_dependency_keys(
640 snapshot,
641 workspace,
642 graph,
643 task,
644 dependencies,
645 cancelled,
646 )
647 .and_then(|dependencies| {
648 hash::fingerprint(
649 snapshot,
650 workspace,
651 task,
652 prepared,
653 &dependencies,
654 cancelled,
655 )
656 });
657 if after.as_deref().ok() != Some(before) {
658 if task.definition.cache == Some(true) {
659 if self.reuse {
660 qk_executor::status!(
661 "qk: {}: not caching because inputs changed during execution",
662 task.id
663 );
664 } else {
665 qk_executor::status!(
666 "qk: {}: excluding execution from flaky results because inputs changed during execution",
667 task.id
668 );
669 }
670 }
671 return false;
672 }
673 true
674 }
675}
676
677pub(crate) fn file_output(content: &str, mode: u32) -> Value {
680 json!({"content": content, "mode": mode})
681}
682
683pub(crate) fn link_output(target: &str) -> Value {
684 json!({"link": target})
685}
686
687pub(crate) fn directory_output() -> Value {
688 json!("directory")
689}
690
691pub(crate) fn combine_outputs(
697 input: &str,
698 files: &BTreeMap<String, Value>,
699 declared: bool,
700) -> Result<String> {
701 let identity = if declared { "declared outputs" } else { input };
702 Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
703 .to_hex()
704 .to_string())
705}
706
707fn outputs_fingerprint(
708 task: &Task,
709 root: &Path,
710 outputs: &paths::Outputs,
711 input: &str,
712) -> Fingerprint {
713 output_fingerprint(root, outputs, input)
714 .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
715}
716
717fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
718 let mut files = BTreeMap::new();
719 for entry in outputs.entries(root)? {
720 let (path, metadata) = entry?;
721 paths::safe_parents(root, &path)?;
722 let absolute = root.join(&path);
723 let value = if metadata.file_type().is_symlink() {
724 link_output(
725 std::fs::read_link(absolute)?
726 .to_str()
727 .context("symlink target must be UTF-8")?,
728 )
729 } else if metadata.is_file() {
730 file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
731 } else {
732 directory_output()
733 };
734 files.insert(path, value);
735 }
736 combine_outputs(input, &files, outputs.declared())
737}
738
739#[doc(hidden)]
741pub fn run_upload_worker() -> anyhow::Result<()> {
742 remote::background::run()
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
751 let workspace = tempfile::tempdir().unwrap();
752 let root = workspace.path();
753 let cache = Cache::new(root.join("cache"));
754 cache.initialize().unwrap();
755 let mut sources = Vec::new();
756 for index in 0..130 {
757 let source = root.join(format!("source-{index}"));
758 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
759 sources.push(source);
760 }
761 let digest = cache.put_blob(&sources[0]).unwrap();
762 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
763 std::fs::remove_file(&sources[31]).unwrap();
764 let results = cache.put_blobs(&sources);
765 assert_eq!(results.len(), sources.len());
766 for (index, result) in results.into_iter().enumerate() {
767 if index == 31 {
768 assert_eq!(
769 result
770 .unwrap_err()
771 .downcast_ref::<std::io::Error>()
772 .unwrap()
773 .kind(),
774 std::io::ErrorKind::NotFound
775 );
776 continue;
777 }
778 let digest = result.unwrap();
779 let held = cache.root.join("blobs").join(&digest);
780 assert_eq!(
781 std::fs::read(&held).unwrap(),
782 format!("content-{}", index % 65).as_bytes()
783 );
784 assert_eq!(hash::digest_file(&held).unwrap(), digest);
785 }
786 }
787
788 #[cfg(unix)]
790 #[test]
791 fn a_blob_the_store_holds_is_not_written_again() {
792 use std::os::unix::fs::MetadataExt;
793 let workspace = tempfile::tempdir().unwrap();
794 let root = workspace.path();
795 let cache = Cache::new(root.join(".qk/cache"));
796 cache.initialize().unwrap();
797 std::fs::write(root.join("first"), "same").unwrap();
798 std::fs::write(root.join("second"), "same").unwrap();
799 std::fs::write(root.join("other"), "different").unwrap();
800 let first = cache.put_blob(&root.join("first")).unwrap();
801 let blob = cache.root.join("blobs").join(&first);
802 let stored = std::fs::metadata(&blob).unwrap().ino();
803 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
804 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
805 std::fs::write(&blob, "corrupt").unwrap();
807 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
808 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
809 {
811 use std::os::unix::fs::PermissionsExt;
812 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
813 }
814 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
815 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
816 let other = cache.put_blob(&root.join("other")).unwrap();
817 assert_ne!(other, first);
818 assert_eq!(
819 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
820 b"different"
821 );
822 }
823
824 #[test]
827 fn manifest_and_disk_output_fingerprints_agree() {
828 let workspace = tempfile::tempdir().unwrap();
829 let root = workspace.path();
830 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
831 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
832 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
833 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
834 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
835 #[cfg(unix)]
836 {
837 use std::os::unix::fs::PermissionsExt;
838 let script = root.join("dist/run.sh");
839 std::fs::write(&script, "#!/bin/sh\n").unwrap();
840 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
841 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
842 std::fs::set_permissions(
843 root.join("dist/nested"),
844 std::fs::Permissions::from_mode(0o500),
845 )
846 .unwrap();
847 std::fs::set_permissions(
848 root.join("bundle/readonly"),
849 std::fs::Permissions::from_mode(0o444),
850 )
851 .unwrap();
852 }
853 let log = root.join("log");
854 std::fs::write(&log, "").unwrap();
855
856 let cache = Cache::new(root.join(".qk/cache"));
857 cache.initialize().unwrap();
858 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
859 let key = "0".repeat(64);
860 let saved = cache.publish(root, &key, &outputs, &log).unwrap();
861 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
862
863 #[cfg(unix)]
864 {
865 use std::os::unix::fs::PermissionsExt;
866 std::fs::set_permissions(
869 root.join("dist/nested"),
870 std::fs::Permissions::from_mode(0o755),
871 )
872 .unwrap();
873 }
874 std::fs::remove_dir_all(root.join("dist")).unwrap();
875 std::fs::remove_dir_all(root.join("bundle")).unwrap();
876 let before = std::time::SystemTime::now();
877 let restored = cache
878 .restore(
879 root,
880 "app:build",
881 &Default::default(),
882 &key,
883 &outputs,
884 &qk_executor::Display::Stream,
885 qk_executor::Shown::LocalCache,
886 )
887 .unwrap();
888 assert_eq!(restored, Some(saved.clone()));
889 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
890 assert!(root.join("dist/nested/empty").is_dir());
891 assert!(root.join("bundle/empty").is_dir());
892 assert_eq!(
893 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
894 "readonly"
895 );
896 #[cfg(unix)]
897 {
898 assert_eq!(
899 std::fs::read_link(root.join("dist/link")).unwrap(),
900 Path::new("nested/a.txt")
901 );
902 assert_eq!(
903 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
904 0o755
905 );
906 assert_eq!(
907 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
908 0o500
909 );
910 assert_eq!(
911 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
912 0o444
913 );
914 }
915 let path = root.join("dist/nested/a.txt");
916 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
917 assert!(modified >= before);
918 let kept = cache
919 .restore(
920 root,
921 "app:build",
922 &Default::default(),
923 &key,
924 &outputs,
925 &qk_executor::Display::Hidden,
926 qk_executor::Shown::LocalCache,
927 )
928 .unwrap();
929 assert_eq!(kept, Some(saved));
930 assert_eq!(
931 std::fs::metadata(&path).unwrap().modified().unwrap(),
932 modified
933 );
934 #[cfg(unix)]
935 {
936 use std::os::unix::fs::PermissionsExt;
937 std::fs::set_permissions(
938 root.join("dist/nested"),
939 std::fs::Permissions::from_mode(0o755),
940 )
941 .unwrap();
942 }
943 }
944}