Library or Binary Splitting a Rust Crate for Testability
Put logic in a library crate and keep main.rs thin, so tests can call functions, integration tests see the public API and other binaries can reuse the code.

Code in main.rs is hard to test from outside because a binary has no importable API. Move it to lib.rs and the same functions become callable by tests, by other binaries and by benchmarks. The split takes five minutes and is the first structural change most projects should make. It is Stage 2 in organizing Rust projects.
Rust 1.90, edition 2024.
The layout
my-tool/
Cargo.toml
src/
lib.rs the logic
main.rs the entry point
tests/
cli.rs integration testsNo [lib] or [[bin]] sections are needed. Cargo detects both files and builds a library and a binary with the package name.
What goes where
use std::path::Path;
pub struct Config {
pub path: String,
pub verbose: bool,
}
pub fn run(config: &Config) -> Result<usize, std::io::Error> {
let text = std::fs::read_to_string(Path::new(&config.path))?;
Ok(text.lines().count())
}use my_tool::{run, Config};
use std::process::ExitCode;
fn main() -> ExitCode {
let path = std::env::args().nth(1).unwrap_or_else(|| "README.md".to_string());
match run(&Config { path, verbose: false }) {
Ok(n) => {
println!("{n}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}main.rs imports the library by its package name, my_tool. Everything that can fail or that you want to assert on lives in run.
Testing the split
A unit test can sit beside run in lib.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_lines() {
let dir = std::env::temp_dir();
let path = dir.join("my_tool_test.txt");
std::fs::write(&path, "a\nb\n").unwrap();
let n = run(&Config { path: path.display().to_string(), verbose: false }).unwrap();
assert_eq!(n, 2);
}
}Integration tests in tests/ call my_tool::run as an outside user would. Using the binary itself in a test is covered in testing CLI tools with assert_cmd, and the test layout in integration tests.
Several binaries
Files in src/bin/ each become a binary: src/bin/migrate.rs builds migrate. All share the library. A service with a serve and a migrate binary is a common use.
The traps
- Visibility. Items used by
main.rsmust bepubin the library. Treat them as an API; the discipline in public API design applies, even if you are the only user. - Names. Do not name a module the same as the package, or imports become confusing.
- Documentation.
cargo docdocuments the library, and a public item without docs is a hint. - Dead code. Warnings change: an unused
pubfunction in a library is not flagged, while one in a binary is.
When to skip it
A throwaway script or a tiny tool of one function does not need a library. Split when a second file of logic appears or when you want a test that does not spawn a process. The Cargo book documents package layout.


