tiderace 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tiderace-0.1.0/Cargo.toml +27 -0
- tiderace-0.1.0/PKG-INFO +24 -0
- tiderace-0.1.0/README.md +7 -0
- tiderace-0.1.0/crates/engine-core/Cargo.toml +12 -0
- tiderace-0.1.0/crates/engine-core/src/cache/cache.rs +18 -0
- tiderace-0.1.0/crates/engine-core/src/cache/cache_key.rs +187 -0
- tiderace-0.1.0/crates/engine-core/src/cache/cached_outcome.rs +33 -0
- tiderace-0.1.0/crates/engine-core/src/cache/dir_cache.rs +187 -0
- tiderace-0.1.0/crates/engine-core/src/cache/local_cache.rs +74 -0
- tiderace-0.1.0/crates/engine-core/src/cache/mod.rs +32 -0
- tiderace-0.1.0/crates/engine-core/src/cache/null_cache.rs +36 -0
- tiderace-0.1.0/crates/engine-core/src/cache/purity.rs +73 -0
- tiderace-0.1.0/crates/engine-core/src/cache/tiered_cache.rs +100 -0
- tiderace-0.1.0/crates/engine-core/src/collection/collector.rs +13 -0
- tiderace-0.1.0/crates/engine-core/src/collection/mod.rs +7 -0
- tiderace-0.1.0/crates/engine-core/src/collection/regex_collector.rs +216 -0
- tiderace-0.1.0/crates/engine-core/src/coverage/coverage_report.rs +39 -0
- tiderace-0.1.0/crates/engine-core/src/coverage/dep_graph.rs +158 -0
- tiderace-0.1.0/crates/engine-core/src/coverage/file_lines.rs +42 -0
- tiderace-0.1.0/crates/engine-core/src/coverage/mod.rs +17 -0
- tiderace-0.1.0/crates/engine-core/src/domain/mod.rs +20 -0
- tiderace-0.1.0/crates/engine-core/src/domain/node_id.rs +57 -0
- tiderace-0.1.0/crates/engine-core/src/domain/outcome.rs +53 -0
- tiderace-0.1.0/crates/engine-core/src/domain/run_report.rs +61 -0
- tiderace-0.1.0/crates/engine-core/src/domain/scope.rs +55 -0
- tiderace-0.1.0/crates/engine-core/src/domain/scope_path.rs +27 -0
- tiderace-0.1.0/crates/engine-core/src/domain/test_item.rs +19 -0
- tiderace-0.1.0/crates/engine-core/src/domain/test_result.rs +51 -0
- tiderace-0.1.0/crates/engine-core/src/domain/test_style.rs +25 -0
- tiderace-0.1.0/crates/engine-core/src/error.rs +26 -0
- tiderace-0.1.0/crates/engine-core/src/exec/fork_permit.rs +51 -0
- tiderace-0.1.0/crates/engine-core/src/exec/fork_plan.rs +124 -0
- tiderace-0.1.0/crates/engine-core/src/exec/fork_worker.rs +68 -0
- tiderace-0.1.0/crates/engine-core/src/exec/memory_governor.rs +164 -0
- tiderace-0.1.0/crates/engine-core/src/exec/mod.rs +38 -0
- tiderace-0.1.0/crates/engine-core/src/exec/shim_protocol.rs +147 -0
- tiderace-0.1.0/crates/engine-core/src/exec/subinterp_worker.rs +189 -0
- tiderace-0.1.0/crates/engine-core/src/exec/subprocess_worker.rs +155 -0
- tiderace-0.1.0/crates/engine-core/src/exec/transport.rs +326 -0
- tiderace-0.1.0/crates/engine-core/src/exec/watermark.rs +84 -0
- tiderace-0.1.0/crates/engine-core/src/exec/watermark_stack.rs +209 -0
- tiderace-0.1.0/crates/engine-core/src/exec/wellspring.rs +77 -0
- tiderace-0.1.0/crates/engine-core/src/exec/worker.rs +9 -0
- tiderace-0.1.0/crates/engine-core/src/exec/worker_caps.rs +35 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/closure_hash.rs +213 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/finalizer.rs +168 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture.rs +109 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_args.rs +48 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_closure.rs +57 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_error.rs +67 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_graph.rs +611 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_instance.rs +52 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_plan.rs +120 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/fixture_resolver.rs +42 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/layered_resolver.rs +542 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/mod.rs +44 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/override_table.rs +176 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/param_value.rs +45 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/scope_layer.rs +97 -0
- tiderace-0.1.0/crates/engine-core/src/fixtures/shim_handle.rs +24 -0
- tiderace-0.1.0/crates/engine-core/src/hooks/hook.rs +17 -0
- tiderace-0.1.0/crates/engine-core/src/hooks/hook_event.rs +18 -0
- tiderace-0.1.0/crates/engine-core/src/hooks/hook_host.rs +154 -0
- tiderace-0.1.0/crates/engine-core/src/hooks/mod.rs +19 -0
- tiderace-0.1.0/crates/engine-core/src/hooks/priority.rs +20 -0
- tiderace-0.1.0/crates/engine-core/src/impact/change.rs +39 -0
- tiderace-0.1.0/crates/engine-core/src/impact/impact_analyzer.rs +108 -0
- tiderace-0.1.0/crates/engine-core/src/impact/mod.rs +17 -0
- tiderace-0.1.0/crates/engine-core/src/impact/selection.rs +21 -0
- tiderace-0.1.0/crates/engine-core/src/lib.rs +27 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/github_reporter.rs +65 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/json_reporter.rs +80 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/junit_xml_reporter.rs +101 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/mod.rs +22 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/reporter.rs +21 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/sarif_reporter.rs +125 -0
- tiderace-0.1.0/crates/engine-core/src/reporter/terminal_reporter.rs +78 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/locality_scheduler.rs +186 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/mod.rs +24 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/round_robin_scheduler.rs +40 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/scheduled_test.rs +36 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/scheduler.rs +45 -0
- tiderace-0.1.0/crates/engine-core/src/scheduler/worker_batch.rs +45 -0
- tiderace-0.1.0/crates/engine-core/src/shim_locate.rs +52 -0
- tiderace-0.1.0/crates/engine-core/src/testing.rs +127 -0
- tiderace-0.1.0/crates/engine-core/tests/cache_impact_integration.rs +103 -0
- tiderace-0.1.0/crates/engine-core/tests/differential.rs +132 -0
- tiderace-0.1.0/crates/engine-core/tests/fixtures_acceptance.rs +527 -0
- tiderace-0.1.0/crates/engine-core/tests/fx_support/mod.rs +275 -0
- tiderace-0.1.0/crates/engine-core/tests/nofork_acceptance.rs +280 -0
- tiderace-0.1.0/crates/engine-core/tests/subinterp_acceptance.rs +161 -0
- tiderace-0.1.0/crates/engine-daemon/Cargo.toml +18 -0
- tiderace-0.1.0/crates/engine-daemon/src/engine_handler.rs +657 -0
- tiderace-0.1.0/crates/engine-daemon/src/fs_watcher.rs +109 -0
- tiderace-0.1.0/crates/engine-daemon/src/invalidator.rs +166 -0
- tiderace-0.1.0/crates/engine-daemon/src/lib.rs +37 -0
- tiderace-0.1.0/crates/engine-daemon/src/main.rs +278 -0
- tiderace-0.1.0/crates/engine-daemon/src/persist.rs +154 -0
- tiderace-0.1.0/crates/engine-daemon/src/pool.rs +334 -0
- tiderace-0.1.0/crates/engine-daemon/src/probe.rs +117 -0
- tiderace-0.1.0/crates/engine-daemon/src/rpc_method.rs +76 -0
- tiderace-0.1.0/crates/engine-daemon/src/rpc_server.rs +180 -0
- tiderace-0.1.0/crates/engine-daemon/src/session.rs +200 -0
- tiderace-0.1.0/crates/engine-daemon/src/socket.rs +19 -0
- tiderace-0.1.0/crates/engine-daemon/src/watch.rs +207 -0
- tiderace-0.1.0/crates/engine-daemon/tests/daemon_e2e.rs +150 -0
- tiderace-0.1.0/crates/tiderace-dist/Cargo.lock +435 -0
- tiderace-0.1.0/crates/tiderace-dist/Cargo.toml +11 -0
- tiderace-0.1.0/crates/tiderace-dist/README.md +7 -0
- tiderace-0.1.0/py-tiderace/tiderace/__init__.py +161 -0
- tiderace-0.1.0/py-tiderace/tiderace/_errors.py +18 -0
- tiderace-0.1.0/py-tiderace/tiderace/_resolve.py +79 -0
- tiderace-0.1.0/py-tiderace/tiderace/_shim/__init__.py +7 -0
- tiderace-0.1.0/py-tiderace/tiderace/_spec.py +44 -0
- tiderace-0.1.0/py-tiderace/tiderace/builtins/__init__.py +80 -0
- tiderace-0.1.0/py-tiderace/tiderace/builtins/_capture.py +97 -0
- tiderace-0.1.0/py-tiderace/tiderace/builtins/_monkeypatch.py +99 -0
- tiderace-0.1.0/py-tiderace/tiderace/builtins/_paths.py +20 -0
- tiderace-0.1.0/py-tiderace/tiderace/migrate.py +466 -0
- tiderace-0.1.0/pyproject.toml +35 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Pure-Rust test engine workspace — the tiderace engine.
|
|
2
|
+
#
|
|
3
|
+
# Lives under engine/ (the earlier pytest-orchestrator crate that used to sit at the repo root has been
|
|
4
|
+
# removed). See planning/current/pure-rust-test-engine/ and design/01-architecture.md for the layout.
|
|
5
|
+
[workspace]
|
|
6
|
+
resolver = "2"
|
|
7
|
+
members = ["crates/engine-core", "crates/engine-cli", "crates/engine-daemon"]
|
|
8
|
+
# ② in-process/FFI backend (ADR-E013): a separate optional crate that links libpython (PyO3). Excluded
|
|
9
|
+
# from the default workspace build + the Windows CI job so the core engine stays dependency-free; built
|
|
10
|
+
# explicitly with `PYO3_PYTHON=<python-with-libpython> cargo build --manifest-path crates/engine-inproc/Cargo.toml`.
|
|
11
|
+
exclude = ["crates/engine-inproc", "crates/tiderace-dist"]
|
|
12
|
+
|
|
13
|
+
[workspace.package]
|
|
14
|
+
edition = "2021"
|
|
15
|
+
version = "0.0.1"
|
|
16
|
+
license = "MIT"
|
|
17
|
+
|
|
18
|
+
[workspace.dependencies]
|
|
19
|
+
serde = { version = "1", features = ["derive"] }
|
|
20
|
+
serde_json = "1"
|
|
21
|
+
thiserror = "2"
|
|
22
|
+
regex = "1"
|
|
23
|
+
# FS watching for the warm daemon (Phase 6, ADR-E007). Same version the legacy crate already vendors.
|
|
24
|
+
notify = "6.1.1"
|
|
25
|
+
|
|
26
|
+
[profile.release]
|
|
27
|
+
opt-level = 3
|
tiderace-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tiderace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Development Status :: 3 - Alpha
|
|
5
|
+
Classifier: Programming Language :: Rust
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Topic :: Software Development :: Testing
|
|
8
|
+
Summary: A pure-Rust test engine for Python — its own runner, not a pytest wrapper.
|
|
9
|
+
Keywords: testing,pytest,rust,test-runner,impact-analysis
|
|
10
|
+
Author: John Aven
|
|
11
|
+
License: MIT
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
14
|
+
Project-URL: Homepage, https://snoodleboot-io.github.io/tiderace/
|
|
15
|
+
Project-URL: Repository, https://github.com/snoodleboot-io/tiderace
|
|
16
|
+
|
|
17
|
+
# tiderace
|
|
18
|
+
|
|
19
|
+
A pure-Rust test engine for Python — its own runner, not a pytest wrapper. Isolation without the fork
|
|
20
|
+
tax, and only runs what changed.
|
|
21
|
+
|
|
22
|
+
`pip install tiderace` ships the `tiderace` and `tiderace-daemon` binaries plus the native authoring
|
|
23
|
+
package. Docs: https://snoodleboot-io.github.io/tiderace/
|
|
24
|
+
|
tiderace-0.1.0/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# tiderace
|
|
2
|
+
|
|
3
|
+
A pure-Rust test engine for Python — its own runner, not a pytest wrapper. Isolation without the fork
|
|
4
|
+
tax, and only runs what changed.
|
|
5
|
+
|
|
6
|
+
`pip install tiderace` ships the `tiderace` and `tiderace-daemon` binaries plus the native authoring
|
|
7
|
+
package. Docs: https://snoodleboot-io.github.io/tiderace/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "engine-core"
|
|
3
|
+
description = "Pure-Rust Python test engine — core library (collection, domain, execution)."
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
version.workspace = true
|
|
6
|
+
license.workspace = true
|
|
7
|
+
|
|
8
|
+
[dependencies]
|
|
9
|
+
serde = { workspace = true }
|
|
10
|
+
serde_json = { workspace = true }
|
|
11
|
+
thiserror = { workspace = true }
|
|
12
|
+
regex = { workspace = true }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
use crate::cache::{CacheKey, CachedOutcome};
|
|
2
|
+
|
|
3
|
+
/// The cache seam (ADR-E005): a content-addressed store of test outcomes. Production wires a
|
|
4
|
+
/// [`TieredCache`](crate::cache::TieredCache) (local + optional remote); `--no-cache` / debugging
|
|
5
|
+
/// wires a [`NullCache`](crate::cache::NullCache).
|
|
6
|
+
///
|
|
7
|
+
/// Implementors use interior mutability (`&self` on `put`) so a single cache can be shared across the
|
|
8
|
+
/// worker pool without a `&mut` bottleneck. The orchestrator's preference order is **cache hit →
|
|
9
|
+
/// impact-skip → run** (ADR-E004): consult [`get`](Self::get) first, fall back to impact selection,
|
|
10
|
+
/// then execute and [`put`](Self::put) the fresh outcome.
|
|
11
|
+
pub trait Cache: Send + Sync {
|
|
12
|
+
/// The cached outcome for `key`, or `None` on a miss.
|
|
13
|
+
fn get(&self, key: &CacheKey) -> Option<CachedOutcome>;
|
|
14
|
+
|
|
15
|
+
/// Store `outcome` under `key`. Callers must only store **pure** outcomes
|
|
16
|
+
/// (`Purity::is_cacheable`) — impure tests are never silently cached (ADR-E004 soundness).
|
|
17
|
+
fn put(&self, key: &CacheKey, outcome: CachedOutcome);
|
|
18
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
use std::collections::BTreeMap;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
use crate::fixtures::{ClosureHash, ClosureHasher};
|
|
5
|
+
|
|
6
|
+
/// The content-addressed cache key for one test outcome (ADR-E004): a hash over the test's
|
|
7
|
+
/// *transitive input closure*, so an outcome is a pure function of its inputs and shareable across
|
|
8
|
+
/// machines/CI. Built by [`CacheKeyBuilder`], which composes the ADR's terms:
|
|
9
|
+
///
|
|
10
|
+
/// ```text
|
|
11
|
+
/// key = H( test_identity + executed_source_closure (from coverage, E006)
|
|
12
|
+
/// + fixture_closure (ClosureHash) + declared_env
|
|
13
|
+
/// + engine_version + python_version + platform )
|
|
14
|
+
/// ```
|
|
15
|
+
///
|
|
16
|
+
/// Reuses the engine's existing deterministic [`ClosureHasher`] (no new crypto dependency — see its
|
|
17
|
+
/// module note; swap-in point is documented there if collision-resistance is later required).
|
|
18
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
19
|
+
pub struct CacheKey(ClosureHash);
|
|
20
|
+
|
|
21
|
+
impl CacheKey {
|
|
22
|
+
/// Wrap a precomputed digest.
|
|
23
|
+
pub fn from_hash(h: ClosureHash) -> Self {
|
|
24
|
+
Self(h)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The underlying digest.
|
|
28
|
+
pub fn hash(&self) -> &ClosureHash {
|
|
29
|
+
&self.0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Lowercase-hex rendering (the index/diagnostics form).
|
|
33
|
+
pub fn to_hex(&self) -> String {
|
|
34
|
+
self.0.to_hex()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Composes a [`CacheKey`] deterministically, independent of call order: environment terms are fed at
|
|
39
|
+
/// construction, the executed-source closure and declared env accumulate into sorted maps, and
|
|
40
|
+
/// [`finish`](Self::finish) feeds everything in a fixed order. The **executed-source closure comes
|
|
41
|
+
/// from coverage** (ADR-E006), not a static guess — that is what makes the cache sound.
|
|
42
|
+
#[derive(Debug, Clone)]
|
|
43
|
+
pub struct CacheKeyBuilder {
|
|
44
|
+
node_id: String,
|
|
45
|
+
fixture_closure: Option<ClosureHash>,
|
|
46
|
+
/// relative source path -> its content hash (sorted for determinism).
|
|
47
|
+
sources: BTreeMap<PathBuf, [u8; 32]>,
|
|
48
|
+
/// declared env var -> value (sorted for determinism).
|
|
49
|
+
env: BTreeMap<String, String>,
|
|
50
|
+
engine_version: String,
|
|
51
|
+
python_version: String,
|
|
52
|
+
platform: String,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
impl CacheKeyBuilder {
|
|
56
|
+
/// Start a key for `node_id`, pinning the environment terms (so a cache built under one
|
|
57
|
+
/// engine/python/platform never poisons another — ADR-E004 invalidation requirement).
|
|
58
|
+
pub fn new(
|
|
59
|
+
node_id: impl Into<String>,
|
|
60
|
+
engine_version: impl Into<String>,
|
|
61
|
+
python_version: impl Into<String>,
|
|
62
|
+
platform: impl Into<String>,
|
|
63
|
+
) -> Self {
|
|
64
|
+
Self {
|
|
65
|
+
node_id: node_id.into(),
|
|
66
|
+
fixture_closure: None,
|
|
67
|
+
sources: BTreeMap::new(),
|
|
68
|
+
env: BTreeMap::new(),
|
|
69
|
+
engine_version: engine_version.into(),
|
|
70
|
+
python_version: python_version.into(),
|
|
71
|
+
platform: platform.into(),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Bind the fixture-closure term (the existing [`ClosureHash`], W14).
|
|
76
|
+
pub fn fixture_closure(&mut self, h: ClosureHash) -> &mut Self {
|
|
77
|
+
self.fixture_closure = Some(h);
|
|
78
|
+
self
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Add one executed source file and its content hash (call once per touched file; order-free).
|
|
82
|
+
pub fn executed_source(
|
|
83
|
+
&mut self,
|
|
84
|
+
path: impl Into<PathBuf>,
|
|
85
|
+
content_hash: [u8; 32],
|
|
86
|
+
) -> &mut Self {
|
|
87
|
+
self.sources.insert(path.into(), content_hash);
|
|
88
|
+
self
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Declare an env var the test is allowed to read (part of the closure; order-free).
|
|
92
|
+
pub fn declared_env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
|
|
93
|
+
self.env.insert(key.into(), value.into());
|
|
94
|
+
self
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Finalize into the content-addressed [`CacheKey`].
|
|
98
|
+
pub fn finish(&self) -> CacheKey {
|
|
99
|
+
let mut h = ClosureHasher::new();
|
|
100
|
+
// Domain-separate each section with a tag so a value can't migrate between fields.
|
|
101
|
+
h.feed_str("engine").feed_str(&self.engine_version);
|
|
102
|
+
h.feed_str("python").feed_str(&self.python_version);
|
|
103
|
+
h.feed_str("platform").feed_str(&self.platform);
|
|
104
|
+
h.feed_str("node").feed_str(&self.node_id);
|
|
105
|
+
h.feed_str("fixtures").feed(
|
|
106
|
+
self.fixture_closure
|
|
107
|
+
.map(|c| *c.as_bytes())
|
|
108
|
+
.unwrap_or([0u8; 32])
|
|
109
|
+
.as_slice(),
|
|
110
|
+
);
|
|
111
|
+
h.feed_str("sources");
|
|
112
|
+
for (path, digest) in &self.sources {
|
|
113
|
+
h.feed(path_bytes(path)).feed(digest);
|
|
114
|
+
}
|
|
115
|
+
h.feed_str("env");
|
|
116
|
+
for (key, value) in &self.env {
|
|
117
|
+
h.feed_str(key).feed_str(value);
|
|
118
|
+
}
|
|
119
|
+
CacheKey::from_hash(h.finish())
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[cfg(unix)]
|
|
124
|
+
fn path_bytes(p: &Path) -> &[u8] {
|
|
125
|
+
use std::os::unix::ffi::OsStrExt;
|
|
126
|
+
p.as_os_str().as_bytes()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[cfg(not(unix))]
|
|
130
|
+
fn path_bytes(p: &Path) -> &[u8] {
|
|
131
|
+
// Non-unix: lossy is acceptable — the digest just needs to be stable per-platform, and the
|
|
132
|
+
// platform term already partitions keys across OSes.
|
|
133
|
+
p.to_str().unwrap_or("").as_bytes()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[cfg(test)]
|
|
137
|
+
mod tests {
|
|
138
|
+
use super::*;
|
|
139
|
+
|
|
140
|
+
fn base() -> CacheKeyBuilder {
|
|
141
|
+
CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "linux-x86_64")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn identical_closures_yield_equal_keys() {
|
|
146
|
+
let a = base().executed_source("src.py", [7u8; 32]).finish();
|
|
147
|
+
let b = base().executed_source("src.py", [7u8; 32]).finish();
|
|
148
|
+
assert_eq!(a, b);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
fn source_content_change_changes_key() {
|
|
153
|
+
let a = base().executed_source("src.py", [7u8; 32]).finish();
|
|
154
|
+
let b = base().executed_source("src.py", [8u8; 32]).finish();
|
|
155
|
+
assert_ne!(a, b, "a changed source must miss the cache");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn env_terms_partition_keys() {
|
|
160
|
+
let linux = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "linux").finish();
|
|
161
|
+
let mac = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12.3", "macos").finish();
|
|
162
|
+
assert_ne!(
|
|
163
|
+
linux, mac,
|
|
164
|
+
"platform must partition the cache (no cross-env poisoning)"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn source_order_does_not_matter() {
|
|
170
|
+
let mut a = base();
|
|
171
|
+
a.executed_source("a.py", [1u8; 32])
|
|
172
|
+
.executed_source("b.py", [2u8; 32]);
|
|
173
|
+
let mut b = base();
|
|
174
|
+
b.executed_source("b.py", [2u8; 32])
|
|
175
|
+
.executed_source("a.py", [1u8; 32]);
|
|
176
|
+
assert_eq!(a.finish(), b.finish());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[test]
|
|
180
|
+
fn fixture_closure_term_participates() {
|
|
181
|
+
let a = base().finish();
|
|
182
|
+
let b = base()
|
|
183
|
+
.fixture_closure(ClosureHash::from_bytes([9u8; 32]))
|
|
184
|
+
.finish();
|
|
185
|
+
assert_ne!(a, b);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
use crate::domain::Outcome;
|
|
4
|
+
|
|
5
|
+
/// The stored artifact for one content-addressed test result (ADR-E004): the outcome plus its
|
|
6
|
+
/// captured diagnostics, enough to reconstruct a [`crate::domain::TestResult`] on a cache hit without
|
|
7
|
+
/// re-running the test. (`duration_ms` is intentionally **not** part of the key's identity — it is
|
|
8
|
+
/// recorded for reporting but a hit reports it as cached/zero.)
|
|
9
|
+
///
|
|
10
|
+
/// `Serialize`/`Deserialize` so a shareable tier ([`DirCache`](crate::cache::DirCache)) can persist it
|
|
11
|
+
/// across machines/CI.
|
|
12
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
13
|
+
pub struct CachedOutcome {
|
|
14
|
+
outcome: Outcome,
|
|
15
|
+
detail: String,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl CachedOutcome {
|
|
19
|
+
pub fn new(outcome: Outcome, detail: impl Into<String>) -> Self {
|
|
20
|
+
Self {
|
|
21
|
+
outcome,
|
|
22
|
+
detail: detail.into(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn outcome(&self) -> Outcome {
|
|
27
|
+
self.outcome
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pub fn detail(&self) -> &str {
|
|
31
|
+
&self.detail
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
3
|
+
|
|
4
|
+
use crate::cache::{Cache, CacheKey, CachedOutcome};
|
|
5
|
+
|
|
6
|
+
/// A **directory-backed** cache tier — the shareable remote store (ADR-E004). Each entry is a JSON file
|
|
7
|
+
/// named by the content hash (`<hex>.json`) in `root`, so the store is just a directory: point it at a
|
|
8
|
+
/// CI cache path (`actions/cache`), a shared mount / NFS, or an artifact dir and a green result computed
|
|
9
|
+
/// on one machine is a free hit on any other with the same inputs.
|
|
10
|
+
///
|
|
11
|
+
/// It sits behind the [`Cache`] seam exactly like [`LocalCache`](crate::cache::LocalCache), so
|
|
12
|
+
/// [`TieredCache`](crate::cache::TieredCache)`::with_remote(local, DirCache::new(dir))` gives the
|
|
13
|
+
/// local→remote→backfill flow with zero orchestrator changes. A future HTTP/object-store client is a
|
|
14
|
+
/// drop-in behind the same trait.
|
|
15
|
+
///
|
|
16
|
+
/// **Best-effort by design**: a cache is an optimization, so every I/O error is a silent miss / no-op —
|
|
17
|
+
/// `get`/`put` never panic and never fail a run. Writes are atomic (temp file + rename) so a concurrent
|
|
18
|
+
/// reader (or a killed writer) never observes a half-written entry.
|
|
19
|
+
#[derive(Debug, Clone)]
|
|
20
|
+
pub struct DirCache {
|
|
21
|
+
root: PathBuf,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Process-global counter making each in-flight temp file name unique (multiple threads may write the
|
|
25
|
+
/// same key concurrently; they must not collide on the temp path before the atomic rename).
|
|
26
|
+
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
|
|
27
|
+
|
|
28
|
+
impl DirCache {
|
|
29
|
+
/// Open (and best-effort create) the cache directory at `root`.
|
|
30
|
+
pub fn new(root: impl Into<PathBuf>) -> Self {
|
|
31
|
+
let root = root.into();
|
|
32
|
+
let _ = std::fs::create_dir_all(&root); // get/put tolerate a missing dir anyway
|
|
33
|
+
Self { root }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// The on-disk path for a key's entry.
|
|
37
|
+
fn entry_path(&self, key: &CacheKey) -> PathBuf {
|
|
38
|
+
self.root.join(format!("{}.json", key.to_hex()))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Number of stored entries (`*.json` files) — diagnostics/tests.
|
|
42
|
+
pub fn len(&self) -> usize {
|
|
43
|
+
std::fs::read_dir(&self.root)
|
|
44
|
+
.map(|rd| {
|
|
45
|
+
rd.filter_map(|e| e.ok())
|
|
46
|
+
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
|
47
|
+
.count()
|
|
48
|
+
})
|
|
49
|
+
.unwrap_or(0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Whether the store has no entries.
|
|
53
|
+
pub fn is_empty(&self) -> bool {
|
|
54
|
+
self.len() == 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// The directory this cache reads/writes (for diagnostics).
|
|
58
|
+
pub fn root(&self) -> &Path {
|
|
59
|
+
&self.root
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
impl Cache for DirCache {
|
|
64
|
+
fn get(&self, key: &CacheKey) -> Option<CachedOutcome> {
|
|
65
|
+
// A missing file, an unreadable one, or a corrupt/foreign entry all read as a plain miss.
|
|
66
|
+
let bytes = std::fs::read(self.entry_path(key)).ok()?;
|
|
67
|
+
serde_json::from_slice(&bytes).ok()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fn put(&self, key: &CacheKey, outcome: CachedOutcome) {
|
|
71
|
+
let Ok(json) = serde_json::to_vec(&outcome) else {
|
|
72
|
+
return; // unserializable ⇒ just don't cache
|
|
73
|
+
};
|
|
74
|
+
// Write to a unique temp file in the same directory, then atomically rename into place, so a
|
|
75
|
+
// reader never sees a partial entry and two writers of the same key can't corrupt each other.
|
|
76
|
+
let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
77
|
+
let tmp = self.root.join(format!(
|
|
78
|
+
".{}.{}.{seq}.tmp",
|
|
79
|
+
key.to_hex(),
|
|
80
|
+
std::process::id()
|
|
81
|
+
));
|
|
82
|
+
if std::fs::write(&tmp, &json).is_ok() {
|
|
83
|
+
if std::fs::rename(&tmp, self.entry_path(key)).is_err() {
|
|
84
|
+
let _ = std::fs::remove_file(&tmp); // rename failed (e.g. dir vanished) → clean up
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
let _ = std::fs::remove_file(&tmp);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#[cfg(test)]
|
|
93
|
+
mod tests {
|
|
94
|
+
use super::*;
|
|
95
|
+
use crate::cache::{CacheKeyBuilder, LocalCache, TieredCache};
|
|
96
|
+
use crate::domain::Outcome;
|
|
97
|
+
|
|
98
|
+
fn key(node: &str) -> CacheKey {
|
|
99
|
+
CacheKeyBuilder::new(node, "0.5.0", "3.12", "linux").finish()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// A unique temp directory per test (removed on drop).
|
|
103
|
+
struct TempDir(PathBuf);
|
|
104
|
+
impl TempDir {
|
|
105
|
+
fn new(tag: &str) -> Self {
|
|
106
|
+
static SEQ: AtomicU64 = AtomicU64::new(0);
|
|
107
|
+
let p = std::env::temp_dir().join(format!(
|
|
108
|
+
"tiderace_dircache_{tag}_{}_{}",
|
|
109
|
+
std::process::id(),
|
|
110
|
+
SEQ.fetch_add(1, Ordering::Relaxed)
|
|
111
|
+
));
|
|
112
|
+
let _ = std::fs::remove_dir_all(&p);
|
|
113
|
+
Self(p)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
impl Drop for TempDir {
|
|
117
|
+
fn drop(&mut self) {
|
|
118
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn miss_then_hit_on_disk() {
|
|
124
|
+
let dir = TempDir::new("hit");
|
|
125
|
+
let c = DirCache::new(&dir.0);
|
|
126
|
+
let k = key("t.py::a");
|
|
127
|
+
assert!(c.get(&k).is_none());
|
|
128
|
+
c.put(&k, CachedOutcome::new(Outcome::Passed, "ok"));
|
|
129
|
+
let hit = c.get(&k).expect("hit after put");
|
|
130
|
+
assert_eq!(hit.outcome(), Outcome::Passed);
|
|
131
|
+
assert_eq!(hit.detail(), "ok");
|
|
132
|
+
assert_eq!(c.len(), 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[test]
|
|
136
|
+
fn persists_across_instances_same_dir() {
|
|
137
|
+
// The "share across machines" property: machine A writes; a fresh cache over the same directory
|
|
138
|
+
// (machine B, having pulled the CI artifact) reads a hit.
|
|
139
|
+
let dir = TempDir::new("share");
|
|
140
|
+
let k = key("t.py::shared");
|
|
141
|
+
DirCache::new(&dir.0).put(&k, CachedOutcome::new(Outcome::Failed, "boom"));
|
|
142
|
+
|
|
143
|
+
let machine_b = DirCache::new(&dir.0); // separate instance, same directory
|
|
144
|
+
let hit = machine_b.get(&k).expect("cross-instance hit");
|
|
145
|
+
assert_eq!(hit.outcome(), Outcome::Failed);
|
|
146
|
+
assert_eq!(hit.detail(), "boom");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#[test]
|
|
150
|
+
fn distinct_keys_do_not_collide() {
|
|
151
|
+
let dir = TempDir::new("distinct");
|
|
152
|
+
let c = DirCache::new(&dir.0);
|
|
153
|
+
c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
|
|
154
|
+
c.put(&key("t.py::b"), CachedOutcome::new(Outcome::Failed, "x"));
|
|
155
|
+
assert_eq!(c.get(&key("t.py::a")).unwrap().outcome(), Outcome::Passed);
|
|
156
|
+
assert_eq!(c.get(&key("t.py::b")).unwrap().outcome(), Outcome::Failed);
|
|
157
|
+
assert_eq!(c.len(), 2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn corrupt_or_foreign_entry_is_a_miss_not_a_panic() {
|
|
162
|
+
let dir = TempDir::new("corrupt");
|
|
163
|
+
let c = DirCache::new(&dir.0);
|
|
164
|
+
let k = key("t.py::a");
|
|
165
|
+
std::fs::write(c.entry_path(&k), b"{ not valid json").unwrap();
|
|
166
|
+
assert!(c.get(&k).is_none(), "corrupt entry reads as a miss");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn get_on_missing_directory_is_a_miss() {
|
|
171
|
+
let c = DirCache::new("/nonexistent/tiderace/cache/dir");
|
|
172
|
+
assert!(c.get(&key("t.py::a")).is_none());
|
|
173
|
+
// put must not panic even if the directory can't be created/written.
|
|
174
|
+
c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn tiered_with_dir_remote_backfills_local() {
|
|
179
|
+
// CI populated the shared dir; a fresh machine's local tier is cold → remote hit → backfill.
|
|
180
|
+
let dir = TempDir::new("tiered");
|
|
181
|
+
let k = key("t.py::a");
|
|
182
|
+
DirCache::new(&dir.0).put(&k, CachedOutcome::new(Outcome::Passed, "")); // "CI" wrote it
|
|
183
|
+
|
|
184
|
+
let tiered = TieredCache::with_remote(LocalCache::new(), DirCache::new(&dir.0));
|
|
185
|
+
assert_eq!(tiered.get(&k).unwrap().outcome(), Outcome::Passed); // local miss → remote hit
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::sync::Mutex;
|
|
3
|
+
|
|
4
|
+
use crate::cache::{Cache, CacheKey, CachedOutcome};
|
|
5
|
+
|
|
6
|
+
/// The local cache tier: an in-process content store keyed by [`CacheKey`]. Backed by a `Mutex`-guarded
|
|
7
|
+
/// map so it satisfies the `Cache: Send + Sync` seam and can be shared across the worker pool.
|
|
8
|
+
///
|
|
9
|
+
/// ADR-E004 specifies a content store + SQLite index on disk for cross-run persistence; this in-memory
|
|
10
|
+
/// store is the faithful `Cache` impl for the warm/inner-loop path and the unit substrate. SQLite-backed
|
|
11
|
+
/// persistence is a drop-in behind the same trait (the `persist`/`load` follow-on).
|
|
12
|
+
#[derive(Debug, Default)]
|
|
13
|
+
pub struct LocalCache {
|
|
14
|
+
entries: Mutex<HashMap<CacheKey, CachedOutcome>>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
impl LocalCache {
|
|
18
|
+
pub fn new() -> Self {
|
|
19
|
+
Self::default()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// The number of cached entries (diagnostics/tests).
|
|
23
|
+
pub fn len(&self) -> usize {
|
|
24
|
+
self.entries.lock().expect("cache mutex").len()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Whether the cache is empty.
|
|
28
|
+
pub fn is_empty(&self) -> bool {
|
|
29
|
+
self.entries.lock().expect("cache mutex").is_empty()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl Cache for LocalCache {
|
|
34
|
+
fn get(&self, key: &CacheKey) -> Option<CachedOutcome> {
|
|
35
|
+
self.entries.lock().expect("cache mutex").get(key).cloned()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fn put(&self, key: &CacheKey, outcome: CachedOutcome) {
|
|
39
|
+
self.entries
|
|
40
|
+
.lock()
|
|
41
|
+
.expect("cache mutex")
|
|
42
|
+
.insert(*key, outcome);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[cfg(test)]
|
|
47
|
+
mod tests {
|
|
48
|
+
use super::*;
|
|
49
|
+
use crate::cache::CacheKeyBuilder;
|
|
50
|
+
use crate::domain::Outcome;
|
|
51
|
+
|
|
52
|
+
fn key(node: &str) -> CacheKey {
|
|
53
|
+
CacheKeyBuilder::new(node, "0.5.0", "3.12", "linux").finish()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[test]
|
|
57
|
+
fn miss_then_hit() {
|
|
58
|
+
let c = LocalCache::new();
|
|
59
|
+
let k = key("t.py::a");
|
|
60
|
+
assert!(c.get(&k).is_none());
|
|
61
|
+
c.put(&k, CachedOutcome::new(Outcome::Passed, ""));
|
|
62
|
+
assert_eq!(c.get(&k).unwrap().outcome(), Outcome::Passed);
|
|
63
|
+
assert_eq!(c.len(), 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#[test]
|
|
67
|
+
fn distinct_keys_do_not_collide() {
|
|
68
|
+
let c = LocalCache::new();
|
|
69
|
+
c.put(&key("t.py::a"), CachedOutcome::new(Outcome::Passed, ""));
|
|
70
|
+
c.put(&key("t.py::b"), CachedOutcome::new(Outcome::Failed, "boom"));
|
|
71
|
+
assert_eq!(c.get(&key("t.py::a")).unwrap().outcome(), Outcome::Passed);
|
|
72
|
+
assert_eq!(c.get(&key("t.py::b")).unwrap().outcome(), Outcome::Failed);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//! Content-addressed result cache (Phase 5, ADR-E004) — a build-system-for-tests.
|
|
2
|
+
//!
|
|
3
|
+
//! The fastest test is the one never run. Each outcome is modelled as a pure function of its inputs
|
|
4
|
+
//! and stored by content hash, so warm/CI runs approach O(changed tests) and a green result is
|
|
5
|
+
//! shareable across machines. The [`CacheKey`] hashes the test's transitive input closure — crucially
|
|
6
|
+
//! the **executed-source closure from coverage** ([`crate::coverage`]), not a static guess, which is
|
|
7
|
+
//! what makes the cache *sound*. Impure tests are never silently cached ([`Purity`]).
|
|
8
|
+
//!
|
|
9
|
+
//! Seams (ADR-E005): the [`Cache`] trait with [`LocalCache`] + [`TieredCache`] for CI sharing (backed
|
|
10
|
+
//! by a shareable [`DirCache`] remote tier) and [`NullCache`] for debugging. One type per file.
|
|
11
|
+
//!
|
|
12
|
+
//! Orchestrator preference order (ADR-E004): **cache hit → impact-skip → run**.
|
|
13
|
+
|
|
14
|
+
#[allow(clippy::module_inception)]
|
|
15
|
+
// file name = snake_case of the `Cache` trait (project convention)
|
|
16
|
+
mod cache;
|
|
17
|
+
mod cache_key;
|
|
18
|
+
mod cached_outcome;
|
|
19
|
+
mod dir_cache;
|
|
20
|
+
mod local_cache;
|
|
21
|
+
mod null_cache;
|
|
22
|
+
mod purity;
|
|
23
|
+
mod tiered_cache;
|
|
24
|
+
|
|
25
|
+
pub use cache::Cache;
|
|
26
|
+
pub use cache_key::{CacheKey, CacheKeyBuilder};
|
|
27
|
+
pub use cached_outcome::CachedOutcome;
|
|
28
|
+
pub use dir_cache::DirCache;
|
|
29
|
+
pub use local_cache::LocalCache;
|
|
30
|
+
pub use null_cache::NullCache;
|
|
31
|
+
pub use purity::{NoSandbox, Purity, SandboxHooks};
|
|
32
|
+
pub use tiered_cache::TieredCache;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
use crate::cache::{Cache, CacheKey, CachedOutcome};
|
|
2
|
+
|
|
3
|
+
/// A cache that stores nothing and always misses (ADR-E005). Wired by `--no-cache` and used in
|
|
4
|
+
/// debugging / differential runs where every test must actually execute. Lets the orchestrator depend
|
|
5
|
+
/// on the [`Cache`] seam unconditionally instead of branching on an `Option<Cache>`.
|
|
6
|
+
#[derive(Debug, Default, Clone, Copy)]
|
|
7
|
+
pub struct NullCache;
|
|
8
|
+
|
|
9
|
+
impl NullCache {
|
|
10
|
+
pub fn new() -> Self {
|
|
11
|
+
Self
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
impl Cache for NullCache {
|
|
16
|
+
fn get(&self, _key: &CacheKey) -> Option<CachedOutcome> {
|
|
17
|
+
None
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn put(&self, _key: &CacheKey, _outcome: CachedOutcome) {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#[cfg(test)]
|
|
24
|
+
mod tests {
|
|
25
|
+
use super::*;
|
|
26
|
+
use crate::cache::CacheKeyBuilder;
|
|
27
|
+
use crate::domain::Outcome;
|
|
28
|
+
|
|
29
|
+
#[test]
|
|
30
|
+
fn never_stores() {
|
|
31
|
+
let c = NullCache::new();
|
|
32
|
+
let k = CacheKeyBuilder::new("t.py::a", "0.5.0", "3.12", "linux").finish();
|
|
33
|
+
c.put(&k, CachedOutcome::new(Outcome::Passed, ""));
|
|
34
|
+
assert!(c.get(&k).is_none());
|
|
35
|
+
}
|
|
36
|
+
}
|