tdd-cli 0.9.0__tar.gz → 0.10.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.
Files changed (87) hide show
  1. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/CHANGELOG.md +13 -0
  2. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/PKG-INFO +19 -2
  3. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/README.md +18 -1
  4. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/pyproject.toml +2 -1
  5. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/__init__.py +1 -1
  6. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/__init__.py +2 -0
  7. tdd_cli-0.10.0/src/tddcli/adapters/cargo_adapter.py +321 -0
  8. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/conftest.py +0 -1
  9. tdd_cli-0.10.0/tests/test_cargo_adapter.py +395 -0
  10. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/.gitignore +0 -0
  11. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/LICENSE +0 -0
  12. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/SECURITY.md +0 -0
  13. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/README.md +0 -0
  14. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  15. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  16. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/plan.md +0 -0
  17. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-drive/README.md +0 -0
  18. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  19. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-handoff/README.md +0 -0
  20. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  21. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/base.py +0 -0
  22. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/exec_adapter.py +0 -0
  23. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/gradle_adapter.py +0 -0
  24. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
  25. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
  26. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/adapters/xctest_adapter.py +0 -0
  27. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/advance.py +0 -0
  28. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/cli.py +0 -0
  29. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/config.py +0 -0
  30. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/contract.py +0 -0
  31. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/envelope.py +0 -0
  32. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/fleet.py +0 -0
  33. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/gitutil.py +0 -0
  34. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/identity.py +0 -0
  35. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/leases.py +0 -0
  36. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/ledger.py +0 -0
  37. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/machine.py +0 -0
  38. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/render.py +0 -0
  39. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/snapshot.py +0 -0
  40. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/staging.py +0 -0
  41. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/src/tddcli/target_lint.py +0 -0
  42. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_advance_adoption.py +0 -0
  43. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_ancillary_files.py +0 -0
  44. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_artifact_regeneration.py +0 -0
  45. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_baseline_integrity.py +0 -0
  46. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_baseline_sanity.py +0 -0
  47. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_batch_collection.py +0 -0
  48. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_concurrent_advance.py +0 -0
  49. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_config_and_staging.py +0 -0
  50. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_config_drift.py +0 -0
  51. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_contract.py +0 -0
  52. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_doctor_attribution.py +0 -0
  53. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_doctor_blockers.py +0 -0
  54. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_end_to_end.py +0 -0
  55. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_evidence_extraction.py +0 -0
  56. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_example_plan.py +0 -0
  57. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_exec_adapter.py +0 -0
  58. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_executor_attribution.py +0 -0
  59. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_executor_notes.py +0 -0
  60. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_failure_clipping.py +0 -0
  61. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_fleet.py +0 -0
  62. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_gradle_adapter.py +0 -0
  63. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_heartbeat.py +0 -0
  64. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_id_normalisation.py +0 -0
  65. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_init_detection.py +0 -0
  66. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_named_leases_and_timeout.py +0 -0
  67. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_pin_cycles.py +0 -0
  68. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_progress.py +0 -0
  69. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_project_commands.py +0 -0
  70. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_project_env.py +0 -0
  71. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_python_env_managers.py +0 -0
  72. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_refactor_cycles.py +0 -0
  73. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_release_surface.py +0 -0
  74. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_run_claim.py +0 -0
  75. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_sensitivity_evidence.py +0 -0
  76. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_single_project_repo.py +0 -0
  77. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_snapshot_and_identity.py +0 -0
  78. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_stub_hint.py +0 -0
  79. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_suite_overrides.py +0 -0
  80. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_target_lint.py +0 -0
  81. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_target_validation.py +0 -0
  82. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_timing_visibility.py +0 -0
  83. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_undeclared_close_gate.py +0 -0
  84. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_undeclared_dedup.py +0 -0
  85. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_vitest_adapter.py +0 -0
  86. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_worker_leases.py +0 -0
  87. {tdd_cli-0.9.0 → tdd_cli-0.10.0}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-09-05
10
+
11
+ ### Added
12
+
13
+ - **cargo adapter** — Rust driver through `cargo test`. Ids are `<target>::<path>`
14
+ (`lib::…` for unit tests, `<tests-file-stem>::…` for integration tests) and compose
15
+ to `--lib`/`--test <name>` + `-- --exact <path>` for targeted runs. Verdicts are
16
+ parsed from cargo's per-test console lines, attributed to targets by the
17
+ `Running …` headers with stderr merged; doc-tests are excluded. A compile error maps
18
+ to `not_collected`, matching gradle/xctest. Collection is `cargo test --tests --
19
+ --list` with a per-file `--test <stem>` fallback; `tdd doctor`'s collectable gate is
20
+ `cargo test --no-run`. Target lint requires the `::` separator.
21
+
9
22
  ## [0.9.0] - 2026-09-03
10
23
 
11
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Ledger-backed TDD process controller for autonomous coding agents
5
5
  Project-URL: Homepage, https://github.com/geuben/tdd-cli
6
6
  Project-URL: Repository, https://github.com/geuben/tdd-cli
@@ -341,7 +341,7 @@ the next plan is written.
341
341
 
342
342
  ## Adapters
343
343
 
344
- `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
344
+ `pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
345
345
  the suite through the project's own environment manager, detected from its marker files —
346
346
  `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
347
347
  checked at the project root first, then the worktree root (workspace layouts keep one
@@ -393,6 +393,23 @@ lease = "ios-simulator"
393
393
  timeout = 900
394
394
  ```
395
395
 
396
+ The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
397
+ `<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
398
+ `roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
399
+ cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
400
+ translation. Verdicts come from cargo's per-test console lines, attributed to targets by
401
+ the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
402
+ (`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
403
+ compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
404
+ `src/` — unit-test modules live inside production files and must not be staged as tests.
405
+
406
+ ```toml
407
+ [project.kernel]
408
+ root = "kernel"
409
+ adapter = "cargo"
410
+ test_paths = ["tests/"]
411
+ ```
412
+
396
413
  Third-party adapters register under the
397
414
  `tddcli.adapters` entry-point group:
398
415
 
@@ -313,7 +313,7 @@ the next plan is written.
313
313
 
314
314
  ## Adapters
315
315
 
316
- `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
316
+ `pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
317
317
  the suite through the project's own environment manager, detected from its marker files —
318
318
  `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
319
319
  checked at the project root first, then the worktree root (workspace layouts keep one
@@ -365,6 +365,23 @@ lease = "ios-simulator"
365
365
  timeout = 900
366
366
  ```
367
367
 
368
+ The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
369
+ `<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
370
+ `roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
371
+ cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
372
+ translation. Verdicts come from cargo's per-test console lines, attributed to targets by
373
+ the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
374
+ (`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
375
+ compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
376
+ `src/` — unit-test modules live inside production files and must not be staged as tests.
377
+
378
+ ```toml
379
+ [project.kernel]
380
+ root = "kernel"
381
+ adapter = "cargo"
382
+ test_paths = ["tests/"]
383
+ ```
384
+
368
385
  Third-party adapters register under the
369
386
  `tddcli.adapters` entry-point group:
370
387
 
@@ -47,10 +47,11 @@ packages = ["src/tddcli"]
47
47
  include = ["src", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md", "SECURITY.md"]
48
48
 
49
49
  [dependency-groups]
50
- dev = ["pytest>=8.0", "pytest-json-report>=1.5", "ruff>=0.5", "zizmor>=1.29"]
50
+ dev = ["pytest>=8.0", "pytest-json-report>=1.5", "pytest-xdist>=3.5", "ruff>=0.5", "zizmor>=1.29"]
51
51
 
52
52
  [tool.pytest.ini_options]
53
53
  testpaths = ["tests"]
54
+ addopts = ["-n", "auto"]
54
55
 
55
56
  [tool.ruff]
56
57
  line-length = 100
@@ -3,4 +3,4 @@
3
3
  State is derived from observed test execution, never asserted by the caller.
4
4
  """
5
5
 
6
- __version__ = "0.9.0"
6
+ __version__ = "0.10.0"
@@ -4,6 +4,7 @@ import importlib.metadata
4
4
  from pathlib import Path
5
5
 
6
6
  from .base import Adapter, Collection, GateResult, Verdict
7
+ from .cargo_adapter import CargoAdapter
7
8
  from .exec_adapter import ExecAdapter
8
9
  from .gradle_adapter import GradleAdapter
9
10
  from .pytest_adapter import PytestAdapter
@@ -11,6 +12,7 @@ from .vitest_adapter import VitestAdapter
11
12
  from .xctest_adapter import XCTestAdapter
12
13
 
13
14
  REGISTRY: dict[str, type[Adapter]] = {
15
+ "cargo": CargoAdapter,
14
16
  "exec": ExecAdapter,
15
17
  "gradle": GradleAdapter,
16
18
  "pytest": PytestAdapter,
@@ -0,0 +1,321 @@
1
+ """cargo adapter — Rust driver through `cargo test` (§10).
2
+
3
+ Test ids are `<target>::<path>`: the *target* is `lib` for unit tests compiled
4
+ into the library (`#[cfg(test)]` modules under `src/`) or the integration-test
5
+ binary name for a file under `tests/` (`tests/roundtrip.rs` → `roundtrip`); the
6
+ *path* is the test's module path exactly as cargo prints it:
7
+
8
+ lib::layout::tests::wraps_short
9
+ roundtrip::renders_cover_only
10
+
11
+ A targeted run composes to cargo's own selectors without translation —
12
+ `cargo test --lib -- --exact layout::tests::wraps_short` or
13
+ `cargo test --test roundtrip -- --exact renders_cover_only` — and nothing else
14
+ is appended.
15
+
16
+ Console output *is* the oracle here: `cargo test` writes no result file, but
17
+ its per-test lines (`test <path> ... ok|FAILED|ignored`) are stable, and each
18
+ target's block is preceded by a `Running unittests src/lib.rs (…)` /
19
+ `Running tests/<name>.rs (…)` header that names the target. cargo prints the
20
+ headers on stderr and the test lines on stdout, so every invocation is run with
21
+ `2>&1` to keep them interleaved in order; attribution is "the most recent
22
+ header". Doc-tests (`Doc-tests <crate>`) have no stable ids and are excluded by
23
+ running `cargo test --tests` by default.
24
+
25
+ A *compile* failure maps to `not_collected`, not `failed`. Rust has no separate
26
+ collection phase — a test naming a missing symbol fails `rustc`, not the run —
27
+ so it is treated like Python's import error and Kotlin's compile error: write a
28
+ compiling stub (a function whose body is `todo!()`), then observe the
29
+ assertion failure. The discriminator is `error: could not compile` (or an
30
+ `error[E…]` diagnostic) with no test line produced.
31
+
32
+ Only files under `test_paths` are tests for staging purposes. Declare
33
+ `tests/`, not `src/`: a `#[cfg(test)]` module lives inside a production file,
34
+ and classifying `src/` as tests would stage implementation in RED commits.
35
+ Cycle targets therefore live in `tests/*.rs`; `lib::` ids are still collected
36
+ and scored so the baseline sees them.
37
+
38
+ Config (`tdd.toml`):
39
+
40
+ [project.kernel]
41
+ root = "transcript-kernel"
42
+ adapter = "cargo"
43
+ test_paths = ["tests/"]
44
+ test_command = "cargo test --tests" # default when omitted
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ import shlex
51
+ import time
52
+ from pathlib import Path
53
+
54
+ from .base import (
55
+ FAILED,
56
+ NOT_COLLECTED,
57
+ PASSED,
58
+ Adapter,
59
+ GateResult,
60
+ Verdict,
61
+ clip_failure,
62
+ )
63
+
64
+ # ` Running unittests src/lib.rs (target/debug/deps/x-abc)` → target "lib"
65
+ # ` Running tests/roundtrip.rs (target/debug/deps/roundtrip-abc)` → "roundtrip"
66
+ _RUNNING_RE = re.compile(r"^\s*Running (?:unittests (\S+)|(\S+)) \(", re.MULTILINE)
67
+ _DOCTEST_RE = re.compile(r"^\s*Doc-tests ", re.MULTILINE)
68
+ # `test layout::tests::wraps_short ... ok` / `... FAILED` / `... ignored`
69
+ _TEST_LINE_RE = re.compile(r"^test (\S+) \.\.\. (ok|FAILED|ignored)\s*$", re.MULTILINE)
70
+ # `layout::tests::wraps_short: test` (from `-- --list`)
71
+ _LIST_LINE_RE = re.compile(r"^(\S+): test\s*$", re.MULTILINE)
72
+ # `---- layout::tests::wraps_short stdout ----` opens a failure block
73
+ _FAILURE_BLOCK_RE = re.compile(r"^---- (\S+) stdout ----\s*$", re.MULTILINE)
74
+ _COMPILE_ERROR_RE = re.compile(r"^error(?:\[E\d+\])?: ", re.MULTILINE)
75
+ _COULD_NOT_COMPILE_RE = re.compile(r"could not compile")
76
+ _NO_LIB_RE = re.compile(r"no library targets found")
77
+
78
+
79
+ def _target_of_header(match: re.Match) -> str | None:
80
+ unit, path = match.group(1), match.group(2)
81
+ if unit is not None:
82
+ return "lib"
83
+ if path.startswith("tests/") and path.endswith(".rs"):
84
+ return Path(path).stem
85
+ return None # examples/benches: not test targets
86
+
87
+
88
+ class CargoAdapter(Adapter):
89
+ name = "cargo"
90
+
91
+ def stub_hint(self) -> str:
92
+ return (
93
+ "a compiling stub — a function whose body is `todo!()` — so the test"
94
+ " fails on its assertion/panic rather than on `rustc`; a missing"
95
+ " symbol is a *compile* failure (not_collected), not RED"
96
+ )
97
+
98
+ def lint_target_id(self, native: str) -> str | None:
99
+ if "::" not in native:
100
+ return (
101
+ f"cargo target ids must be '<target>::<path>' (got {native!r}); expected"
102
+ " shape: lib::module::tests::name or <tests-file-stem>::name"
103
+ )
104
+ return None
105
+
106
+ def target_path(self, native: str) -> str | None:
107
+ target, _, _ = native.partition("::")
108
+ if target == "lib":
109
+ return None
110
+ return f"tests/{target}.rs"
111
+
112
+ # ------------------------------------------------------------------
113
+ # Commands
114
+ # ------------------------------------------------------------------
115
+
116
+ def _test_cmd(self) -> str:
117
+ return self.project.test_command or "cargo test --tests"
118
+
119
+ @staticmethod
120
+ def _selector(native_id: str) -> str:
121
+ target, _, path = native_id.partition("::")
122
+ scope = "--lib" if target == "lib" else f"--test {shlex.quote(target)}"
123
+ return f"{scope} -- --exact {shlex.quote(path)}"
124
+
125
+ def _targeted_cmd(self, native_id: str) -> str:
126
+ """`cargo test <flags…>` from the declared command, with the suite-wide
127
+ target selector (`--tests`) dropped so the per-target scope can be set."""
128
+ return f"{self._targeted_prefix()} {self._selector(native_id)}"
129
+
130
+ # ------------------------------------------------------------------
131
+ # Discovery — integration test files
132
+ # ------------------------------------------------------------------
133
+
134
+ def _test_files(self) -> list[Path]:
135
+ found: set[Path] = set()
136
+ for pattern in self.project.test_patterns or []:
137
+ if pattern.endswith("/"):
138
+ base = self.root / pattern
139
+ if base.is_dir():
140
+ found.update(p for p in base.glob("*.rs") if p.is_file())
141
+ else:
142
+ found.update(p for p in self.root.glob(pattern) if p.is_file())
143
+ return sorted(found)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Collection — `cargo test … -- --list`, attributed by target header
147
+ # ------------------------------------------------------------------
148
+
149
+ def _collect_invocations(self):
150
+ return [(f"{self._test_cmd()} -- --list", self._suite_env(None))]
151
+
152
+ def _collect_batch(self, command, env):
153
+ code, out, _err = self._run_suite(f"{command} 2>&1", env)
154
+ if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
155
+ return None
156
+ tests = self._parse_list(out)
157
+ files = {f"tests/{t.split('::', 1)[0]}.rs" for t in tests if not t.startswith("lib::")}
158
+ return {self.qualify(t) for t in tests}, files
159
+
160
+ def _collect_per_file(self, rels, result):
161
+ for rel in sorted(rels):
162
+ stem = Path(rel).stem
163
+ cmd = f"{self._targeted_prefix()} --test {shlex.quote(stem)} -- --list 2>&1"
164
+ code, out, _err = self._run_suite(cmd, self._suite_env(None))
165
+ if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
166
+ result.failed_files[rel] = clip_failure(self._errors(out))
167
+ continue
168
+ result.tests |= {self.qualify(t) for t in self._parse_list(out, default=stem)}
169
+ return result
170
+
171
+ def _targeted_prefix(self) -> str:
172
+ parts = shlex.split(self._test_cmd())
173
+ parts = [p for p in parts if p != "--tests"]
174
+ if "--" in parts:
175
+ parts = parts[: parts.index("--")]
176
+ return shlex.join(parts)
177
+
178
+ @staticmethod
179
+ def _parse_list(output: str, default: str | None = None) -> set[str]:
180
+ """Walk `--list` output, prefixing each `path: test` line with the target
181
+ named by the most recent `Running …` header (or `default` when the
182
+ invocation was already scoped to one target)."""
183
+ tests: set[str] = set()
184
+ target = default
185
+ for line in output.splitlines():
186
+ header = _RUNNING_RE.match(line)
187
+ if header:
188
+ target = _target_of_header(header)
189
+ continue
190
+ if _DOCTEST_RE.match(line):
191
+ target = None
192
+ continue
193
+ entry = _LIST_LINE_RE.match(line)
194
+ if entry and target is not None:
195
+ tests.add(f"{target}::{entry.group(1)}")
196
+ return tests
197
+
198
+ def collectable(self) -> GateResult:
199
+ code, out, err = self._run_suite(
200
+ f"{self._targeted_prefix()} --no-run 2>&1", self._suite_env(None)
201
+ )
202
+ if code == 0:
203
+ return GateResult(ok=True)
204
+ return GateResult(ok=False, output=clip_failure(self._errors(out + err)))
205
+
206
+ # ------------------------------------------------------------------
207
+ # Results
208
+ # ------------------------------------------------------------------
209
+
210
+ @staticmethod
211
+ def _parse_run(output: str, default: str | None = None):
212
+ """`(passed, failed, failures)` from a merged `cargo test` console."""
213
+ passed: set[str] = set()
214
+ failed: set[str] = set()
215
+ failures: dict[str, str] = {}
216
+ target = default
217
+ # Position of each header, alongside the target it switches to — used to
218
+ # attribute a failure block by the header most recently seen before it,
219
+ # since two targets can share a test path (e.g. both name a test
220
+ # `tests::it_works`) and a path-only lookup would collide between them.
221
+ header_spans: list[tuple[int, str | None]] = []
222
+ pos = 0
223
+ for line in output.splitlines(keepends=True):
224
+ header = _RUNNING_RE.match(line)
225
+ if header:
226
+ target = _target_of_header(header)
227
+ header_spans.append((pos, target))
228
+ elif _DOCTEST_RE.match(line):
229
+ target = None
230
+ header_spans.append((pos, target))
231
+ else:
232
+ entry = _TEST_LINE_RE.match(line)
233
+ if entry and target is not None:
234
+ path, outcome = entry.groups()
235
+ tid = f"{target}::{path}"
236
+ if outcome == "ok":
237
+ passed.add(tid)
238
+ elif outcome == "FAILED":
239
+ failed.add(tid)
240
+ pos += len(line)
241
+
242
+ def _target_at(offset: int) -> str | None:
243
+ current = default
244
+ for start, tgt in header_spans:
245
+ if start > offset:
246
+ break
247
+ current = tgt
248
+ return current
249
+
250
+ # Failure blocks: from `---- <path> stdout ----` up to the next block or
251
+ # the trailing `failures:` summary list.
252
+ blocks = list(_FAILURE_BLOCK_RE.finditer(output))
253
+ for i, m in enumerate(blocks):
254
+ end = blocks[i + 1].start() if i + 1 < len(blocks) else len(output)
255
+ body = output[m.end() : end]
256
+ body = body.split("\nfailures:\n", 1)[0].strip()
257
+ path = m.group(1)
258
+ tid = f"{_target_at(m.start()) or 'lib'}::{path}"
259
+ failures[tid] = clip_failure(body) if body else "test failed"
260
+ return passed, failed, failures
261
+
262
+ @staticmethod
263
+ def _errors(combined: str) -> str:
264
+ lines = [
265
+ ln
266
+ for ln in combined.splitlines()
267
+ if _COMPILE_ERROR_RE.match(ln) or "-->" in ln or "could not compile" in ln
268
+ ]
269
+ return "\n".join(lines) if lines else combined
270
+
271
+ def run(self, target: str | None = None) -> Verdict:
272
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
273
+ env = self._suite_env(None)
274
+
275
+ native = self.strip(target) if target is not None else None
276
+ if native is not None:
277
+ cmd = self._targeted_cmd(native)
278
+ default = native.partition("::")[0]
279
+ else:
280
+ cmd = self._test_cmd()
281
+ default = None
282
+
283
+ started = time.monotonic()
284
+ code, out, err = self._run_suite(f"{cmd} 2>&1", env)
285
+ verdict.duration_ms = int((time.monotonic() - started) * 1000)
286
+ combined = out + err
287
+
288
+ passed, failed, failures = self._parse_run(combined, default=default)
289
+ verdict.passed = sorted(self.qualify(t) for t in passed)
290
+ verdict.failed = sorted(self.qualify(t) for t in failed)
291
+
292
+ if not passed and not failed:
293
+ if _COULD_NOT_COMPILE_RE.search(combined) or (
294
+ code != 0 and _COMPILE_ERROR_RE.search(combined)
295
+ ):
296
+ errors = clip_failure(self._errors(combined))
297
+ if target is not None:
298
+ verdict.target_outcome = NOT_COLLECTED
299
+ verdict.target_failure = errors
300
+ else:
301
+ verdict.error = errors
302
+ elif target is not None and _NO_LIB_RE.search(combined):
303
+ verdict.target_outcome = NOT_COLLECTED
304
+ verdict.target_failure = clip_failure(combined)
305
+ # else: filter matched nothing → NOT_FOUND; empty full run is clean
306
+ return verdict
307
+
308
+ if target is None or native is None:
309
+ return verdict
310
+
311
+ if target in verdict.passed:
312
+ verdict.target_outcome = PASSED
313
+ elif target in verdict.failed:
314
+ verdict.target_outcome = FAILED
315
+ text = failures.get(native, "test failed")
316
+ verdict.target_failure = text
317
+ verdict.target_evidence = next(
318
+ (ln for ln in text.splitlines() if "panicked" in ln or "assertion" in ln),
319
+ next((ln for ln in text.splitlines() if ln.strip()), ""),
320
+ )
321
+ return verdict
@@ -64,7 +64,6 @@ def repo(tmp_path, ledger_home):
64
64
  ".pytest_cache/\n__pycache__/\n*.pyc\n.coverage\njsonreport*.json\n"
65
65
  )
66
66
 
67
- git(root.parent, "init", "-q", str(root)) if False else None
68
67
  subprocess.run(["git", "init", "-q", str(root)], check=True)
69
68
  git(root, "config", "user.email", "test@example.com")
70
69
  git(root, "config", "user.name", "Test")
@@ -0,0 +1,395 @@
1
+ """cargo adapter — pinned against real `cargo test` console output.
2
+
3
+ All tests run without a Rust toolchain: they exercise console parsing, id
4
+ namespacing and command construction against output captured from cargo
5
+ 1.98 (2026-09-05), exactly as test_gradle_adapter.py does for Gradle.
6
+ `_run_suite` is mocked.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from unittest.mock import patch
13
+
14
+ from tddcli import config as config_mod
15
+ from tddcli.adapters import available
16
+ from tddcli.adapters.base import FAILED, NOT_COLLECTED, NOT_FOUND, PASSED
17
+ from tddcli.adapters.cargo_adapter import CargoAdapter
18
+
19
+ TOML = """
20
+ [project.kernel]
21
+ root = "kernel"
22
+ adapter = "cargo"
23
+ test_paths = ["tests/"]
24
+ """
25
+
26
+ TOML_WITH_ENV = """
27
+ [project.kernel]
28
+ root = "kernel"
29
+ adapter = "cargo"
30
+ test_paths = ["tests/"]
31
+ env = { CARGO_HOME = "/opt/cargo" }
32
+ """
33
+
34
+ TOML_CUSTOM_CMD = """
35
+ [project.kernel]
36
+ root = "kernel"
37
+ adapter = "cargo"
38
+ test_paths = ["tests/"]
39
+ test_command = "cargo test --tests --features fuzzing"
40
+ """
41
+
42
+ # `cargo test --tests 2>&1` — one lib unit test fails, one integration test passes.
43
+ FULL_RUN = """\
44
+ Compiling probe_kernel v0.1.0 (/work/kernel)
45
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 0.49s
46
+ Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
47
+
48
+ running 3 tests
49
+ test layout::tests::fails_on_purpose ... FAILED
50
+ test tests::version_is_one ... ok
51
+ test layout::tests::wraps_short ... ok
52
+
53
+ failures:
54
+
55
+ ---- layout::tests::fails_on_purpose stdout ----
56
+
57
+ thread 'layout::tests::fails_on_purpose' (173172849) panicked at src/lib.rs:7:41:
58
+ assertion `left == right` failed: expected width mismatch
59
+ left: 2
60
+ right: 3
61
+ note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
62
+
63
+
64
+ failures:
65
+ layout::tests::fails_on_purpose
66
+
67
+ test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
68
+
69
+ error: test failed, to rerun pass `--lib`
70
+ Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
71
+
72
+ running 1 test
73
+ test renders_cover_only ... ok
74
+
75
+ test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
76
+
77
+ """
78
+
79
+ # Two targets each fail a test at the same bare path (`tests::it_works`) — the
80
+ # panic text must attribute to the target whose header preceded each block,
81
+ # not collide on the shared path.
82
+ COLLIDING_PATHS_RUN = """\
83
+ Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-aaaa)
84
+
85
+ running 1 test
86
+ test tests::it_works ... FAILED
87
+
88
+ failures:
89
+
90
+ ---- tests::it_works stdout ----
91
+
92
+ thread 'tests::it_works' panicked at src/lib.rs:3:5:
93
+ lib assertion failed
94
+
95
+
96
+ failures:
97
+ tests::it_works
98
+
99
+ test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
100
+
101
+ error: test failed, to rerun pass `--lib`
102
+ Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-bbbb)
103
+
104
+ running 1 test
105
+ test tests::it_works ... FAILED
106
+
107
+ failures:
108
+
109
+ ---- tests::it_works stdout ----
110
+
111
+ thread 'tests::it_works' panicked at tests/roundtrip.rs:5:5:
112
+ roundtrip assertion failed
113
+
114
+
115
+ failures:
116
+ tests::it_works
117
+
118
+ test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
119
+
120
+ """
121
+
122
+ # `cargo test --tests -- --list 2>&1`
123
+ LIST_OUTPUT = """\
124
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
125
+ Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
126
+ layout::tests::fails_on_purpose: test
127
+ layout::tests::wraps_short: test
128
+ tests::version_is_one: test
129
+
130
+ 3 tests, 0 benchmarks
131
+ Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
132
+ renders_cover_only: test
133
+
134
+ 1 test, 0 benchmarks
135
+ Doc-tests probe_kernel
136
+ some_doc_example: test
137
+
138
+ 1 test, 0 benchmarks
139
+ """
140
+
141
+ # `cargo test --test roundtrip -- --exact renders_cover_only 2>&1`
142
+ TARGETED_PASS = """\
143
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
144
+ Running tests/roundtrip.rs (/work/target/debug/deps/roundtrip-1d2c3b4a5f6e7d8c)
145
+
146
+ running 1 test
147
+ test renders_cover_only ... ok
148
+
149
+ test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
150
+
151
+ """
152
+
153
+ # `cargo test --lib -- --exact layout::tests::nope 2>&1` — filter matched nothing.
154
+ TARGETED_NOT_FOUND = """\
155
+ Finished `test` profile [unoptimized + debuginfo] target(s) in 0.02s
156
+ Running unittests src/lib.rs (/work/target/debug/deps/probe_kernel-f0a2d29d7c4f02a1)
157
+
158
+ running 0 tests
159
+
160
+ test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s
161
+
162
+ """
163
+
164
+ # A type error in the crate: nothing ran.
165
+ COMPILE_ERROR = """\
166
+ Compiling probe_kernel v0.1.0 (/work/kernel)
167
+ error[E0308]: mismatched types
168
+ --> src/lib.rs:12:28
169
+ |
170
+ 12 | fn broken() { let x: u32 = "s"; }
171
+ | --- ^^^ expected `u32`, found `&str`
172
+ | |
173
+ | expected due to this
174
+
175
+ For more information about this error, try `rustc --explain E0308`.
176
+ error: could not compile `probe_kernel` (lib test) due to 1 previous error
177
+ """
178
+
179
+ NO_LIB = "error: no library targets found in package `probe_bin`\n"
180
+
181
+ LIB_FAIL = "kernel::lib::layout::tests::fails_on_purpose"
182
+ LIB_PASS = "kernel::lib::layout::tests::wraps_short"
183
+ INT_PASS = "kernel::roundtrip::renders_cover_only"
184
+
185
+
186
+ def make_adapter(tmp_path: Path, toml: str = TOML) -> CargoAdapter:
187
+ (tmp_path / "tdd.toml").write_text(toml)
188
+ (tmp_path / "kernel" / "tests").mkdir(parents=True)
189
+ (tmp_path / "kernel" / "tests" / "roundtrip.rs").write_text("#[test] fn renders_cover_only() {}\n")
190
+ cfg = config_mod.load(tmp_path)
191
+ return CargoAdapter(cfg.project("kernel"), tmp_path)
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Registration and ids
196
+ # ---------------------------------------------------------------------------
197
+
198
+
199
+ def test_cargo_is_a_builtin_adapter():
200
+ assert "cargo" in available()
201
+
202
+
203
+ def test_target_id_lint_requires_target_separator(tmp_path):
204
+ a = make_adapter(tmp_path)
205
+ assert a.lint_target_id("renders_cover_only") is not None
206
+ assert a.lint_target_id("roundtrip::renders_cover_only") is None
207
+ assert a.lint_target_id("lib::layout::tests::wraps_short") is None
208
+
209
+
210
+ def test_target_path_maps_integration_target_to_its_file(tmp_path):
211
+ a = make_adapter(tmp_path)
212
+ assert a.target_path("roundtrip::renders_cover_only") == "tests/roundtrip.rs"
213
+ assert a.target_path("lib::layout::tests::wraps_short") is None
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Command construction
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def test_targeted_command_scopes_to_the_target_and_uses_exact(tmp_path):
222
+ a = make_adapter(tmp_path)
223
+ assert a._targeted_cmd("roundtrip::renders_cover_only") == (
224
+ "cargo test --test roundtrip -- --exact renders_cover_only"
225
+ )
226
+ assert a._targeted_cmd("lib::layout::tests::wraps_short") == (
227
+ "cargo test --lib -- --exact layout::tests::wraps_short"
228
+ )
229
+
230
+
231
+ def test_targeted_command_keeps_declared_flags_but_drops_tests_selector(tmp_path):
232
+ a = make_adapter(tmp_path, TOML_CUSTOM_CMD)
233
+ assert a._targeted_cmd("roundtrip::renders_cover_only") == (
234
+ "cargo test --features fuzzing --test roundtrip -- --exact renders_cover_only"
235
+ )
236
+
237
+
238
+ def test_run_merges_stderr_into_stdout(tmp_path):
239
+ a = make_adapter(tmp_path)
240
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_PASS, "")) as rs:
241
+ a.run(INT_PASS)
242
+ cmd = rs.call_args.args[0]
243
+ assert cmd.endswith("2>&1"), cmd
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Suite run parsing
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def test_full_run_attributes_tests_to_targets_by_header(tmp_path):
252
+ a = make_adapter(tmp_path)
253
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, FULL_RUN, "")):
254
+ v = a.run()
255
+ assert v.error is None
256
+ assert set(v.passed) == {LIB_PASS, "kernel::lib::tests::version_is_one", INT_PASS}
257
+ assert v.failed == [LIB_FAIL]
258
+
259
+
260
+ def test_full_run_does_not_collide_failures_sharing_a_bare_path(tmp_path):
261
+ a = make_adapter(tmp_path)
262
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
263
+ v = a.run()
264
+ assert set(v.failed) == {"kernel::lib::tests::it_works", "kernel::roundtrip::tests::it_works"}
265
+
266
+
267
+ def test_targeted_run_attributes_the_right_target_when_paths_collide(tmp_path):
268
+ a = make_adapter(tmp_path)
269
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
270
+ lib_v = a.run("kernel::lib::tests::it_works")
271
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COLLIDING_PATHS_RUN, "")):
272
+ int_v = a.run("kernel::roundtrip::tests::it_works")
273
+ assert lib_v.target_outcome == FAILED
274
+ assert "lib assertion failed" in lib_v.target_failure
275
+ assert int_v.target_outcome == FAILED
276
+ assert "roundtrip assertion failed" in int_v.target_failure
277
+
278
+
279
+ def test_targeted_pass(tmp_path):
280
+ a = make_adapter(tmp_path)
281
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_PASS, "")):
282
+ v = a.run(INT_PASS)
283
+ assert v.target_outcome == PASSED
284
+
285
+
286
+ def test_targeted_failure_carries_panic_message_as_evidence(tmp_path):
287
+ a = make_adapter(tmp_path)
288
+ # A targeted lib run prints the same shape as the full run's lib block.
289
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, FULL_RUN, "")):
290
+ v = a.run(LIB_FAIL)
291
+ assert v.target_outcome == FAILED
292
+ assert "expected width mismatch" in v.target_failure
293
+ assert "panicked at src/lib.rs:7:41" in v.target_evidence
294
+ # The trailing `failures:` summary list is not part of the failure text.
295
+ assert " layout::tests::fails_on_purpose" not in v.target_failure
296
+
297
+
298
+ def test_targeted_filter_matching_nothing_is_not_found(tmp_path):
299
+ a = make_adapter(tmp_path)
300
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, TARGETED_NOT_FOUND, "")):
301
+ v = a.run("kernel::lib::layout::tests::nope")
302
+ assert v.target_outcome == NOT_FOUND
303
+ assert v.target_failure == ""
304
+
305
+
306
+ def test_compile_error_is_not_collected_for_a_target(tmp_path):
307
+ a = make_adapter(tmp_path)
308
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
309
+ v = a.run(LIB_PASS)
310
+ assert v.target_outcome == NOT_COLLECTED
311
+ assert "error[E0308]: mismatched types" in v.target_failure
312
+ assert "src/lib.rs:12:28" in v.target_failure
313
+
314
+
315
+ def test_compile_error_is_a_suite_error_for_a_full_run(tmp_path):
316
+ a = make_adapter(tmp_path)
317
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
318
+ v = a.run()
319
+ assert v.error is not None and "could not compile" in v.error
320
+ assert v.passed == [] and v.failed == []
321
+
322
+
323
+ def test_missing_lib_target_is_not_collected(tmp_path):
324
+ a = make_adapter(tmp_path)
325
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, NO_LIB, "")):
326
+ v = a.run(LIB_PASS)
327
+ assert v.target_outcome == NOT_COLLECTED
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # Collection
332
+ # ---------------------------------------------------------------------------
333
+
334
+
335
+ def test_batch_collection_prefixes_ids_and_skips_doctests(tmp_path):
336
+ a = make_adapter(tmp_path)
337
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")):
338
+ c = a.collect()
339
+ assert c.tests == {
340
+ LIB_FAIL,
341
+ LIB_PASS,
342
+ "kernel::lib::tests::version_is_one",
343
+ INT_PASS,
344
+ }
345
+ assert c.failed_files == {}
346
+
347
+
348
+ def test_batch_collection_command_lists_with_merged_streams(tmp_path):
349
+ a = make_adapter(tmp_path)
350
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")) as rs:
351
+ a.collect()
352
+ assert rs.call_args_list[0].args[0] == "cargo test --tests -- --list 2>&1"
353
+
354
+
355
+ def test_per_file_collection_records_compile_failure_against_its_file(tmp_path):
356
+ a = make_adapter(tmp_path)
357
+ (tmp_path / "kernel" / "tests" / "broken.rs").write_text("fn nope( {}\n")
358
+
359
+ def side_effect(cmd, env=None, timeout=None):
360
+ if cmd.startswith("cargo test --tests -- --list"):
361
+ return (101, COMPILE_ERROR, "") # batch fails → per-file loop
362
+ if "--test broken" in cmd:
363
+ return (101, COMPILE_ERROR, "")
364
+ if "--test roundtrip" in cmd:
365
+ return (0, "renders_cover_only: test\n\n1 test, 0 benchmarks\n", "")
366
+ raise AssertionError(cmd)
367
+
368
+ with patch.object(CargoAdapter, "_run_suite", side_effect=side_effect):
369
+ c = a.collect()
370
+ assert INT_PASS in c.tests
371
+ assert "tests/broken.rs" in c.failed_files
372
+ assert "could not compile" in c.failed_files["tests/broken.rs"]
373
+
374
+
375
+ def test_collectable_is_a_no_run_build(tmp_path):
376
+ a = make_adapter(tmp_path)
377
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, "", "")) as rs:
378
+ assert a.collectable().ok
379
+ assert rs.call_args.args[0] == "cargo test --no-run 2>&1"
380
+ with patch.object(CargoAdapter, "_run_suite", return_value=(101, COMPILE_ERROR, "")):
381
+ g = a.collectable()
382
+ assert not g.ok and "E0308" in g.output
383
+
384
+
385
+ def test_every_invocation_carries_the_project_env(tmp_path):
386
+ """`env` is how a repo points at a toolchain outside PATH (e.g. an external-disk
387
+ rustup); the doctor gate, collection and runs must all see it."""
388
+ a = make_adapter(tmp_path, TOML_WITH_ENV)
389
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, LIST_OUTPUT, "")) as rs:
390
+ a.collectable()
391
+ a.collect()
392
+ a.run(INT_PASS)
393
+ for call in rs.call_args_list:
394
+ env = call.args[1] if len(call.args) > 1 else call.kwargs.get("extra_env")
395
+ assert env == {"CARGO_HOME": "/opt/cargo"}, call
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes