mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
refactor(ecstore): move S3ErrorCode to rustfs-s3-types by codemod (#8432)
* test(ecstore): pin the InvalidRange responses of new_getobjectreader Both error branches of new_getobjectreader build an ErrorResponse with S3ErrorCode::InvalidRange, and no test observed either code. Pin the code, its wire name and the location fields of each branch before the code type moves to rustfs-s3-types (rustfs/backlog#2743), so a changed code on either branch fails a test. The ecstore test-count row rises by the two tests. * chore(scripts): add the s3s-to-rustfs-s3-types error codemod Task T1.2 of rustfs/backlog#2734 (rustfs/backlog#2743) moves S3Error, S3ErrorCode, S3Result and s3_error! from s3s to rustfs-s3-types by script, one crate closure per PR. The codemod rewrites use trees (splitting mixed s3s imports) and qualified code paths, never comments or literals, adds the rustfs-s3-types dependency, drops s3s only from a crate that no longer names it, refuses every file of the s3s edge allowlist, takes --hold for a file whose type is fixed outside the closure, and prints a per-crate census. A second run changes nothing. Its self-test runs under make script-tests. * refactor(ecstore): take S3ErrorCode from rustfs-s3-types Output of scripts/codemods/s3_error_to_s3_types.py over crates/ecstore, crates/lifecycle, crates/scanner and crates/replication (rustfs/backlog#2743 batches 1 and 2). The ErrorResponse code field, the two InvalidRange constructions in new_getobjectreader and an unused tier import now use rustfs_s3_types::S3ErrorCode; the variant names and wire names are unchanged. lifecycle, scanner and replication reference none of the four error names. warm_backend.rs and warm_backend_s3.rs are held: they compare against rustfs_s3_client::ErrorResponse.code, which stays the s3s type until s3-client moves.
This commit is contained in:
@@ -51,6 +51,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ rustfs-connect-symbol-catalog 6 0
|
|||||||
rustfs-credentials 24 0
|
rustfs-credentials 24 0
|
||||||
rustfs-crypto 67 0
|
rustfs-crypto 67 0
|
||||||
rustfs-data-usage 54 0
|
rustfs-data-usage 54 0
|
||||||
rustfs-ecstore 6220 1
|
rustfs-ecstore 6222 1
|
||||||
rustfs-extension-schema 23 0
|
rustfs-extension-schema 23 0
|
||||||
rustfs-filemeta 306 0
|
rustfs-filemeta 306 0
|
||||||
rustfs-heal 836 3
|
rustfs-heal 836 3
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
use crate::bucket::error::BucketMetadataError;
|
use crate::bucket::error::BucketMetadataError;
|
||||||
use crate::disk::error::DiskError;
|
use crate::disk::error::DiskError;
|
||||||
use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError};
|
use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError};
|
||||||
|
use rustfs_s3_types::S3ErrorCode;
|
||||||
use rustfs_utils::path::decode_dir_object;
|
use rustfs_utils::path::decode_dir_object;
|
||||||
use s3s::S3ErrorCode;
|
|
||||||
|
|
||||||
pub type Error = StorageError;
|
pub type Error = StorageError;
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
|
|||||||
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||||
use rustfs_filemeta::ObjectPartInfo;
|
use rustfs_filemeta::ObjectPartInfo;
|
||||||
use rustfs_rio::HashReader;
|
use rustfs_rio::HashReader;
|
||||||
use s3s::S3ErrorCode;
|
use rustfs_s3_types::S3ErrorCode;
|
||||||
|
|
||||||
//#[derive(Clone)]
|
//#[derive(Clone)]
|
||||||
pub struct PutObjReader {
|
pub struct PutObjReader {
|
||||||
@@ -231,6 +231,45 @@ mod tests {
|
|||||||
assert_eq!(result.2, 1024 * 1024);
|
assert_eq!(result.2, 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_getobjectreader_rejects_a_range_past_the_object_as_invalid_range() {
|
||||||
|
let oi = multipart_object_info();
|
||||||
|
let past_the_end = Some(HTTPRangeSpec {
|
||||||
|
is_suffix_length: false,
|
||||||
|
start: oi.size,
|
||||||
|
end: oi.size + 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
let err = new_getobjectreader(&past_the_end, &oi, &ObjectOptions::default(), &HeaderMap::new())
|
||||||
|
.err()
|
||||||
|
.expect("a range starting at the object size must be rejected");
|
||||||
|
|
||||||
|
assert_eq!(err.code, S3ErrorCode::InvalidRange);
|
||||||
|
assert_eq!(err.code.as_str(), "InvalidRange");
|
||||||
|
assert_eq!(err.key, None);
|
||||||
|
assert_eq!(err.bucket_name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_getobjectreader_rejects_an_unknown_size_without_a_range_as_invalid_range() {
|
||||||
|
let oi = ObjectInfo {
|
||||||
|
bucket: "bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
size: -1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = new_getobjectreader(&None, &oi, &ObjectOptions::default(), &HeaderMap::new())
|
||||||
|
.err()
|
||||||
|
.expect("an object of unknown size cannot be read without a range");
|
||||||
|
|
||||||
|
assert_eq!(err.code, S3ErrorCode::InvalidRange);
|
||||||
|
assert_eq!(err.code.as_str(), "InvalidRange");
|
||||||
|
assert_eq!(err.message, "Invalid range");
|
||||||
|
assert_eq!(err.key.as_deref(), Some("object"));
|
||||||
|
assert_eq!(err.bucket_name.as_deref(), Some("bucket"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_to_s3s_etag() {
|
fn test_to_s3s_etag() {
|
||||||
// Test unquoted ETag - should become strong etag
|
// Test unquoted ETag - should become strong etag
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ use crate::{
|
|||||||
use rustfs_filemeta::FileInfo;
|
use rustfs_filemeta::FileInfo;
|
||||||
use rustfs_rio::HashReader;
|
use rustfs_rio::HashReader;
|
||||||
use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
|
use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
|
||||||
|
use rustfs_s3_types::S3ErrorCode;
|
||||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join};
|
use rustfs_utils::path::{SLASH_SEPARATOR, path_join};
|
||||||
use s3s::{S3ErrorCode, dto::BucketLifecycleConfiguration};
|
use s3s::dto::BucketLifecycleConfiguration;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CREDENTIALS, ERR_TIER_PERM_ERR},
|
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CREDENTIALS, ERR_TIER_PERM_ERR},
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ their issue closes.
|
|||||||
|
|
||||||
| Entry | Status | Purpose | Wiring / docs |
|
| Entry | Status | Purpose | Wiring / docs |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `codemods/` | dev-tool | Scripted, idempotent source migrations for the gateway migration (rustfs/backlog#2734); `s3_error_to_s3_types.py` moves s3s error-type paths onto `rustfs-s3-types` per crate closure and refuses the s3s edge allowlist | `make script-tests` (`--self-test`); usage in each script's docstring |
|
||||||
| `fuzz/` | ci-gate | Unified cargo-fuzz runner and helpers for the `fuzz/` sub-workspace | fuzz.yml; `fuzz/README.md` |
|
| `fuzz/` | ci-gate | Unified cargo-fuzz runner and helpers for the `fuzz/` sub-workspace | fuzz.yml; `fuzz/README.md` |
|
||||||
| `release/` | ci-gate | Release creation and DEB/RPM version-normalization helpers | build.yml; package.yml |
|
| `release/` | ci-gate | Release creation and DEB/RPM version-normalization helpers | build.yml; package.yml |
|
||||||
| `s3-tests/` | ci-gate | ceph/s3-tests compatibility harness (allow-lists, patches, report tooling) | ci.yml; e2e-s3tests.yml; `scripts/s3-tests/README.md` |
|
| `s3-tests/` | ci-gate | ceph/s3-tests compatibility harness (allow-lists, patches, report tooling) | ci.yml; e2e-s3tests.yml; `scripts/s3-tests/README.md` |
|
||||||
|
|||||||
Executable
+848
@@ -0,0 +1,848 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Move s3s error-type references onto rustfs-s3-types, one crate closure at a time.
|
||||||
|
|
||||||
|
Task T1.2 of rustfs/backlog#2734 (spec rustfs/backlog#2743). rustfs-s3-types
|
||||||
|
offers `S3Error`, `S3ErrorCode`, `S3Result` and the `s3_error!` macro with the
|
||||||
|
same call syntax as s3s, so the switch is a path rewrite and never a call-site
|
||||||
|
rewrite:
|
||||||
|
|
||||||
|
* `use s3s::{S3ErrorCode, dto::X};` becomes `use rustfs_s3_types::S3ErrorCode;`
|
||||||
|
plus `use s3s::dto::X;` (attributes and visibility are kept on both);
|
||||||
|
* a qualified path in code (`s3s::S3ErrorCode::Foo`, `s3s::s3_error!(...)`)
|
||||||
|
gets the `rustfs_s3_types::` prefix;
|
||||||
|
* the manifest of a crate the run rewrote gains `rustfs-s3-types = { workspace
|
||||||
|
= true }` in [dependencies] when absent there, and loses its `s3s`
|
||||||
|
dependency only when no source file of the crate names s3s any more and no
|
||||||
|
feature mentions it. A crate the run did not rewrite keeps its manifest.
|
||||||
|
|
||||||
|
Comments and string or character literals are never rewritten; references left
|
||||||
|
there are counted in the census. A use tree that holds a comment or a literal
|
||||||
|
is not rewritten either: it is reported and the run exits 1.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/codemods/s3_error_to_s3_types.py [--hold FILE]... [--no-fmt] CRATE_DIR...
|
||||||
|
scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||||
|
|
||||||
|
CRATE_DIR is a repository-relative package directory, or a directory inside
|
||||||
|
one (for example rustfs/src/admin): only the Rust files below it are rewritten,
|
||||||
|
while the manifest decisions read the whole package. Files of nested packages
|
||||||
|
are left to their own package.
|
||||||
|
|
||||||
|
The run is idempotent: a second run over the same arguments changes nothing
|
||||||
|
and prints a census of zero changed files.
|
||||||
|
|
||||||
|
Files matched by .config/s3s-edge-allowlist.txt (the s3s edge that keeps s3s
|
||||||
|
until the flip) are refused: never rewritten, and listed in the census. A
|
||||||
|
missing allowlist is an error, never an empty list.
|
||||||
|
|
||||||
|
--hold FILE leaves one file untouched for this run and lists it in the census
|
||||||
|
with its remaining references. It is for a file whose s3s error type is fixed
|
||||||
|
by a crate outside this closure (for example a field of another crate's public
|
||||||
|
struct that the file compares against); the hold is visible in the census so it
|
||||||
|
cannot be forgotten.
|
||||||
|
|
||||||
|
Unless --no-fmt is given, rustfmt formats each rewritten file afterwards, so the
|
||||||
|
split imports land where rustfmt sorts them.
|
||||||
|
|
||||||
|
Exit status: 0 when every non-refused, non-held file is free of s3s error-type
|
||||||
|
references in code; 1 when references remain that the codemod could not
|
||||||
|
rewrite (listed); 2 on a usage error or a broken input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NAMES = ("S3ErrorCode", "S3Error", "S3Result", "s3_error")
|
||||||
|
OLD_CRATE = "s3s"
|
||||||
|
NEW_CRATE = "rustfs_s3_types"
|
||||||
|
NEW_DEP_KEY = "rustfs-s3-types"
|
||||||
|
NEW_DEP_LINE = "rustfs-s3-types = { workspace = true }"
|
||||||
|
ALLOWLIST = ".config/s3s-edge-allowlist.txt"
|
||||||
|
|
||||||
|
CODE, COMMENT, LITERAL = 0, 1, 2
|
||||||
|
|
||||||
|
IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
|
||||||
|
CHAR_LITERAL = re.compile(r"'(?:\\(?:[nrt\\0'\"]|x[0-9a-fA-F]{2}|u\{[0-9a-fA-F_]{1,6}\})|[^\\'\n])'")
|
||||||
|
RAW_STRING_START = re.compile(r'r(#*)"')
|
||||||
|
USE_START = re.compile(
|
||||||
|
r"(?m)^(?P<indent>[ \t]*)(?P<attrs>(?:#\[[^\]\n]*\][ \t]*)*)(?P<vis>(?:pub(?:[ \t]*\([^)\n]*\))?[ \t]+)?)"
|
||||||
|
r"use[ \t]+(?P<lead>::)?" + OLD_CRATE + r"::"
|
||||||
|
)
|
||||||
|
NAME_PATTERN = "|".join(sorted(NAMES, key=len, reverse=True))
|
||||||
|
QUALIFIED = re.compile(r"(?<![A-Za-z0-9_])" + OLD_CRATE + r"::(?P<name>" + NAME_PATTERN + r")(?![A-Za-z0-9_])")
|
||||||
|
OLD_CRATE_PATH = re.compile(r"(?<![A-Za-z0-9_])" + OLD_CRATE + r"(?:::|[ \t]*;|[ \t]+as[ \t])")
|
||||||
|
NEW_CRATE_PATH = re.compile(r"(?<![A-Za-z0-9_])" + NEW_CRATE + r"::")
|
||||||
|
ATTR_LINE = re.compile(r"^[ \t]*#\[[^\n]*\][ \t]*$")
|
||||||
|
TOML_KEY = re.compile(r"^(?P<key>[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_.-]+)?[ \t]*=")
|
||||||
|
|
||||||
|
|
||||||
|
class UsageError(Exception):
|
||||||
|
"""A broken argument or input: exit 2."""
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> bytearray:
|
||||||
|
"""Return one kind byte per character: CODE, COMMENT or LITERAL."""
|
||||||
|
kinds = bytearray(len(text))
|
||||||
|
i, n = 0, len(text)
|
||||||
|
|
||||||
|
def mark(start: int, end: int, kind: int) -> None:
|
||||||
|
kinds[start:end] = bytes([kind]) * (end - start)
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
ch = text[i]
|
||||||
|
if text.startswith("//", i):
|
||||||
|
end = text.find("\n", i)
|
||||||
|
end = n if end < 0 else end
|
||||||
|
mark(i, end, COMMENT)
|
||||||
|
i = end
|
||||||
|
elif text.startswith("/*", i):
|
||||||
|
depth, j = 1, i + 2
|
||||||
|
while j < n and depth:
|
||||||
|
if text.startswith("/*", j):
|
||||||
|
depth, j = depth + 1, j + 2
|
||||||
|
elif text.startswith("*/", j):
|
||||||
|
depth, j = depth - 1, j + 2
|
||||||
|
else:
|
||||||
|
j += 1
|
||||||
|
mark(i, j, COMMENT)
|
||||||
|
i = j
|
||||||
|
elif ch == "r" and (i == 0 or text[i - 1] not in IDENT_CHARS or _is_byte_prefix(text, i - 1)):
|
||||||
|
raw = RAW_STRING_START.match(text, i)
|
||||||
|
if raw is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
closing = '"' + raw.group(1)
|
||||||
|
end = text.find(closing, raw.end())
|
||||||
|
end = n if end < 0 else end + len(closing)
|
||||||
|
mark(i, end, LITERAL)
|
||||||
|
i = end
|
||||||
|
elif ch == '"':
|
||||||
|
j = i + 1
|
||||||
|
while j < n and text[j] != '"':
|
||||||
|
j += 2 if text[j] == "\\" else 1
|
||||||
|
mark(i, min(j + 1, n), LITERAL)
|
||||||
|
i = j + 1
|
||||||
|
elif ch == "'":
|
||||||
|
literal = CHAR_LITERAL.match(text, i)
|
||||||
|
if literal is None: # a lifetime or a loop label
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
mark(i, literal.end(), LITERAL)
|
||||||
|
i = literal.end()
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
|
||||||
|
def _is_byte_prefix(text: str, index: int) -> bool:
|
||||||
|
"""True when text[index] is the `b` of a `br"..."` raw byte string."""
|
||||||
|
return text[index] == "b" and (index == 0 or text[index - 1] not in IDENT_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
def split_top_level(inner: str) -> list[str]:
|
||||||
|
items, depth, start = [], 0, 0
|
||||||
|
for i, ch in enumerate(inner):
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
elif ch == "," and depth == 0:
|
||||||
|
items.append(inner[start:i])
|
||||||
|
start = i + 1
|
||||||
|
items.append(inner[start:])
|
||||||
|
return [re.sub(r"\s+", " ", item).strip() for item in items if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def tree_items(tree: str) -> list[str]:
|
||||||
|
tree = tree.strip()
|
||||||
|
if tree.startswith("{") and tree.endswith("}") and _closing_brace(tree, 0) == len(tree) - 1:
|
||||||
|
return split_top_level(tree[1:-1])
|
||||||
|
return [re.sub(r"\s+", " ", tree)]
|
||||||
|
|
||||||
|
|
||||||
|
def _closing_brace(text: str, open_index: int) -> int:
|
||||||
|
depth = 0
|
||||||
|
for i in range(open_index, len(text)):
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def item_head(item: str) -> str:
|
||||||
|
match = re.match(r"[A-Za-z_][A-Za-z0-9_]*", item)
|
||||||
|
return match.group(0) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
def render_tree(items: list[str]) -> str:
|
||||||
|
return items[0] if len(items) == 1 else "{" + ", ".join(items) + "}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileResult:
|
||||||
|
text: str
|
||||||
|
use_statements: int = 0
|
||||||
|
qualified_paths: int = 0
|
||||||
|
in_comments: int = 0
|
||||||
|
in_literals: int = 0
|
||||||
|
unsupported: list[int] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(text: str) -> FileResult:
|
||||||
|
"""Rewrite one Rust source; pure, so a second pass over its output is a no-op."""
|
||||||
|
kinds = classify(text)
|
||||||
|
out: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
result = FileResult(text)
|
||||||
|
skipped_spans: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
for match in USE_START.finditer(text):
|
||||||
|
start = match.start("indent")
|
||||||
|
use_keyword = match.start("vis") + len(match.group("vis"))
|
||||||
|
if start < cursor or kinds[use_keyword] != CODE:
|
||||||
|
continue
|
||||||
|
tree_start = match.end()
|
||||||
|
end, depth = tree_start, 0
|
||||||
|
while end < len(text):
|
||||||
|
if kinds[end] == CODE:
|
||||||
|
if text[end] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[end] == "}":
|
||||||
|
depth -= 1
|
||||||
|
elif text[end] == ";" and depth == 0:
|
||||||
|
break
|
||||||
|
end += 1
|
||||||
|
if end >= len(text):
|
||||||
|
continue
|
||||||
|
items = tree_items(code_only(text, kinds, tree_start, end))
|
||||||
|
moved = [item for item in items if item_head(item) in NAMES]
|
||||||
|
if not moved:
|
||||||
|
continue
|
||||||
|
if any(kind != CODE for kind in kinds[tree_start:end]):
|
||||||
|
result.unsupported.append(text.count("\n", 0, start) + 1)
|
||||||
|
skipped_spans.append((start, end))
|
||||||
|
continue
|
||||||
|
kept = [item for item in items if item_head(item) not in NAMES]
|
||||||
|
indent, vis, lead = match.group("indent"), match.group("vis"), match.group("lead") or ""
|
||||||
|
attrs = match.group("attrs")
|
||||||
|
preceding = _preceding_attribute_lines(text, start)
|
||||||
|
statements = [f"{indent}{attrs}{vis}use {lead}{NEW_CRATE}::{render_tree(moved)};"]
|
||||||
|
if kept:
|
||||||
|
statements.append(f"{preceding}{indent}{attrs}{vis}use {lead}{OLD_CRATE}::{render_tree(kept)};")
|
||||||
|
out.append(text[cursor:start])
|
||||||
|
out.append("\n".join(statements))
|
||||||
|
cursor = end + 1
|
||||||
|
result.use_statements += 1
|
||||||
|
out.append(text[cursor:])
|
||||||
|
text = "".join(out)
|
||||||
|
|
||||||
|
kinds = classify(text)
|
||||||
|
skipped_spans = [(a, b) for a, b in _respan(result.text, text, skipped_spans)]
|
||||||
|
pieces: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for match in QUALIFIED.finditer(text):
|
||||||
|
start = match.start()
|
||||||
|
if _is_nested_module_path(text, start) or any(a <= start < b for a, b in skipped_spans):
|
||||||
|
continue
|
||||||
|
if kinds[start] == COMMENT:
|
||||||
|
result.in_comments += 1
|
||||||
|
continue
|
||||||
|
if kinds[start] == LITERAL:
|
||||||
|
result.in_literals += 1
|
||||||
|
continue
|
||||||
|
pieces.append(text[cursor:start])
|
||||||
|
pieces.append(f"{NEW_CRATE}::{match.group('name')}")
|
||||||
|
cursor = match.end()
|
||||||
|
result.qualified_paths += 1
|
||||||
|
pieces.append(text[cursor:])
|
||||||
|
result.text = "".join(pieces)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def code_only(text: str, kinds: bytearray, start: int, end: int) -> str:
|
||||||
|
"""text[start:end] with every comment and literal character blanked out."""
|
||||||
|
return "".join(ch if kinds[i] == CODE else " " for i, ch in enumerate(text[start:end], start))
|
||||||
|
|
||||||
|
|
||||||
|
def _respan(before: str, after: str, spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||||
|
"""Re-locate skipped use statements after earlier statements changed length."""
|
||||||
|
located = []
|
||||||
|
for start, end in spans:
|
||||||
|
snippet = before[start:end]
|
||||||
|
index = after.find(snippet)
|
||||||
|
if index >= 0:
|
||||||
|
located.append((index, index + len(snippet)))
|
||||||
|
return located
|
||||||
|
|
||||||
|
|
||||||
|
def _preceding_attribute_lines(text: str, start: int) -> str:
|
||||||
|
"""The contiguous `#[...]` lines right above `start`, to repeat on a split."""
|
||||||
|
lines: list[str] = []
|
||||||
|
cursor = start
|
||||||
|
while cursor > 0:
|
||||||
|
line_start = text.rfind("\n", 0, cursor - 1) + 1
|
||||||
|
line = text[line_start : cursor - 1] if text[cursor - 1] == "\n" else ""
|
||||||
|
if not line or not ATTR_LINE.match(line):
|
||||||
|
break
|
||||||
|
lines.insert(0, line + "\n")
|
||||||
|
cursor = line_start
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nested_module_path(text: str, start: int) -> bool:
|
||||||
|
"""`foo::s3s::S3Error` names a module called s3s, not the crate."""
|
||||||
|
if start >= 2 and text[start - 2 : start] == "::":
|
||||||
|
before = start - 2
|
||||||
|
return before > 0 and text[before - 1] in IDENT_CHARS
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def remaining_lines(text: str) -> list[int]:
|
||||||
|
"""Lines whose code still names one of NAMES through the s3s crate."""
|
||||||
|
kinds = classify(text)
|
||||||
|
lines = set()
|
||||||
|
for match in QUALIFIED.finditer(text):
|
||||||
|
if kinds[match.start()] == CODE and not _is_nested_module_path(text, match.start()):
|
||||||
|
lines.add(text.count("\n", 0, match.start()) + 1)
|
||||||
|
for match in USE_START.finditer(text):
|
||||||
|
if kinds[match.end() - 1] != CODE:
|
||||||
|
continue
|
||||||
|
end = match.end()
|
||||||
|
depth = 0
|
||||||
|
while end < len(text) and not (kinds[end] == CODE and text[end] == ";" and depth == 0):
|
||||||
|
if kinds[end] == CODE:
|
||||||
|
depth += {"{": 1, "}": -1}.get(text[end], 0)
|
||||||
|
end += 1
|
||||||
|
if any(item_head(item) in NAMES for item in tree_items(code_only(text, kinds, match.end(), end))):
|
||||||
|
lines.add(text.count("\n", 0, match.start()) + 1)
|
||||||
|
return sorted(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def names_crate(text: str, pattern: re.Pattern[str]) -> bool:
|
||||||
|
kinds = classify(text)
|
||||||
|
return any(kinds[match.start()] == CODE for match in pattern.finditer(text))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Repository inputs
|
||||||
|
|
||||||
|
|
||||||
|
def git_files(root: Path, *pathspecs: str) -> list[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(root), "ls-files", "-z", "--", *pathspecs],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise UsageError(f"git ls-files {' '.join(pathspecs)} failed: {result.stderr.decode().strip()}")
|
||||||
|
return sorted(path for path in result.stdout.decode().split("\0") if path)
|
||||||
|
|
||||||
|
|
||||||
|
def allowlisted_files(root: Path) -> set[str]:
|
||||||
|
path = root / ALLOWLIST
|
||||||
|
if not path.is_file():
|
||||||
|
raise UsageError(f"allowlist '{ALLOWLIST}' is missing; refusing to run without it")
|
||||||
|
files: set[str] = set()
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
entry = raw.strip()
|
||||||
|
if not entry or entry.startswith("#"):
|
||||||
|
continue
|
||||||
|
files.update(git_files(root, f":(glob){entry}"))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def package_of(root: Path, directory: str) -> str:
|
||||||
|
"""The nearest directory at or above `directory` whose Cargo.toml has [package]."""
|
||||||
|
path = Path(directory)
|
||||||
|
while True:
|
||||||
|
manifest = root / path / "Cargo.toml"
|
||||||
|
if manifest.is_file() and re.search(r"(?m)^\[package\]", manifest.read_text(encoding="utf-8")):
|
||||||
|
return path.as_posix()
|
||||||
|
if path == Path("."):
|
||||||
|
raise UsageError(f"'{directory}' is not a directory inside a package (no [package] manifest above it)")
|
||||||
|
path = path.parent
|
||||||
|
|
||||||
|
|
||||||
|
def package_sources(root: Path, package: str, under: str) -> list[str]:
|
||||||
|
"""Tracked Rust files below `under` that belong to `package`, not to a nested package."""
|
||||||
|
prefix = "" if package == "." else package + "/"
|
||||||
|
files = git_files(root, f":(glob){under}/**/*.rs")
|
||||||
|
nested = {
|
||||||
|
str(Path(manifest).parent)
|
||||||
|
for manifest in git_files(root, f":(glob){prefix}**/Cargo.toml")
|
||||||
|
if str(Path(manifest).parent) != package
|
||||||
|
}
|
||||||
|
return [path for path in files if not any(path.startswith(directory + "/") for directory in nested)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manifest edits
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_entries(lines: list[str]) -> tuple[int, list[tuple[str, int, int]]]:
|
||||||
|
"""Return the [dependencies] header index and (key, first, last) line spans."""
|
||||||
|
try:
|
||||||
|
header = next(i for i, line in enumerate(lines) if line.strip() == "[dependencies]")
|
||||||
|
except StopIteration:
|
||||||
|
return -1, []
|
||||||
|
entries = []
|
||||||
|
i = header + 1
|
||||||
|
while i < len(lines) and not lines[i].lstrip().startswith("["):
|
||||||
|
key = TOML_KEY.match(lines[i])
|
||||||
|
if key is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
first, balance = i, 0
|
||||||
|
while True:
|
||||||
|
balance += _bracket_balance(lines[i])
|
||||||
|
if balance <= 0 or i + 1 >= len(lines):
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
entries.append((key.group("key"), first, i))
|
||||||
|
i += 1
|
||||||
|
return header, entries
|
||||||
|
|
||||||
|
|
||||||
|
def _bracket_balance(line: str) -> int:
|
||||||
|
balance, in_string = 0, False
|
||||||
|
for ch in line.split("#", 1)[0] if '"' not in line else line:
|
||||||
|
if ch == '"':
|
||||||
|
in_string = not in_string
|
||||||
|
elif not in_string and ch in "{[":
|
||||||
|
balance += 1
|
||||||
|
elif not in_string and ch in "}]":
|
||||||
|
balance -= 1
|
||||||
|
return balance
|
||||||
|
|
||||||
|
|
||||||
|
def edit_manifest(text: str, needs_new: bool, may_drop_old: bool) -> tuple[str, list[str]]:
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
notes: list[str] = []
|
||||||
|
header, entries = _dependency_entries(lines)
|
||||||
|
if header < 0:
|
||||||
|
if needs_new:
|
||||||
|
raise UsageError("manifest has no [dependencies] table to add rustfs-s3-types to")
|
||||||
|
return text, notes
|
||||||
|
keys = {key for key, _, _ in entries}
|
||||||
|
if may_drop_old and OLD_CRATE in keys:
|
||||||
|
if re.search(r"(?m)^\s*[^#\n]*\b" + OLD_CRATE + r"\b", _features_section(text)):
|
||||||
|
notes.append("kept s3s: a [features] entry mentions it")
|
||||||
|
else:
|
||||||
|
_, first, last = next(entry for entry in entries if entry[0] == OLD_CRATE)
|
||||||
|
del lines[first : last + 1]
|
||||||
|
notes.append("removed s3s")
|
||||||
|
header, entries = _dependency_entries(lines)
|
||||||
|
if needs_new and NEW_DEP_KEY not in keys:
|
||||||
|
rustfs = [entry for entry in entries if entry[0].startswith("rustfs-")]
|
||||||
|
before = [entry for entry in rustfs if entry[0] < NEW_DEP_KEY]
|
||||||
|
if before:
|
||||||
|
position = before[-1][2] + 1
|
||||||
|
elif rustfs:
|
||||||
|
position = rustfs[0][1]
|
||||||
|
else:
|
||||||
|
position = header + 1
|
||||||
|
lines.insert(position, NEW_DEP_LINE + "\n")
|
||||||
|
notes.append("added rustfs-s3-types")
|
||||||
|
return "".join(lines), notes
|
||||||
|
|
||||||
|
|
||||||
|
def _features_section(text: str) -> str:
|
||||||
|
match = re.search(r"(?ms)^\[features\]\s*$(.*?)(?=^\[|\Z)", text)
|
||||||
|
return match.group(1) if match else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Driver
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrateCensus:
|
||||||
|
crate: str
|
||||||
|
changed: list[str] = field(default_factory=list)
|
||||||
|
held: list[tuple[str, int]] = field(default_factory=list)
|
||||||
|
refused: list[tuple[str, int]] = field(default_factory=list)
|
||||||
|
use_statements: int = 0
|
||||||
|
qualified_paths: int = 0
|
||||||
|
in_comments: int = 0
|
||||||
|
in_literals: int = 0
|
||||||
|
remaining: list[str] = field(default_factory=list)
|
||||||
|
manifest: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def run(root: Path, crates: list[str], holds: list[str], fmt: bool = True, out=sys.stdout) -> int:
|
||||||
|
crates = [crate.rstrip("/") for crate in crates]
|
||||||
|
packages: dict[str, str] = {}
|
||||||
|
for crate in crates:
|
||||||
|
if Path(crate).is_absolute() or ".." in Path(crate).parts:
|
||||||
|
raise UsageError(f"crate directory '{crate}' must be repository-relative")
|
||||||
|
if not (root / crate).is_dir():
|
||||||
|
raise UsageError(f"'{crate}' is not a directory inside a package (no such directory)")
|
||||||
|
packages[crate] = package_of(root, crate)
|
||||||
|
refused_files = allowlisted_files(root)
|
||||||
|
sources = {crate: package_sources(root, packages[crate], crate) for crate in crates}
|
||||||
|
hold_set = set(holds)
|
||||||
|
for hold in hold_set:
|
||||||
|
if not any(hold in files for files in sources.values()):
|
||||||
|
raise UsageError(f"--hold '{hold}' is not a tracked Rust file of the given crates")
|
||||||
|
|
||||||
|
censuses: list[CrateCensus] = []
|
||||||
|
rewritten: list[Path] = []
|
||||||
|
for crate in crates:
|
||||||
|
census = CrateCensus(crate)
|
||||||
|
for relative in sources[crate]:
|
||||||
|
path = root / relative
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if relative in refused_files or relative in hold_set:
|
||||||
|
bucket = census.refused if relative in refused_files else census.held
|
||||||
|
left = remaining_lines(text)
|
||||||
|
if left:
|
||||||
|
bucket.append((relative, len(left)))
|
||||||
|
continue
|
||||||
|
result = rewrite(text)
|
||||||
|
census.use_statements += result.use_statements
|
||||||
|
census.qualified_paths += result.qualified_paths
|
||||||
|
census.in_comments += result.in_comments
|
||||||
|
census.in_literals += result.in_literals
|
||||||
|
census.remaining += [f"{relative}:{line}" for line in remaining_lines(result.text)]
|
||||||
|
if result.text != text:
|
||||||
|
path.write_text(result.text, encoding="utf-8")
|
||||||
|
census.changed.append(relative)
|
||||||
|
rewritten.append(path)
|
||||||
|
# A crate this run did not rewrite keeps its manifest: a dev-only use of
|
||||||
|
# rustfs-s3-types or an s3s entry kept for other reasons is not ours to edit.
|
||||||
|
# Whether the package still names either crate is read from all of its
|
||||||
|
# sources, not only from the directory this run was given.
|
||||||
|
if census.changed:
|
||||||
|
package = packages[crate]
|
||||||
|
texts = [(root / f).read_text(encoding="utf-8") for f in package_sources(root, package, package)]
|
||||||
|
crate_names_old = any(names_crate(text, OLD_CRATE_PATH) for text in texts)
|
||||||
|
crate_names_new = any(names_crate(text, NEW_CRATE_PATH) for text in texts)
|
||||||
|
manifest_path = root / package / "Cargo.toml"
|
||||||
|
manifest = manifest_path.read_text(encoding="utf-8")
|
||||||
|
edited, census.manifest = edit_manifest(manifest, crate_names_new, not crate_names_old)
|
||||||
|
if edited != manifest:
|
||||||
|
manifest_path.write_text(edited, encoding="utf-8")
|
||||||
|
censuses.append(census)
|
||||||
|
|
||||||
|
if fmt and rewritten:
|
||||||
|
edition = _workspace_edition(root)
|
||||||
|
result = subprocess.run(["rustfmt", "--edition", edition, *map(str, rewritten)], check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise UsageError("rustfmt failed on the rewritten files")
|
||||||
|
|
||||||
|
return _print_census(censuses, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_edition(root: Path) -> str:
|
||||||
|
match = re.search(r'(?m)^edition\s*=\s*"(\d{4})"', (root / "Cargo.toml").read_text(encoding="utf-8"))
|
||||||
|
return match.group(1) if match else "2024"
|
||||||
|
|
||||||
|
|
||||||
|
def _print_census(censuses: list[CrateCensus], out) -> int:
|
||||||
|
total = 0
|
||||||
|
remaining = 0
|
||||||
|
for census in censuses:
|
||||||
|
total += len(census.changed)
|
||||||
|
remaining += len(census.remaining)
|
||||||
|
manifest = ", ".join(census.manifest) or "unchanged"
|
||||||
|
print(
|
||||||
|
f"{census.crate}: {len(census.changed)} files changed "
|
||||||
|
f"({census.use_statements} use statements, {census.qualified_paths} qualified paths); "
|
||||||
|
f"manifest {manifest}; {len(census.held)} held, {len(census.refused)} refused by the allowlist; "
|
||||||
|
f"left in comments {census.in_comments}, in literals {census.in_literals}",
|
||||||
|
file=out,
|
||||||
|
)
|
||||||
|
for path in census.changed:
|
||||||
|
print(f" changed {path}", file=out)
|
||||||
|
for path, count in census.held:
|
||||||
|
print(f" held {path} ({count} lines still name s3s error types)", file=out)
|
||||||
|
for path, count in census.refused:
|
||||||
|
print(f" refused {path} ({count} lines still name s3s error types)", file=out)
|
||||||
|
for location in census.remaining:
|
||||||
|
print(f" REMAINS {location}", file=out)
|
||||||
|
print(f"total: {total} files changed in {len(censuses)} crates", file=out)
|
||||||
|
if remaining:
|
||||||
|
print(f"error: {remaining} s3s error-type references could not be rewritten (REMAINS above)", file=out)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if argv == ["--self-test"]:
|
||||||
|
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SelfTest)
|
||||||
|
return 0 if unittest.TextTestRunner(verbosity=1).run(suite).wasSuccessful() else 1
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||||||
|
parser.add_argument("crates", nargs="+", metavar="CRATE_DIR")
|
||||||
|
parser.add_argument("--hold", action="append", default=[], metavar="FILE")
|
||||||
|
parser.add_argument("--no-fmt", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
top = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False)
|
||||||
|
if top.returncode != 0:
|
||||||
|
print("error: not inside a git checkout", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
return run(Path(top.stdout.strip()), args.crates, args.hold, fmt=not args.no_fmt)
|
||||||
|
except UsageError as error:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Self-test
|
||||||
|
|
||||||
|
|
||||||
|
class SelfTest(unittest.TestCase):
|
||||||
|
def rewritten(self, source: str) -> str:
|
||||||
|
return rewrite(source).text
|
||||||
|
|
||||||
|
# -- positive cases --
|
||||||
|
|
||||||
|
def test_single_import_moves(self):
|
||||||
|
self.assertEqual(self.rewritten("use s3s::S3ErrorCode;\n"), "use rustfs_s3_types::S3ErrorCode;\n")
|
||||||
|
|
||||||
|
def test_mixed_brace_import_splits_and_keeps_the_rest_on_s3s(self):
|
||||||
|
source = "use s3s::{\n S3ErrorCode,\n dto::{A, B},\n};\nfn f() {}\n"
|
||||||
|
self.assertEqual(
|
||||||
|
self.rewritten(source),
|
||||||
|
"use rustfs_s3_types::S3ErrorCode;\nuse s3s::dto::{A, B};\nfn f() {}\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_name_moves_together_with_aliases_and_variant_paths(self):
|
||||||
|
source = "pub(crate) use s3s::{Body, S3Error as E, S3ErrorCode::InvalidRequest, S3Result, s3_error};\n"
|
||||||
|
self.assertEqual(
|
||||||
|
self.rewritten(source),
|
||||||
|
"pub(crate) use rustfs_s3_types::{S3Error as E, S3ErrorCode::InvalidRequest, S3Result, s3_error};\n"
|
||||||
|
"pub(crate) use s3s::Body;\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_qualified_paths_in_code_move(self):
|
||||||
|
source = "fn f() -> s3s::S3Result<()> { Err(s3s::s3_error!(NoSuchKey, \"x\")) }\nlet c = s3s::S3ErrorCode::Foo;\n"
|
||||||
|
self.assertEqual(
|
||||||
|
self.rewritten(source),
|
||||||
|
"fn f() -> rustfs_s3_types::S3Result<()> { Err(rustfs_s3_types::s3_error!(NoSuchKey, \"x\")) }\n"
|
||||||
|
"let c = rustfs_s3_types::S3ErrorCode::Foo;\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_attributes_are_repeated_on_both_halves_of_a_split(self):
|
||||||
|
source = "#[cfg(test)]\nuse s3s::{S3Error, dto::X};\n#[cfg(test)] use s3s::{S3Result, Body};\n"
|
||||||
|
self.assertEqual(
|
||||||
|
self.rewritten(source),
|
||||||
|
"#[cfg(test)]\nuse rustfs_s3_types::S3Error;\n#[cfg(test)]\nuse s3s::dto::X;\n"
|
||||||
|
"#[cfg(test)] use rustfs_s3_types::S3Result;\n#[cfg(test)] use s3s::Body;\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_pass_is_a_no_op(self):
|
||||||
|
source = "use s3s::{S3ErrorCode, dto::X};\nfn f() { let _ = s3s::S3ErrorCode::A; }\n"
|
||||||
|
once = self.rewritten(source)
|
||||||
|
self.assertNotEqual(once, source)
|
||||||
|
twice = rewrite(once)
|
||||||
|
self.assertEqual(twice.text, once)
|
||||||
|
self.assertEqual((twice.use_statements, twice.qualified_paths), (0, 0))
|
||||||
|
|
||||||
|
# -- negative cases --
|
||||||
|
|
||||||
|
def test_comments_are_counted_not_rewritten(self):
|
||||||
|
source = "// s3s::S3Error\n/* nested /* s3s::S3ErrorCode */ s3s::s3_error */\n/// [`s3s::S3Result`]\n"
|
||||||
|
result = rewrite(source)
|
||||||
|
self.assertEqual(result.text, source)
|
||||||
|
self.assertEqual(result.in_comments, 4)
|
||||||
|
|
||||||
|
def test_string_and_char_literals_are_counted_not_rewritten(self):
|
||||||
|
source = (
|
||||||
|
'const A: &str = "use s3s::S3Error;";\n'
|
||||||
|
'const B: &str = r#"s3s::S3ErrorCode "quoted" "#;\n'
|
||||||
|
"const C: &[u8] = br\"s3s::S3Result\";\n"
|
||||||
|
'const D: &str = "esc \\" s3s::s3_error";\n'
|
||||||
|
)
|
||||||
|
result = rewrite(source)
|
||||||
|
self.assertEqual(result.text, source)
|
||||||
|
self.assertEqual(result.in_literals, 4)
|
||||||
|
|
||||||
|
def test_lifetimes_do_not_open_a_literal(self):
|
||||||
|
source = "fn f<'a>(x: &'a str) -> &'a s3s::S3Error { let c = '\\''; let d = 's'; s3s::s3_error!(A) }\n"
|
||||||
|
self.assertEqual(
|
||||||
|
self.rewritten(source),
|
||||||
|
"fn f<'a>(x: &'a str) -> &'a rustfs_s3_types::S3Error { let c = '\\''; let d = 's'; rustfs_s3_types::s3_error!(A) }\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_s3s_items_are_untouched(self):
|
||||||
|
source = "use s3s::dto::X;\nuse s3s::{Body, S3Request};\nuse s3s::S3ErrorCodeExt;\nuse s3s::header::S3Error;\n"
|
||||||
|
self.assertEqual(self.rewritten(source), source)
|
||||||
|
|
||||||
|
def test_paths_that_are_not_the_s3s_crate_are_untouched(self):
|
||||||
|
source = "let a = foo::s3s::S3Error::new();\nlet b = my_s3s::S3Error;\nlet c = s3s::S3Errors;\nlet d = xs3s::S3ErrorCode::A;\n"
|
||||||
|
self.assertEqual(self.rewritten(source), source)
|
||||||
|
|
||||||
|
def test_a_use_tree_holding_a_comment_is_reported_not_rewritten(self):
|
||||||
|
source = "use s3s::{\n // why\n S3Error,\n Body,\n};\n"
|
||||||
|
result = rewrite(source)
|
||||||
|
self.assertEqual(result.text, source)
|
||||||
|
self.assertEqual(result.unsupported, [1])
|
||||||
|
self.assertEqual(remaining_lines(result.text), [1])
|
||||||
|
|
||||||
|
def test_remaining_lines_ignores_comments_literals_and_moved_code(self):
|
||||||
|
source = '// s3s::S3Error\nconst A: &str = "s3s::S3Error";\nuse rustfs_s3_types::S3Error;\nuse s3s::dto::X;\n'
|
||||||
|
self.assertEqual(remaining_lines(source), [])
|
||||||
|
|
||||||
|
def test_manifest_keeps_s3s_when_a_feature_mentions_it(self):
|
||||||
|
manifest = '[dependencies]\ns3s = { workspace = true }\n\n[features]\nminio = ["s3s/minio"]\n'
|
||||||
|
edited, notes = edit_manifest(manifest, needs_new=False, may_drop_old=True)
|
||||||
|
self.assertEqual(edited, manifest)
|
||||||
|
self.assertEqual(notes, ["kept s3s: a [features] entry mentions it"])
|
||||||
|
|
||||||
|
def test_manifest_without_a_new_reference_gains_nothing(self):
|
||||||
|
manifest = "[dependencies]\nrustfs-utils.workspace = true\ns3s = { workspace = true }\n"
|
||||||
|
self.assertEqual(edit_manifest(manifest, needs_new=False, may_drop_old=False), (manifest, []))
|
||||||
|
|
||||||
|
def test_manifest_with_the_dependency_already_present_is_unchanged(self):
|
||||||
|
manifest = "[dependencies]\nrustfs-s3-types.workspace = true\n"
|
||||||
|
self.assertEqual(edit_manifest(manifest, needs_new=True, may_drop_old=False), (manifest, []))
|
||||||
|
|
||||||
|
def test_manifest_inserts_in_rustfs_order_and_drops_a_multi_line_s3s(self):
|
||||||
|
manifest = (
|
||||||
|
"[dependencies]\nrustfs-config.workspace = true\nrustfs-utils = { workspace = true }\n"
|
||||||
|
's3s = { workspace = true, features = [\n "minio",\n] }\ntokio.workspace = true\n\n[dev-dependencies]\ns3s.workspace = true\n'
|
||||||
|
)
|
||||||
|
edited, notes = edit_manifest(manifest, needs_new=True, may_drop_old=True)
|
||||||
|
self.assertEqual(
|
||||||
|
edited,
|
||||||
|
"[dependencies]\nrustfs-config.workspace = true\nrustfs-s3-types = { workspace = true }\n"
|
||||||
|
"rustfs-utils = { workspace = true }\ntokio.workspace = true\n\n[dev-dependencies]\ns3s.workspace = true\n",
|
||||||
|
)
|
||||||
|
self.assertEqual(notes, ["removed s3s", "added rustfs-s3-types"])
|
||||||
|
|
||||||
|
# -- the driver against a scratch repository --
|
||||||
|
|
||||||
|
def scratch_repo(self, files: dict[str, str]) -> Path:
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="s3-error-codemod-"))
|
||||||
|
self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
|
||||||
|
for relative, text in files.items():
|
||||||
|
(tmp / relative).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
(tmp / relative).write_text(text, encoding="utf-8")
|
||||||
|
subprocess.run(["git", "init", "-q", str(tmp)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(tmp), "add", "-A"], check=True)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
def base_files(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
ALLOWLIST: "# edge\ncrates/edge/src/edge.rs\n",
|
||||||
|
"crates/a/Cargo.toml": "[package]\nname = \"a\"\n\n[dependencies]\ns3s.workspace = true\n",
|
||||||
|
"crates/a/src/lib.rs": "use s3s::S3Error;\npub fn f() -> S3Error { todo!() }\n",
|
||||||
|
"crates/a/src/held.rs": "use s3s::S3ErrorCode;\n",
|
||||||
|
"crates/a/nested/Cargo.toml": "[package]\nname = \"nested\"\n",
|
||||||
|
"crates/a/nested/src/lib.rs": "use s3s::S3Error;\n",
|
||||||
|
"crates/b/Cargo.toml": "[package]\nname = \"b\"\n\n[dependencies]\ns3s.workspace = true\n",
|
||||||
|
"crates/b/src/lib.rs": "use s3s::{S3Result, dto::X};\n",
|
||||||
|
"crates/edge/Cargo.toml": "[package]\nname = \"edge\"\n\n[dependencies]\ns3s.workspace = true\n",
|
||||||
|
"crates/edge/src/edge.rs": "use s3s::S3Error;\n",
|
||||||
|
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]\n",
|
||||||
|
"docs/README.md": "docs\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_driver_rewrites_counts_and_is_idempotent(self):
|
||||||
|
root = self.scratch_repo(self.base_files())
|
||||||
|
first = _Capture()
|
||||||
|
status = run(root, ["crates/a", "crates/b", "crates/edge"], ["crates/a/src/held.rs"], fmt=False, out=first)
|
||||||
|
self.assertEqual(status, 0, first.text)
|
||||||
|
self.assertIn("crates/a: 1 files changed (1 use statements, 0 qualified paths); manifest added rustfs-s3-types; 1 held", first.text)
|
||||||
|
self.assertIn("crates/b: 1 files changed", first.text)
|
||||||
|
self.assertIn("refused crates/edge/src/edge.rs (1 lines", first.text)
|
||||||
|
self.assertIn("total: 2 files changed in 3 crates", first.text)
|
||||||
|
self.assertEqual((root / "crates/a/src/held.rs").read_text(), "use s3s::S3ErrorCode;\n")
|
||||||
|
self.assertEqual((root / "crates/a/nested/src/lib.rs").read_text(), "use s3s::S3Error;\n")
|
||||||
|
self.assertEqual((root / "crates/edge/src/edge.rs").read_text(), "use s3s::S3Error;\n")
|
||||||
|
self.assertIn("s3s.workspace = true", (root / "crates/a/Cargo.toml").read_text())
|
||||||
|
self.assertIn("s3s.workspace = true", (root / "crates/b/Cargo.toml").read_text())
|
||||||
|
second = _Capture()
|
||||||
|
self.assertEqual(run(root, ["crates/a", "crates/b", "crates/edge"], ["crates/a/src/held.rs"], fmt=False, out=second), 0)
|
||||||
|
self.assertIn("total: 0 files changed in 3 crates", second.text)
|
||||||
|
self.assertNotIn("manifest added", second.text)
|
||||||
|
|
||||||
|
def test_driver_drops_s3s_once_the_crate_is_free_of_it(self):
|
||||||
|
files = self.base_files()
|
||||||
|
files["crates/a/src/held.rs"] = "pub struct H;\n"
|
||||||
|
root = self.scratch_repo(files)
|
||||||
|
out = _Capture()
|
||||||
|
self.assertEqual(run(root, ["crates/a"], [], fmt=False, out=out), 0, out.text)
|
||||||
|
self.assertIn("manifest removed s3s, added rustfs-s3-types", out.text)
|
||||||
|
self.assertNotIn("s3s", (root / "crates/a/Cargo.toml").read_text())
|
||||||
|
|
||||||
|
def test_driver_leaves_the_manifest_of_a_crate_it_did_not_rewrite(self):
|
||||||
|
files = self.base_files()
|
||||||
|
manifest = "[package]\nname = \"c\"\n\n[dependencies]\ns3s.workspace = true\n\n[dev-dependencies]\nrustfs-s3-types.workspace = true\n"
|
||||||
|
files["crates/c/Cargo.toml"] = manifest
|
||||||
|
files["crates/c/src/lib.rs"] = "#[cfg(test)]\nmod tests {\n use rustfs_s3_types::EventName;\n}\n"
|
||||||
|
root = self.scratch_repo(files)
|
||||||
|
out = _Capture()
|
||||||
|
self.assertEqual(run(root, ["crates/c"], [], fmt=False, out=out), 0, out.text)
|
||||||
|
self.assertIn("crates/c: 0 files changed (0 use statements, 0 qualified paths); manifest unchanged", out.text)
|
||||||
|
self.assertEqual((root / "crates/c/Cargo.toml").read_text(), manifest)
|
||||||
|
|
||||||
|
def test_driver_rewrites_only_below_a_subdirectory_and_reads_the_whole_package(self):
|
||||||
|
files = self.base_files()
|
||||||
|
files["crates/a/src/admin/x.rs"] = "use s3s::S3Result;\n"
|
||||||
|
files["crates/a/src/held.rs"] = "pub struct H;\n"
|
||||||
|
root = self.scratch_repo(files)
|
||||||
|
out = _Capture()
|
||||||
|
self.assertEqual(run(root, ["crates/a/src/admin"], [], fmt=False, out=out), 0, out.text)
|
||||||
|
self.assertIn("crates/a/src/admin: 1 files changed", out.text)
|
||||||
|
self.assertIn("manifest added rustfs-s3-types;", out.text)
|
||||||
|
self.assertEqual((root / "crates/a/src/admin/x.rs").read_text(), "use rustfs_s3_types::S3Result;\n")
|
||||||
|
self.assertEqual((root / "crates/a/src/lib.rs").read_text(), files["crates/a/src/lib.rs"])
|
||||||
|
self.assertIn("s3s.workspace = true", (root / "crates/a/Cargo.toml").read_text())
|
||||||
|
|
||||||
|
def test_driver_exits_1_on_a_reference_it_cannot_rewrite(self):
|
||||||
|
files = self.base_files()
|
||||||
|
files["crates/b/src/lib.rs"] = "use s3s::{\n // keep\n S3Result,\n};\n"
|
||||||
|
root = self.scratch_repo(files)
|
||||||
|
out = _Capture()
|
||||||
|
self.assertEqual(run(root, ["crates/b"], [], fmt=False, out=out), 1)
|
||||||
|
self.assertIn("REMAINS crates/b/src/lib.rs:1", out.text)
|
||||||
|
|
||||||
|
def test_driver_refuses_a_missing_allowlist(self):
|
||||||
|
files = self.base_files()
|
||||||
|
del files[ALLOWLIST]
|
||||||
|
root = self.scratch_repo(files)
|
||||||
|
with self.assertRaisesRegex(UsageError, "allowlist"):
|
||||||
|
run(root, ["crates/a"], [], fmt=False, out=_Capture())
|
||||||
|
self.assertEqual((root / "crates/a/src/lib.rs").read_text(), self.base_files()["crates/a/src/lib.rs"])
|
||||||
|
|
||||||
|
def test_driver_rejects_a_bad_crate_or_hold(self):
|
||||||
|
root = self.scratch_repo(self.base_files())
|
||||||
|
for crates, holds, message in (
|
||||||
|
(["crates/missing"], [], "not a directory inside a package"),
|
||||||
|
(["docs"], [], "not a directory inside a package"),
|
||||||
|
(["../crates/a"], [], "repository-relative"),
|
||||||
|
(["crates/a"], ["crates/b/src/lib.rs"], "not a tracked Rust file"),
|
||||||
|
):
|
||||||
|
with self.subTest(crates=crates, holds=holds), self.assertRaisesRegex(UsageError, message):
|
||||||
|
run(root, crates, holds, fmt=False, out=_Capture())
|
||||||
|
self.assertEqual((root / "crates/a/src/lib.rs").read_text(), self.base_files()["crates/a/src/lib.rs"])
|
||||||
|
|
||||||
|
|
||||||
|
class _Capture:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.text = ""
|
||||||
|
|
||||||
|
def write(self, chunk: str) -> int:
|
||||||
|
self.text += chunk
|
||||||
|
return len(chunk)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Reference in New Issue
Block a user