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
723                            .changed_since(&workspace.root, started, &unlisted, &listed)
724                            .map(|changed| changed.to_string())
725                        {
726                            Some(reason) => Err(reason),
727                            None => Ok(set),
728                        }
729                    }),
730            ),
731            _ => None,
732        };
733        if let Some(Err(reason)) = &path_set {
734            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
735            recorded = Some(Recorded::Failed {
736                reason: reason.clone(),
737            });
738        }
739        if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
740            let publishing = profile::span(&task.id, "publish");
741            let published = self.publish(&workspace.root, &key, &outputs, log.path());
742            drop(publishing);
743            match published {
744                Ok(()) => {
745                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
746                    if let Some(remote) = &self.remote {
747                        remote.upload(&self.root, &key);
748                    }
749                }
750                Err(error) => {
751                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
752                }
753            }
754            // Its result is kept under the key of what it read, too.
755            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
756                let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
757                    .and_then(|strong| {
758                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
759                        recorded::save(&self.root, weak, set)?;
760                        // Shared, the sets are merged with those the store
761                        // held when this task looked it up.
762                        if let Some(remote) = &self.remote {
763                            remote.upload(&self.root, &strong);
764                            if let Some(record) = recorded::record(&self.root, weak) {
765                                remote.upload_path_sets(&self.root, weak, record);
766                            }
767                        }
768                        Ok(strong)
769                    });
770                recorded = Some(match saved {
771                    Ok(key) => Recorded::Saved { key },
772                    Err(error) => {
773                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
774                        Recorded::Failed {
775                            reason: format!("{error:#}"),
776                        }
777                    }
778                });
779            }
780        }
781        Ok(TaskResult {
782            outcome,
783            cache: CacheStatus::Miss,
784            key: keyed,
785            warm: warm_report,
786            execution,
787            recorded,
788            shadow,
789        })
790    }
791
792    /// Looks a task up by its recordings: by each path set's key in the local
793    /// cache, then, with none there, among the path sets and results the
794    /// remote store holds, adding its path sets to `sets`. With `restore`, a
795    /// result found is restored; without, the lookup only says whether there
796    /// is one.
797    #[allow(clippy::too_many_arguments)]
798    fn by_recordings(
799        &self,
800        workspace: &Workspace,
801        task: &Task,
802        prepared: &PreparedTask,
803        snapshot: &hash::Snapshot,
804        outputs: &paths::Outputs,
805        unlisted: &recorded::Unlisted,
806        sets: &mut Vec<recorded::PathSet>,
807        weak: &str,
808        declared: &str,
809        restore: bool,
810        cancelled: &AtomicBool,
811    ) -> Found {
812        let strong = |set: &recorded::PathSet| {
813            recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
814                .inspect_err(|error| {
815                    qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
816                })
817                .ok()
818        };
819        let restored = |strong: &str, shown| {
820            self.restore(
821                &workspace.root,
822                &task.id,
823                &task.dependencies,
824                strong,
825                outputs,
826                &prepared.display,
827                shown,
828            )
829            .unwrap_or_else(|error| {
830                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
831                false
832            })
833        };
834        let local = |strong: &str| {
835            if restore {
836                restored(strong, qk_executor::Shown::LocalCache)
837            } else {
838                self.root
839                    .join("entries")
840                    .join(format!("{strong}.json"))
841                    .is_file()
842            }
843        };
844        let mut keys = Vec::new();
845        for set in sets.iter() {
846            if let Some(strong) = strong(set) {
847                if local(&strong) {
848                    return Found::Local(strong);
849                }
850                keys.push(strong);
851            }
852        }
853        let Some(remote) = &self.remote else {
854            return Found::Nothing;
855        };
856        // Without path sets here, a result here under the declared key is
857        // the task's, and asking the store for path sets would only delay
858        // it, run after run, and have them shadow it. Looking up without
859        // restoring, for mode "record", the lookup is all there is to do.
860        if restore
861            && sets.is_empty()
862            && self
863                .root
864                .join("entries")
865                .join(format!("{declared}.json"))
866                .is_file()
867        {
868            return Found::Nothing;
869        }
870        // Path sets other machines recorded, new here.
871        let fetched = remote
872            .fetch_path_sets(&self.root, weak, &task.id)
873            .and_then(|record| match record {
874                Some(record) => recorded::merge(&self.root, weak, &record),
875                None => Ok(Vec::new()),
876            });
877        match fetched {
878            Ok(added) => {
879                for set in added {
880                    if let Some(strong) = strong(&set) {
881                        if local(&strong) {
882                            sets.push(set);
883                            return Found::Local(strong);
884                        }
885                        keys.push(strong);
886                    }
887                    sets.push(set);
888                }
889            }
890            Err(error) => {
891                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
892            }
893        }
894        for strong in keys {
895            let found = if restore {
896                remote
897                    .fetch(&self.root, &strong, &task.id, cancelled)
898                    .unwrap_or_else(|error| {
899                        qk_executor::status!(
900                            "qk: {}: remote cache unavailable ({error:#})",
901                            task.id
902                        );
903                        false
904                    })
905                    && restored(&strong, qk_executor::Shown::RemoteCache)
906            } else {
907                remote.holds(&strong).unwrap_or(false)
908            };
909            if found {
910                return Found::Remote(strong);
911            }
912        }
913        Found::Nothing
914    }
915
916    /// The path set of a task's recording, or why there is none.
917    fn path_set(
918        &self,
919        workspace: &Workspace,
920        recording: &qk_executor::Recording,
921        prepared: &PreparedTask,
922        settings: &ultracache::Settings,
923        own: &[&paths::Outputs],
924    ) -> std::result::Result<recorded::PathSet, String> {
925        let failed = |error: anyhow::Error| format!("{error:#}");
926        let canonical = workspace
927            .root
928            .canonicalize()
929            .map_err(|e| failed(e.into()))?;
930        // Only what a path set can hold is kept and counted: a build reads
931        // far more in node_modules and outside the workspace than in it.
932        let keep = |path: &std::path::Path| {
933            [&workspace.root, &canonical].iter().any(|root| {
934                path.strip_prefix(root).is_ok_and(|relative| {
935                    !relative.components().any(|component| {
936                        matches!(
937                            component.as_os_str().to_str(),
938                            Some("node_modules" | ".git")
939                        )
940                    })
941                })
942            })
943        };
944        let limits = qk_input_analysis::strace::Limits {
945            records: 1_000_000,
946            bytes: 256 * 1024 * 1024,
947            accesses: 500_000,
948            keep: Some(&keep),
949        };
950        let (accesses, diagnostics) =
951            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
952                .map_err(failed)?;
953        // A partial trace would key the task by part of what it read.
954        if let Some(diagnostic) = diagnostics.first() {
955            return Err(diagnostic.clone());
956        }
957        let ignore = settings
958            .ignore_reads
959            .iter()
960            .map(|glob| Pattern::new(glob, false))
961            .collect::<Result<Vec<_>>>()
962            .map_err(failed)?;
963        // qk's own directories, where they lie inside the workspace.
964        let state = [self.root.clone(), worktree_state(&workspace.root)]
965            .iter()
966            .filter_map(|path| {
967                [&workspace.root, &canonical]
968                    .iter()
969                    .find_map(|root| path.strip_prefix(root).ok())
970                    .and_then(|relative| relative.to_str())
971                    .map(|relative| relative.replace('\\', "/"))
972            })
973            .collect();
974        let exclusions = recorded::Exclusions {
975            roots: [&workspace.root, &canonical],
976            outputs: own,
977            ignore,
978            state,
979        };
980        Ok(recorded::PathSet::from_accesses(
981            &accesses,
982            &exclusions,
983            &canonical,
984        ))
985    }
986
987    /// Rechecks declared inputs for successful and failed executions alike.
988    #[allow(clippy::too_many_arguments)]
989    fn inputs_match(
990        &self,
991        snapshot: &hash::Snapshot,
992        workspace: &Workspace,
993        graph: &TaskGraph,
994        task: &Task,
995        prepared: &PreparedTask,
996        before: &str,
997        cancelled: &AtomicBool,
998    ) -> bool {
999        if cancelled.load(Ordering::SeqCst) {
1000            return false;
1001        }
1002        let _profile = profile::span(&task.id, "inputs_recheck");
1003        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1004            .and_then(|dependencies| {
1005                hash::fingerprint(
1006                    snapshot,
1007                    workspace,
1008                    task,
1009                    prepared,
1010                    &dependencies,
1011                    cancelled,
1012                )
1013            });
1014        if after.as_deref().ok() != Some(before) {
1015            if task.definition.cache == Some(true) {
1016                if self.reuse {
1017                    qk_executor::status!(
1018                        "qk: {}: not caching because inputs changed during execution",
1019                        task.id
1020                    );
1021                } else {
1022                    qk_executor::status!(
1023                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
1024                        task.id
1025                    );
1026                }
1027            }
1028            return false;
1029        }
1030        true
1031    }
1032}
1033
1034/// A task's key, and the inputs it is computed from, or why it has none.
1035pub type Keyed = std::result::Result<(String, Value), String>;
1036
1037/// The key each task in `graph` would be cached under if it ran now, and what
1038/// it is computed from, without running a task. Runtime inputs are executed,
1039/// and the dependency outputs a task reads are taken as they are on disk.
1040pub fn keys(
1041    workspace: &Workspace,
1042    graph: &TaskGraph,
1043    prepared: &BTreeMap<String, PreparedTask>,
1044    cancelled: &AtomicBool,
1045) -> Result<BTreeMap<String, Keyed>> {
1046    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1047    Ok(graph
1048        .tasks
1049        .values()
1050        .map(|task| {
1051            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1052            (task.id.clone(), keyed)
1053        })
1054        .collect())
1055}
1056
1057fn key_now(
1058    snapshot: &hash::Snapshot,
1059    workspace: &Workspace,
1060    graph: &TaskGraph,
1061    task: &Task,
1062    prepared: &BTreeMap<String, PreparedTask>,
1063    cancelled: &AtomicBool,
1064) -> Keyed {
1065    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1066    let dependencies =
1067        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1068    let inputs = hash::inputs(
1069        snapshot,
1070        workspace,
1071        task,
1072        &prepared[&task.id],
1073        &dependencies,
1074        cancelled,
1075    )
1076    .map_err(failed)?;
1077    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1078}
1079
1080/// Runs the private detached-upload protocol used by the qk executable.
1081#[doc(hidden)]
1082pub fn run_upload_worker() -> anyhow::Result<()> {
1083    remote::background::run()
1084}
1085
1086/// The declared outputs of every target in the workspace, as their targets
1087/// declare them and as each of their configurations does with its options,
1088/// which can move an output; one whose outputs cannot be read is left out.
1089fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1090    let mut outputs = Vec::new();
1091    for (name, project) in &workspace.projects {
1092        for (target, definition) in &project.targets {
1093            let configured = definition
1094                .configurations
1095                .iter()
1096                .map(|(configuration, options)| {
1097                    let mut definition = definition.clone();
1098                    definition.options.extend(options.clone());
1099                    (Some(configuration.clone()), definition)
1100                });
1101            for (configuration, definition) in
1102                std::iter::once((None, definition.clone())).chain(configured)
1103            {
1104                let task = Task {
1105                    id: format!("{name}:{target}"),
1106                    project: name.clone(),
1107                    target: target.clone(),
1108                    configuration: configuration.clone(),
1109                    args: Vec::new(),
1110                    definition,
1111                    dependencies: Default::default(),
1112                    edges: Default::default(),
1113                };
1114                if let Some(declared) = paths::Outputs::new(workspace, &task)
1115                    .ok()
1116                    .filter(paths::Outputs::declared)
1117                {
1118                    outputs.push(((name.clone(), target.clone(), configuration), declared));
1119                }
1120            }
1121        }
1122    }
1123    let roots = workspace
1124        .projects
1125        .iter()
1126        .map(|(name, project)| (name.clone(), project.root.clone()))
1127        .collect();
1128    recorded::WorkspaceOutputs::new(outputs, &roots)
1129}
1130
1131/// `task`'s target and those of every task it depends on, each with the
1132/// configuration it runs with.
1133fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1134    let of = |task: &Task| {
1135        (
1136            task.project.clone(),
1137            task.target.clone(),
1138            task.configuration.clone(),
1139        )
1140    };
1141    let mut targets = BTreeSet::from([of(task)]);
1142    let mut seen = BTreeSet::new();
1143    let mut pending: Vec<&String> = task.dependencies.iter().collect();
1144    while let Some(id) = pending.pop() {
1145        if !seen.insert(id) {
1146            continue;
1147        }
1148        if let Some(dependency) = graph.tasks.get(id) {
1149            targets.insert(of(dependency));
1150            pending.extend(&dependency.dependencies);
1151        }
1152    }
1153    targets
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158    use super::*;
1159
1160    #[test]
1161    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1162        let workspace = tempfile::tempdir().unwrap();
1163        let root = workspace.path();
1164        let cache = Cache::new(root.join("cache"));
1165        cache.initialize().unwrap();
1166        let mut sources = Vec::new();
1167        for index in 0..130 {
1168            let source = root.join(format!("source-{index}"));
1169            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1170            sources.push(source);
1171        }
1172        let digest = cache.put_blob(&sources[0]).unwrap();
1173        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1174        std::fs::remove_file(&sources[31]).unwrap();
1175        let results = cache.put_blobs(&sources);
1176        assert_eq!(results.len(), sources.len());
1177        for (index, result) in results.into_iter().enumerate() {
1178            if index == 31 {
1179                assert_eq!(
1180                    result
1181                        .unwrap_err()
1182                        .downcast_ref::<std::io::Error>()
1183                        .unwrap()
1184                        .kind(),
1185                    std::io::ErrorKind::NotFound
1186                );
1187                continue;
1188            }
1189            let digest = result.unwrap();
1190            let held = cache.root.join("blobs").join(&digest);
1191            assert_eq!(
1192                std::fs::read(&held).unwrap(),
1193                format!("content-{}", index % 65).as_bytes()
1194            );
1195            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1196        }
1197    }
1198
1199    /// Content the store holds already is reused, not copied over again.
1200    #[cfg(unix)]
1201    #[test]
1202    fn a_blob_the_store_holds_is_not_written_again() {
1203        use std::os::unix::fs::MetadataExt;
1204        let workspace = tempfile::tempdir().unwrap();
1205        let root = workspace.path();
1206        let cache = Cache::new(root.join(".qk/cache"));
1207        cache.initialize().unwrap();
1208        std::fs::write(root.join("first"), "same").unwrap();
1209        std::fs::write(root.join("second"), "same").unwrap();
1210        std::fs::write(root.join("other"), "different").unwrap();
1211        let first = cache.put_blob(&root.join("first")).unwrap();
1212        let blob = cache.root.join("blobs").join(&first);
1213        let stored = std::fs::metadata(&blob).unwrap().ino();
1214        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1215        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1216        // A stored blob whose content no longer matches is stored anew.
1217        std::fs::write(&blob, "corrupt").unwrap();
1218        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1219        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1220        // So is one that cannot be read.
1221        {
1222            use std::os::unix::fs::PermissionsExt;
1223            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1224        }
1225        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1226        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1227        let other = cache.put_blob(&root.join("other")).unwrap();
1228        assert_ne!(other, first);
1229        assert_eq!(
1230            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1231            b"different"
1232        );
1233    }
1234
1235    /// Complete-directory restore preserves contents, links, modes and
1236    /// timestamps, and the subsequent hit keeps restored files in place.
1237    #[test]
1238    fn complete_directory_restores_preserve_outputs() {
1239        let workspace = tempfile::tempdir().unwrap();
1240        let root = workspace.path();
1241        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1242        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1243        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1244        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1245        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1246        #[cfg(unix)]
1247        {
1248            use std::os::unix::fs::PermissionsExt;
1249            let script = root.join("dist/run.sh");
1250            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1251            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1252            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1253            std::fs::set_permissions(
1254                root.join("dist/nested"),
1255                std::fs::Permissions::from_mode(0o500),
1256            )
1257            .unwrap();
1258            std::fs::set_permissions(
1259                root.join("bundle/readonly"),
1260                std::fs::Permissions::from_mode(0o444),
1261            )
1262            .unwrap();
1263        }
1264        let log = root.join("log");
1265        std::fs::write(&log, "").unwrap();
1266
1267        let cache = Cache::new(root.join(".qk/cache"));
1268        cache.initialize().unwrap();
1269        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1270        let key = "0".repeat(64);
1271        cache.publish(root, &key, &outputs, &log).unwrap();
1272
1273        #[cfg(unix)]
1274        {
1275            use std::os::unix::fs::PermissionsExt;
1276            // The manifest retains the restrictive mode; cleanup needs write
1277            // permission on the old directory to remove its files.
1278            std::fs::set_permissions(
1279                root.join("dist/nested"),
1280                std::fs::Permissions::from_mode(0o755),
1281            )
1282            .unwrap();
1283        }
1284        std::fs::remove_dir_all(root.join("dist")).unwrap();
1285        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1286        let before = std::time::SystemTime::now();
1287        let restored = cache
1288            .restore(
1289                root,
1290                "app:build",
1291                &Default::default(),
1292                &key,
1293                &outputs,
1294                &qk_executor::Display::Stream,
1295                qk_executor::Shown::LocalCache,
1296            )
1297            .unwrap();
1298        assert!(restored);
1299        assert!(root.join("dist/nested/empty").is_dir());
1300        assert!(root.join("bundle/empty").is_dir());
1301        assert_eq!(
1302            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1303            "readonly"
1304        );
1305        #[cfg(unix)]
1306        {
1307            assert_eq!(
1308                std::fs::read_link(root.join("dist/link")).unwrap(),
1309                std::path::Path::new("nested/a.txt")
1310            );
1311            assert_eq!(
1312                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1313                0o755
1314            );
1315            assert_eq!(
1316                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1317                0o500
1318            );
1319            assert_eq!(
1320                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1321                0o444
1322            );
1323        }
1324        let path = root.join("dist/nested/a.txt");
1325        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1326        assert!(modified >= before);
1327        let kept = cache
1328            .restore(
1329                root,
1330                "app:build",
1331                &Default::default(),
1332                &key,
1333                &outputs,
1334                &qk_executor::Display::Hidden,
1335                qk_executor::Shown::LocalCache,
1336            )
1337            .unwrap();
1338        assert!(kept);
1339        assert_eq!(
1340            std::fs::metadata(&path).unwrap().modified().unwrap(),
1341            modified
1342        );
1343        #[cfg(unix)]
1344        {
1345            use std::os::unix::fs::PermissionsExt;
1346            std::fs::set_permissions(
1347                root.join("dist/nested"),
1348                std::fs::Permissions::from_mode(0o755),
1349            )
1350            .unwrap();
1351        }
1352    }
1353
1354    #[test]
1355    fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1356        let temp = tempfile::tempdir().unwrap();
1357        let root = temp.path();
1358        std::fs::write(root.join("nx.json"), "{}").unwrap();
1359        std::fs::create_dir_all(root.join("app")).unwrap();
1360        std::fs::create_dir_all(root.join("lib")).unwrap();
1361        // A project at the workspace root, with an output in a folder of its
1362        // own and one inside another project.
1363        std::fs::write(
1364            root.join("project.json"),
1365            r#"{"name":"workspace","targets":{"cache":{"command":"true",
1366                "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1367        )
1368        .unwrap();
1369        std::fs::write(
1370            root.join("app/project.json"),
1371            r#"{"name":"app","root":"./app","targets":{
1372                "build":{"command":"true","outputs":["{options.outputPath}"],
1373                  "options":{"outputPath":"app/dist"},
1374                  "configurations":{"production":{"outputPath":"app/dist-production"}}},
1375                "lint":{"command":"true"},
1376                "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1377                "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1378                  "configurations":{"production":{}}}}}"#,
1379        )
1380        .unwrap();
1381        std::fs::write(
1382            root.join("lib/project.json"),
1383            r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1384                "options":{"outputPath":"lib/dist"},
1385                "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1386        )
1387        .unwrap();
1388        let workspace = Workspace::load(root).unwrap();
1389        let graph = qk_taskgraph::TaskGraph::build(
1390            &workspace,
1391            &[
1392                qk_taskgraph::Request::parse("app:lint").unwrap(),
1393                qk_taskgraph::Request::parse("app:test").unwrap(),
1394                qk_taskgraph::Request::parse("app:test:production").unwrap(),
1395            ],
1396        )
1397        .unwrap();
1398        let outputs = workspace_outputs(&workspace);
1399        let others = |task: &str| recorded::Others {
1400            outputs: &outputs,
1401            upstream: upstream(&graph, &graph.tasks[task]),
1402        };
1403        let lint = others("app:lint");
1404        assert!(lint.matches("app/dist/main.js"));
1405        assert!(lint.matches("app/dist-production/main.js"));
1406        assert!(lint.matches("lib/dist"));
1407        assert!(!lint.matches("app/src/main.ts"));
1408        // The directories holding an output count as it, not its project's.
1409        assert!(lint.matches("app/target"));
1410        assert!(lint.matches("app/target/wasm"));
1411        assert!(!lint.matches("app"));
1412        // A root project's folders are top-level, but never another project.
1413        assert!(lint.matches(".cache"));
1414        assert!(lint.matches("app/extra"));
1415        assert!(!lint.matches("lib"));
1416        // Before the task runs, the directories those folders come and go
1417        // in are listed, another project's root among them.
1418        let unlisted = recorded::Unlisted {
1419            own: Vec::new(),
1420            others: Some(lint),
1421            ignore: &[],
1422        };
1423        let directories = unlisted.directories("lib");
1424        for directory in [".", ".cache", "app", "app/extra"] {
1425            assert!(directories.contains(directory), "{directory}");
1426        }
1427        // A dependency's output is the test's input, and only that of the
1428        // configuration it depends on.
1429        assert!(!others("app:test").matches("lib/dist"));
1430        assert!(others("app:test").matches("lib/dist-production"));
1431        let production = others("app:test:production");
1432        assert!(!production.matches("lib/dist-production"));
1433        assert!(production.matches("lib/dist"));
1434    }
1435}