Porting an MCP Server from Go to Rust Using LLMs

published: ⏰ 23

A while ago there was a post about agentic development for a project. This is a follow-up, but in a slightly different, more practical direction. The task was an MCP service / KAG / GraphRAG in Go. After completion, it “unexpectedly” turned out that the project contained a fatal flaw in the shape of the letter G. So a deliberate decision was made to migrate to Rust. This venture required changing the tech stack, the libraries, the algorithms, implementing an LSM-like mechanism for the vector database, and so on. In other words, it was not a mechanical port but a migration with substantial rework.

An additional goal was to use only models available locally at the moment, with no paid subscriptions — in other words, the equivalent of working in an air-gapped environment, without specialized PII-filtering proxies and the like. There was one caveat: for the orchestrator, DeepSeek V4 Flash (Free) and Ox Alpha (Free) were used for a while, while they were available free of charge for a couple of weeks. Both can, if desired, be self-hosted locally given suitable hardware.

Introduction

The MCP server (RAG + knowledge-graph) in Go started out mostly as an experiment in building through LLMs, which then accumulated functionality. Go’s additional advantages were fast development, cross-compilation out of the box, LLMs’ solid understanding of the language, and compact code (the language syntax is simple). During the work, certain issues surfaced that later led to the idea of rewriting everything in Rust — again using LLMs. And, along the way, to check whether it is even possible.

Examples of the problems:

  • Slow vec0 (brute-force) speed on large vector arrays on a laptop.
  • CGO, which sometimes causes cross-compilation problems.
  • Many third-party libraries that are either abandoned and unmaintained or incomplete, which forces you to assemble some subsystems from different components. Rust turned out to be more convenient for systems programming. For example, Rust has ANN engines like usearch with all the needed functionality (HNSW, on-disk storage).
  • GC. This is more of a theoretical limitation, since on a laptop with small data volumes it is not very critical. Dropping it is, so to speak, an investment in the future, to squeeze maximum performance out of limited resources.

In practice, porting an MCP server with a management CLI means porting the observable behavior: 12 MCP tools, CLI commands, and several subsystems for file indexing and hybrid search. The old version has a lot of code, but there is no behavior specification, since the plans were deleted once the work was finished. This post covers the full process of such a migration.

General Porting Rules

If you don’t feel like reading further, here is the short version:

  • Port behavior, not code. The ported version is a functional copy, not an architectural one.
  • The parity harness module (Rust crate crates/parity-harness) is built before the first line of Rust code and used as the tool for verifying the parity of the ported functionality. The module is temporary and is removed once the port is complete.
  • The plan lives in openspec, as short verifiable tasks (~500 lines of diff), from the core to the periphery.
  • The stack is frozen before the port begins (frozen stack in openspec/config.yaml), especially with LLM agents in the loop.
  • Always verify the agents, because deviations happen even with explicit prohibitions in the prompt.
  • Changes outside the accepted process are forbidden, including manual hotfixes. Making changes outside the accepted process is highly undesirable: there is a high probability of divergence from the plan and of changes appearing that are unexpected for the LLM.
  • Don’t leave tech debt behind, not even in the form of small tasks. Better to spend extra time on proper work now. Otherwise it will “poison” subsequent work by injecting false premises into the LLM’s decisions.

Functional Parity

Let’s start with the principle that defines everything else: the ported version is not a copy in terms of code. It is a functional copy, designed for the new stack. Parity is measured from the outside: what goes in (MCP call parameters, config, dataset files) and what comes out (tool JSON responses, CLI output, exit codes, timings within the expected percentiles).

Why does this matter? Go and Rust have different idioms, and a “line-for-line translation” gives you the worst of both worlds — Go architecture in Rust syntax. Typical artifacts of a literal port (general-case examples):

  • interfaces introduced in Go for the sake of mocks become dyn Trait where generics or an enum would have sufficed;
  • string errors fmt.Errorf("...") instead of typed thiserror errors.

For example, Go:

Go
type Converter struct {
    out io.Writer
}

func (c *Converter) Emit(s string) {
    if c.out == nil {          // nil is a legal state
        c.out = os.Stdout
    }
    fmt.Fprintln(c.out, s) // write errors are silently swallowed
}

Idiomatic Rust:

Rust
pub struct Converter<W: Write> {
    out: W,                     // there is no nil state
}

impl<W: Write> Converter<W> {
    pub fn emit(&mut self, s: &str) -> io::Result<()> {
        writeln!(self.out, "{s}") // the error is returned to the caller
    }
}

The signatures and semantics differ, but the behavior (“print a string with a trailing newline”) is the same.

The examples above are for illustration only; they are not the project’s real code. In a real port, what gets transferred is:

  • domain algorithms (indexing, search, ranking, NER);
  • input/output file formats (YAML configs, vector binaries);
  • test data and fixtures;
  • error message texts (partially, with adaptation).

Tests are ported by meaning, adapted to the language’s specifics. Hence a rule for LLM agents that is worth writing into AGENTS.md:

Do not copy the source project’s structure. Design the module from scratch and verify behavior against parity.

Without an explicit prohibition, the agent will transliterate Go by default — that is the shortest path to the “right answer” from its point of view.

Migration Process

The overall sequence of steps is as follows:

The key feature: the reference is frozen for the entire duration of the migration. Only the new version changes.

The Parity Verification Module

The parity verification module (parity harness) is an automated mechanism for comparing two binaries as black boxes. Without it, a migration turns into “rewrite it, run it manually, seems to work” — and discrepancies are discovered by users, or not discovered at all. Obviously, the more complex the project, the harder it is to verify parity by hand.

Implementation in the project: build a Rust crate crates/parity-harness (various comparison tools, an rmcp MCP-client wrapper, and so on). The crate is temporary and will be deleted after completion; nothing depends on it, and none of its code is used in the rest of the application.

Fixtures are recorded ONCE from the project being ported. These are configs, a database dump with data, vectors (to check the convergence of semantic search), sample JSON responses for MCP, and so on.

The data in my case was NOT migrated: Rust builds the database from scratch. If you need a data migration, you also have to write data transfer scripts.

The most important thing is continuous convergence with the original project. While this module runs with errors, it is useless: you cannot tell a regression from noise. The minimal set of measures:

  • TZ=UTC, LC_ALL=C, NO_COLOR=1, TERM=dumb — if console output matters;
  • isolated HOME and TMPDIR for every run (mktemp -d);
  • fixed seeds for the LLM;
  • time — via an application flag or output normalization;
  • network — fake or fully disabled;
  • non-deterministic output fragments (ranking scores in search results, internal ordering, timestamps) — normalize them.

The parity module workflow:

Migration Plan

Formalized specifications (openspec) work better here than a set of plans and task lists in an unstructured or loosely structured form, for one reason: a spec is a machine-readable source of requirements, shared by humans and agents. For an LLM this is critical, because the code and the task description must not drift apart even a single step.

Properties of a “right” task:

  • Short — up to ~500 lines of changes, closed within a single agent session.
  • Verifiable — acceptance criteria (Definition of Done) reference specific parity rules and the ported tests.
  • With explicit scope — the list of files and functionality that may be changed.
  • With explicit prohibitions — the frozen stack, access to neighboring modules for changes, and so on.
  • With dependencies — which tasks it depends on, and which ones it blocks.

Example (adapted from a real openspec):

Markdown
# change: add-usearch-ann-engine

## Task 1.2 — UsearchEngine behind the VectorIndex trait (bf16, L2sq, disk-backed)

- Scope: `crates/vectors/src/usearch_engine.rs`, `crates/vectors/Cargo.toml` (feature `engine-usearch`)
- Dependencies: 1.1 (spike: cxx FFI builds on 5 CI targets)
- Forbidden: `crates/parity-harness/`, foreign crates

## Acceptance criteria (machine-checked)

- [ ] `cargo test -p vectors --features engine-usearch` is green: create → add 100 vectors dim=1024 → search top-10 → save → restore → same keys
- [ ] harness: recall@10 ≥ 0.95; p50 (search) ≤ 2× and p95 (search) ≤ 5× the Go baseline
- [ ] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` clean
- [ ] Oracle refs: N/A (the Go original's ANN = vec0, there is no usearch equivalent; test template — `crates/vectors/src/engine.rs`, LanceEngine)

Why do short tasks catch old-version bugs? Porting a subsystem in a small slice forces you to understand its contract precisely. All the spots that are “inconvenient to port” (weird error handling, implicit defaults, initialization order) are most often exactly the bugs or the undocumented behavior. In a large port they drown in the overall mass of changes; in a small one they surface and demand a decision right now.

Deviations are recorded in the openspec change (proposal/design, “Deviations” section), in ADRs, and in the (later deleted) port-verification-report.md. At the E2E stage, every unexplained change is cross-checked against these records.

Acceptance criteria are mostly generated by LLMs for LLMs based on the human’s requirements; reading them yourself is hard. If you do want to adjust them manually, you have to account for the full context of the change to avoid conflicts. That’s why it is better to ask the LLM to do it.

The rough effort distribution:

StageShare
Parity harness~5%
Stage 1: infrastructure~10%
Stage 2: porting subsystems45–55%
Stage 3: E2E + coverage~15%
Stage 4: documentation5–10%
Stage 5: cleanup~5%

Stage 1 — Infrastructure, ADRs, and R&D

Stage goal: lock in the tech stack and infrastructure before porting the logic. Everything that influences dependency choices is decided here — afterwards the stack is frozen.

The project’s real ADRs:

  • ADR 0001: SQLite + FTS5 (rusqlite bundled, no CGO)
  • ADR 0002: ONNX Runtime (ort, load-dynamic, external .so)
  • ADR 0003: ANN Engine (usearch vs lance)
  • ADR 0004: usearch LSM segments
  • ADR 0005: Event task queue: one typed event queue for all background work

Three R&D spikes:

  • SQLite+FTS5 (rusqlite)
  • ONNX (ort — the successor of the inactive onnxruntime-rs)
  • ANN (usearch vs lance)

The results are saved as ADRs. The MCP transport (rmcp) required no separate analysis.

The frozen stack is stored in openspec/config.yaml, which explicitly lists all dependencies and versions. For example:

  • Rust + tokio (async) + axum (HTTP for /health);
  • rusqlite 0.40 (bundled FTS5, no CGO);
  • ort (ONNX, external .so, load-dynamic);
  • usearch 2.x (ANN, C++11 HNSW via cxx, disk-backed, scalar-quantized);
  • rmcp 3.x (MCP);
  • cel 0.14; tokenizers;
  • clap 4.6.6 — Builder API;
  • tracing (only in cli);
  • noyalib (YAML, the successor of serde_yaml);
  • thiserror (errors in libraries) + anyhow (CLI boundary only).

You can additionally build a map of equivalents, which can be useful later during the port and for comparing the available functionality when implementing tasks.

TaskGoRust
ANNsqlite-vec (vec0, CGO)usearch 2.x
ONNXonnxruntime_go (CGO)ort (external .so, load-dynamic)
SQLitemattn/go-sqlite3 (CGO)rusqlite (bundled, no CGO)
MCPmcp-go v0.57.0rmcp 3.x
YAMLgopkg.in/yaml.v3noyalib
Loggingzaptracing (cli)
JSONencoding/jsonserde/serde_json
Errorsfmt.Errorfthiserror (libs) + anyhow (CLI)
CELcel-gocel 0.14
Tokenizersugarme/tokenizertokenizers
CrossGOOS/GOARCHcargo-zigbuild

CI/CD appears at this stage, before the first domain code: fmt, clippy -D warnings, tests. It matters for verifying that the project works at every step. While CI is broken, we do not move on.

A fragment of the minimal task set:

YAML
# .github/workflows/ci.yml
checks:
  - run: cargo fmt --check
  - run: cargo clippy --all-targets -- -D warnings
  - run: cargo test

Stage 2 — Subsystems

At this stage the work moves from the core building blocks that other modules depend on toward the peripheral subsystems (interfaces). In this project the split was:

  • Base layer: config, db, vectors, utils, llm
  • Middle layer: embedding, ingestion, graph
  • Interfaces: search → mcp → cli

The order is determined by the dependency graph. A simple way to obtain it is, for example, to lay out the package import graph (for Go that’s goda):

  • the core is the nodes with the most dependencies pointing at them;
  • the interfaces are the nodes with the fewest dependencies pointing at them;

The port proceeds in reverse topological order — first the nodes that the rest depend on. Why not the other way around? If you start with the CLI and the output, you have to write stubs for all the domain behavior — and every stub that mimics working functionality becomes a false premise for the following tasks (more on that in the section about LLMs). Porting from the core out, every subsystem immediately gets its real dependencies and real tests.

Stubs still appear — for cross-cutting things (logging, configuration). The rules:

  • A stub must fail loudly: todo!("S-042"), never return a plausible value.
  • Every stub is registered and implemented later.
  • A stub on domain logic is a signal that the traversal order is broken.

A full check after every task is highly desirable: run the related functionality in the parity harness. Cases may fail “for expected reasons” (dependencies not ported yet) — that’s normal. New errors are not.

In theory, independent DAG branches can be handed to different agents. The condition: the branches must not overlap in files, and every branch merges through the shared CI.

And the last important point — bugs or deviations from the original are never introduced quietly. There are two paths: copy the behavior as a “legacy bug”/“deviation” if external scripts might rely on it, or fix it and record it in the change/ADR. A fix with no record in the history is an unexplained or lost change when the E2E stage checks it. For example, the graph entity-linking mechanism was changed during the implementation. The task was completed, but the requirement to wire the new mechanism into the existing functionality was missed, because the old one had been removed together with its code (this dependency could no longer be inferred from anywhere). In the end it existed, was covered by unit tests, and simply was never used.

Stage 3 — E2E and Verification via Comparison

A full run of all cases. The project’s real parity metrics:

MetricToleranceComment
recall@10≥ 0.95ANN vs the original’s brute-force (same embedding model)
p50/p95 latencyp50 ≤ 2×, p95 ≤ 5×the search tool vs Go (p50≈107 ms, p95≈109 ms)
JSON response samplesnormalizednumber of results, top result, counters, normalization of ranking/order/timestamps of the responses
CLI –helpstructuralfunctional equivalence, not byte-for-byte

Every deviation gets one of four causes: a new-version bug / a deliberate deviation (recorded in the change/ADR) / an old-version bug / normalizer noise. The exit criterion for the stage is zero unexplained deviations.

After that come the non-functional checks: cold start, RSS, binary size. The thresholds you must hit should be set back in stage 1: if binary size was one of the arguments for the migration, the final number must confirm it.

Stage 4 — Documentation

At this stage the final project documentation is written (including --help for the CLI). Why now? Because on the early stages there can be — and will be — functional changes that would inevitably have to be reflected in the documentation, which significantly increases the porting time. That is literally pointless work.

Given that a project can have extensive documentation (website, user docs, developer docs), this can be done in several passes.

Stage 5 — Cleanup and Finalization

This is the final stage, but with caveats (see below).

The readiness criteria are checked. In this project:

  • Parity with the original is verified: recall@10 ≥ 0.95, p50 ≤ 2×, p95 ≤ 5× of Go (search), clean JSON (with normalization).
  • Zero stubs, TODOs, and “temporary” solutions.
  • The specs are synchronized with the implementation.
  • All parity checks pass.
  • clippy, fmt, tests are clean.
  • The documentation has been ported.
  • --help is structurally at parity (Rust-idiomatic texts).
  • All intermediate plans and tasks are completed and archived (openspec).

Project cleanup:

  • Search for stub and temporary-solution markers — must return zero results.
  • Removing references to the old project: names, paths, legacy env-variable prefixes (whatever is kept deliberately is recorded in the change/ADR).
  • Removing the parity harness module.
  • Removing all mentions of the previous project and the reasons for the migration. This information was useful to the LLM for understanding parts of the code and knowing what to compare against; now it is no longer needed and will only get in the way.
  • A final full run on clean environments, including a build from scratch.
  • Tag and release.

Temporary code lives exactly until this stage. Not a day longer.

Now, about the caveats. At this stage, errors or gaps in the implementation will inevitably surface. For example, on the final run. You are lucky if they are things like the one described above — the forgotten entity-linking wiring. Some may require heavy refactoring. The fix: extend and strengthen the verification of the intermediate stages. I checked only briefly, so I missed at least two issues: non-working entity linking (fixed by simply wiring it up) and incomplete task-queue functionality (fixed by refactoring).

Code Size: Before → After (scc)

Let me add the project sizes by scc for comparison. The migration included not only the server but also the website (ported, the content rewritten for Rust).

Language (scc, code)Go — beforeRust — afterWhat
Go (server)49,654—original
Rust (server)—66,017the port
TypeScript (site)1,5661,556site engine and components
MDX (site)3,7174,156content, +8 pages
CSS (site)2,1352,135theme, unchanged
Markdown (docs)7,98918,461ADR, openspec, COVERAGE
Other (JSON/YAML/SQL/XML/TOML/…)2,1232,524configs, migrations
Total (code)67,18494,849+41%
Complexity (scc)¹12,5133,080see the caveat

¹ Complexity in scc is a heuristic that depends on the language (a different set of heuristics for Go and Rust), so the Go ↔ Rust ratio in this row is conditional.

  • Server rewritten Go → Rust: 49,654 → 66,017 (+33%) — redesign, explicit types and error handling, doc comments.
  • Website ported and rewritten: the site engine and components unchanged (TS/CSS roughly equal), MDX content +12% (new Rust-related pages).
  • Internal project documentation (Markdown) ×2.3: process artifacts (openspec, ADR, COVERAGE).
  • Project total: 67,184 → 94,849 (+41%).

The real project’s timeline: 3 weeks, 600 commits, 52 archived changes in openspec.

Raw scc output

Go (synopsis)

Text
───────────────────────────────────────────────────────────────────────────────
Language            Files       Lines    Blanks  Comments       Code Complexity
───────────────────────────────────────────────────────────────────────────────
Go                    225      61,086     8,201     3,231     49,654     12,415
TypeScript             56       1,801       164        71      1,566         64
Markdown               42      10,239     2,250         0      7,989          0
MDX                    40       4,862     1,145         0      3,717          0
JSON                   20         663         0         0        663          0
CSS                    19       2,807       399       273      2,135          0
YAML                    7         575        53       110        412          0
SQL                     5         163        21        17        125          3
XML                     5         511        51        80        380          0
Go Template             4         120        19         0        101         14
SVG                     3          34         5         0         29          2
Shell                   2         168        25        30        113         12
Dockerfile              1          43        13        12         18          0
License                 1         202        33         0        169          0
Makefile                1         117        24        16         77          3
Plain Text              1          36         0         0         36          0
───────────────────────────────────────────────────────────────────────────────
Total                 432      83,427    12,403     3,840     67,184     12,513
───────────────────────────────────────────────────────────────────────────────

Rust (synopsis-rs)

Text
───────────────────────────────────────────────────────────────────────────────
Language            Files       Lines    Blanks  Comments       Code Complexity
───────────────────────────────────────────────────────────────────────────────
Markdown              243      23,226     4,765         0     18,461          0
Rust                  172      90,535     7,569    16,949     66,017      3,011
TypeScript             55       1,787       160        71      1,556         66
YAML                   49       1,294       106       271        917          0
MDX                    46       5,568     1,412         0      4,156          0
CSS                    19       2,807       399       273      2,135          0
TOML                   13         699        51       351        297          0
Go Template             8         274        26         0        248          0
JSON                    7         217         0         0        217          0
XML                     6         686        59        94       533          0
SQL                     2         268        19       113        136          1
SVG                     2           6         0         0          6          2
C Header                1          10         0         9          1          0
License                 1         202        33         0        169          0
───────────────────────────────────────────────────────────────────────────────
Total                 624     127,579    14,599    18,131     94,849      3,080
───────────────────────────────────────────────────────────────────────────────

Using LLMs

A migration is an almost ideal task for LLM agents: there is a reference implementation, an objective criterion (the parity harness), and a simple, clear decomposition into tasks. But the specifics of exactly this class of tasks are a huge volume of mechanical work combined with a high price of hidden errors.

I won’t go into human-in-the-loop and the choice of specialized agents — those are the topics of previous posts (one, two).

ℹ️ Warning

Using an LLM with a context below 140k is practically useless: in most cases you will hit context compression. At least 262k or more is recommended. Especially with “talkative” models like qwen3.8

The project’s real agents:

RoleAgentModelResponsibility
Orchestratororchestratorqwen3.8-27b / DeepSeek V4 Flash (Free) / Ox Alpha (Free)Coordinates, never touches the code, routes to subagents, requires explicit human approval
Analyststask-evaluator + problem-analystqwen3.6-35b / DeepSeek V4 Flash (Free)Request/complexity estimation, in-depth analysis
Developer (backend)rust-implementerqwen3.8-27bOne task per call, runs gates/tests
Tech lead / Architectrust-reviewerqwen3.6-35b / DeepSeek V4 Flash (Free)4-stage review (task compliance, code quality, security, performance)
Tech writerdocs-writerqwen3.8-27bDocumentation
Developer (frontend)site-implementerqwen3.8-27bWebsite

Always Verify the Agents

The main thing to accept: agents deviate from the rules even with explicit prohibitions in the prompt. A prohibition in the prompt is no guarantee that it will be followed. Typical deviations and countermeasures:

DeviationCountermeasure
Added a crate “to solve the task faster”Frozen tech stack in config.yaml + human confirmation in the orchestrator
Bumped a dependency to a major versionCargo.lock committed, cargo update forbidden
Rewrote a failing test instead of fixing the codeThe reviewer checks the tests first
Refactored a neighboring module “for consistency”Scope limits in the task, diff size limit
“Improved” error message textsJSON-diff with normalization catches this automatically
Worked around a blocker with a clever hackRule: a blocker = return the task, not a quick fix

Control is built in layers: CI catches mechanical violations, the reviewer agent — semantic deviations from the task statement, the human — selectively, but always at the planning stage.

A separate note on traceability. In every PR description, the agent must point to the source of the behavior, for example a link to the contract spec or a fixture number. This turns the review from “check everything” into “check compliance with the source” — a fast, verifiable operation.

The Frozen Stack

A frozen stack description is mandatory; it lets you block changes made at the LLM’s own discretion. LLM agents are prone to pulling in extra dependencies or using old ones as they see fit. This constraint forces them to work within explicit bounds and look for solutions inside them. Stack expansion is sanctioned explicitly, by a human. The mechanics are described in stage 1 (config.yaml). What matters here is that it exists specifically for the agents, not for people.

Changes Only Through openspec

This prohibition also covers manual edits. openspec here is given as an example; it could be a different framework or your own documentation process. Why? The agent builds its task context from the spec and the code. If the code is not what the spec says, a conflict of sources arises. A typical scenario: a developer hotfixes a bug by hand, bypassing the process. In the next task the agent sees code that contradicts the spec and either reverts it or builds new logic on the old premises. Fixing this is expensive, because the divergence has already spread across several closed tasks.

The rule: the only path for changes is a change request (spec → code → tests) — for agents and for humans alike. Even a hotfix should preferably go through a spec, just a short one.

Avoiding Tech Debt

The mechanics of code being “poisoned” by past tech debt: a small TODO or a half-finished piece of code becomes part of the context of all subsequent tasks. Example: // TODO: temporarily return an empty list until the cache exists. Twenty tasks later, the agent designs a new feature, finds that TODO, accepts the empty list as an established fact, and builds it into the logic. Nobody remembers anymore that the behavior was temporary — while for the model the TODO is formally “in force” and legitimate. Even worse are the variants with no explicit TODO — simply not-quite-correct logic. That is extremely hard to refactor and fix afterwards, even through an LLM. Plus the consequences ripple across the rest of the functionality.

Hence the rules:

  • A task may only be closed with no TODO/FIXME left;
  • If the blocker is real, the task is honestly moved to blocked, and the temporary behavior is described in a separate spec with removal criteria — not in a code comment.
  • The reviewer agent checks for TODO/FIXME and blocks the commit.

Better to spend an extra hour on proper work now than a week digging up false premises later.

Context Hygiene

The rules for the agents live in AGENTS.md in the repository root and get into every context. An example of the overall AGENTS.md structure of synopsis-rs:

  • Execution model: fresh agent per task, ~100k token context budget, self-contained task body
  • Frozen stack: config.yaml — no new dependencies without openspec change
  • Hard constraints: no 1:1 Go copy, re-architect for Rust idioms; scope enforcement
  • Layout: config, db, vectors, utils, llm → embedding, ingestion, graph → search → mcp → cli
  • Commands: cargo fmt, cargo clippy --all-targets -- -D warnings, cargo test
  • Gotchas: CGO alternatives (rusqlite bundled, ort load-dynamic), async (tokio+axum for server)
  • OpenSpec workflow: proposal → design → specs → tasks → apply → archive

The spec is updated together with the code. Every task gets a fresh context: the spec, the relevant files, the results of the previous run — not a dump of previous dialogs.

The pipeline of a single task:

Go → Rust Specifics

Besides the process, there are pitfalls that surface in every port like this:

GoRustPitfall
error strings, errors.Is/Asthiserror / anyhowtype the errors but keep the message texts — they are part of parity
nilOption<T>len(nil) == 0 in Go — decide what None means at each site
interface{} / anyenum / serde_json::Valueprefer a closed enum
goroutines, channelsthreads, mpsc, rayondon’t drag over the “share everything through mutexes” model
context.Contextexplicit cancellation (tokens, flags)design cancellation explicitly
io.Writerimpl Writegenerics instead of Box<dyn Write> unless needed
panicpanic = buguser-facing errors — Result only
map (random order)BTreeMappin down any order that affects the output
inti64 / usizechoose deliberately
strings = bytesString (UTF-8) / Vec<u8>binary “strings” are not String
path/filepathstd::path, caminoWindows, OsStr

And a few things that don’t fit in a table:

  • Exit codes and buffers. std::process::exit doesn’t run destructors and doesn’t flush buffers — an explicit flush is needed before exiting. In Go, main with os.Exit has the same trap, but it’s easy to forget that the behavior must match.
  • SIGPIPE. Rust ignores SIGPIPE by default: piping into head produces write errors instead of the “quiet death” Unix users are used to. Restore the handler manually if needed.
  • Flag grammar. The stdlib flag package is not GNU-style. If you used cobra/pflag, the grammar is close to clap’s, but there are edge cases to re-check.
  • Number formatting. Short float representations in Go and Rust match in most cases, but edge cases exist — cover them with parity harness cases.
  • init() and global state. Don’t port them into lazy_static singletons: do explicit initialization and pass it through the code. This is exactly the case where a “copy of the code” breaks the architecture.

Antipatterns

The most significant things you should not do:

  • One giant rewrite task. It simply won’t work. With a high probability you’ll get a pile of changes and a project that runs but does nothing useful.
  • A literal translation of the code without redesigning for the target language and ecosystem. Essentially, it produces hidden tech debt and the need to rewrite the project all over again.
  • Hidden bug fixes or improvements of the original — unexplained changes during the E2E runs. You’ll then have to restore the documentation’s consistency across the project.
  • One task = many different changes. Don’t group things together or add things along the way; keep the focus and the specs clean.
  • Non-deterministic usage scenarios that are hard to verify or not verifiable at all.
  • Stubs returning plausible data. I believe the consequences are clear without explanation.
  • TODOs in closed tasks, or silently skipped, incorrect implementations. This accumulates tech debt that still has to be paid off, only with a larger blast radius.
  • Edits outside openspec because “by hand is faster”. That’s the classic “hack, hack, ship to prod” approach. It’s faster now; later comes the slow process of restoring a consistent picture.

Conclusion

The porting task is, in a sense, simplified by having an example. The old project is essentially an executable specification, and for every debatable situation there is a way to ask the reference: “what’s the right behavior?”. But keep in mind that the tech stack and the structure differ: “similar” does not mean “equivalent”, so the arbiter is the parity harness, not the feeling that the code looks right.

The second, not entirely obvious problem is losing your understanding of the project. Without detailed review, the result is formally yours, but in fact an “unfamiliar” project. If development will continue through LLMs, this is less critical. It is mitigated by a detailed review of every slice, ADRs for the key decisions, and the human’s involvement in designing the subsystem boundaries.

And the last thing. All the rules above — the frozen stack, ADRs, documentation, openspec as the single source of truth and the change request mechanism, the ban on tech debt — discipline, first and foremost, not the agents but the people. People deviate from the process just the same. Migrations fail not because the project is complex, but because the process is not built.

Project SYNOPSIS [MEMEX]

Porting Go Rust Agentic Llm Ai Genai