For a C developer writing Rust for the first time, the unfamiliar part is often not if, for, or function calls. The first real difference is the engineering entry point: C programs often begin with main.c, gcc, Makefiles, and argc/argv; Rust programs usually begin inside a Cargo project.
That is not just a tooling difference. Cargo manages source layout, dependencies, builds, tests, and package metadata. main also becomes more than a function returning an integer: error handling, standard output, standard error, and exit status enter the program structure early.
Start with a small tool: read one file, count its lines, and print the result.
linecount README.md
It is small, but it covers the first things a command-line tool needs: arguments, files, errors, output, and exit status.
How C Usually Starts
In C, this can begin as a single main.c:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE *fp;
int ch;
unsigned long lines = 0;
if (argc != 2) {
fprintf(stderr, "usage: linecount <file>\n");
return 2;
}
fp = fopen(argv[1], "r");
if (fp == NULL) {
perror("linecount");
return 1;
}
while ((ch = fgetc(fp)) != EOF) {
if (ch == '\n') {
lines++;
}
}
if (ferror(fp)) {
perror("linecount");
fclose(fp);
return 1;
}
fclose(fp);
printf("%lu\t%s\n", lines, argv[1]);
return 0;
}
The structure is familiar:
argc/argvhandles argumentsfopenobtains a file resourcefgetcreads bytesfprintf(stderr, ...)andperrorprint errorsreturn 0/1/2communicates process statusfclosereleases the file resource
The hard part is not reading the code. The hard part is that every resource and error path must be paired by hand. Once the file is open, every failure path has to remember cleanup. Add more buffers, files, and return points, and the cleanup logic grows quickly.
Rust Usually Starts with Cargo
Rust can compile a single file with rustc main.rs, but real tools and libraries usually start as Cargo projects:
cargo new linecount
cd linecount
cargo run -- README.md
The minimal project has two important files:
linecount/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml is the project manifest. It is not a direct Makefile replacement, but it carries information that C projects often spread across Makefiles, package scripts, and release notes:
[package]
name = "linecount"
version = "0.1.0"
edition = "2024"
src/main.rs is the binary entry point. The function is still named main, but the project habit is different: Cargo fixes the source layout, package name, dependency model, build command, and test entry point from the beginning.
For a C developer, that has a practical benefit. A small tool can later grow tests, dependencies, release builds, and a reusable library without redesigning the project structure.
A Working Rust Version
Here is the Rust version:
use std::{env, fs, io, process};
fn main() {
if let Err(err) = run() {
eprintln!("linecount: {err}");
process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let path = env::args().nth(1).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "usage: linecount <file>")
})?;
let text = fs::read_to_string(&path)?;
let lines = text.lines().count();
println!("{lines}\t{path}");
Ok(())
}
From a C point of view, this code maps to familiar jobs:
env::args()corresponds toargc/argvfs::read_to_stringopens and reads the fileResultmeans the function can fail?returns the current error to the callereprintln!writes to standard errorprintln!writes to standard outputprocess::exit(1)sets a non-zero exit status
Two changes matter immediately.
First, there is no explicit close. When read_to_string returns, the internally opened file has finished its job. Later articles will cover why Rust does not scatter manual fclose calls across error paths; ownership and Drop handle resource release.
Second, the error path is not a chain of if blocks. ? does not hide errors. It returns the current error to the caller. run describes the workflow, while main turns the final error into stderr output and a process exit code.
Keep main Thin
C tools often put most logic directly inside main, especially when they are small. Rust can do that too, but a common pattern is to keep main responsible only for the process boundary:
fn main() {
if let Err(err) = run() {
eprintln!("linecount: {err}");
process::exit(1);
}
}
The actual work lives in run:
fn run() -> Result<(), Box<dyn std::error::Error>> {
// parse args, read file, print result
Ok(())
}
This is useful for command-line tools. main handles stderr, exit status, and final error presentation. run remains an ordinary function, which is easier to test and easier to move into a library later.
This is not foreign to C practice. Many C projects keep main small and put core behavior in library functions. Rust just makes error return and resource lifetime show up earlier in function signatures.
Standard Arguments Are Enough First
Rust has mature argument parsing crates such as clap, but the first article does not need one.
let path = env::args().nth(1).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "usage: linecount <file>")
})?;
This line does one job: get the first argument, or return an error.
In C, the same check often looks like this:
if (argc != 2) {
fprintf(stderr, "usage: linecount <file>\n");
return 2;
}
The goal is the same. The difference is where the error flows. The C version prints and returns directly inside main. The Rust version first represents the bad argument as an error, then lets main print it in one place.
For small tools, the standard library is enough. Once arguments grow into subcommands, defaults, help text, and validation, introducing clap becomes natural.
stdout, stderr, and Exit Status Still Matter
Rust does not decide how your command-line tool should interact with the shell. The C rules still apply:
- write normal output to stdout
- write errors to stderr
- return 0 on success
- return non-zero on failure
The example uses this for normal output:
println!("{lines}\t{path}");
And this for errors:
eprintln!("linecount: {err}");
process::exit(1);
This is not a Rust syntax detail. It is the external interface of the tool. Once shell scripts, CI jobs, or other programs call it, stdout, stderr, and exit status become part of its contract.
Cargo Handles the Entry Point
For C developers, Cargo is easiest to first understand as the build entry point, but it does more than build.
The common commands are few:
cargo run -- README.md
cargo build
cargo build --release
cargo test
Arguments after -- go to the program, not to Cargo:
cargo run -- README.md
That is equivalent to building and then running:
./target/debug/linecount README.md
This is close to running make and then executing the binary in a C project. The difference is that Cargo already knows the package name, source entry point, dependency versions, tests, and release build mode.
What to Keep from the First Article
This article has not entered ownership, borrowing, or lifetimes yet. It only establishes the first model for a C developer writing a Rust tool:
C: main.c + gcc/Makefile + argc/argv + return code
Rust: Cargo.toml + src/main.rs + env::args + Result + process exit
Rust does not ask C developers to forget their engineering experience. Command-line arguments, files, error output, and exit status still exist. The change is that Rust asks you to make error paths, resource release, and API shape explicit earlier.
The next step is basic types, structs, and memory layout. That is where C intuition about int32_t, size_t, struct, alignment, and ABI starts to connect with Rust’s type system.