Turning a Program into a Library
How to extract reusable code from a Rust binary into a library crate so other code, tests, or future binaries can depend on it.
Every Rust program starts as a main.rs that does something useful—reading input, processing data, printing output. As the program grows, some of that logic will be worth reusing: the same data validation you use in a CLI tool might belong in a web server later, or you might want to write unit tests that don’t need to spin up the whole binary. Rust’s module system makes it straightforward to pull that logic out into a library while keeping the executable intact.
Why a Program Becomes a Library
A single main.rs file tends to mix “what to do” with “how to do it.” The “how” part—transforming strings, parsing dates, connecting to a database—is often independent of the particular command-line interface or event loop that calls it. Moving that code into a library crate gives you three concrete benefits:
- Reuse – The library can be consumed by multiple binaries in the same package (e.g., a CLI tool and a background worker) without duplication.
- Testability – Unit tests for library functions run faster and don’t need to construct the full binary environment. Integration tests can target the library directly.
- Clear API boundaries – You are forced to think about what other code should see. That deliberate visibility (
pub) becomes documentation for future maintainers.
Rust packages support having both a library and one or more binaries at the same time. Cargo will build the library first, then link the binary against it, all from a single Cargo.toml.
Library vs. binary in a package:
A package can have at most one library crate but any number of binary crates. By convention, src/lib.rs is the library root and src/main.rs is a binary root. Cargo discovers them automatically—no extra configuration required.
How a Package with Both Targets Works
When Cargo sees src/lib.rs and src/main.rs in the same package, it treats them as two distinct crates that are part of the same package:
- The library crate has the same name as the package (specified in
Cargo.toml). - The binary crate is an independent crate that automatically has a dependency on the library crate of the same package. In
main.rsyou can refer to the library withuse package_name::....
This means the binary can call any pub item from the library, but the library cannot see the binary’s code. The library stands alone; it doesn’t know whether it is being used by a binary in the same package or by an external consumer.
Private by default still applies across crate boundaries:
Items in a module are private unless explicitly marked pub. Even though the binary and library are in the same package, they are separate crates. The binary can only access the library’s pub items.
Step-by-Step Conversion of a Program into a Library
The process is mechanical: move reusable definitions to src/lib.rs, mark them public, and update main.rs to use them. Below is a concrete walkthrough using a tiny program that sums numbers.
The starting binary, src/main.rs, contains all the logic:
// src/main.rs
use std::env;
fn parse_numbers(args: &[String]) -> Vec<i32> {
args.iter()
.filter_map(|s| s.parse().ok())
.collect()
}
fn sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let numbers = parse_numbers(&args);
let total = sum(&numbers);
println!("Sum: {}", total);
}
The parsing and summation are clearly separate from the command-line handling. We want those two functions to be callable from tests or from another binary later.
Step 1: Create the library root file
Add a new file at src/lib.rs. Initially, it can be empty. Cargo will detect it and know this package now produces a library in addition to the binary.
Step 2: Move the reusable functions into the library
Cut the parse_numbers and sum function definitions from main.rs and paste them into lib.rs. At this point, the library code exists but is completely private.
// src/lib.rs (first draft)
fn parse_numbers(args: &[String]) -> Vec<i32> {
args.iter()
.filter_map(|s| s.parse().ok())
.collect()
}
fn sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
Step 3: Mark public items with `pub`
Everything in a crate is private unless you say otherwise. Add pub to each function you want the binary to use.
// src/lib.rs
pub fn parse_numbers(args: &[String]) -> Vec<i32> {
args.iter()
.filter_map(|s| s.parse().ok())
.collect()
}
pub fn sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
Forgetting `pub` causes a compile error:
If you skip this step, the binary will fail with an error like error[E0603]: function \parse_numbers` is private`. The binary is a separate crate and cannot see unexported items.
Step 4: Update the binary to use the library
Remove the function definitions from main.rs. Instead, bring the library’s items into scope with a use statement that references the package name (the name field in Cargo.toml). For a package named calculator, you would write:
// src/main.rs
use std::env;
use calculator::{parse_numbers, sum};
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let numbers = parse_numbers(&args);
let total = sum(&numbers);
println!("Sum: {}", total);
}
The binary crate automatically depends on the library crate of the same package; you do not need to add anything to Cargo.toml.
Step 5: Build and run to verify
Run cargo build and then cargo run -- 3 5 7. You should see the same output as before: Sum: 15. Cargo compiles the library first, then the binary that links against it.
If everything compiles and runs correctly, the program is now a library-backed binary.
Both artifacts are built:
After a successful cargo build, you will see target/debug/libcalculator.rlib (or .so/.dll depending on platform) and the calculator executable. The library is usable by other crates if you publish it, and the binary remains fully functional.
Extending the Library with Modules
A library that contains only two functions doesn’t need much organization. As it grows, you will want to split it into modules, just like you would in a binary. The process is identical: add mod declarations, create files like src/parser.rs, and adjust paths.
Suppose you want to separate the parsing logic from the math logic. You could restructure the library like this:
src/
├── lib.rs
├── math.rs
└── parser.rs
In lib.rs, declare the modules and re-export the public API if desired:
// src/lib.rs
mod parser;
mod math;
pub use parser::parse_numbers;
pub use math::sum;
The pub use statements flatten the module hierarchy so that external consumers (including main.rs) can still write use calculator::parse_numbers; instead of use calculator::parser::parse_numbers;. This is an optional but common technique to keep the public API stable even as internal module structure changes.
Module structure does not change the binary dependency:
Even after splitting into modules, the binary’s use statements don’t need to change if you re-export. Without re-exports, users would have to write the full path, e.g., use calculator::parser::parse_numbers;.
Common Mistakes When Converting
A few errors show up repeatedly when developers first extract a library from a binary. Recognizing them early saves time.
- Forgetting to declare a module – If you move code into
src/parser.rsbut do not addmod parser;inlib.rs, the compiler will report that it cannot find the code. Themodkeyword is what tells Rust to look for that file. - Misunderstanding crate names – The binary uses the package name to refer to the library. If your
Cargo.tomlhasname = "my-app", thenuse my_app::some_fn;works. Renaming the package later will break the binary’susepaths. - Making too much public too early – It’s tempting to add
pubeverywhere. Start with the smallest surface area the binary needs, then expand intentionally. This keeps the API from becoming a maintenance burden. - Confusing
modwithuse–moddeclares that a module exists and pulls its code into the crate;usebrings a path into the current scope for convenience. You still needmod parser;even if you writeuse parser::parse_numbers;.
Real-World Usage Patterns
Many Rust projects follow this pattern from the start: src/main.rs is thin, often just parsing arguments and calling a run() function defined in the library. That run() function contains all the meaningful logic and can be tested through cargo test without building the binary.
A mature example is the cargo tool itself—its binary is minimal, while the heavy lifting lives in library crates within a workspace. For a single-developer tool, extracting a library early ensures that when you later add a second binary (like a background daemon) or expose the functionality via FFI, the core logic is already usable without modification.
Testing the library directly:
Once you have a library, running cargo test will execute all #[test] functions in the library and its modules, as well as integration tests in tests/. Unit tests for parsing and summation can now live right alongside the implementation, with no dependency on the binary’s main.
Summary
Turning a Rust program into a library is less about learning new syntax and more about a deliberate file reorganisation: move pure functions and types to src/lib.rs, expose them with pub, and have main.rs call them through the package name. The binary and library live in the same package, sharing a single Cargo.toml, and Cargo handles the build order automatically.
The result is a codebase that is easier to test, simpler to extend with multiple binaries, and ready to be used as a dependency by other crates—whether inside the same workspace or published to a registry. This pattern is the default architecture for most non-trivial Rust applications.