Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod evict;
4mod glob;
5mod hash;
6mod ignored;
7mod inputs;
8mod paths;
9pub mod profile;
10mod record;
11pub mod recorded;
12mod remote;
13mod store;
14pub mod ultracache;
15pub mod warm;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::PathBuf;
19use std::sync::OnceLock;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use anyhow::Result;
23use qk_config::Workspace;
24use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
25use qk_taskgraph::{Task, TaskGraph};
26use serde_json::Value;
27
28pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
29pub use glob::Pattern;
30pub use hash::{DependencyGlob, Selects, SourceIgnore, dependency_globs, source_files};
31pub use inputs::{
32    InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
33    manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
34    without_resolution,
35};
36/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
37pub use paths::expand as expand_project_path;
38pub use paths::{
39    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
40    worktree_state,
41};
42
43/// The cache for one run; its workspace snapshot is taken on first use.
44pub struct Cache {
45    pub root: PathBuf,
46    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
47    remote: Option<std::sync::Arc<remote::Remote>>,
48    /// Whether results and warm state may be restored or published.
49    reuse: bool,
50    /// The tasks whose outputs this run left exactly as they were.
51    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
52    /// Warm state being saved in the background.
53    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
54    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
55    /// Every target's declared outputs, which recordings' listings leave out.
56    workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
57}
58
59/// Where a task's result came from.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum CacheStatus {
63    LocalHit,
64    RemoteHit,
65    Miss,
66    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
67    Uncached,
68}
69
70/// One actual keyed execution, independent of reusable cache entries.
71pub struct Execution {
72    pub log: qk_executor::RecordedLog,
73    pub inputs_unchanged: bool,
74}
75
76pub struct TaskResult {
77    pub outcome: Outcome,
78    pub cache: CacheStatus,
79    /// The task's key and what it was computed from, when it has one.
80    pub key: Option<(String, Value)>,
81    /// What warm state did, for targets that keep it.
82    pub warm: Option<warm::WarmReport>,
83    pub execution: Option<Execution>,
84    /// How a task keyed by its recordings was keyed: see [`recorded`].
85    pub recorded: Option<Recorded>,
86    /// For a task with `qk:ultracache` mode `"record"`, what its recordings
87    /// would have done.
88    pub shadow: Option<Shadow>,
89}
90
91/// What the recordings of a task keyed by its declared inputs would have done.
92#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93#[serde(rename_all = "camelCase", tag = "kind")]
94pub enum Shadow {
95    /// One of its path sets' keys had a result, here or in the remote store.
96    WouldHit { key: String },
97    /// None of its path sets' keys had a result.
98    WouldMiss,
99    /// It has no path set yet.
100    Unrecorded,
101}
102
103/// What a lookup by a task's recordings found.
104enum Found {
105    /// A result under this key in the local cache.
106    Local(String),
107    /// A result under this key in the remote store.
108    Remote(String),
109    Nothing,
110}
111
112/// How a task with `qk:ultracache` was keyed.
113#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
114#[serde(rename_all = "camelCase", tag = "kind")]
115pub enum Recorded {
116    /// One of its path sets' keys had a result, restored under that key.
117    Hit { key: String },
118    /// This run recorded it, and stored its result under its new key.
119    Saved { key: String },
120    /// None of its path sets' keys had a result, and this run did not record.
121    Missed,
122    /// It has no path set yet, and this run did not record one: it was keyed
123    /// by its declared inputs.
124    Unrecorded,
125    /// Recording it failed, with why.
126    Failed { reason: String },
127}
128
129impl TaskResult {
130    pub fn uncached(outcome: Outcome) -> Self {
131        Self {
132            outcome,
133            cache: CacheStatus::Uncached,
134            key: None,
135            warm: None,
136            execution: None,
137            recorded: None,
138            shadow: None,
139        }
140    }
141}
142
143impl Cache {
144    pub fn new(root: PathBuf) -> Self {
145        Self {
146            root,
147            snapshot: OnceLock::new(),
148            remote: None,
149            reuse: true,
150            kept: Default::default(),
151            saves: Default::default(),
152            cleanup: Default::default(),
153            workspace_outputs: OnceLock::new(),
154        }
155    }
156
157    /// Records verified executions without configuring or using result stores.
158    pub fn for_observation(workspace: &Workspace) -> Self {
159        Self {
160            reuse: false,
161            ..Self::new(paths::cache_location(workspace))
162        }
163    }
164
165    /// The workspace's cache, with the remote store its nx.json `s3`,
166    /// `storeServer` or `qk:storeServer` key configures, if any. Credentials
167    /// and modes are read from `environment`, which includes the workspace's
168    /// dotenv files. A remote store that cannot be used is reported and left
169    /// out; the local cache still works.
170    pub fn for_workspace(
171        workspace: &Workspace,
172        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
173    ) -> Self {
174        let root = paths::cache_location(workspace);
175        let extra = &workspace.config.extra;
176        // Nx never reads `qk:storeServer`, so a workspace can name a store
177        // server for qk while Nx's own cache plugin keeps `s3`.
178        let (s3, server) = match extra.get("qk:storeServer") {
179            Some(server) => (None, Some(server)),
180            None => (extra.get("s3"), extra.get("storeServer")),
181        };
182        let remote = match remote::configure(s3, server, environment) {
183            Ok(remote) => remote.map(std::sync::Arc::new),
184            Err(error) => {
185                qk_executor::status!("qk: remote cache disabled: {error:#}");
186                None
187            }
188        };
189        if let Some(remote) = &remote {
190            remote.read_index(&root);
191        }
192        Self {
193            remote,
194            ..Self::new(root)
195        }
196    }
197
198    /// Saves what later runs reuse and waits for background saves, uploads
199    /// and retired-output cleanup, reporting failures.
200    pub fn finish(&self, workspace: &Workspace) {
201        if !self.reuse {
202            return;
203        }
204        if let Some(Ok(snapshot)) = self.snapshot.get() {
205            snapshot.save_digests(&workspace.root);
206        }
207        // Before the uploads, which a save may add to.
208        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
209            let _ = save.join();
210        }
211        if let Some(remote) = &self.remote {
212            let failures = remote.finish();
213            for failure in &failures {
214                qk_executor::status!("qk: remote cache upload failed for {failure}");
215            }
216        }
217        let _profile = profile::span("cache", "restore_cleanup_wait");
218        self.cleanup.lock().unwrap().take();
219    }
220
221    fn snapshot(
222        &self,
223        workspace: &Workspace,
224        graph: &TaskGraph,
225    ) -> std::result::Result<&hash::Snapshot, String> {
226        self.snapshot
227            .get_or_init(|| {
228                hash::Snapshot::new(workspace, graph, &self.root)
229                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
230            })
231            .as_ref()
232            .map_err(Clone::clone)
233    }
234
235    pub fn run(
236        &self,
237        workspace: &Workspace,
238        graph: &TaskGraph,
239        task: &Task,
240        prepared: &PreparedTask,
241        cancelled: &AtomicBool,
242    ) -> Result<TaskResult> {
243        let result = self.run_task(workspace, graph, task, prepared, cancelled);
244        if let Some(Ok(snapshot)) = self.snapshot.get() {
245            snapshot.outputs_written(workspace, task);
246        }
247        result
248    }
249
250    fn run_task(
251        &self,
252        workspace: &Workspace,
253        graph: &TaskGraph,
254        task: &Task,
255        prepared: &PreparedTask,
256        cancelled: &AtomicBool,
257    ) -> Result<TaskResult> {
258        let cacheable = task.definition.cache == Some(true);
259        // Held until the task is done, whether it ran or came from the cache.
260        let _kept = self
261            .reuse
262            .then(|| warm::keep_across(workspace, graph, task));
263        let entries = if self.reuse {
264            warm::config(workspace, task).unwrap_or_else(|error| {
265                qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
266                Vec::new()
267            })
268        } else {
269            Vec::new()
270        };
271        // Names an entry in messages: the group it shares, if any.
272        let entry_label = |warm: &warm::Warm| match warm.group() {
273            Some(group) => format!(" (group {group})"),
274            None => String::new(),
275        };
276        // The warm variables point tools at their state; they are not inputs,
277        // so the key is computed from the task without them.
278        let mut running = prepared.clone();
279        for warm in &entries {
280            for (name, value) in &warm.env {
281                running.execution.insert(name.into(), value.into());
282            }
283        }
284        // What each entry restored, and which groups were on disk already.
285        let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
286            let (mut restored, mut present) = (Vec::new(), Vec::new());
287            for warm in &entries {
288                match self
289                    .initialize()
290                    .and_then(|()| self.restore_warm(workspace, task, warm, running))
291                {
292                    Ok((entry, groups)) => {
293                        if !entry.groups.is_empty() {
294                            restored.push(entry);
295                        }
296                        present.extend(groups);
297                    }
298                    Err(error) => qk_executor::status!(
299                        "qk: {}: warm state{} not restored ({error:#})",
300                        task.id,
301                        entry_label(warm)
302                    ),
303                }
304            }
305            (restored, present)
306        };
307        let after = |outcome: Outcome,
308                     (restored, present): (Vec<warm::Restored>, Vec<String>),
309                     running: &PreparedTask|
310         -> Option<warm::WarmReport> {
311            if entries.is_empty() {
312                return None;
313            }
314            let mut save_ms = None;
315            let mut background = false;
316            for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
317                let started = std::time::Instant::now();
318                let saved = if warm.background {
319                    self.save_warm_in_background(workspace, task, warm, running)
320                } else {
321                    self.save_warm(workspace, task, warm, running)
322                };
323                match saved {
324                    Ok(()) if warm.background => background = true,
325                    Ok(()) => {
326                        *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
327                    }
328                    Err(error) => qk_executor::status!(
329                        "qk: {}: warm state{} not saved ({error:#})",
330                        task.id,
331                        entry_label(warm)
332                    ),
333                }
334            }
335            Some(warm::WarmReport {
336                restored,
337                present,
338                save_ms,
339                background,
340            })
341        };
342        // Reports why this task runs uncached.
343        let bypass = |reason: String| {
344            if cacheable {
345                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
346            }
347            let _profile = profile::span(&task.id, "execute");
348            execute(prepared, cancelled).map(TaskResult::uncached)
349        };
350        let snapshot = match self.snapshot(workspace, graph) {
351            Ok(snapshot) => snapshot,
352            Err(reason) => return bypass(reason),
353        };
354        let dependency_profile = profile::span(&task.id, "dependency_inputs");
355        let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
356        {
357            Ok(dependencies) => dependencies,
358            Err(error) => return bypass(format!("{error:#}")),
359        };
360        drop(dependency_profile);
361        let input_profile = profile::span(&task.id, "inputs");
362        let (key, inputs) = match hash::inputs(
363            snapshot,
364            workspace,
365            task,
366            prepared,
367            &dependencies,
368            cancelled,
369        )
370        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
371        {
372            Ok(key) => key,
373            Err(error) => return bypass(format!("{error:#}")),
374        };
375        drop(input_profile);
376        let keyed = Some((key.clone(), inputs));
377        // As in Nx, the task sees its hash.
378        running
379            .execution
380            .insert("NX_TASK_HASH".into(), key.clone().into());
381        let running = &running;
382        let outputs = match paths::Outputs::new(workspace, task) {
383            Ok(outputs) => outputs,
384            Err(error) => return bypass(format!("{error:#}")),
385        };
386        if !cacheable {
387            let restored = before(running);
388            let executing = profile::span(&task.id, "execute");
389            let outcome = execute(running, cancelled)?;
390            drop(executing);
391            let warm_report = after(outcome, restored, running);
392            return Ok(TaskResult {
393                outcome,
394                cache: CacheStatus::Uncached,
395                key: keyed,
396                warm: warm_report,
397                execution: None,
398                recorded: None,
399                shadow: None,
400            });
401        }
402        if !self.reuse {
403            let capture = Capture::new(None, prepared.display.clone()).retain_log();
404            let executing = profile::span(&task.id, "execute");
405            let outcome = execute_captured(running, cancelled, Some(&capture))?;
406            drop(executing);
407            let inputs_unchanged =
408                self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
409            return Ok(TaskResult {
410                outcome,
411                cache: CacheStatus::Uncached,
412                key: keyed,
413                warm: None,
414                execution: capture.take_log().map(|log| Execution {
415                    log,
416                    inputs_unchanged,
417                }),
418                recorded: None,
419                shadow: None,
420            });
421        }
422        if let Err(error) = self.initialize() {
423            return bypass(format!("cache unavailable: {error}"));
424        }
425        let (_lock, waited) = match self.lock(&key, cancelled) {
426            Ok(Some(lock)) => lock,
427            Ok(None) => {
428                return Ok(TaskResult::uncached(Outcome::Cancelled));
429            }
430            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
431        };
432        // Inputs may have changed while another worktree held this key's lock.
433        if waited
434            && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
435                .and_then(|dependencies| {
436                    hash::fingerprint(
437                        snapshot,
438                        workspace,
439                        task,
440                        prepared,
441                        &dependencies,
442                        cancelled,
443                    )
444                })
445                .ok()
446                .as_ref()
447                != Some(&key)
448        {
449            return bypass("inputs changed while waiting for another run".into());
450        }
451        // Keyed by its recordings, a task's path sets stand in for its
452        // declared file inputs: see `recorded`.
453        let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
454            qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
455            None
456        });
457        let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
458        let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
459            .chain(warm_outputs.as_ref())
460            .collect();
461        // What its recordings' listings leave out, by the workspace's
462        // configuration rather than what this run schedules, so that the
463        // same recording keys the same in any run.
464        let others = settings.as_ref().map(|_| recorded::Others {
465            outputs: self
466                .workspace_outputs
467                .get_or_init(|| workspace_outputs(workspace)),
468            upstream: upstream(graph, task),
469        });
470        let ignore: Vec<Pattern> = settings
471            .as_ref()
472            .map(|settings| {
473                settings
474                    .ignore_reads
475                    .iter()
476                    .filter_map(|glob| Pattern::new(glob, false).ok())
477                    .collect()
478            })
479            .unwrap_or_default();
480        let unlisted = recorded::Unlisted {
481            own: own.clone(),
482            others,
483            ignore: &ignore,
484        };
485        let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
486        let mut recordings = settings.and_then(|settings| {
487            let weak = hash::weak_key(
488                snapshot,
489                workspace,
490                graph,
491                task,
492                prepared,
493                inputs,
494                &settings.ignore_reads,
495                cancelled,
496            );
497            match weak {
498                Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
499                Err(error) => {
500                    qk_executor::status!(
501                        "qk: {}: not keyed by its recordings ({error:#})",
502                        task.id
503                    );
504                    None
505                }
506            }
507        });
508        let mut shadow = None;
509        if let Some((sets, weak, settings)) = &mut recordings {
510            let recorded_profile = profile::span(&task.id, "recorded_restore");
511            let keyed_by = settings.mode == ultracache::Mode::On;
512            let found = self.by_recordings(
513                workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
514                keyed_by, cancelled,
515            );
516            drop(recorded_profile);
517            match (keyed_by, found) {
518                (true, found @ (Found::Local(_) | Found::Remote(_))) => {
519                    let (strong, remote) = match found {
520                        Found::Local(strong) => {
521                            if let Some(remote) = &self.remote {
522                                remote.held(&strong);
523                            }
524                            (strong, None)
525                        }
526                        Found::Remote(strong) => (strong, Some(())),
527                        Found::Nothing => unreachable!(),
528                    };
529                    recorded::touch(&self.root, weak);
530                    qk_executor::report::event(qk_executor::report::Event::Cache {
531                        id: task.id.clone(),
532                        cached: if remote.is_some() {
533                            qk_executor::report::Cached::RemoteHit
534                        } else {
535                            qk_executor::report::Cached::Hit
536                        },
537                    });
538                    return Ok(TaskResult {
539                        outcome: Outcome::Success,
540                        cache: if remote.is_some() {
541                            CacheStatus::RemoteHit
542                        } else {
543                            CacheStatus::LocalHit
544                        },
545                        key: keyed,
546                        warm: None,
547                        execution: None,
548                        recorded: Some(Recorded::Hit { key: strong }),
549                        shadow: None,
550                    });
551                }
552                (true, Found::Nothing) => {}
553                (false, Found::Local(strong) | Found::Remote(strong)) => {
554                    shadow = Some(Shadow::WouldHit { key: strong });
555                }
556                (false, Found::Nothing) if sets.is_empty() => shadow = Some(Shadow::Unrecorded),
557                (false, Found::Nothing) => shadow = Some(Shadow::WouldMiss),
558            }
559        }
560        // A task with path sets is keyed by them alone: its declared inputs
561        // are what a recording found wanting. In mode "record" it is keyed by
562        // its declared inputs all the same.
563        let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
564            sets.is_empty() || settings.mode == ultracache::Mode::Record
565        });
566        // How its recordings keyed it, unless it runs and records.
567        let unrecorded = recordings
568            .as_ref()
569            .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
570            .map(|_| Recorded::Unrecorded);
571        // An entry fetched ahead of this task is local, but it came from the
572        // remote store, and is reported as such.
573        let prefetched = declared
574            && self.remote.as_ref().is_some_and(|remote| {
575                remote.resolved(&key);
576                remote.prefetched(&key)
577            });
578        let local_profile = profile::span(&task.id, "local_restore");
579        let restored = if declared {
580            self.restore(
581                &workspace.root,
582                &task.id,
583                &task.dependencies,
584                &key,
585                &outputs,
586                &prepared.display,
587                if prefetched {
588                    qk_executor::Shown::RemoteCache
589                } else {
590                    qk_executor::Shown::LocalCache
591                },
592            )
593        } else {
594            Ok(false)
595        };
596        match restored {
597            Ok(true) => {
598                let claimed = match &self.remote {
599                    Some(remote) if prefetched && remote.claim(&key) => true,
600                    Some(remote) => {
601                        remote.held(&key);
602                        false
603                    }
604                    None => false,
605                };
606                qk_executor::report::event(qk_executor::report::Event::Cache {
607                    id: task.id.clone(),
608                    cached: if claimed {
609                        qk_executor::report::Cached::RemoteHit
610                    } else {
611                        qk_executor::report::Cached::Hit
612                    },
613                });
614                return Ok(TaskResult {
615                    outcome: Outcome::Success,
616                    cache: if claimed {
617                        CacheStatus::RemoteHit
618                    } else {
619                        CacheStatus::LocalHit
620                    },
621                    key: keyed,
622                    warm: None,
623                    execution: None,
624                    recorded: unrecorded,
625                    shadow,
626                });
627            }
628            Ok(false) => {}
629            Err(error) => {
630                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
631            }
632        }
633        drop(local_profile);
634        if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
635            let fetch_profile = profile::span(&task.id, "remote_fetch");
636            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
637            drop(fetch_profile);
638            let fetched = fetched.and_then(|found| {
639                if !found {
640                    return Ok(false);
641                }
642                self.restore(
643                    &workspace.root,
644                    &task.id,
645                    &task.dependencies,
646                    &key,
647                    &outputs,
648                    &prepared.display,
649                    qk_executor::Shown::RemoteCache,
650                )
651            });
652            match fetched {
653                Ok(true) => {
654                    qk_executor::report::event(qk_executor::report::Event::Cache {
655                        id: task.id.clone(),
656                        cached: qk_executor::report::Cached::RemoteHit,
657                    });
658                    return Ok(TaskResult {
659                        outcome: Outcome::Success,
660                        cache: CacheStatus::RemoteHit,
661                        key: keyed,
662                        warm: None,
663                        execution: None,
664                        recorded: unrecorded,
665                        shadow,
666                    });
667                }
668                Ok(false) => {}
669                Err(error) => {
670                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
671                }
672            }
673        }
674        qk_executor::report::event(qk_executor::report::Event::Cache {
675            id: task.id.clone(),
676            cached: qk_executor::report::Cached::Miss,
677        });
678        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
679            Ok(log) => log,
680            Err(error) => return bypass(format!("cache log unavailable: {error}")),
681        };
682        let capture =
683            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
684        let restored = before(running);
685        let listed = match (&recordings, &prepared.recording) {
686            (Some(_), Some(_)) => {
687                let project_root = workspace
688                    .projects
689                    .get(&task.project)
690                    .map_or("", |project| project.root.as_str());
691                recorded::Before::take(
692                    &workspace.root,
693                    unlisted.directories(project_root),
694                    &unlisted,
695                )
696            }
697            _ => recorded::Before::default(),
698        };
699        let started = recorded::now(&self.root.join("tmp"));
700        let executing = profile::span(&task.id, "execute");
701        let outcome = execute_captured(running, cancelled, Some(&capture))?;
702        drop(executing);
703        let warm_report = after(outcome, restored, running);
704        let inputs_unchanged =
705            self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
706        let execution = capture.take_log().map(|log| Execution {
707            log,
708            inputs_unchanged,
709        });
710        let mut recorded = unrecorded.map(|unrecorded| match &recordings {
711            Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
712            _ => unrecorded,
713        });
714        let path_set = match (&recordings, &prepared.recording) {
715            (Some((_, _, settings)), Some(recording)) => Some(
716                self.path_set(workspace, recording, prepared, settings, &own)
717                    .and_then(|set| {
718                        // Keyed by nothing, a recording would match any workspace.
719                        if set.is_empty() {
720                            return Err("its trace named no path in the workspace".to_owned());
721                        }
722                        match set.changed_since(&workspace.root, started, &unlisted, &listed) {
723                            Some(path) => Err(format!("{path} changed while it ran")),
724                            None => Ok(set),
725                        }
726                    }),
727            ),
728            _ => None,
729        };
730        if let Some(Err(reason)) = &path_set {
731            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
732            recorded = Some(Recorded::Failed {
733                reason: reason.clone(),
734            });
735        }
736        if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
737            let publishing = profile::span(&task.id, "publish");
738            let published = self.publish(&workspace.root, &key, &outputs, log.path());
739            drop(publishing);
740            match published {
741                Ok(()) => {
742                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
743                    if let Some(remote) = &self.remote {
744                        remote.upload(&self.root, &key);
745                    }
746                }
747                Err(error) => {
748                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
749                }
750            }
751            // Its result is kept under the key of what it read, too.
752            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
753                let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
754                    .and_then(|strong| {
755                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
756                        recorded::save(&self.root, weak, set)?;
757                        // Shared, the sets are merged with those the store
758                        // held when this task looked it up.
759                        if let Some(remote) = &self.remote {
760                            remote.upload(&self.root, &strong);
761                            if let Some(record) = recorded::record(&self.root, weak) {
762                                remote.upload_path_sets(&self.root, weak, record);
763                            }
764                        }
765                        Ok(strong)
766                    });
767                recorded = Some(match saved {
768                    Ok(key) => Recorded::Saved { key },
769                    Err(error) => {
770                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
771                        Recorded::Failed {
772                            reason: format!("{error:#}"),
773                        }
774                    }
775                });
776            }
777        }
778        Ok(TaskResult {
779            outcome,
780            cache: CacheStatus::Miss,
781            key: keyed,
782            warm: warm_report,
783            execution,
784            recorded,
785            shadow,
786        })
787    }
788
789    /// Looks a task up by its recordings: by each path set's key in the local
790    /// cache, then, with none there, among the path sets and results the
791    /// remote store holds, adding its path sets to `sets`. With `restore`, a
792    /// result found is restored; without, the lookup only says whether there
793    /// is one.
794    #[allow(clippy::too_many_arguments)]
795    fn by_recordings(
796        &self,
797        workspace: &Workspace,
798        task: &Task,
799        prepared: &PreparedTask,
800        snapshot: &hash::Snapshot,
801        outputs: &paths::Outputs,
802        unlisted: &recorded::Unlisted,
803        sets: &mut Vec<recorded::PathSet>,
804        weak: &str,
805        declared: &str,
806        restore: bool,
807        cancelled: &AtomicBool,
808    ) -> Found {
809        let strong = |set: &recorded::PathSet| {
810            recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
811                .inspect_err(|error| {
812                    qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
813                })
814                .ok()
815        };
816        let restored = |strong: &str, shown| {
817            self.restore(
818                &workspace.root,
819                &task.id,
820                &task.dependencies,
821                strong,
822                outputs,
823                &prepared.display,
824                shown,
825            )
826            .unwrap_or_else(|error| {
827                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
828                false
829            })
830        };
831        let local = |strong: &str| {
832            if restore {
833                restored(strong, qk_executor::Shown::LocalCache)
834            } else {
835                self.root
836                    .join("entries")
837                    .join(format!("{strong}.json"))
838                    .is_file()
839            }
840        };
841        let mut keys = Vec::new();
842        for set in sets.iter() {
843            if let Some(strong) = strong(set) {
844                if local(&strong) {
845                    return Found::Local(strong);
846                }
847                keys.push(strong);
848            }
849        }
850        let Some(remote) = &self.remote else {
851            return Found::Nothing;
852        };
853        // Without path sets here, a result here under the declared key is
854        // the task's, and asking the store for path sets would only delay
855        // it, run after run, and have them shadow it. Looking up without
856        // restoring, for mode "record", the lookup is all there is to do.
857        if restore
858            && sets.is_empty()
859            && self
860                .root
861                .join("entries")
862                .join(format!("{declared}.json"))
863                .is_file()
864        {
865            return Found::Nothing;
866        }
867        // Path sets other machines recorded, new here.
868        let fetched = remote
869            .fetch_path_sets(&self.root, weak, &task.id)
870            .and_then(|record| match record {
871                Some(record) => recorded::merge(&self.root, weak, &record),
872                None => Ok(Vec::new()),
873            });
874        match fetched {
875            Ok(added) => {
876                for set in added {
877                    if let Some(strong) = strong(&set) {
878                        if local(&strong) {
879                            sets.push(set);
880                            return Found::Local(strong);
881                        }
882                        keys.push(strong);
883                    }
884                    sets.push(set);
885                }
886            }
887            Err(error) => {
888                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
889            }
890        }
891        for strong in keys {
892            let found = if restore {
893                remote
894                    .fetch(&self.root, &strong, &task.id, cancelled)
895                    .unwrap_or_else(|error| {
896                        qk_executor::status!(
897                            "qk: {}: remote cache unavailable ({error:#})",
898                            task.id
899                        );
900                        false
901                    })
902                    && restored(&strong, qk_executor::Shown::RemoteCache)
903            } else {
904                remote.holds(&strong).unwrap_or(false)
905            };
906            if found {
907                return Found::Remote(strong);
908            }
909        }
910        Found::Nothing
911    }
912
913    /// The path set of a task's recording, or why there is none.
914    fn path_set(
915        &self,
916        workspace: &Workspace,
917        recording: &qk_executor::Recording,
918        prepared: &PreparedTask,
919        settings: &ultracache::Settings,
920        own: &[&paths::Outputs],
921    ) -> std::result::Result<recorded::PathSet, String> {
922        let failed = |error: anyhow::Error| format!("{error:#}");
923        let canonical = workspace
924            .root
925            .canonicalize()
926            .map_err(|e| failed(e.into()))?;
927        // Only what a path set can hold is kept and counted: a build reads
928        // far more in node_modules and outside the workspace than in it.
929        let keep = |path: &std::path::Path| {
930            [&workspace.root, &canonical].iter().any(|root| {
931                path.strip_prefix(root).is_ok_and(|relative| {
932                    !relative.components().any(|component| {
933                        matches!(
934                            component.as_os_str().to_str(),
935                            Some("node_modules" | ".git")
936                        )
937                    })
938                })
939            })
940        };
941        let limits = qk_input_analysis::strace::Limits {
942            records: 1_000_000,
943            bytes: 256 * 1024 * 1024,
944            accesses: 500_000,
945            keep: Some(&keep),
946        };
947        let (accesses, diagnostics) =
948            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
949                .map_err(failed)?;
950        // A partial trace would key the task by part of what it read.
951        if let Some(diagnostic) = diagnostics.first() {
952            return Err(diagnostic.clone());
953        }
954        let ignore = settings
955            .ignore_reads
956            .iter()
957            .map(|glob| Pattern::new(glob, false))
958            .collect::<Result<Vec<_>>>()
959            .map_err(failed)?;
960        // qk's own directories, where they lie inside the workspace.
961        let state = [self.root.clone(), worktree_state(&workspace.root)]
962            .iter()
963            .filter_map(|path| {
964                [&workspace.root, &canonical]
965                    .iter()
966                    .find_map(|root| path.strip_prefix(root).ok())
967                    .and_then(|relative| relative.to_str())
968                    .map(|relative| relative.replace('\\', "/"))
969            })
970            .collect();
971        let exclusions = recorded::Exclusions {
972            roots: [&workspace.root, &canonical],
973            outputs: own,
974            ignore,
975            state,
976        };
977        Ok(recorded::PathSet::from_accesses(
978            &accesses,
979            &exclusions,
980            &canonical,
981        ))
982    }
983
984    /// Rechecks declared inputs for successful and failed executions alike.
985    #[allow(clippy::too_many_arguments)]
986    fn inputs_match(
987        &self,
988        snapshot: &hash::Snapshot,
989        workspace: &Workspace,
990        graph: &TaskGraph,
991        task: &Task,
992        prepared: &PreparedTask,
993        before: &str,
994        cancelled: &AtomicBool,
995    ) -> bool {
996        if cancelled.load(Ordering::SeqCst) {
997            return false;
998        }
999        let _profile = profile::span(&task.id, "inputs_recheck");
1000        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1001            .and_then(|dependencies| {
1002                hash::fingerprint(
1003                    snapshot,
1004                    workspace,
1005                    task,
1006                    prepared,
1007                    &dependencies,
1008                    cancelled,
1009                )
1010            });
1011        if after.as_deref().ok() != Some(before) {
1012            if task.definition.cache == Some(true) {
1013                if self.reuse {
1014                    qk_executor::status!(
1015                        "qk: {}: not caching because inputs changed during execution",
1016                        task.id
1017                    );
1018                } else {
1019                    qk_executor::status!(
1020                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
1021                        task.id
1022                    );
1023                }
1024            }
1025            return false;
1026        }
1027        true
1028    }
1029}
1030
1031/// A task's key, and the inputs it is computed from, or why it has none.
1032pub type Keyed = std::result::Result<(String, Value), String>;
1033
1034/// The key each task in `graph` would be cached under if it ran now, and what
1035/// it is computed from, without running a task. Runtime inputs are executed,
1036/// and the dependency outputs a task reads are taken as they are on disk.
1037pub fn keys(
1038    workspace: &Workspace,
1039    graph: &TaskGraph,
1040    prepared: &BTreeMap<String, PreparedTask>,
1041    cancelled: &AtomicBool,
1042) -> Result<BTreeMap<String, Keyed>> {
1043    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1044    Ok(graph
1045        .tasks
1046        .values()
1047        .map(|task| {
1048            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1049            (task.id.clone(), keyed)
1050        })
1051        .collect())
1052}
1053
1054fn key_now(
1055    snapshot: &hash::Snapshot,
1056    workspace: &Workspace,
1057    graph: &TaskGraph,
1058    task: &Task,
1059    prepared: &BTreeMap<String, PreparedTask>,
1060    cancelled: &AtomicBool,
1061) -> Keyed {
1062    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1063    let dependencies =
1064        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1065    let inputs = hash::inputs(
1066        snapshot,
1067        workspace,
1068        task,
1069        &prepared[&task.id],
1070        &dependencies,
1071        cancelled,
1072    )
1073    .map_err(failed)?;
1074    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1075}
1076
1077/// Runs the private detached-upload protocol used by the qk executable.
1078#[doc(hidden)]
1079pub fn run_upload_worker() -> anyhow::Result<()> {
1080    remote::background::run()
1081}
1082
1083/// The declared outputs of every target in the workspace, as their targets
1084/// declare them and as each of their configurations does with its options,
1085/// which can move an output; one whose outputs cannot be read is left out.
1086fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1087    let mut outputs = Vec::new();
1088    for (name, project) in &workspace.projects {
1089        for (target, definition) in &project.targets {
1090            let configured = definition
1091                .configurations
1092                .iter()
1093                .map(|(configuration, options)| {
1094                    let mut definition = definition.clone();
1095                    definition.options.extend(options.clone());
1096                    (Some(configuration.clone()), definition)
1097                });
1098            for (configuration, definition) in
1099                std::iter::once((None, definition.clone())).chain(configured)
1100            {
1101                let task = Task {
1102                    id: format!("{name}:{target}"),
1103                    project: name.clone(),
1104                    target: target.clone(),
1105                    configuration: configuration.clone(),
1106                    args: Vec::new(),
1107                    definition,
1108                    dependencies: Default::default(),
1109                    edges: Default::default(),
1110                };
1111                if let Some(declared) = paths::Outputs::new(workspace, &task)
1112                    .ok()
1113                    .filter(paths::Outputs::declared)
1114                {
1115                    outputs.push(((name.clone(), target.clone(), configuration), declared));
1116                }
1117            }
1118        }
1119    }
1120    recorded::WorkspaceOutputs::new(outputs)
1121}
1122
1123/// `task`'s target and those of every task it depends on, each with the
1124/// configuration it runs with.
1125fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1126    let of = |task: &Task| {
1127        (
1128            task.project.clone(),
1129            task.target.clone(),
1130            task.configuration.clone(),
1131        )
1132    };
1133    let mut targets = BTreeSet::from([of(task)]);
1134    let mut seen = BTreeSet::new();
1135    let mut pending: Vec<&String> = task.dependencies.iter().collect();
1136    while let Some(id) = pending.pop() {
1137        if !seen.insert(id) {
1138            continue;
1139        }
1140        if let Some(dependency) = graph.tasks.get(id) {
1141            targets.insert(of(dependency));
1142            pending.extend(&dependency.dependencies);
1143        }
1144    }
1145    targets
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    #[test]
1153    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1154        let workspace = tempfile::tempdir().unwrap();
1155        let root = workspace.path();
1156        let cache = Cache::new(root.join("cache"));
1157        cache.initialize().unwrap();
1158        let mut sources = Vec::new();
1159        for index in 0..130 {
1160            let source = root.join(format!("source-{index}"));
1161            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1162            sources.push(source);
1163        }
1164        let digest = cache.put_blob(&sources[0]).unwrap();
1165        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1166        std::fs::remove_file(&sources[31]).unwrap();
1167        let results = cache.put_blobs(&sources);
1168        assert_eq!(results.len(), sources.len());
1169        for (index, result) in results.into_iter().enumerate() {
1170            if index == 31 {
1171                assert_eq!(
1172                    result
1173                        .unwrap_err()
1174                        .downcast_ref::<std::io::Error>()
1175                        .unwrap()
1176                        .kind(),
1177                    std::io::ErrorKind::NotFound
1178                );
1179                continue;
1180            }
1181            let digest = result.unwrap();
1182            let held = cache.root.join("blobs").join(&digest);
1183            assert_eq!(
1184                std::fs::read(&held).unwrap(),
1185                format!("content-{}", index % 65).as_bytes()
1186            );
1187            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1188        }
1189    }
1190
1191    /// Content the store holds already is reused, not copied over again.
1192    #[cfg(unix)]
1193    #[test]
1194    fn a_blob_the_store_holds_is_not_written_again() {
1195        use std::os::unix::fs::MetadataExt;
1196        let workspace = tempfile::tempdir().unwrap();
1197        let root = workspace.path();
1198        let cache = Cache::new(root.join(".qk/cache"));
1199        cache.initialize().unwrap();
1200        std::fs::write(root.join("first"), "same").unwrap();
1201        std::fs::write(root.join("second"), "same").unwrap();
1202        std::fs::write(root.join("other"), "different").unwrap();
1203        let first = cache.put_blob(&root.join("first")).unwrap();
1204        let blob = cache.root.join("blobs").join(&first);
1205        let stored = std::fs::metadata(&blob).unwrap().ino();
1206        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1207        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1208        // A stored blob whose content no longer matches is stored anew.
1209        std::fs::write(&blob, "corrupt").unwrap();
1210        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1211        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1212        // So is one that cannot be read.
1213        {
1214            use std::os::unix::fs::PermissionsExt;
1215            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1216        }
1217        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1218        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1219        let other = cache.put_blob(&root.join("other")).unwrap();
1220        assert_ne!(other, first);
1221        assert_eq!(
1222            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1223            b"different"
1224        );
1225    }
1226
1227    /// Complete-directory restore preserves contents, links, modes and
1228    /// timestamps, and the subsequent hit keeps restored files in place.
1229    #[test]
1230    fn complete_directory_restores_preserve_outputs() {
1231        let workspace = tempfile::tempdir().unwrap();
1232        let root = workspace.path();
1233        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1234        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1235        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1236        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1237        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1238        #[cfg(unix)]
1239        {
1240            use std::os::unix::fs::PermissionsExt;
1241            let script = root.join("dist/run.sh");
1242            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1243            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1244            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1245            std::fs::set_permissions(
1246                root.join("dist/nested"),
1247                std::fs::Permissions::from_mode(0o500),
1248            )
1249            .unwrap();
1250            std::fs::set_permissions(
1251                root.join("bundle/readonly"),
1252                std::fs::Permissions::from_mode(0o444),
1253            )
1254            .unwrap();
1255        }
1256        let log = root.join("log");
1257        std::fs::write(&log, "").unwrap();
1258
1259        let cache = Cache::new(root.join(".qk/cache"));
1260        cache.initialize().unwrap();
1261        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1262        let key = "0".repeat(64);
1263        cache.publish(root, &key, &outputs, &log).unwrap();
1264
1265        #[cfg(unix)]
1266        {
1267            use std::os::unix::fs::PermissionsExt;
1268            // The manifest retains the restrictive mode; cleanup needs write
1269            // permission on the old directory to remove its files.
1270            std::fs::set_permissions(
1271                root.join("dist/nested"),
1272                std::fs::Permissions::from_mode(0o755),
1273            )
1274            .unwrap();
1275        }
1276        std::fs::remove_dir_all(root.join("dist")).unwrap();
1277        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1278        let before = std::time::SystemTime::now();
1279        let restored = cache
1280            .restore(
1281                root,
1282                "app:build",
1283                &Default::default(),
1284                &key,
1285                &outputs,
1286                &qk_executor::Display::Stream,
1287                qk_executor::Shown::LocalCache,
1288            )
1289            .unwrap();
1290        assert!(restored);
1291        assert!(root.join("dist/nested/empty").is_dir());
1292        assert!(root.join("bundle/empty").is_dir());
1293        assert_eq!(
1294            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1295            "readonly"
1296        );
1297        #[cfg(unix)]
1298        {
1299            assert_eq!(
1300                std::fs::read_link(root.join("dist/link")).unwrap(),
1301                std::path::Path::new("nested/a.txt")
1302            );
1303            assert_eq!(
1304                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1305                0o755
1306            );
1307            assert_eq!(
1308                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1309                0o500
1310            );
1311            assert_eq!(
1312                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1313                0o444
1314            );
1315        }
1316        let path = root.join("dist/nested/a.txt");
1317        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1318        assert!(modified >= before);
1319        let kept = cache
1320            .restore(
1321                root,
1322                "app:build",
1323                &Default::default(),
1324                &key,
1325                &outputs,
1326                &qk_executor::Display::Hidden,
1327                qk_executor::Shown::LocalCache,
1328            )
1329            .unwrap();
1330        assert!(kept);
1331        assert_eq!(
1332            std::fs::metadata(&path).unwrap().modified().unwrap(),
1333            modified
1334        );
1335        #[cfg(unix)]
1336        {
1337            use std::os::unix::fs::PermissionsExt;
1338            std::fs::set_permissions(
1339                root.join("dist/nested"),
1340                std::fs::Permissions::from_mode(0o755),
1341            )
1342            .unwrap();
1343        }
1344    }
1345
1346    #[test]
1347    fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1348        let temp = tempfile::tempdir().unwrap();
1349        let root = temp.path();
1350        std::fs::write(root.join("nx.json"), "{}").unwrap();
1351        std::fs::create_dir_all(root.join("app")).unwrap();
1352        std::fs::create_dir_all(root.join("lib")).unwrap();
1353        std::fs::write(
1354            root.join("app/project.json"),
1355            r#"{"name":"app","targets":{
1356                "build":{"command":"true","outputs":["{options.outputPath}"],
1357                  "options":{"outputPath":"app/dist"},
1358                  "configurations":{"production":{"outputPath":"app/dist-production"}}},
1359                "lint":{"command":"true"},
1360                "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1361                  "configurations":{"production":{}}}}}"#,
1362        )
1363        .unwrap();
1364        std::fs::write(
1365            root.join("lib/project.json"),
1366            r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1367                "options":{"outputPath":"lib/dist"},
1368                "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1369        )
1370        .unwrap();
1371        let workspace = Workspace::load(root).unwrap();
1372        let graph = qk_taskgraph::TaskGraph::build(
1373            &workspace,
1374            &[
1375                qk_taskgraph::Request::parse("app:lint").unwrap(),
1376                qk_taskgraph::Request::parse("app:test").unwrap(),
1377                qk_taskgraph::Request::parse("app:test:production").unwrap(),
1378            ],
1379        )
1380        .unwrap();
1381        let outputs = workspace_outputs(&workspace);
1382        let others = |task: &str| recorded::Others {
1383            outputs: &outputs,
1384            upstream: upstream(&graph, &graph.tasks[task]),
1385        };
1386        let lint = others("app:lint");
1387        assert!(lint.matches("app/dist/main.js"));
1388        assert!(lint.matches("app/dist-production/main.js"));
1389        assert!(lint.matches("lib/dist"));
1390        assert!(!lint.matches("app/src/main.ts"));
1391        // A dependency's output is the test's input, and only that of the
1392        // configuration it depends on.
1393        assert!(!others("app:test").matches("lib/dist"));
1394        assert!(others("app:test").matches("lib/dist-production"));
1395        let production = others("app:test:production");
1396        assert!(!production.matches("lib/dist-production"));
1397        assert!(production.matches("lib/dist"));
1398    }
1399}