The refactoring that is mostly imports
Moving a function to another module is cut and paste and then an unbounded amount of fixing every file that imported it. Building the tool that does both, and the two bugs that made it quietly skip the second.

On this page · 5 sections
Changing what a function takes was the refactoring no tool owns. Here is the other one. Ask rust-analyzer what it can do at a function declaration and it offers to inline the function, or to generate a type alias for it:
$ prod-code assists crates/prod-code-mcp/src/fixture.rs 73 8
inline_into_callers [RefactorInline] Inline into all callers
generate_fn_type_alias_named [Generate] Generate a type alias for function with named params
generate_fn_type_alias_unnamed [Generate] Generate a type alias for function with unnamed params
It does have moves - move_module_to_file, move_to_mod_rs, move_const_to_impl. They move a
module between files, or a const into an impl. None of them takes this function and puts it in
another module, which is what a person means by “move this”.
So it is done by hand. Cut the text, paste it, then find every file that mentioned the name and
fix its use. The cut and paste is bounded work. The rest is not bounded by anything: it
is however many files happened to import it, minus the ones you forget, which you find out about
from the compiler three steps later.
One item, three kinds of file
code_move takes the declaration and the module it should end up in:
$ prod-code move snake_case --to crates/prod-code-mcp/src/lang.rs
`snake_case` moved
- from: crates/prod-code-mcp/src/fixture.rs (prod_code_mcp::fixture)
- to: crates/prod-code-mcp/src/lang.rs (prod_code_mcp::lang)
- callers now import: `prod_code_mcp::lang::snake_case`
- 15 line(s) moved
32 changed line(s) in 2 file(s)
--- a/crates/prod-code-mcp/src/fixture.rs
-/// `SliceReport` -> `slice_report`.
-pub fn snake_case(name: &str) -> String {
…
+use crate::lang::snake_case;
imports:
crates/prod-code-mcp/src/fixture.rs: added `use crate::lang::snake_case;`
the analyzer accepts the result: 0 errors
nothing was written; pass `apply: true` to make these edits
Fifteen lines moved, and the interesting part is the one line added back. The file the function left still calls it six times, so that file now imports it - which is exactly the edit a person forgets, because the item used to be right there.
The three kinds of file are the whole design:
The new home gets the item whole - signature, body, doc comment, attributes - and the use
statements the item actually spells, narrowed to the names it needs. A function that mentions
Path and Result brings use std::path::Path; and use anyhow::Result;, and does not bring
the Context that sat in the same use group. An import the target gains and does not use is a
warning somebody has to delete later.
The module it left keeps everything else and, if it still calls the item, imports it.
Every other user is one of two cases, and they are not the same edit. A file that spelled the
name bare - snake_case(x), with a use at the top - has its import rewritten, and a grouped
import keeps its other names: use a::{B, snake_case, C}; becomes use a::{B, C}; plus the new
line. A file that always qualified it - crate::fixture::snake_case(x) - has the path rewritten
in place and needs no import at all. Adding one there would be the warning again.
What it refuses, and why that is the useful part
The item that moves is rarely self-contained. It calls a private helper, or names a private type, and both of those stay behind. That is not a failure of the tool; it is the shape of the problem, and the only question is whether you find out now or from a build:
$ prod-code move parse_shape --to crates/prod-code-mcp/src/lang.rs
…
the analyzer rejects the result:
no such value in this scope [E0425] (crates/prod-code-mcp/src/lang.rs:60:25)
type annotations needed [E0282] (crates/prod-code-mcp/src/lang.rs:62:22)
no such value in this scope [E0425] (crates/prod-code-mcp/src/lang.rs:62:26)
…
names it cannot see from `prod_code_mcp::lang`: the item used something private to
`prod_code_mcp::fixture`. Move that too, or widen it to `pub(crate)`, and run this again.
nothing was written; pass `apply: true` to make these edits
parse_shape calls strip_visibility and split_top_level, both private to fixture. The
analyzer produces ten diagnostics for that, which are ten ways of saying one thing, so the
report says the one thing underneath them.
The check itself is the same overlay
every write tool in this series uses: the changed files are judged together, in memory, before
anything is written, and --apply on a result that does not compile refuses.
Two bugs that made it quietly do half the job
Both were found by running it on the repository that contains it, and both are the kind that produce a confident, wrong, plausible answer rather than an error.
The cut tidied up. Removing an item from between two blank lines leaves two blank lines where there was one, so the first version collapsed them. It looked like good manners. But every position the analyzer reported was measured against the file before the cut, and the code adjusts them by the number of lines the item occupied - and now the number of lines removed was one more than that. Every reference below the hole was looked for one line too low, matched nothing, and the tool reported a move with no imports at all. It compiled nowhere, and it said so only because the type check ran afterwards.
The fix is to cut exactly the item’s lines and leave the blank line for the formatter. The lesson is narrower than “be careful”: a function whose result another function does arithmetic on cannot also be the function that tidies up. The doc comment on it now says so.
An import inside a function body counted as one of the file’s imports. The scan for use
statements checked that the statement began a line, after trimming the whitespace - and a use std::io::Write; inside a function body begins its line too. So the new import was inserted
after the last use the scan found, which was inside that body:
) -> Result<()> {
use std::io::Write;
use prod_code_mcp::lang::uri_to_path; // ← where it landed
let cwd = env::current_dir()?;
Requiring column zero fixes it. What is worth keeping is why the bug was invisible until it was in a diff: the tool’s own report showed the import as added, in the right file, with the right path. Every fact in the report was true. Only the position was wrong, and a report has no position.
What it does not do
- It does not create the target module. The file must exist and be declared by its parent.
Creating a module means writing a
modline into a file the move was never asked to touch. - Only the ordinary crate layout.
src/a.rsandsrc/a/mod.rsare both the modulea; a file reached through#[path]is named as left alone rather than guessed at. - An item at a time, not a file, not a module, and not a method onto another type - that last one is a different refactoring wearing the same verb.
- Rust only, and re-run your formatter afterwards: the item arrives at the end of its new file, which is where it goes, not where it belongs.
What it costs
One documentSymbol, one references, and one overlay type check covering every changed file -
the same overlay as change signature, though not the same
bill: that one spends most of its time in a structural replace, and this one runs none. The move
above takes 0.6-0.9 s against a warm gateway.
The comparison worth making is not against doing it by hand quickly. It is against doing it by hand and being wrong in one file, which costs a build, a diagnostic, a context switch, and the small tax of wondering what else you missed.
Cite this article
Alexander Panasenko (2026-10-03). The refactoring that is mostly imports. https://prod.codes/blog/the-refactoring-that-is-mostly-imports/