I ported `python-semanticversion` to Rust in 72 hours

I ported python-semanticversion to Rust in 72 hours

我在 72 小时内将 python-semanticversion 移植到了 Rust

For Port Mortem 2026 (a 72-hour “resurrect dead code” hackathon, Track D: Python → Rust), I rewrote python-semanticversion — SemVer 2.0 parsing/comparison plus npm-style SimpleSpec / NpmSpec / LegacySpec range matching — as a from-scratch Rust port. Solo. 为了参加 Port Mortem 2026(一个 72 小时的“复活死代码”黑客松,赛道 D:Python → Rust),我从零开始用 Rust 重写了 python-semanticversion,涵盖了 SemVer 2.0 的解析/比较,以及 npm 风格的 SimpleSpec / NpmSpec / LegacySpec 范围匹配。全程由我一人完成。

The result: the original, unmodified pytest suite passes against the Rust build — 54 passed, 16 skipped, 586 subtests, zero test edits — with zero unsafe, 0 fuzz divergences over 24,500 differential pairs, and 0 panics over 2.5M crash-fuzz runs. This is the story of how, and the mistakes that nearly sank it. 结果是:原始且未经修改的 pytest 测试套件在 Rust 构建版本上全部通过——54 个通过,16 个跳过,586 个子测试,无需修改任何测试代码。整个过程零 unsafe 代码,在 24,500 组差异化测试中零偏差,在 250 万次崩溃模糊测试中零 panic。这就是整个过程的故事,以及那些差点让项目失败的错误。

The one constraint that shaped everything

塑造一切的唯一约束

The rules said the original test suite must pass unmodified, hashed at kickoff. That’s a brutal constraint, and it’s the best thing about the event — you can’t fudge your way to green. It forced the central design decision: I built a PyO3/maturin extension named semantic_version. In the test venv, import semantic_version resolves to my Rust code, so the original tests run byte-for-byte as written against the port. No shims, no test edits, no “adapted” suite. 规则要求原始测试套件必须在未经修改的情况下通过,并在开始时进行哈希校验。这是一个残酷的约束,但也是该活动最棒的地方——你无法通过作弊来让测试变绿。这迫使我做出了核心设计决策:我构建了一个名为 semantic_version 的 PyO3/maturin 扩展。在测试虚拟环境中,import semantic_version 会解析到我的 Rust 代码,因此原始测试是逐字节地针对该移植版本运行的。没有垫片,没有测试修改,也没有“适配”套件。

make does the whole thing in one command: make 用一条命令完成所有工作:

$ make VIRTUAL_ENV=... maturin develop pytest tests/original/ -q
54 passed, 16 skipped, 586 subtests passed in 0.37s

(The 16 skips are the Django tests, which skip identically in the original baseline — “Django not installed”. Parity, not exclusion.) (那 16 个跳过的测试是 Django 测试,在原始基准测试中它们也会以同样的方式跳过——提示“未安装 Django”。这是对等性,而非排除。)

Method: ground truth first, port second

方法:先确立事实,后进行移植

The fastest way to fail a port is to port your assumptions. Python’s semantic_version is full of deliberate quirks, so before writing a line of Rust I probed the original and captured its exact behavior — AST shapes, match results, error strings — and treated that as the spec. 移植失败最快的方法就是移植你的假设。Python 的 semantic_version 充满了刻意的怪癖,所以在写一行 Rust 代码之前,我先探测了原始版本并捕获了它的确切行为——AST 形状、匹配结果、错误字符串——并将这些视为规范。

A few things the probes revealed that I would have gotten wrong: 探测揭示了一些我原本会弄错的事情:

  • __eq__ includes build metadata; ordering does not. Version("1.0.0+a") == Version("1.0.0+b") is False, but neither is < nor > the other. That violates Rust’s Ord contract (a == b ⟺ cmp(a,b) == Equal), so I removed Ord from Version and exposed explicit precedence_lt/le/gt/ge helpers instead.
  • __eq__ 包含构建元数据,但排序不包含。Version("1.0.0+a") == Version("1.0.0+b") 为 False,但两者互不小于也不大于对方。这违反了 Rust 的 Ord 契约(a == b ⟺ cmp(a,b) == Equal),所以我从 Version 中移除了 Ord,转而显式暴露了 precedence_lt/le/gt/ge 辅助函数。
  • __ne__ compares raw tuples, not !eq — a Python-2-era relic where a partial and non-partial version can be eq yet ne.
  • __ne__ 比较的是原始元组,而不是 !eq——这是 Python 2 时代的遗物,导致部分版本和非部分版本可能相等但又不相等。
  • Error messages are single-quoted (Invalid version string: 'garbage'), matching Python’s %r.
  • 错误消息使用单引号(Invalid version string: 'garbage'),与 Python 的 %r 匹配。
  • Spec is just LegacySpec. One class, two names. The binding exposes one pyclass + an alias.
  • Spec 就是 LegacySpec。一个类,两个名字。绑定暴露了一个 pyclass 和一个别名。

The hardest 40 lines: npm’s prerelease OR-expansion

最难的 40 行代码:npm 的预发布 OR 展开

npm ranges with a prerelease bound don’t expand to a simple interval. >=1.0.0-rc.1 <2.0.0 becomes a two-branch AnyOf: 带有预发布边界的 npm 范围不会展开为简单的区间。>=1.0.0-rc.1 <2.0.0 会变成一个双分支的 AnyOf

AnyOf(
    AllOf(<1.0.1 [always], >=1.0.0-rc.1 [same-patch]), # prerelease branch
    AllOf(>=1.0.0 [same-patch], <2.0.0 [same-patch])    # release branch
)

My first pass flattened this and silently diverged on ||-joined specs with a single-block group. The differential fuzzer is what caught it — more on that below. 我的第一版代码将其扁平化了,导致在处理带有单块组的 || 连接规范时出现了静默偏差。是差异化模糊测试发现了这个问题——下文会详细说明。

The proof layer

证明层

Passing the suite is necessary but not sufficient; the suite only covers what the original authors thought to test. So I built a proof layer: 通过测试套件是必要条件,但不是充分条件;测试套件只覆盖了原作者想到的测试场景。所以我构建了一个证明层:

  • Differential fuzz: 49 seeds × 500 pairs = 24,500 random (version, spec) inputs run through the original Python and the Rust binding, compared on parse/match/compare/str/repr/hash. 0 hard divergences. (1,619 soft diffs are error-wording only, both sides raising ValueError — documented, not hidden.)
  • 差异化模糊测试: 49 个种子 × 500 对 = 24,500 个随机 (版本, 规范) 输入,分别在原始 Python 和 Rust 绑定中运行,并在解析/匹配/比较/str/repr/hash 上进行对比。零硬偏差。(1,619 个软差异仅在于错误措辞,双方都抛出了 ValueError——这是已记录的,并非隐藏的。)
  • Crash fuzz: 2,554,822 libFuzzer runs over arbitrary bytes → 0 panics.
  • 崩溃模糊测试: 2,554,822 次 libFuzzer 针对任意字节的运行 → 0 panic。
  • Zero unsafe. The whole port is safe Rust; grep -rn unsafe src/ is empty.
  • 零 unsafe。 整个移植版本都是安全的 Rust 代码;grep -rn unsafe src/ 的结果为空。
  • A 20-entry decision log (DECISIONS.md, D00–D19), every non-trivial divergence with Python behavior → Rust choice → rationale → tradeoff → test impact.
  • 一个包含 20 条记录的决策日志(DECISIONS.md, D00–D19),记录了每一个与 Python 行为不平凡的偏差 → Rust 选择 → 原理 → 权衡 → 测试影响。

What the fuzzer caught (honesty section)

模糊测试发现了什么(诚实部分)

The differential fuzzer found 8 latent bugs — all in my port, none in the original. Including 18 u64 overflow-panic sites (Python has bignums; Rust doesn’t) that I hardened with saturating_add, an empty-prerelease acceptance, ~*/^* wildcard gates, and the || empty-group case above. 差异化模糊测试发现了 8 个潜在 Bug——全部在我的移植版本中,原始版本中没有。包括 18 个 u64 溢出 panic 点(Python 有大整数,Rust 没有),我用 saturating_add 进行了加固,还修复了空预发布接受、~*/^* 通配符门控以及上述的 || 空组情况。

I’m not claiming a “bug catcher” bonus: the original library was correct, and my job was to converge to it. But the fuzzer turning my own blind spots into a fix-list is exactly why differential testing is the only oracle that matters. 我并不是在吹嘘“Bug 捕手”的功劳:原始库是正确的,我的工作是向它靠拢。但模糊测试将我自己的盲点变成了修复列表,这正是为什么差异化测试是唯一重要的预言机。

Benchmarks, with the boring parts included

基准测试(包含枯燥的部分)

On a hackathon cloud VM (16GB RAM 2 physical / 4 logical cores, not bare metal): 在黑客松云虚拟机上(16GB 内存,2 物理核 / 4 逻辑核,非裸机):

  • ~9× aggregate speedup, 60× on npm spec matching, ~11× on parsing
  • 综合速度提升约 9 倍,npm 规范匹配提升 60 倍,解析提升约 11 倍
  • 21% lower peak RSS (12.5 MB vs 15.9 MB)
  • 峰值 RSS 降低 21%(12.5 MB 对比 15.9 MB)

And the honest caveat: the PyO3 precedence_key path drags one aggregate number down (Python tuple overhead); native precedence runs at ~386 ns p50. Throughput-only benchmarks are marketing. Distributions + confounders are engineering. 诚实的警告:PyO3 的 precedence_key 路径拖累了一个综合指标(Python 元组开销);原生优先级运行速度约为 386 ns p50。仅看吞吐量的基准测试是营销,分布 + 混杂因素才是工程。

What I’d tell myself at hour 0

如果回到第 0 小时,我会对自己说什么

  • Probe before you port. The original is the spec; your memory of it is not.
  • 移植前先探测。 原始版本就是规范;你的记忆不是。
  • Let a fuzzer argue with you. It will find the cases your tests never imagined.
  • 让模糊测试与你争论。 它会发现你测试中从未想到的情况。
  • Honesty is a feature. Judges trust “94% and here’s why” over “100%” that won’t reproduce.
  • 诚实是一种特性。 评委更信任“94% 且原因如下”,而不是无法复现的“100%”。
  • An AI agent is a force multiplier only if a human gates every commit. Multi-model, single-writer, review-everything.
  • AI 代理只有在人类把控每一次提交时才是力量倍增器。 多模型、单作者、全审查。

Try it

尝试一下

git clone https://github.com/rahulgupta0-dev/semanticversion-rs
cd semanticversion-rs && make # builds + runs the ORIGINAL suite against Rust

3,683 lines of safe Rust, 20 decisions, one command to believe it. Whether or not it places, it’s the most rigorously verified thing I’ve ever shipped in 72 hours. 3,683 行安全的 Rust 代码,20 个决策,一条命令即可验证。无论是否获奖,这都是我在 72 小时内交付的最严谨验证的项目。