feat: extract domain logic into focused microcrates#21
Conversation
…d integrate with existing workflow
Summary by CodeRabbit
WalkthroughThis pull request introduces seven new workspace crates to modularize shipper functionality: shipper-duration, shipper-schema, shipper-plan, shipper-policy, shipper-levels, shipper-sparse-index, and shipper-cargo-failure. Existing crates are refactored to delegate to these new modules rather than maintaining inline implementations. CI configuration is expanded to include micro-plan feature testing and new fuzz targets. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @EffortlessSteven, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly refactors the Highlights
Changelog
Ignored Files
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This is an impressive and well-executed refactoring. Decomposing the core shipper logic into focused microcrates significantly improves modularity, testability, and maintainability. The new crates are well-defined, and the addition of comprehensive unit, property, and fuzz tests for each is excellent. The overall structure is much cleaner and easier to navigate. I have one minor suggestion for simplification, but otherwise, this is a fantastic change.
| pub(crate) fn policy_effects(opts: &RuntimeOptions) -> shipper_policy::PolicyEffects { | ||
| let policy = match opts.policy { | ||
| crate::types::PublishPolicy::Safe => shipper_policy::PolicyKind::Safe, | ||
| crate::types::PublishPolicy::Balanced => shipper_policy::PolicyKind::Balanced, | ||
| crate::types::PublishPolicy::Fast => shipper_policy::PolicyKind::Fast, | ||
| }; | ||
|
|
||
| pub(crate) fn apply_policy(opts: &RuntimeOptions) -> PolicyEffects { | ||
| match opts.policy { | ||
| PublishPolicy::Safe => PolicyEffects { | ||
| run_dry_run: !opts.no_verify, | ||
| check_ownership: !opts.skip_ownership_check, | ||
| strict_ownership: opts.strict_ownership, | ||
| readiness_enabled: opts.readiness.enabled, | ||
| }, | ||
| PublishPolicy::Balanced => PolicyEffects { | ||
| run_dry_run: !opts.no_verify, | ||
| check_ownership: false, | ||
| strict_ownership: false, | ||
| readiness_enabled: opts.readiness.enabled, | ||
| }, | ||
| PublishPolicy::Fast => PolicyEffects { | ||
| run_dry_run: false, | ||
| check_ownership: false, | ||
| strict_ownership: false, | ||
| readiness_enabled: false, | ||
| }, | ||
| } | ||
| shipper_policy::evaluate( | ||
| policy, | ||
| opts.no_verify, | ||
| opts.skip_ownership_check, | ||
| opts.strict_ownership, | ||
| opts.readiness.enabled, | ||
| ) | ||
| } |
There was a problem hiding this comment.
This function appears to be a reimplementation of shipper_policy::apply_policy. Since shipper-policy is already a dependency and its apply_policy function has the same signature and logic, you can simplify this by calling it directly. This will reduce code duplication and keep the logic centralized in the shipper-policy crate.
pub(crate) fn policy_effects(opts: &RuntimeOptions) -> shipper_policy::PolicyEffects {
shipper_policy::apply_policy(opts)
}
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dc73ee0391
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .api_base | ||
| .clone() | ||
| .unwrap_or_else(|| "https://crates.io".to_string()); | ||
| let index_base = cli.api_base.as_ref().map(|_| api_base.clone()); |
There was a problem hiding this comment.
Preserve derived sparse index host when api base is overridden
Setting index_base to the same value as --api-base makes index readiness checks hit the API host instead of the sparse-index host. This regresses users who pass --api-base (including https://crates.io) and run with --readiness-method index (or both with index preferred): readiness polling can never observe the new version because requests go to the wrong endpoint, causing false publish failures/timeouts after upload.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
This PR extracts tightly-coupled domain logic from the core shipper crate into 7 new focused microcrates, significantly improving modularity, testability, and compile-time parallelism. The refactoring maintains backward compatibility through careful delegation and re-exports while adding comprehensive testing infrastructure including 6 new fuzz targets and extensive property-based tests.
Changes:
- Extracted 7 domain-specific microcrates (schema, duration, plan, policy, cargo-failure, levels, sparse-index) with ~1,700 lines of focused, reusable code
- Refactored core crates to delegate to microcrates while maintaining public API compatibility
- Added 6 new fuzz targets and extensive property-based tests for hardening critical parsing and classification logic
Reviewed changes
Copilot reviewed 56 out of 57 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/shipper-schema/* | New microcrate for schema version parsing and backward-compatibility validation |
| crates/shipper-duration/* | New microcrate for human-readable duration parsing with bidirectional serde codecs |
| crates/shipper-plan/* | New microcrate extracting workspace plan generation and topological sorting logic |
| crates/shipper-policy/* | New microcrate isolating publish policy evaluation into PolicyEffects |
| crates/shipper-cargo-failure/* | New microcrate for cargo publish failure classification heuristics |
| crates/shipper-levels/* | New microcrate implementing Kahn's algorithm for dependency-level grouping |
| crates/shipper-sparse-index/* | New microcrate for sparse-index path computation and version lookup |
| crates/shipper/src/engine.rs | Delegates policy evaluation and failure classification to microcrates |
| crates/shipper/src/plan.rs | Delegates level grouping to shipper-levels microcrate |
| crates/shipper/src/state.rs | Delegates schema validation to shipper-schema microcrate |
| crates/shipper/src/store.rs | Delegates schema validation to shipper-schema microcrate |
| crates/shipper/src/registry.rs | Delegates sparse-index logic to shipper-sparse-index microcrate |
| crates/shipper/src/types.rs | Re-exports duration codecs from shipper-duration |
| crates/shipper/src/lib.rs | Adds cargo_failure public module re-export for API continuity |
| crates/shipper-types/src/lib.rs | Delegates duration serde and level grouping to microcrates |
| crates/shipper-cli/src/main.rs | Uses shipper-duration for CLI duration parsing |
| fuzz/fuzz_targets/* | 6 new fuzz targets for microcrate hardening |
| crates/shipper/tests/* | New integration tests validating microcrate contracts |
| crates/shipper-cli/tests/bdd_publish.rs | Extended BDD tests covering new functionality |
| features/*.feature | Updated Gherkin scenarios for policy and readiness modes |
| docs/testing.md | Updated documentation for new test targets and microcrates |
| Cargo.toml | Added 7 new workspace members |
| .github/workflows/ci.yml | Updated feature matrix and MSRV check configuration |
| - name: Install Rust (MSRV) | ||
| uses: dtolnay/rust-toolchain@stable | ||
| with: | ||
| toolchain: 1.92.0 |
There was a problem hiding this comment.
The toolchain action configuration has changed from using a pinned version dtolnay/rust-toolchain@1.100.0 to dtolnay/rust-toolchain@stable with an explicit toolchain: 1.92.0 parameter. This is actually more correct - the action version (like @stable or @1.100.0) refers to the GitHub Action itself, not the Rust version. The Rust version should be specified via the toolchain parameter, which this change properly does. However, @stable is an alias that could point to different action versions over time. Consider using a specific action version hash or tag for reproducibility.
| let index_suffix = match lower.len() { | ||
| 1 => format!("1/{lower}"), | ||
| 2 => format!("2/{lower}"), | ||
| 3 => format!("3/{}/{lower}", &lower[..1]), | ||
| _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), | ||
| }; |
There was a problem hiding this comment.
The sparse index path computation logic is duplicated here (lines 232-237) instead of reusing the shipper_sparse_index::sparse_index_path function. This creates a maintenance burden - if the sparse index path logic changes, this test will need to be updated separately. Consider importing and using shipper_sparse_index::sparse_index_path directly.
| cargo fuzz run resolve_token --target x86_64-unknown-linux-gnu -- -max_total_time=60 | ||
| cargo fuzz run schema_version --target x86_64-unknown-linux-gnu -- -max_total_time=60 | ||
| cargo fuzz run release_levels --target x86_64-unknown-linux-gnu -- -max_total_time=60 |
There was a problem hiding this comment.
The PR adds 6 new fuzz targets (duration_codec, schema_version, policy_effects, cargo_failure_classifier, release_levels, sparse_index) but the CI workflow only runs 2 of them (schema_version and release_levels). The other 4 targets should be added to ensure they're regularly exercised and don't regress.
| cargo fuzz run resolve_token --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run schema_version --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run release_levels --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run resolve_token --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run duration_codec --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run schema_version --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run policy_effects --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run cargo_failure_classifier --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run release_levels --target x86_64-unknown-linux-gnu -- -max_total_time=60 | |
| cargo fuzz run sparse_index --target x86_64-unknown-linux-gnu -- -max_total_time=60 |
There was a problem hiding this comment.
Actionable comments posted: 25
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.github/workflows/ci.yml (1)
101-117:⚠️ Potential issue | 🟡 MinorMSRV job should use
cargo buildper build-command guideline.
Switching fromcargo checkaligns the debug build command with the workspace standard.As per coding guidelines, "Use `cargo build` for debug builds and `cargo build --release` for release builds with LTO and stripping".💡 Suggested change
- - name: Check with MSRV - run: cargo check --workspace + - name: Build with MSRV + run: cargo build --workspace🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/ci.yml around lines 101 - 117, Replace the debug check command in the MSRV job: the step currently named "Check with MSRV" runs "cargo check --workspace" — change it to run "cargo build --workspace" (and optionally rename the step to "Build with MSRV") so the MSRV job uses a debug build per the build-command guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/shipper-cargo-failure/tests/classifier_contract_integration.rs`:
- Around line 1-19: The tests only exercise a single pattern per class; add unit
tests that call classify_publish_failure with additional representative strings
from RETRYABLE_PATTERNS and PERMANENT_PATTERNS (e.g., rate-limit messages, auth
failures, "already uploaded" messages) and assert CargoFailureClass::Retryable
or ::Permanent accordingly, and add a proptest using proptest::prelude::*, e.g.,
a test classify_never_panics that generates arbitrary stderr and stdout strings
and asserts the returned outcome.class is one of CargoFailureClass::Retryable |
CargoFailureClass::Permanent | CargoFailureClass::Ambiguous to ensure no panics
or unexpected behavior from classify_publish_failure.
In `@crates/shipper-cli/src/main.rs`:
- Around line 279-294: The current code sets index_base =
cli.api_base.as_ref().map(|_| api_base.clone()), causing index readiness checks
to call the API host; change index_base handling so it is left None when the
user supplies --api-base (allow Registry::get_index_base to derive the index
host), or explicitly derive an index URL from the provided api_base (e.g.,
convert https://crates.io -> https://index.crates.io) before constructing
ReleaseSpec; update the variable index_base and the ReleaseSpec/Registry
construction (see index_base, api_base, ReleaseSpec and Registry) to use the
corrected value.
In `@crates/shipper-cli/tests/bdd_publish.rs`:
- Around line 77-162: Extract the duplicated workspace builders
(create_single_crate_workspace and create_parallel_workspace) into a shared test
helper module (e.g., tests/support or a common test_utils module) and update
this test and the other consumer (parallel_levels_integration.rs) to call the
shared helpers; specifically, move the implementation of both functions into the
shared module, export them (pub) so tests can use them, replace the local
definitions in crates/shipper-cli/tests/bdd_publish.rs with imports from the
shared helper, and update any use sites to call the new shared function names to
avoid drift and duplication.
- Around line 227-268: The test server in spawn_index_readiness_registry
currently stops after exactly four requests (for _ in 0..4), which can make
tests flaky; change the fixed-count loop to a loop that continues until
server.recv_timeout returns None or Err (i.e., loop { match
server.recv_timeout(Duration::from_secs(5)) { Ok(Some(req)) => { ... handle req
(same logic using api_path/index_path/index_body) }, Ok(None) | Err(_) => break
} } ) so the thread serves requests until a timeout occurs (or make the Duration
configurable) rather than a hard limit of 4.
- Around line 313-352: Replace the hard-coded asserts in the test function
given_parallelizable_workspace_when_grouping_levels_then_independent_crates_share_level
with an insta snapshot assertion: import insta::assert_yaml_snapshot at the top
of the test module, serialize the computed levels (the levels variable produced
by ws.plan.group_by_levels()) into a snapshot-friendly form (e.g., map levels to
their package names using the existing iterator logic) and call
assert_yaml_snapshot! with that value so the expected level grouping is stored
and managed by insta instead of inline assert_eq! calls.
- Around line 565-625: Add an insta JSON snapshot assertion for the receipt
instead of (or in addition to) the manual package assertion: import
insta::assert_json_snapshot at the top of the test module and replace the
current packages/assert! check in
given_index_readiness_mode_when_publish_then_reads_sparse_index_metadata with a
call to assert_json_snapshot!(receipt) (or assert_json_snapshot!(packages) if
you prefer to snapshot only the packages array) so the test records the receipt
JSON output for future diffs.
In `@crates/shipper-duration/src/lib.rs`:
- Around line 34-39: The current serialize_duration function casts
duration.as_millis() (u128) to u64, which truncates values > u64::MAX; change
serialize_duration to call duration.as_millis().try_into() and handle the
Result—on Ok pass the u64 into serializer.serialize_u64, on Err return a serde
serialization error (e.g., via S::Error::custom or serializer error conversion)
so overflow is detected and reported instead of silently truncating. Ensure you
update the function signature uses S: Serializer and keep the
serializer.serialize_u64 call for the success path.
- Around line 10-13: Change parse_duration to return anyhow::Result<Duration>
instead of Result<Duration, humantime::DurationError>: add anyhow to
dependencies and update the signature of parse_duration and its body to
map/convert the humantime error into an anyhow::Error (for example via
.map_err(anyhow::Error::from) or using anyhow::Context). Specifically, update
the function named parse_duration to return anyhow::Result<Duration> and convert
the result of humantime::parse_duration(input) into an anyhow result; also add
anyhow = "..." to the crate's Cargo.toml dependencies.
In `@crates/shipper-duration/tests/duration_config_integration.rs`:
- Around line 19-51: Add insta snapshot assertions to the three tests instead of
literal assert_eq checks: add insta = "1" to dev-dependencies, import insta
macros in the test module, and replace the existing equality checks in
toml_accepts_human_readable_durations, json_accepts_millisecond_integers, and
json_serializes_as_milliseconds with insta::assert_snapshot! calls of the
serialized output (e.g., for toml test serialize cfg with toml::to_string(&cfg)
and assert_snapshot!(toml_out), for json tests use serde_json::to_string(&cfg)
or reuse the existing out variable and assert_snapshot!(out)); keep the rest of
the test logic intact so the parsed RetryLikeConfig values are what get
serialized for snapshots.
In `@crates/shipper-levels/src/lib.rs`:
- Around line 148-223: Add insta as a dev-dependency in the crate by adding
`insta = "1"` to the `[dev-dependencies]` in Cargo.toml, then convert the
equality assertions in the tests in lib.rs (tests like
returns_empty_for_empty_input, assigns_chain_to_strict_levels,
assigns_independent_branches_to_same_level,
ignores_dependencies_outside_the_plan,
falls_back_deterministically_when_cycle_is_present) from
assert_eq!(names(&levels), ...) to insta snapshots, e.g. replace each assert_eq!
with insta::assert_debug_snapshot!(names(&levels)); so the tests use insta
snapshots for the output of names(&levels).
In `@crates/shipper-plan/src/lib.rs`:
- Around line 1-9: Add the crate-level attribute to forbid unsafe code by
inserting #![forbid(unsafe_code)] at the top of the crate root (lib.rs) before
the use/imports so the compiler enforces the workspace unsafe-code prohibition
for this crate; ensure the attribute appears as the very first item in the file
above the existing use statements.
- Around line 24-226: The dependency name lists inserted into the plan are
nondeterministic because they iterate over deps_of (keyed by PackageId); in
build_plan, update the logic that builds the dependencies BTreeMap so that for
each id you collect dependency package names, sort them (e.g., lexicographically
by package name) before inserting into dependencies; locate the construction
around the deps_of.get(id) mapping in build_plan and ensure the Vec<String>
dep_names is sorted deterministically prior to dependencies.insert(pkg_name,
dep_names).
- Around line 451-626: Add serial_test = "3" to dev-dependencies in
crates/shipper-plan/Cargo.toml, then import the crate (use serial_test::serial
or #[serial] attribute) in lib.rs and mark the filesystem-mutating tests with
#[serial]: build_plan_filters_publishability_and_orders_dependencies,
build_plan_rejects_publishable_depending_on_non_publishable,
build_plan_package_selection_ignores_unrelated_invalid_deps,
build_plan_allows_dev_dep_on_non_publishable,
build_plan_selected_packages_include_internal_dependencies,
build_plan_selected_single_package_does_not_include_dependents,
build_plan_errors_for_unknown_selected_package, topo_sort_reports_cycles, and
build_plan_is_deterministic_for_independent_nodes_by_name; leave
build_plan_errors_for_missing_manifest unchanged. Ensure the import and
attribute usage reference the serial_test crate so tests run serially when they
touch the filesystem.
- Around line 312-329: The tests module uses verbose inline assertions; add
insta to dev-dependencies and convert those assertions to snapshot tests: update
Cargo.toml [dev-dependencies] to include insta, then in the tests mod (functions
using proptest and the test helpers like write_file, MetadataCommand usages and
the tests that currently call assert_eq! on package name vectors and
assert!(format!("{err:#}").contains(...))) replace those equality and
error-contains checks with insta snapshot assertions (e.g., capture the vector
or formatted error as a string and call insta::assert_snapshot! or
assert_display_snapshot!) so the package name lists and error messages are
stored as snapshots for easier maintenance. Ensure to import insta macros in the
tests module and keep existing test setup (tempdir, write_file, etc.) intact.
In `@crates/shipper-policy/src/lib.rs`:
- Around line 83-123: Add insta as a dev-dependency in the crate's Cargo.toml
(insta = "1.46.3"), import the macro in the test module with use
insta::assert_debug_snapshot;, and replace the three assert_eq! checks in the
tests safe_policy_respects_verify_ownership_and_readiness_flags,
balanced_policy_disables_ownership_enforcement, and
fast_policy_disables_safety_checks with assert_debug_snapshot!(effects); so each
test snapshot-asserts the result of evaluate(PolicyKind::..., ...), referencing
the evaluate function, PolicyKind enum, and PolicyEffects struct.
In `@crates/shipper-policy/tests/runtime_options_contract.rs`:
- Around line 36-81: Add the insta snapshot testing dependency and replace the
manual boolean assertions with snapshot assertions: add insta = "1.46" to
[dev-dependencies] in the crate's Cargo.toml (match workspace version), add use
insta::assert_debug_snapshot at the top of
crates/shipper-policy/tests/runtime_options_contract.rs, and in each test
(apply_policy_reads_runtime_options_for_safe_mode,
apply_policy_balanced_ignores_strict_ownership,
apply_policy_fast_disables_all_safety_checks) replace the multiple assert! calls
with a single assert_debug_snapshot!(effects) to snapshot the apply_policy
output created from sample_runtime_options and apply_policy.
In `@crates/shipper-schema/src/lib.rs`:
- Around line 6-15: The validation in parse_schema_version is too permissive
because parts[0].starts_with("shipper") accepts values like "shipperx"; change
the check to require an exact match (parts[0] == "shipper") and keep the
existing v-prefixed version check on parts[2]; then continue parsing
version_part as before (ensure you still slice off the leading 'v' from parts[2]
and parse into u32). This update should be made inside the parse_schema_version
function where parts, parts[0], and parts[2] are inspected.
In `@crates/shipper-sparse-index/src/lib.rs`:
- Around line 18-26: The function sparse_index_path panics on non-ASCII crate
names because it uses byte-range slicing like &lower[..2] and &lower[2..4];
change it to operate on chars (e.g., iterate lower.chars()) and build prefix
segments from characters rather than byte indices so multibyte UTF-8 chars don't
split; compute segments by collecting the first 0/1/2 chars and the next 2 chars
as needed (falling back to available chars for short names), then format the
same "0/", "1/{...}", "2/{...}", "3/{}/{...}", and "{}/{}/{}" patterns using
those char-derived strings to preserve behavior without panics in
sparse_index_path and any callers using lower.
In `@crates/shipper-types/tests/release_levels_integration.rs`:
- Around line 15-59: Add insta snapshot testing for the test
release_plan_groups_parallel_levels_from_shared_microcrate_logic: add insta to
dev-dependencies in crates/shipper-types/Cargo.toml, then replace the three
explicit assert_eq checks against levels with a single snapshot assertion that
serializes `levels` (the value returned from ReleasePlan::group_by_levels())
into a stable string (e.g., using Debug/serde JSON or Display) and call
insta::assert_snapshot! with that serialized output; keep the test name and use
the `levels` variable to generate the snapshot so future changes to
ReleasePlan::group_by_levels() are validated via insta snapshots.
In `@crates/shipper/src/lib.rs`:
- Around line 160-164: The feature-gated module swap causes two distinct
PlannedWorkspace types; fix by moving the PlannedWorkspace definition into a
shared types crate and making both implementations import and re-export that
single type so crate::plan::PlannedWorkspace is identical whether feature
"micro-plan" is enabled or not: create a new crate (e.g., shipper-types) that
defines PlannedWorkspace, update crates/shipper/src/plan.rs to use and re-export
shipper-types::PlannedWorkspace, update crates/shipper-plan to depend on and use
the same shipper-types::PlannedWorkspace (and have plan_micro.rs re-export it),
then keep engine.rs and engine_parallel.rs importing
crate::plan::PlannedWorkspace unchanged.
In `@crates/shipper/tests/parallel_levels_integration.rs`:
- Around line 93-117: The test currently uses multiple assert_eq! checks on the
levels variable to validate ordering; replace these with an insta snapshot
assertion by converting levels into a stable, human-readable representation (for
example map each level's packages to their names via levels.iter().map(|l|
l.packages.iter().map(|p|
p.name.as_str()).collect::<Vec<_>>()).collect::<Vec<_>>() or otherwise stringify
the levels) and call insta::assert_snapshot! on that serialized value so the
expected ordering is stored in a snapshot; update the test to import insta and
snapshot the derived Vec of package-name lists from levels (referencing the
levels variable and packages/name fields).
- Around line 1-7: The filesystem-mutating test that uses tempdir() and
write_file() must be marked with the serial test attribute to avoid concurrent
runs; add use serial_test::serial to the imports and annotate the test function
that performs the tempdir()/write_file() operations with #[serial] (place the
attribute immediately above the test fn declaration, e.g., the test that calls
tempdir()/write_file()), ensuring the test remains annotated alongside any
existing #[test] attribute.
In `@features/preflight_checks.feature`:
- Around line 45-49: The test Scenario "Balanced policy ignores strict ownership
requirement" currently uses the "--no-verify" flag which can short-circuit
ownership checks; either remove "--no-verify" from the When step that runs
"shipper preflight" so the preflight performs real ownership verification, or
keep the flag but add an explicit assertion that ownership verification was
executed and that it was bypassed due to the balanced policy (e.g. assert the
verification log/message or a specific exit-path indicator); update the Scenario
step referencing the command and the policy flags ("--policy balanced" and
"--strict-ownership") and add the extra assertion step that checks for the
ownership-verification outcome to ensure the test actually validates policy
behavior.
In `@fuzz/fuzz_targets/schema_version.rs`:
- Around line 10-11: Tests only fuzz the first parameter of
validate_schema_version because validate_schema_version calls
parse_schema_version on both the fuzz-controlled input and the hardcoded
minimum_supported value; the hardcoded side is never exercised. Modify the fuzz
target in schema_version.rs to split the incoming fuzz bytes into two slices
(e.g., via simple length prefix or half-splitting) and pass one slice to
shipper_schema::parse_schema_version and the other slice as the
minimum_supported argument to shipper_schema::validate_schema_version so both
parse_schema_version and the minimum_supported parsing are fuzz-exercised.
In `@fuzz/fuzz_targets/sparse_index.rs`:
- Around line 3-29: The fuzz harness builds JSON by embedding raw fuzzer strings
with format! which fails when inputs contain quotes or backslashes; update the
code that creates JSON lines (the mapping in content_lines and the guaranteed
string) to build JSON objects via serde_json (e.g., use the json! macro or
serde_json::to_string on a map) so values are properly escaped, add the
appropriate use/import for serde_json::json at the top, and replace the two
format! calls that produce "{\"vers\":\"...\"}" (used when building
content_lines and guaranteed_content) with serde_json-produced strings; keep all
existing variable names and calls to contains_version and sparse_index_path
unchanged.
---
Outside diff comments:
In @.github/workflows/ci.yml:
- Around line 101-117: Replace the debug check command in the MSRV job: the step
currently named "Check with MSRV" runs "cargo check --workspace" — change it to
run "cargo build --workspace" (and optionally rename the step to "Build with
MSRV") so the MSRV job uses a debug build per the build-command guideline.
ℹ️ Review info
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (56)
.github/workflows/ci.ymlCargo.tomlcrates/shipper-cargo-failure/Cargo.tomlcrates/shipper-cargo-failure/src/lib.rscrates/shipper-cargo-failure/tests/classifier_contract_integration.rscrates/shipper-cli/Cargo.tomlcrates/shipper-cli/src/main.rscrates/shipper-cli/tests/bdd_publish.rscrates/shipper-duration/Cargo.tomlcrates/shipper-duration/src/lib.rscrates/shipper-duration/tests/duration_config_integration.rscrates/shipper-levels/Cargo.tomlcrates/shipper-levels/src/lib.rscrates/shipper-plan/Cargo.tomlcrates/shipper-plan/src/lib.rscrates/shipper-policy/Cargo.tomlcrates/shipper-policy/src/lib.rscrates/shipper-policy/tests/runtime_options_contract.rscrates/shipper-registry/Cargo.tomlcrates/shipper-registry/src/lib.rscrates/shipper-schema/Cargo.tomlcrates/shipper-schema/src/lib.rscrates/shipper-schema/tests/schema_contract_integration.rscrates/shipper-sparse-index/Cargo.tomlcrates/shipper-sparse-index/src/lib.rscrates/shipper-sparse-index/tests/sparse_index_contract_integration.rscrates/shipper-state/Cargo.tomlcrates/shipper-state/src/lib.rscrates/shipper-store/Cargo.tomlcrates/shipper-store/src/lib.rscrates/shipper-types/Cargo.tomlcrates/shipper-types/src/lib.rscrates/shipper-types/tests/release_levels_integration.rscrates/shipper/Cargo.tomlcrates/shipper/src/engine.rscrates/shipper/src/engine_parallel.rscrates/shipper/src/lib.rscrates/shipper/src/plan.rscrates/shipper/src/plan_micro.rscrates/shipper/src/registry.rscrates/shipper/src/state.rscrates/shipper/src/store.rscrates/shipper/src/types.rscrates/shipper/tests/parallel_levels_integration.rscrates/shipper/tests/schema_version_integration.rsdocs/testing.mdfeatures/parallel_levels.featurefeatures/preflight_checks.featurefeatures/publish_resume.featurefuzz/Cargo.tomlfuzz/fuzz_targets/cargo_failure_classifier.rsfuzz/fuzz_targets/duration_codec.rsfuzz/fuzz_targets/policy_effects.rsfuzz/fuzz_targets/release_levels.rsfuzz/fuzz_targets/schema_version.rsfuzz/fuzz_targets/sparse_index.rs
| use shipper_cargo_failure::{CargoFailureClass, classify_publish_failure}; | ||
|
|
||
| #[test] | ||
| fn classifies_common_registry_throttling_errors_as_retryable() { | ||
| let outcome = classify_publish_failure("received HTTP 503 from index", ""); | ||
| assert_eq!(outcome.class, CargoFailureClass::Retryable); | ||
| } | ||
|
|
||
| #[test] | ||
| fn classifies_manifest_validation_errors_as_permanent() { | ||
| let outcome = classify_publish_failure("", "error: failed to parse manifest at Cargo.toml"); | ||
| assert_eq!(outcome.class, CargoFailureClass::Permanent); | ||
| } | ||
|
|
||
| #[test] | ||
| fn unknown_output_stays_ambiguous() { | ||
| let outcome = classify_publish_failure("tool exited with status 101", "see logs"); | ||
| assert_eq!(outcome.class, CargoFailureClass::Ambiguous); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Expand coverage: only one pattern per failure class is tested.
Each test exercises a single representative string per class, but the classifier matches against arrays of patterns (RETRYABLE_PATTERNS, PERMANENT_PATTERNS). Regressions in other patterns (e.g., rate-limit messages, authentication errors, "already uploaded" permanents) would go undetected.
Consider:
- Adding tests for additional patterns in each class (at least the most critical ones).
- Adding a
proptesttest that generates arbitrary(stderr, stdout)strings and asserts the result is always one of{Retryable, Permanent, Ambiguous}— this would catch panics/crashes on unexpected input.
As per coding guidelines, "Use property-based testing with proptest for testing with generated inputs".
💡 Example proptest for exhaustive class coverage
// At bottom of file or in a separate proptest module:
use proptest::prelude::*;
proptest! {
#[test]
fn classify_never_panics(stderr in ".*", stdout in ".*") {
let outcome = classify_publish_failure(&stderr, &stdout);
// Result is always a valid class
match outcome.class {
CargoFailureClass::Retryable
| CargoFailureClass::Permanent
| CargoFailureClass::Ambiguous => {}
}
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper-cargo-failure/tests/classifier_contract_integration.rs` around
lines 1 - 19, The tests only exercise a single pattern per class; add unit tests
that call classify_publish_failure with additional representative strings from
RETRYABLE_PATTERNS and PERMANENT_PATTERNS (e.g., rate-limit messages, auth
failures, "already uploaded" messages) and assert CargoFailureClass::Retryable
or ::Permanent accordingly, and add a proptest using proptest::prelude::*, e.g.,
a test classify_never_panics that generates arbitrary stderr and stdout strings
and asserts the returned outcome.class is one of CargoFailureClass::Retryable |
CargoFailureClass::Permanent | CargoFailureClass::Ambiguous to ensure no panics
or unexpected behavior from classify_publish_failure.
| let api_base = cli | ||
| .api_base | ||
| .clone() | ||
| .unwrap_or_else(|| "https://crates.io".to_string()); | ||
| let index_base = cli.api_base.as_ref().map(|_| api_base.clone()); | ||
|
|
||
| let spec = ReleaseSpec { | ||
| manifest_path: cli.manifest_path.clone(), | ||
| registry: Registry { | ||
| name: cli | ||
| .registry | ||
| .clone() | ||
| .unwrap_or_else(|| "crates-io".to_string()), | ||
| api_base: cli | ||
| .api_base | ||
| .clone() | ||
| .unwrap_or_else(|| "https://crates.io".to_string()), | ||
| index_base: None, | ||
| api_base, | ||
| index_base, | ||
| }, |
There was a problem hiding this comment.
Fix index_base default when --api-base is set.
Line 283 sets index_base to the API base, so index readiness checks will hit the API host instead of the derived index host (e.g., https://crates.io vs https://index.crates.io). Leave it unset (and let Registry::get_index_base derive) or derive explicitly.
🐛 Suggested fix
- let index_base = cli.api_base.as_ref().map(|_| api_base.clone());
+ let index_base = None;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let api_base = cli | |
| .api_base | |
| .clone() | |
| .unwrap_or_else(|| "https://crates.io".to_string()); | |
| let index_base = cli.api_base.as_ref().map(|_| api_base.clone()); | |
| let spec = ReleaseSpec { | |
| manifest_path: cli.manifest_path.clone(), | |
| registry: Registry { | |
| name: cli | |
| .registry | |
| .clone() | |
| .unwrap_or_else(|| "crates-io".to_string()), | |
| api_base: cli | |
| .api_base | |
| .clone() | |
| .unwrap_or_else(|| "https://crates.io".to_string()), | |
| index_base: None, | |
| api_base, | |
| index_base, | |
| }, | |
| let api_base = cli | |
| .api_base | |
| .clone() | |
| .unwrap_or_else(|| "https://crates.io".to_string()); | |
| let index_base = None; | |
| let spec = ReleaseSpec { | |
| manifest_path: cli.manifest_path.clone(), | |
| registry: Registry { | |
| name: cli | |
| .registry | |
| .clone() | |
| .unwrap_or_else(|| "crates-io".to_string()), | |
| api_base, | |
| index_base, | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper-cli/src/main.rs` around lines 279 - 294, The current code sets
index_base = cli.api_base.as_ref().map(|_| api_base.clone()), causing index
readiness checks to call the API host; change index_base handling so it is left
None when the user supplies --api-base (allow Registry::get_index_base to derive
the index host), or explicitly derive an index URL from the provided api_base
(e.g., convert https://crates.io -> https://index.crates.io) before constructing
ReleaseSpec; update the variable index_base and the ReleaseSpec/Registry
construction (see index_base, api_base, ReleaseSpec and Registry) to use the
corrected value.
| fn create_single_crate_workspace(root: &Path) { | ||
| write_file( | ||
| &root.join("Cargo.toml"), | ||
| r#" | ||
| [workspace] | ||
| members = ["demo"] | ||
| resolver = "2" | ||
| "#, | ||
| ); | ||
|
|
||
| write_file( | ||
| &root.join("demo/Cargo.toml"), | ||
| r#" | ||
| [package] | ||
| name = "demo" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
| "#, | ||
| ); | ||
| write_file(&root.join("demo/src/lib.rs"), "pub fn demo() {}\n"); | ||
| } | ||
|
|
||
| fn create_parallel_workspace(root: &Path) { | ||
| write_file( | ||
| &root.join("Cargo.toml"), | ||
| r#" | ||
| [workspace] | ||
| members = ["core", "api", "cli", "app"] | ||
| resolver = "2" | ||
| "#, | ||
| ); | ||
|
|
||
| write_file( | ||
| &root.join("core/Cargo.toml"), | ||
| r#" | ||
| [package] | ||
| name = "core" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
| "#, | ||
| ); | ||
| write_file(&root.join("core/src/lib.rs"), "pub fn core() {}\n"); | ||
|
|
||
| write_file( | ||
| &root.join("api/Cargo.toml"), | ||
| r#" | ||
| [package] | ||
| name = "api" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
|
|
||
| [dependencies] | ||
| core = { path = "../core" } | ||
| "#, | ||
| ); | ||
| write_file(&root.join("api/src/lib.rs"), "pub fn api() {}\n"); | ||
|
|
||
| write_file( | ||
| &root.join("cli/Cargo.toml"), | ||
| r#" | ||
| [package] | ||
| name = "cli" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
|
|
||
| [dependencies] | ||
| core = { path = "../core" } | ||
| "#, | ||
| ); | ||
| write_file(&root.join("cli/src/lib.rs"), "pub fn cli() {}\n"); | ||
|
|
||
| write_file( | ||
| &root.join("app/Cargo.toml"), | ||
| r#" | ||
| [package] | ||
| name = "app" | ||
| version = "0.1.0" | ||
| edition = "2021" | ||
|
|
||
| [dependencies] | ||
| api = { path = "../api" } | ||
| cli = { path = "../cli" } | ||
| "#, | ||
| ); | ||
| write_file(&root.join("app/src/lib.rs"), "pub fn app() {}\n"); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider extracting workspace builders to shared test support.
create_parallel_workspace overlaps with the helper in crates/shipper/tests/parallel_levels_integration.rs; sharing avoids drift between test suites.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper-cli/tests/bdd_publish.rs` around lines 77 - 162, Extract the
duplicated workspace builders (create_single_crate_workspace and
create_parallel_workspace) into a shared test helper module (e.g., tests/support
or a common test_utils module) and update this test and the other consumer
(parallel_levels_integration.rs) to call the shared helpers; specifically, move
the implementation of both functions into the shared module, export them (pub)
so tests can use them, replace the local definitions in
crates/shipper-cli/tests/bdd_publish.rs with imports from the shared helper, and
update any use sites to call the new shared function names to avoid drift and
duplication.
| fn spawn_index_readiness_registry(crate_name: &str, version: &str) -> TestRegistry { | ||
| let server = Server::http("127.0.0.1:0").expect("server"); | ||
| let base_url = format!("http://{}", server.server_addr()); | ||
| let api_path = format!("/api/v1/crates/{crate_name}/{version}"); | ||
| let lower = crate_name.to_ascii_lowercase(); | ||
| let index_suffix = match lower.len() { | ||
| 1 => format!("1/{lower}"), | ||
| 2 => format!("2/{lower}"), | ||
| 3 => format!("3/{}/{lower}", &lower[..1]), | ||
| _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), | ||
| }; | ||
| let index_path = format!("/{index_suffix}"); | ||
| let index_body = format!( | ||
| "{{\"name\":\"{crate_name}\",\"vers\":\"{version}\",\"deps\":[],\"cksum\":\"deadbeef\"}}" | ||
| ); | ||
|
|
||
| let handle = thread::spawn(move || { | ||
| for _ in 0..4 { | ||
| let req = match server.recv_timeout(Duration::from_secs(5)) { | ||
| Ok(Some(req)) => req, | ||
| _ => break, | ||
| }; | ||
|
|
||
| let (status, body) = if req.url() == api_path { | ||
| (404, "{}".to_string()) | ||
| } else if req.url() == index_path { | ||
| (200, index_body.clone()) | ||
| } else { | ||
| (404, "{}".to_string()) | ||
| }; | ||
|
|
||
| let resp = Response::from_string(body) | ||
| .with_status_code(StatusCode(status)) | ||
| .with_header( | ||
| Header::from_bytes("Content-Type", "application/json").expect("header"), | ||
| ); | ||
| req.respond(resp).expect("respond"); | ||
| } | ||
| }); | ||
|
|
||
| TestRegistry { base_url, handle } | ||
| } |
There was a problem hiding this comment.
Avoid hard‑coding the registry to only four requests.
If publish flow adds more index/API checks, the server thread may stop early and cause flaky tests. Prefer looping until timeout (or make the limit configurable).
🔧 Suggested change
- let handle = thread::spawn(move || {
- for _ in 0..4 {
- let req = match server.recv_timeout(Duration::from_secs(5)) {
- Ok(Some(req)) => req,
- _ => break,
- };
+ let handle = thread::spawn(move || {
+ loop {
+ let req = match server.recv_timeout(Duration::from_secs(5)) {
+ Ok(Some(req)) => req,
+ _ => break,
+ };
let (status, body) = if req.url() == api_path {
(404, "{}".to_string())
} else if req.url() == index_path {
(200, index_body.clone())
} else {
(404, "{}".to_string())
};
@@
req.respond(resp).expect("respond");
}
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fn spawn_index_readiness_registry(crate_name: &str, version: &str) -> TestRegistry { | |
| let server = Server::http("127.0.0.1:0").expect("server"); | |
| let base_url = format!("http://{}", server.server_addr()); | |
| let api_path = format!("/api/v1/crates/{crate_name}/{version}"); | |
| let lower = crate_name.to_ascii_lowercase(); | |
| let index_suffix = match lower.len() { | |
| 1 => format!("1/{lower}"), | |
| 2 => format!("2/{lower}"), | |
| 3 => format!("3/{}/{lower}", &lower[..1]), | |
| _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), | |
| }; | |
| let index_path = format!("/{index_suffix}"); | |
| let index_body = format!( | |
| "{{\"name\":\"{crate_name}\",\"vers\":\"{version}\",\"deps\":[],\"cksum\":\"deadbeef\"}}" | |
| ); | |
| let handle = thread::spawn(move || { | |
| for _ in 0..4 { | |
| let req = match server.recv_timeout(Duration::from_secs(5)) { | |
| Ok(Some(req)) => req, | |
| _ => break, | |
| }; | |
| let (status, body) = if req.url() == api_path { | |
| (404, "{}".to_string()) | |
| } else if req.url() == index_path { | |
| (200, index_body.clone()) | |
| } else { | |
| (404, "{}".to_string()) | |
| }; | |
| let resp = Response::from_string(body) | |
| .with_status_code(StatusCode(status)) | |
| .with_header( | |
| Header::from_bytes("Content-Type", "application/json").expect("header"), | |
| ); | |
| req.respond(resp).expect("respond"); | |
| } | |
| }); | |
| TestRegistry { base_url, handle } | |
| } | |
| fn spawn_index_readiness_registry(crate_name: &str, version: &str) -> TestRegistry { | |
| let server = Server::http("127.0.0.1:0").expect("server"); | |
| let base_url = format!("http://{}", server.server_addr()); | |
| let api_path = format!("/api/v1/crates/{crate_name}/{version}"); | |
| let lower = crate_name.to_ascii_lowercase(); | |
| let index_suffix = match lower.len() { | |
| 1 => format!("1/{lower}"), | |
| 2 => format!("2/{lower}"), | |
| 3 => format!("3/{}/{lower}", &lower[..1]), | |
| _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), | |
| }; | |
| let index_path = format!("/{index_suffix}"); | |
| let index_body = format!( | |
| "{{\"name\":\"{crate_name}\",\"vers\":\"{version}\",\"deps\":[],\"cksum\":\"deadbeef\"}}" | |
| ); | |
| let handle = thread::spawn(move || { | |
| loop { | |
| let req = match server.recv_timeout(Duration::from_secs(5)) { | |
| Ok(Some(req)) => req, | |
| _ => break, | |
| }; | |
| let (status, body) = if req.url() == api_path { | |
| (404, "{}".to_string()) | |
| } else if req.url() == index_path { | |
| (200, index_body.clone()) | |
| } else { | |
| (404, "{}".to_string()) | |
| }; | |
| let resp = Response::from_string(body) | |
| .with_status_code(StatusCode(status)) | |
| .with_header( | |
| Header::from_bytes("Content-Type", "application/json").expect("header"), | |
| ); | |
| req.respond(resp).expect("respond"); | |
| } | |
| }); | |
| TestRegistry { base_url, handle } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper-cli/tests/bdd_publish.rs` around lines 227 - 268, The test
server in spawn_index_readiness_registry currently stops after exactly four
requests (for _ in 0..4), which can make tests flaky; change the fixed-count
loop to a loop that continues until server.recv_timeout returns None or Err
(i.e., loop { match server.recv_timeout(Duration::from_secs(5)) { Ok(Some(req))
=> { ... handle req (same logic using api_path/index_path/index_body) },
Ok(None) | Err(_) => break } } ) so the thread serves requests until a timeout
occurs (or make the Duration configurable) rather than a hard limit of 4.
| // Scenario: Workspace fan-out/fan-in groups independent crates into a shared parallel level | ||
| #[test] | ||
| fn given_parallelizable_workspace_when_grouping_levels_then_independent_crates_share_level() { | ||
| let td = tempdir().expect("tempdir"); | ||
| create_parallel_workspace(td.path()); | ||
|
|
||
| let spec = shipper::types::ReleaseSpec { | ||
| manifest_path: td.path().join("Cargo.toml"), | ||
| registry: shipper::types::Registry::crates_io(), | ||
| selected_packages: None, | ||
| }; | ||
| let ws = shipper::plan::build_plan(&spec).expect("plan"); | ||
| let levels = ws.plan.group_by_levels(); | ||
|
|
||
| assert_eq!(levels.len(), 3); | ||
| assert_eq!( | ||
| levels[0] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["core"] | ||
| ); | ||
| assert_eq!( | ||
| levels[1] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["api", "cli"] | ||
| ); | ||
| assert_eq!( | ||
| levels[2] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["app"] | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if insta is available in dev-dependencies
rg -n "insta" Cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
# Check if insta snapshot assertions are used anywhere in the test suite
rg -n "insta::assert.*snapshot" crates/shipper-cli/testsRepository: EffortlessMetrics/shipper
Length of output: 134
🏁 Script executed:
# Also check the entire workspace for insta usage
rg -n "insta::assert.*snapshot" --type rsRepository: EffortlessMetrics/shipper
Length of output: 95
🏁 Script executed:
# Find all Cargo.toml files and search for insta in each
find . -name "Cargo.toml" -exec grep -l "insta" {} \;Repository: EffortlessMetrics/shipper
Length of output: 128
🏁 Script executed:
# Check if the file bdd_publish.rs exists and verify the code snippet
cat -n crates/shipper-cli/tests/bdd_publish.rs | sed -n '313,352p'Repository: EffortlessMetrics/shipper
Length of output: 1651
🏁 Script executed:
# Check the structure of insta usage in the existing test file
head -20 crates/shipper-cli/tests/cli_e2e.rsRepository: EffortlessMetrics/shipper
Length of output: 541
🏁 Script executed:
# Check if insta is imported in bdd_publish.rs
head -30 crates/shipper-cli/tests/bdd_publish.rs | grep -n "insta"Repository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
# Check if there are any snapshot tests in bdd_publish.rs currently
rg -n "insta::" crates/shipper-cli/tests/bdd_publish.rsRepository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
# Check the dev-dependencies in shipper-cli Cargo.toml to confirm insta
grep -A 30 "\[dev-dependencies\]" crates/shipper-cli/Cargo.toml | head -40Repository: EffortlessMetrics/shipper
Length of output: 221
Use insta snapshot for level-grouping expectations.
This aligns with the project's snapshot testing guidelines and makes the expected level structure more maintainable.
📸 Suggested snapshot-based assertion
- assert_eq!(levels.len(), 3);
- assert_eq!(
- levels[0]
- .packages
- .iter()
- .map(|p| p.name.as_str())
- .collect::<Vec<_>>(),
- vec!["core"]
- );
- assert_eq!(
- levels[1]
- .packages
- .iter()
- .map(|p| p.name.as_str())
- .collect::<Vec<_>>(),
- vec!["api", "cli"]
- );
- assert_eq!(
- levels[2]
- .packages
- .iter()
- .map(|p| p.name.as_str())
- .collect::<Vec<_>>(),
- vec!["app"]
- );
+ let snapshot = levels
+ .iter()
+ .map(|level| {
+ level
+ .packages
+ .iter()
+ .map(|p| p.name.as_str())
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ insta::assert_yaml_snapshot!(snapshot);Add use insta::assert_yaml_snapshot; at the top of the test module. The insta crate is already available in dev-dependencies.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Scenario: Workspace fan-out/fan-in groups independent crates into a shared parallel level | |
| #[test] | |
| fn given_parallelizable_workspace_when_grouping_levels_then_independent_crates_share_level() { | |
| let td = tempdir().expect("tempdir"); | |
| create_parallel_workspace(td.path()); | |
| let spec = shipper::types::ReleaseSpec { | |
| manifest_path: td.path().join("Cargo.toml"), | |
| registry: shipper::types::Registry::crates_io(), | |
| selected_packages: None, | |
| }; | |
| let ws = shipper::plan::build_plan(&spec).expect("plan"); | |
| let levels = ws.plan.group_by_levels(); | |
| assert_eq!(levels.len(), 3); | |
| assert_eq!( | |
| levels[0] | |
| .packages | |
| .iter() | |
| .map(|p| p.name.as_str()) | |
| .collect::<Vec<_>>(), | |
| vec!["core"] | |
| ); | |
| assert_eq!( | |
| levels[1] | |
| .packages | |
| .iter() | |
| .map(|p| p.name.as_str()) | |
| .collect::<Vec<_>>(), | |
| vec!["api", "cli"] | |
| ); | |
| assert_eq!( | |
| levels[2] | |
| .packages | |
| .iter() | |
| .map(|p| p.name.as_str()) | |
| .collect::<Vec<_>>(), | |
| vec!["app"] | |
| ); | |
| } | |
| // Scenario: Workspace fan-out/fan-in groups independent crates into a shared parallel level | |
| #[test] | |
| fn given_parallelizable_workspace_when_grouping_levels_then_independent_crates_share_level() { | |
| let td = tempdir().expect("tempdir"); | |
| create_parallel_workspace(td.path()); | |
| let spec = shipper::types::ReleaseSpec { | |
| manifest_path: td.path().join("Cargo.toml"), | |
| registry: shipper::types::Registry::crates_io(), | |
| selected_packages: None, | |
| }; | |
| let ws = shipper::plan::build_plan(&spec).expect("plan"); | |
| let levels = ws.plan.group_by_levels(); | |
| let snapshot = levels | |
| .iter() | |
| .map(|level| { | |
| level | |
| .packages | |
| .iter() | |
| .map(|p| p.name.as_str()) | |
| .collect::<Vec<_>>() | |
| }) | |
| .collect::<Vec<_>>(); | |
| insta::assert_yaml_snapshot!(snapshot); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper-cli/tests/bdd_publish.rs` around lines 313 - 352, Replace the
hard-coded asserts in the test function
given_parallelizable_workspace_when_grouping_levels_then_independent_crates_share_level
with an insta snapshot assertion: import insta::assert_yaml_snapshot at the top
of the test module, serialize the computed levels (the levels variable produced
by ws.plan.group_by_levels()) into a snapshot-friendly form (e.g., map levels to
their package names using the existing iterator logic) and call
assert_yaml_snapshot! with that value so the expected level grouping is stored
and managed by insta instead of inline assert_eq! calls.
| use std::fs; | ||
| use std::path::Path; | ||
|
|
||
| use shipper::plan; | ||
| use shipper::types::{Registry, ReleaseSpec}; | ||
| use tempfile::tempdir; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd crates/shipper && find . -name "parallel_levels_integration.rs" -type fRepository: EffortlessMetrics/shipper
Length of output: 107
🏁 Script executed:
cat -n crates/shipper/tests/parallel_levels_integration.rs | head -120Repository: EffortlessMetrics/shipper
Length of output: 3444
🏁 Script executed:
grep -n "#\[serial\]" crates/shipper/tests/parallel_levels_integration.rsRepository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
grep -n "#\[test\]" crates/shipper/tests/parallel_levels_integration.rsRepository: EffortlessMetrics/shipper
Length of output: 79
🏁 Script executed:
# Check if serial_test is in dependencies
rg "serial_test" Cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
# Check if insta is used in the test file
rg "insta" crates/shipper/tests/parallel_levels_integration.rsRepository: EffortlessMetrics/shipper
Length of output: 51
🏁 Script executed:
cat crates/shipper/Cargo.toml | head -50Repository: EffortlessMetrics/shipper
Length of output: 2406
🏁 Script executed:
# Check test dependencies in shipper crate
rg "\[dev-dependencies\]" -A 10 crates/shipper/Cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 204
Add #[serial] attribute to filesystem-mutating test.
This test writes to the filesystem via tempdir() and write_file(); it must be marked with #[serial] to prevent concurrent interference with other tests.
🔧 Suggested patch
use std::fs;
use std::path::Path;
use shipper::plan;
use shipper::types::{Registry, ReleaseSpec};
+use serial_test::serial;
use tempfile::tempdir;
-#[test]
+#[test]
+#[serial]
fn build_plan_groups_independent_packages_into_same_parallel_level() {Per coding guidelines, **/*.rs: Use #[serial] attribute from serial_test for tests that mutate filesystem state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper/tests/parallel_levels_integration.rs` around lines 1 - 7, The
filesystem-mutating test that uses tempdir() and write_file() must be marked
with the serial test attribute to avoid concurrent runs; add use
serial_test::serial to the imports and annotate the test function that performs
the tempdir()/write_file() operations with #[serial] (place the attribute
immediately above the test fn declaration, e.g., the test that calls
tempdir()/write_file()), ensuring the test remains annotated alongside any
existing #[test] attribute.
| assert_eq!(levels.len(), 3); | ||
| assert_eq!( | ||
| levels[0] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["core"] | ||
| ); | ||
| assert_eq!( | ||
| levels[1] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["api", "cli"] | ||
| ); | ||
| assert_eq!( | ||
| levels[2] | ||
| .packages | ||
| .iter() | ||
| .map(|p| p.name.as_str()) | ||
| .collect::<Vec<_>>(), | ||
| vec!["app"] | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if the file exists and show the content around lines 93-117
echo "=== Checking file existence and content ==="
if [ -f "crates/shipper/tests/parallel_levels_integration.rs" ]; then
echo "File exists."
sed -n '93,117p' crates/shipper/tests/parallel_levels_integration.rs
else
echo "File not found"
fiRepository: EffortlessMetrics/shipper
Length of output: 691
🏁 Script executed:
#!/bin/bash
# Check if insta is in the workspace dependencies
echo "=== Checking for insta in Cargo.toml files ==="
rg "insta" --type toml Cargo.toml
# Check specifically in test dependencies
echo ""
echo "=== Checking shipper crate Cargo.toml ==="
if [ -f "crates/shipper/Cargo.toml" ]; then
rg "insta|snapshot" crates/shipper/Cargo.toml || echo "No insta found in shipper Cargo.toml"
fiRepository: EffortlessMetrics/shipper
Length of output: 175
🏁 Script executed:
#!/bin/bash
# Check if insta is used in existing test files
echo "=== Checking for insta usage in test files ==="
rg "use insta" --type rust crates/shipper/tests/
echo ""
echo "=== Checking for insta::assert_snapshot ==="
rg "insta::assert" --type rust crates/shipper/tests/Repository: EffortlessMetrics/shipper
Length of output: 160
🏁 Script executed:
#!/bin/bash
# Get a broader view of what snapshot tests exist
echo "=== Looking for all test files in shipper crate ==="
find crates/shipper/tests -name "*.rs" -type f
echo ""
echo "=== Checking test structure and usage ==="
ls -la crates/shipper/tests/Repository: EffortlessMetrics/shipper
Length of output: 536
🏁 Script executed:
#!/bin/bash
# Show the full test function to understand context
echo "=== Full test file content ==="
cat -n crates/shipper/tests/parallel_levels_integration.rsRepository: EffortlessMetrics/shipper
Length of output: 3475
🏁 Script executed:
#!/bin/bash
# Search for insta usage across the entire codebase
echo "=== Searching for insta across entire codebase ==="
rg "use insta|assert_snapshot|insta::" --type rust
echo ""
echo "=== Checking if insta is listed as test dependency ==="
rg "\[dev-dependencies\]" -A 20 crates/shipper/Cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 665
Use insta snapshot testing for level assertions.
Replace the multiple assert_eq! calls with insta::assert_snapshot! to consolidate the level ordering expectations. Snapshot testing is the required pattern for test assertions per project guidelines, and insta is already used in CLI tests.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/shipper/tests/parallel_levels_integration.rs` around lines 93 - 117,
The test currently uses multiple assert_eq! checks on the levels variable to
validate ordering; replace these with an insta snapshot assertion by converting
levels into a stable, human-readable representation (for example map each
level's packages to their names via levels.iter().map(|l|
l.packages.iter().map(|p|
p.name.as_str()).collect::<Vec<_>>()).collect::<Vec<_>>() or otherwise stringify
the levels) and call insta::assert_snapshot! on that serialized value so the
expected ordering is stored in a snapshot; update the test to import insta and
snapshot the derived Vec of package-name lists from levels (referencing the
levels variable and packages/name fields).
| Scenario: Balanced policy ignores strict ownership requirement | ||
| Given no registry token is configured | ||
| And the registry returns "not found" for all crates | ||
| When I run "shipper preflight" with "--policy balanced --strict-ownership --no-verify" | ||
| Then the exit code is 0 |
There was a problem hiding this comment.
Avoid --no-verify masking the policy behavior.
This scenario is intended to prove that the balanced policy ignores strict ownership, but --no-verify could short-circuit ownership checks and make the test pass regardless of policy. Consider removing --no-verify (if stubbing already avoids network) or adding an assertion that ownership verification actually ran and was bypassed specifically due to the policy.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@features/preflight_checks.feature` around lines 45 - 49, The test Scenario
"Balanced policy ignores strict ownership requirement" currently uses the
"--no-verify" flag which can short-circuit ownership checks; either remove
"--no-verify" from the When step that runs "shipper preflight" so the preflight
performs real ownership verification, or keep the flag but add an explicit
assertion that ownership verification was executed and that it was bypassed due
to the balanced policy (e.g. assert the verification log/message or a specific
exit-path indicator); update the Scenario step referencing the command and the
policy flags ("--policy balanced" and "--strict-ownership") and add the extra
assertion step that checks for the ownership-verification outcome to ensure the
test actually validates policy behavior.
| let _ = shipper_schema::parse_schema_version(input); | ||
| let _ = shipper_schema::validate_schema_version(input, "shipper.receipt.v1", "schema"); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
minimum_supported is never fuzz-exercised — minor coverage gap.
validate_schema_version internally calls parse_schema_version on both input (fuzz-controlled) and the hardcoded "shipper.receipt.v1" (always valid). The code path where parse_schema_version(minimum_supported) receives an arbitrary string is never reached. To close this gap, consider splitting the fuzz input and exercising both parameters:
♻️ Suggested enhancement
fuzz_target!(|data: &[u8]| {
let Ok(input) = std::str::from_utf8(data) else {
return;
};
+ // Split on first ASCII space to vary both parameters
+ let (version, minimum) = match input.find(' ') {
+ Some(i) => (&input[..i], &input[i + 1..]),
+ None => (input, "shipper.receipt.v1"),
+ };
+
- let _ = shipper_schema::parse_schema_version(input);
- let _ = shipper_schema::validate_schema_version(input, "shipper.receipt.v1", "schema");
+ let _ = shipper_schema::parse_schema_version(version);
+ let _ = shipper_schema::parse_schema_version(minimum);
+ let _ = shipper_schema::validate_schema_version(version, minimum, "schema");
+ let _ = shipper_schema::validate_schema_version(minimum, version, "schema");
});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@fuzz/fuzz_targets/schema_version.rs` around lines 10 - 11, Tests only fuzz
the first parameter of validate_schema_version because validate_schema_version
calls parse_schema_version on both the fuzz-controlled input and the hardcoded
minimum_supported value; the hardcoded side is never exercised. Modify the fuzz
target in schema_version.rs to split the incoming fuzz bytes into two slices
(e.g., via simple length prefix or half-splitting) and pass one slice to
shipper_schema::parse_schema_version and the other slice as the
minimum_supported argument to shipper_schema::validate_schema_version so both
parse_schema_version and the minimum_supported parsing are fuzz-exercised.
| use libfuzzer_sys::fuzz_target; | ||
| use shipper_sparse_index::{contains_version, sparse_index_path}; | ||
|
|
||
| fuzz_target!(|data: (String, String, Vec<String>)| { | ||
| let (crate_name, target, versions) = data; | ||
|
|
||
| let path = sparse_index_path(&crate_name); | ||
| assert_eq!(path, sparse_index_path(&crate_name)); | ||
|
|
||
| if !crate_name.is_empty() { | ||
| assert!(path.ends_with(&crate_name.to_ascii_lowercase())); | ||
| } | ||
|
|
||
| let mut content_lines = versions | ||
| .iter() | ||
| .take(256) | ||
| .map(|version| format!("{{\"vers\":\"{}\"}}", version)) | ||
| .collect::<Vec<_>>(); | ||
| content_lines.push("not-json".to_string()); | ||
| let content = content_lines.join("\n"); | ||
|
|
||
| let result = contains_version(&content, &target); | ||
| assert_eq!(result, contains_version(&content, &target)); | ||
|
|
||
| let guaranteed = format!("{{\"vers\":\"{}\"}}", target); | ||
| let guaranteed_content = format!("{content}\n{guaranteed}"); | ||
| assert!(contains_version(&guaranteed_content, &target)); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if serde_json is in fuzz/Cargo.toml dependencies
cat fuzz/Cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 1628
🏁 Script executed:
#!/bin/bash
# Verify the actual code in the fuzz target file
cat -n fuzz/fuzz_targets/sparse_index.rsRepository: EffortlessMetrics/shipper
Length of output: 1264
🏁 Script executed:
#!/bin/bash
# Check what dependencies are available in the workspace
git ls-files | grep -i cargo.tomlRepository: EffortlessMetrics/shipper
Length of output: 922
Escape JSON strings in fuzz content to prevent invalid JSON generation.
Lines 19 and 27 use format!() to construct JSON objects with unescaped string values. When fuzzer-generated input contains " or \, the resulting JSON becomes invalid—for example, version="1.0\"beta" produces {"vers":"1.0"beta"}. This causes unpredictable behavior in contains_version() and false test failures unrelated to the tested logic.
serde_json is already a dependency in fuzz/Cargo.toml, so replace the format calls with json!() macro:
Proposed fix
use libfuzzer_sys::fuzz_target;
+use serde_json::json;
use shipper_sparse_index::{contains_version, sparse_index_path};
.map(|version| format!("{{\"vers\":\"{}\"}}", version))
+ .map(|version| json!({ "vers": version }).to_string())
- let guaranteed = format!("{{\"vers\":\"{}\"}}", target);
+ let guaranteed = json!({ "vers": target }).to_string();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@fuzz/fuzz_targets/sparse_index.rs` around lines 3 - 29, The fuzz harness
builds JSON by embedding raw fuzzer strings with format! which fails when inputs
contain quotes or backslashes; update the code that creates JSON lines (the
mapping in content_lines and the guaranteed string) to build JSON objects via
serde_json (e.g., use the json! macro or serde_json::to_string on a map) so
values are properly escaped, add the appropriate use/import for serde_json::json
at the top, and replace the two format! calls that produce "{\"vers\":\"...\"}"
(used when building content_lines and guaranteed_content) with
serde_json-produced strings; keep all existing variable names and calls to
contains_version and sparse_index_path unchanged.
Summary
Decomposes tightly-coupled domain logic from the core
shippercrate into 7 new focused microcrates, improving testability, reusability, and compile-time parallelism. Each microcrate owns a single concern with comprehensive tests (unit, property-based, and fuzz targets).New crates
2s,500ms,1m) with bidirectional serde codecsSafe/Balanced/Fast) → concretePolicyEffectsflagsRetryable/Permanent/Ambiguouserror classesCore crate refactoring
engine.rs— Policy evaluation delegated toshipper-policy; failure classification delegated toshipper-cargo-failureplan.rs— Level grouping delegated toshipper-levelsstate.rs/store.rs— Schema version validation delegated toshipper-schematypes.rs— Duration serde codecs delegated toshipper-durationregistry.rs— Sparse-index helpers delegated toshipper-sparse-indexlib.rs— Re-exportsshipper-cargo-failureascargo_failurefor public API continuityTesting & fuzzing
cargo_failure_classifier,duration_codec,policy_effects,release_levels,schema_version,sparse_indexbdd_publish.rs) and parallel levels integration testOther changes
dtolnay/rust-toolchainbumped to 1.100.0effortlessmetricsorg.gitignoreupdated for fuzz artifactsTest plan
cargo test --workspacepassescargo clippy --workspace --all-targets --all-features -- -D warningscleancargo fmt --all -- --checkcleancargo +nightly fuzz listcargo run -p shipper-cli -- --helpworks as before