3.2 src/main.rs, src/lib.rs, and the Cargo Convention
Right, let’s get our hands dirty with the actual project structure. You’ve run cargo new my_cool_project and you’re staring at a few files. The Cargo.toml is the manifest, the dinner menu for your application. But the kitchen, the place where the actual cooking happens, is the src directory. This is where we live.
By sacred convention, enforced by Cargo with the subtlety of a brick through a window, your executable’s main entry point must be src/main.rs. Not main.rs, not source/main.rs, not please_work/main.rs. src/main.rs. Break this rule and Cargo will simply shrug and refuse to build a binary. It’s not being difficult; it’s being relentlessly consistent, and you’ll come to love it for that.
Here’s what you get for free, the “Hello, world!” that Cargo generates:
fn main() {
println!("Hello, world!");
}
You run it with cargo run, and it does the thing. Beautiful. But this is just the starting pistol. The real power move is understanding that main.rs is for your binary crate—the thing you run. But most interesting projects aren’t just one big blob of code in main.rs. This is where src/lib.rs enters the picture.
The Unsung Hero: src/lib.rs
If main.rs is the flashy front-of-house restaurant, lib.rs is the engine room, the library crate. Its existence is what transforms your project from a script into a proper, reusable, testable package. Cargo automatically knows to look for this file and treat it as the root of your library.
The primary reason for this separation is dependency management. Your main.rs should be concerned with orchestration: parsing command-line arguments, handling errors for the user, and starting the application. The core logic, the algorithms, the data structures—all the genuinely interesting bits—should live in your library. This means you can also write integration tests (in the tests/ directory) that pull in your library just like any other external dependency, which is incredibly powerful.
So, a typical small project structure looks like this:
my_cool_project
├── Cargo.toml
└── src/
├── main.rs // uses the library
└── lib.rs // the core logic
Your main.rs becomes gloriously slim. Let’s say you have a function in your library that does something useful.
// In src/lib.rs
pub fn do_something_awesome(input: &str) -> String {
format!("You are awesome because you said: {}", input)
}
Notice the pub keyword. It’s the bouncer at the club of your code; it says this function is public and can be let out into the wider world. Without it, your main.rs can’t see it.
Now, in main.rs, you can use it like you would any other crate:
// In src/main.rs
use my_cool_project::do_something_awesome;
fn main() {
let awesome_output = do_something_awesome("I love Rust!");
println!("{}", awesome_output);
}
When you cargo run, Cargo first builds the library crate (lib.rs) and then the binary crate (main.rs) that depends on it. It’s a beautiful, self-contained system.
Why This Convention is a Masterstroke
This main.rs/lib.rs split isn’t just pedantry. It solves several problems elegantly:
- Testing: You can test your library code exhaustively without having to go through the binary’s
main()function every time. - Reuse: You or others can use your project as a dependency in another project by simply adding it to
Cargo.toml. If everything was inmain.rs, this would be impossible. - Organization: It forces you to think about the public API of your code (what goes in
lib.rsand is markedpub) versus the private implementation details. This is fundamental to writing maintainable software.
The One Weird Trick: Binary Targets in src/bin/
Sometimes, one binary isn’t enough. You’re writing a CLI tool that might have a main command (my-tool) and a helper command (my-tool-helper). Cargo has a convention for this, too. You can place additional binary source files in src/bin/. Each .rs file in that directory will be compiled into its own separate executable.
So your structure could be:
my_cool_project
├── Cargo.toml
└── src/
├── lib.rs
├── main.rs // compiled to `my_cool_project`
└── bin/
└── helper_tool.rs // compiled to `helper_tool`
These helper binaries can also use the public API from your library crate, keeping all the shared logic in one place. It’s a fantastic way to manage complex tools without creating a spaghetti dependency nightmare.
The takeaway? Embrace the convention. Put your startup code in main.rs, your real code in lib.rs, and any additional tools in src/bin/. It feels a bit rigid at first, but it scales perfectly from a weekend hack to a massive production system, and that’s a trade-off I’ll take any day.