notes · · 6 min

Two conversions, three decisions

A type migration that writes .into() at every broken site sounds like the dangerous version. It holds up when the analyzer decides which conversions stay: only the ones that type-check and break nothing else are kept; the rest are taken back and left for a person. Type-correct, not judged right.

On this page · 6 sections
  1. .into() is the type’s own answer
  2. Six sites, two conversions
  3. The rules that keep it honest
  4. Two things the analyzer’s answers taught it
  5. What it costs, and how it is pinned
  6. What it does not do

Part 18 built the reporting half of a type migration: change a declared type, type-check the workspace, list every site that no longer fits. It argued, at some length, against writing the conversions. For a u64 becoming a Duration, Duration::from_secs(x) is almost always right and silently wrong wherever the number was not seconds, and a page of confident conversions, some of them wrong, is worse than a list.

That argument is about choosing a conversion. This post is about the one conversion that involves no choice.

This account follows the Rust implementation at revision 4e2227fc, merged as PR #121 on September 23, 2026 from the request in issue #119. The run outcomes, cargo check results and timings below are the ones reported in PR #121; this review checked the pinned source and the test definitions and did not repeat those runs. The tool changed afterwards: PR #729 extended convert to other languages and added a transitive mode, and PR #937 split the module into smaller files.

.into() is the type’s own answer

x.into() compiles only if an Into<Target> exists for the source type, which in practice comes from a From impl on the target (a direct Into impl is possible, and rare), written by the type’s author as the conversion. Type-checking says the conversion exists, not that it is the right one at a given site. u64 declares one from u32, since every u32 fits. Box<str> declares one from String. Duration declares none from any integer, precisely because the unit is ambiguous. So .into() never picks between from_secs and from_millis: where the choice exists, .into() does not compile.

That makes the automatic half simple to state. With convert, code_migrate_type writes .into() at every site whose error is expected one of the two types, found the other. Then it checks the whole overlay again and keeps a conversion only where the analyzer accepts it.

Six sites, two conversions

The scratch crate from issue #119: a timeout: u32 field migrated to u64, read and written in two files. Before convert, the issue’s run left all six sites to a person. PR #121 reports the run below; where the PR text abbreviates the repeated note on the tried sites, it is written out here as the pinned renderer prints it.

The field timeout changes from u32 to u64 with convert. Five sites get .into(). Two are kept: secs in the struct literal becomes secs.into(), and secs as u32 becomes (secs as u32).into(). Three are taken back: l.timeout * 2 and let t: u32 = l.timeout have no From conversion from u64 to u32, and t + build(1, "x").timeout still fails because u32 + u64 does not exist. A conversion stays only where the analyzer accepts it
Every candidate gets .into(). The analyzer decides which ones stay. Cases from the scratch-crate run reported in PR #121.
2 site(s) converted with `.into()`, each accepted by the analyzer:
  src/caller.rs:8  `secs as u32` → `(secs as u32).into()`
  src/lib.rs:9  `secs` → `secs.into()`

4 site(s) in 1 file(s) do not fit the new type:

src/caller.rs
  4:5  expected u32, found u64 [E0308]
      l.timeout * 2
      try: `.into()` was tried here and does not type-check: …
  16:20  expected u32, found u64 [E0308]
      let t: u32 = l.timeout;
      try: `.into()` was tried here and does not type-check: …
  17:5  expected u32, found u64 [E0308]
      t + build(1, "x").timeout
      try: `.into()` was tried here and does not type-check: …
  17:5  the trait bound `u32: Add<u64>` is not satisfied [E0277]
      t + build(1, "x").timeout

The two widenings convert. The four errors that are left, on three lines (line 17 has two), are the migration’s real questions. A place that still wants a u32 is now handed a u64: truncate, fail, or migrate it too? That is a decision about overflow, and .into() has no opinion on it, which is exactly why it does not compile there. The tool tried, the analyzer said no, the conversion was taken back, and the report says it was tried, so nobody tries it again by hand.

One conversion keeps what the code did and is not pretty: (secs as u32).into() still truncates to 32 bits, as the code did before. A person might write secs as u64, which no longer truncates - a different program, and so not the tool’s to write. It adds only .into(), and parentheses around anything that is not a path, a call chain or a literal, such as a cast or a * 2; it never edits what was already there.

Migrating the other field, name: String to Box<str>, converts both of its sites. The timeout field migrated to Duration converts none of its four candidates, which is the argument of part 18 settled by the types themselves. PR #121 reports that in all three cases cargo check on a build node agreed with the analyzer once the change was applied or forced through.

The rules that keep it honest

A rejected conversion is taken back. After each round of conversions the overlay is checked again, and every conversion whose line still has an error is reverted. The remaining ones are checked again, since reverting one can change what the others see, for up to four rounds.

A conversion that breaks something else drops them all. If the kept conversions type-check where they are but an error appears on a line that was not in the original report, none of them is kept, and the report names the new error. A conversion that compiles only by moving the problem somewhere else is the plausible damage the whole approach exists to avoid.

apply writes the declaration with the kept conversions, and still refuses while any site remains, unless forced.

Two things the analyzer’s answers taught it

PR #121 records two runs that went wrong before the type-name and range fixes it carries. The first Box<str> run converted nothing: no site was even a candidate. The analyzer writes Box<str, Global>, allocator included, so the comparison with the Box<str> the user asked for failed. Type names are now compared the way the analyzer spells them. The same change fixed an older bug in the suggestions: the rule that turned std::time::Duration into Duration split on the last ::, and made Vec<std::string::String> into String>.

The second run converted one site and left the other, which should have converted. The reason was the analyzer’s range for a method call: for label.to_string() it covers to_string, the method’s name, without the parentheses. .into() written at the end of that range gave label.to_string.into()(). The re-check caught it, as it should, and took it back. A range followed by an argument list is now extended over it. A range that starts after a . gets .into() only in its plain form, because parentheses there would cut the chain in two; where it would need them, the site is left unconverted.

What it costs, and how it is pinned

PR #121 reports runs with convert on the scratch crate at 0.40 to 0.84 seconds, each being the first check plus at least one round of conversions and its own check. Those are reported timings from one scratch crate, not a benchmark, and they were not repeated for this post.

The integration test plays the analyzer from the text it is actually sent. For that, the scripted test gateway now shows didOpen and didChange to its script. The field is u32 to u64, and .into() is accepted in one file and rejected in the other. Switch off the taking back and the test fails on the count of conversions, two instead of one, and PR #121 reports that run. Switch off the check for new errors, and the second half of the test fails, also reported there: that half makes the kept conversion break another line, and the conversion is kept anyway.

What it does not do

  • Pick a constructor. Duration::from_secs, PathBuf::from, Some(…) written by name: none of them. Only .into(), which covers Some (Option<T> declares a From<T>) and any other conversion a type declares.
  • Migrate further. A site that should become u64 too, such as one whose surrounding code still requires u32, is reported. Migrating it is its own run, with its own report.
  • Judge a From impl. If a type declares a conversion that is wrong for a particular site, the tool writes it. .into() means what the type’s author said it means.
  • Rust only, at this revision. PR #729 of September 29, 2026 added conversions for TypeScript, Python, C++, Swift and Go and a transitive mode; that later work is outside this post.
Cite this article
Citation
Alexander Panasenko (2026-10-19). Two conversions, three decisions. https://prod.codes/blog/two-conversions-three-decisions/