chore: enforce dependency declaration structure (#2667)

This commit is contained in:
Antonio 2026-05-04 13:47:11 +02:00 committed by GitHub
parent 5a352d2222
commit 3b54edf586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 400 additions and 73 deletions

View File

@ -57,6 +57,15 @@ jobs:
- name: Run cargo-deny
run: cargo-deny --locked --all-features check --hide-inclusion-graph -c .cargo-deny.toml --show-stats -D warnings
workspace-deps:
name: Check for correctly declared dependencies in the workspace
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 # Version 4.2.2
- name: Run workspace dependency policies check
run: scripts/check-workspace-dependency-policies.sh
unused-deps:
name: Check for unused dependencies
strategy:

15
Cargo.lock generated
View File

@ -4491,7 +4491,7 @@ dependencies = [
"logos-blockchain-core",
"logos-blockchain-demo-sequencer",
"owo-colors",
"redb 3.1.3",
"redb",
"reqwest",
"serde",
"serde_json",
@ -4514,7 +4514,7 @@ dependencies = [
"logos-blockchain-key-management-system-service",
"owo-colors",
"rand 0.8.6",
"redb 2.6.3",
"redb",
"reqwest",
"serde",
"serde_json",
@ -5144,7 +5144,7 @@ dependencies = [
"num-bigint",
"quickcheck",
"quickcheck_macros",
"rand 0.9.4",
"rand 0.8.6",
"rpds",
"serde",
"thiserror 2.0.18",
@ -6945,15 +6945,6 @@ dependencies = [
"libc",
]
[[package]]
name = "redb"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a"
dependencies = [
"libc",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"

View File

@ -0,0 +1,329 @@
#!/usr/bin/env sh
set -eu
# Enforces workspace dependency policy across Cargo manifests.
#
# Checks:
# - Workspace members must declare dependencies using `workspace = true` only
# (no direct version/path/git dependency declarations).
# - Workspace members must not override `default-features` on workspace deps.
# - Root `[workspace.dependencies]` entries must set `default-features = false`.
# - Root `[workspace.dependencies]` entries must not set `features`.
#
# Exit codes:
# - 0: all checks passed.
# - 1: one or more policy violations were found.
# - 255: internal/tooling error while running the checker.
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
root_manifest="$repo_root/Cargo.toml"
FAILURE_DETECTED_EXIT_CODE=1
INTERNAL_ERROR_EXIT_CODE=255
if [ ! -f "$root_manifest" ]; then
echo "error: could not find workspace root Cargo.toml at $root_manifest" >&2
exit $INTERNAL_ERROR_EXIT_CODE
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "error: cargo is required" >&2
exit $INTERNAL_ERROR_EXIT_CODE
fi
manifest_list_file="$(mktemp "$repo_root/.tmp-workspace-manifests.XXXXXX")"
awk_script_file="$(mktemp "$repo_root/.tmp-check-workspace-deps.XXXXXX.awk")"
cleanup() {
rm -f "$manifest_list_file" "$awk_script_file"
}
trap cleanup 0 HUP INT TERM
if ! (
cd "$repo_root" && cargo metadata --no-deps --format-version 1
) | grep -o '"manifest_path":"[^"]*"' \
| sed -E 's/^"manifest_path":"(.*)"$/\1/' \
| sed 's#\\/#/#g' \
| sort -u >"$manifest_list_file"; then
echo "error: failed to read workspace manifests via cargo metadata" >&2
exit $INTERNAL_ERROR_EXIT_CODE
fi
if [ ! -s "$manifest_list_file" ]; then
echo "error: could not extract package manifests from cargo metadata" >&2
exit $INTERNAL_ERROR_EXIT_CODE
fi
root_present=0
while IFS= read -r manifest; do
if [ "$manifest" = "$root_manifest" ]; then
root_present=1
break
fi
done <"$manifest_list_file"
if [ "$root_present" -eq 0 ]; then
printf '%s\n' "$root_manifest" >>"$manifest_list_file"
fi
cat <<'AWK' >"$awk_script_file"
function trim(s) {
sub(/^[[:space:]]+/, "", s)
sub(/[[:space:]]+$/, "", s)
return s
}
function normalize_dep_name(name) {
name = trim(name)
gsub(/^"|"$/, "", name)
gsub(/^'|'$/, "", name)
return name
}
function record_violation(rule, dep, line_no, details) {
violations += 1
printf("- [%s] %s:%d dependency=%s %s\n", rule, manifest_path, line_no, dep, details)
}
function braces_delta(s, opens, closes, copy) {
copy = s
opens = gsub(/\{/, "{", copy)
copy = s
closes = gsub(/\}/, "}", copy)
return opens - closes
}
function has_key(text, key) {
return text ~ ("(^|[,{[:space:]])" key "[[:space:]]*=")
}
function has_workspace_true(text) {
return text ~ /workspace[[:space:]]*=[[:space:]]*true/
}
function has_default_features_false(text) {
return text ~ /default-features[[:space:]]*=[[:space:]]*false/
}
function in_member_dependency_table(section_name) {
return section_name ~ /(^|\.)dependencies$/ ||
section_name ~ /(^|\.)dev-dependencies$/ ||
section_name ~ /(^|\.)build-dependencies$/
}
function in_member_dependency_item_table(section_name) {
return section_name ~ /(^|\.)dependencies\.[^\.]+$/ ||
section_name ~ /(^|\.)dev-dependencies\.[^\.]+$/ ||
section_name ~ /(^|\.)build-dependencies\.[^\.]+$/
}
function is_workspace_dependencies_table(section_name) {
return section_name == "workspace.dependencies"
}
function is_workspace_dependency_item_table(section_name) {
return section_name ~ /^workspace\.dependencies\.[^\.]+$/
}
function reset_specific_state() {
specific_active = 0
specific_kind = ""
specific_dep = ""
specific_dep_line = 0
specific_workspace_true = 0
specific_default_features_seen = 0
specific_default_features_false = 0
specific_features_seen = 0
}
function finalize_specific_state() {
if (!specific_active) {
return
}
if (specific_kind == "member") {
if (!specific_workspace_true) {
record_violation("workspace-only", specific_dep, specific_dep_line, "must set workspace = true")
}
if (specific_workspace_true && specific_default_features_seen) {
record_violation("no-default-features-override", specific_dep, specific_dep_line, "must not set default-features when using workspace dependency")
}
} else if (specific_kind == "workspace-root") {
if (!specific_default_features_seen || !specific_default_features_false) {
record_violation("workspace-default-features-false", specific_dep, specific_dep_line, "must set default-features = false")
}
if (specific_features_seen) {
record_violation("no-features", specific_dep, specific_dep_line, "must not set features")
}
}
reset_specific_state()
}
function analyze_dependency_assignment(dep, value_text, line_no) {
dep = normalize_dep_name(dep)
if (current_section == "workspace.dependencies" && is_root_manifest) {
if (!has_default_features_false(value_text)) {
record_violation("workspace-default-features-false", dep, line_no, "must set default-features = false")
}
if (has_key(value_text, "features")) {
record_violation("no-features", dep, line_no, "must not set features")
}
return
}
if (!is_root_manifest && in_member_dependency_table(current_section)) {
workspace_true = has_workspace_true(value_text)
default_features_set = has_key(value_text, "default-features")
if (!workspace_true) {
record_violation("workspace-only", dep, line_no, "must use workspace = true (no direct dependency declarations)")
}
if (workspace_true && default_features_set) {
record_violation("no-default-features-override", dep, line_no, "must not set default-features when using workspace dependency")
}
}
}
BEGIN {
violations = 0
current_section = ""
reset_specific_state()
}
{
original_line = $0
line = $0
sub(/[[:space:]]*#.*$/, "", line)
if (line ~ /^[[:space:]]*\[\[[^]]+\]\][[:space:]]*$/) {
finalize_specific_state()
section_value = line
sub(/^[[:space:]]*\[\[/, "", section_value)
sub(/\]\][[:space:]]*$/, "", section_value)
current_section = trim(section_value)
next
}
if (line ~ /^[[:space:]]*\[[^]]+\][[:space:]]*$/) {
finalize_specific_state()
section_value = line
sub(/^[[:space:]]*\[/, "", section_value)
sub(/\][[:space:]]*$/, "", section_value)
current_section = trim(section_value)
if (!is_root_manifest && in_member_dependency_item_table(current_section)) {
specific_active = 1
specific_kind = "member"
specific_dep = normalize_dep_name(current_section)
sub(/^.*\./, "", specific_dep)
specific_dep = normalize_dep_name(specific_dep)
specific_dep_line = NR
next
}
if (is_root_manifest && is_workspace_dependency_item_table(current_section)) {
specific_active = 1
specific_kind = "workspace-root"
specific_dep = normalize_dep_name(current_section)
sub(/^.*\./, "", specific_dep)
specific_dep = normalize_dep_name(specific_dep)
specific_dep_line = NR
next
}
next
}
if (specific_active) {
eq_pos = index(line, "=")
if (eq_pos > 0) {
key = trim(substr(line, 1, eq_pos - 1))
value = trim(substr(line, eq_pos + 1))
if (key == "workspace" && value ~ /^true([[:space:]]|$)/) {
specific_workspace_true = 1
}
if (key == "default-features") {
specific_default_features_seen = 1
if (value ~ /^false([[:space:]]|$)/) {
specific_default_features_false = 1
}
}
if (key == "features") {
specific_features_seen = 1
}
}
next
}
if ((is_root_manifest && current_section == "workspace.dependencies") ||
(!is_root_manifest && in_member_dependency_table(current_section))) {
eq_pos = index(line, "=")
if (eq_pos == 0) {
next
}
dep = trim(substr(line, 1, eq_pos - 1))
value = trim(substr(line, eq_pos + 1))
start_line = NR
if (value ~ /^\{/) {
buffer = value
delta = braces_delta(value)
while (delta > 0 && getline next_line) {
NR += 0
temp = next_line
sub(/[[:space:]]*#.*$/, "", temp)
buffer = buffer " " trim(temp)
delta += braces_delta(temp)
}
analyze_dependency_assignment(dep, buffer, start_line)
} else {
analyze_dependency_assignment(dep, value, start_line)
}
}
}
END {
finalize_specific_state()
if (violations > 0) {
exit 2
}
}
AWK
had_violations=0
had_internal_error=0
while IFS= read -r manifest; do
is_root=0
if [ "$manifest" = "$root_manifest" ]; then
is_root=1
fi
if output="$(awk -v manifest_path="$manifest" -v is_root_manifest="$is_root" -f "$awk_script_file" "$manifest")"; then
:
else
status=$?
if [ "$status" -eq 2 ]; then
had_violations=1
if [ -n "$output" ]; then
printf '%s\n' "$output"
fi
else
had_internal_error=1
echo "error: failed while checking $manifest" >&2
fi
fi
done <"$manifest_list_file"
if [ "$had_internal_error" -eq 1 ]; then
exit $INTERNAL_ERROR_EXIT_CODE
fi
if [ "$had_violations" -eq 1 ]; then
exit $FAILURE_DETECTED_EXIT_CODE
fi
echo "workspace dependency policy check passed"

View File

@ -23,14 +23,13 @@ lb-common-http-client = { workspace = true }
lb-core = { workspace = true }
lb-demo-sequencer = { workspace = true }
owo-colors = { workspace = true }
# TODO: Replace with unified workspace dependency
redb = { default-features = false, version = "3.1.0" }
reqwest = { features = ["json", "rustls-tls"], workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
tower-http = { features = ["cors"], workspace = true }
url = { workspace = true }
redb = { workspace = true }
reqwest = { features = ["json", "rustls-tls"], workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tokio-util = { workspace = true }
tower-http = { features = ["cors"], workspace = true }
url = { workspace = true }

View File

@ -2,8 +2,8 @@ use std::sync::Arc;
use lb_core::codec::{DeserializeOp as _, SerializeOp as _};
use redb::{
CommitError, Database, DatabaseError, ReadableDatabase as _, ReadableTable as _, StorageError,
TableDefinition, TableError, TransactionError,
CommitError, Database, DatabaseError, ReadableTable as _, StorageError, TableDefinition,
TableError, TransactionError,
};
use thiserror::Error;
use tokio::sync::RwLock;

View File

@ -21,8 +21,7 @@ thiserror = { workspace = true }
[dev-dependencies]
quickcheck = { workspace = true }
quickcheck_macros = { workspace = true }
# TODO: Replace with unified workspace dependency
rand = { default-features = false, features = ["thread_rng"], version = "0.9" }
rand = { workspace = true }
[lints]
workspace = true

View File

@ -231,7 +231,7 @@ mod serde {
mod tests {
use quickcheck::{Arbitrary, Gen};
use quickcheck_macros::quickcheck;
use rand::rng;
use rand::thread_rng;
use super::*;
use crate::test_fr::TestFr;
@ -246,7 +246,7 @@ mod tests {
#[test]
fn test_single_insert() {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let item = TestFr::from_rng(&mut rng());
let item = TestFr::from_rng(&mut thread_rng());
let key = item;
let (tree_with_item, _pos) = tree.insert(key, item);
@ -260,9 +260,9 @@ mod tests {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let items = [
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
];
let mut current_tree = tree;
@ -281,7 +281,7 @@ mod tests {
fn test_remove_existing_item() {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let item = TestFr::from_rng(&mut rng());
let item = TestFr::from_rng(&mut thread_rng());
let key = item;
let (tree_with_item, _) = tree.insert(key, item);
@ -297,11 +297,11 @@ mod tests {
fn test_remove_non_existing_item() {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let item = TestFr::from_rng(&mut rng());
let item = TestFr::from_rng(&mut thread_rng());
let key = item;
let (tree_with_item, _) = tree.insert(key, item);
let non_existing_key = TestFr::from_rng(&mut rng());
let non_existing_key = TestFr::from_rng(&mut thread_rng());
let result = tree_with_item.remove(&non_existing_key);
assert!(matches!(result, Err(Error::NotFound)));
}
@ -310,7 +310,7 @@ mod tests {
fn test_remove_from_empty_tree() {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let key = TestFr::from_rng(&mut rng());
let key = TestFr::from_rng(&mut thread_rng());
let result = tree.remove(&key);
assert!(matches!(result, Err(Error::NotFound)));
}
@ -319,8 +319,8 @@ mod tests {
fn test_structural_sharing() {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let item1 = TestFr::from_rng(&mut rng());
let item2 = TestFr::from_rng(&mut rng());
let item1 = TestFr::from_rng(&mut thread_rng());
let item2 = TestFr::from_rng(&mut thread_rng());
let key1 = item1;
let key2 = item2;
@ -338,7 +338,7 @@ mod tests {
let empty_root = tree.root();
let item = TestFr::from_rng(&mut rng());
let item = TestFr::from_rng(&mut thread_rng());
let key = item;
let (tree_with_item, _) = tree.insert(key, item);
let root_with_item = tree_with_item.root();
@ -357,9 +357,9 @@ mod tests {
let tree2: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let items = vec![
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
];
let mut current_tree1 = tree1;
@ -382,10 +382,10 @@ mod tests {
let mut current_tree = tree;
let items = vec![
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
];
for item in &items {
@ -401,7 +401,7 @@ mod tests {
let (tree_after_removal2, _) = tree_after_removal.remove(&items[3]).unwrap();
assert_eq!(tree_after_removal2.size(), 2);
let new_item = TestFr::from_rng(&mut rng());
let new_item = TestFr::from_rng(&mut thread_rng());
let new_key = new_item;
let (final_tree, _) = tree_after_removal2.insert(new_key, new_item);
assert_eq!(final_tree.size(), 3);
@ -420,9 +420,9 @@ mod tests {
let tree: UtxoTree<TestFr, TestFr, TestHash> = UtxoTree::new();
let items = vec![
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
TestFr::from_rng(&mut thread_rng()),
];
let mut current_tree = tree;
let mut positions = Vec::new();

View File

@ -490,7 +490,7 @@ mod tests {
#[test]
fn test_hole_management() {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
let a = TestFr::from_rng(&mut rng);
let b = TestFr::from_rng(&mut rng);
let c = TestFr::from_rng(&mut rng);
@ -510,7 +510,7 @@ mod tests {
#[test]
fn test_root_consistency() {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
let a = TestFr::from_rng(&mut rng);
let b = TestFr::from_rng(&mut rng);
let (tree1, _) = tree.insert(a);
@ -527,7 +527,7 @@ mod tests {
#[test]
fn test_deterministic_root() {
let mut rng = rand::rng();
let mut rng = rand::thread_rng();
let a = TestFr::from_rng(&mut rng);
let b = TestFr::from_rng(&mut rng);
let tree1: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
@ -545,14 +545,14 @@ mod tests {
#[should_panic(expected = "Index out of bounds")]
fn test_remove_out_of_bounds() {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
tree.remove(1 << 32);
}
#[test]
fn test_single_insert() {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let item = TestFr::from_rng(&mut rand::rng());
let item = TestFr::from_rng(&mut rand::thread_rng());
let (tree_with_item, index) = tree.insert(item);
assert_eq!(tree_with_item.size(), 1);
@ -565,9 +565,9 @@ mod tests {
fn test_multiple_inserts() {
let mut tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let items = [
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::thread_rng()),
TestFr::from_rng(&mut rand::thread_rng()),
TestFr::from_rng(&mut rand::thread_rng()),
];
for (i, item) in items.iter().enumerate() {
@ -583,7 +583,7 @@ mod tests {
#[test]
fn test_remove_single_item() {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let item = TestFr::from_rng(&mut rand::rng());
let item = TestFr::from_rng(&mut rand::thread_rng());
let (tree_with_item, _) = tree.insert(item);
let tree_after_removal = tree_with_item.remove(0);
@ -595,9 +595,9 @@ mod tests {
fn test_remove_and_reinsert() {
let mut tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let items = vec![
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::rng()),
TestFr::from_rng(&mut rand::thread_rng()),
TestFr::from_rng(&mut rand::thread_rng()),
TestFr::from_rng(&mut rand::thread_rng()),
];
for item in &items {
@ -609,7 +609,7 @@ mod tests {
assert_eq!(tree_after_removal.size(), 2);
let (tree_after_reinsert, index) =
tree_after_removal.insert(TestFr::from_rng(&mut rand::rng()));
tree_after_removal.insert(TestFr::from_rng(&mut rand::thread_rng()));
assert_eq!(tree_after_reinsert.size(), 3);
assert_eq!(index, 1);
}
@ -617,8 +617,8 @@ mod tests {
#[test]
fn test_structural_sharing() {
let tree1: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
let (tree2, _) = tree1.insert(TestFr::from_rng(&mut rand::rng()));
let (tree3, _) = tree2.insert(TestFr::from_rng(&mut rand::rng()));
let (tree2, _) = tree1.insert(TestFr::from_rng(&mut rand::thread_rng()));
let (tree3, _) = tree2.insert(TestFr::from_rng(&mut rand::thread_rng()));
assert_eq!(tree1.size(), 0);
assert_eq!(tree2.size(), 1);
@ -634,11 +634,11 @@ mod tests {
let tree: DynamicMerkleTree<TestFr, TestHash> = DynamicMerkleTree::new();
// Insert items at positions 0, 1, 2, 3, 4
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
let (tree, _) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
// Remove items at positions 3, 1, 4 (creating holes in that order)
let tree = tree.remove(3);
@ -647,15 +647,15 @@ mod tests {
// Now we have holes at positions 1, 3, 4
// The smallest hole should be selected first (position 1)
let (tree, index1) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, index1) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
assert_eq!(index1, 1, "Should select smallest hole first");
// Next insertion should use the next smallest hole (position 3)
let (tree, index2) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (tree, index2) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
assert_eq!(index2, 3, "Should select next smallest hole");
// Final insertion should use the last hole (position 4)
let (_, index3) = tree.insert(TestFr::from_rng(&mut rand::rng()));
let (_, index3) = tree.insert(TestFr::from_rng(&mut rand::thread_rng()));
assert_eq!(index3, 4, "Should select remaining hole");
}