The cross-platform trap
You spent the weekend building a CLI tool that processes log files. It works perfectly on your Linux machine. You zip the binary, email it to a colleague on Windows, and they reply with a screenshot: "Error: path not found." You look at the code. You hardcoded /tmp/logs. Windows doesn't have /tmp. The binary runs, but the logic assumes a Unix world.
This is the cross-platform trap. Rust compiles to native code for dozens of platforms. That capability is a superpower, but it also means your code runs in environments with different file systems, different environment variable names, different line endings, and different system calls. Writing cross-platform Rust isn't about avoiding platform differences. It's about using the abstractions that handle those differences so you don't have to.
The universal adapter
Think of cross-platform code like a universal power adapter. Your code is the device that needs power. Linux, Windows, and macOS are different wall sockets with different shapes and voltages. If you hardcode the pin shape, your device only works in one country. Rust gives you a universal adapter in the standard library. You plug into the adapter, and it handles the pin shapes, voltage conversions, and local quirks so your device just gets power.
The rule is simple: never touch the wall socket directly. Use the adapter. In Rust, the adapters are types like std::path::Path, traits like std::io::Read, and functions like std::env::var_os. These abstractions inspect the host OS at runtime and do the right thing. When you use them, your code runs everywhere. When you bypass them with string concatenation or hardcoded paths, your code becomes a local dialect.
Paths are not strings
The most common cross-platform failure is treating file paths as plain strings. On Unix, paths use forward slashes. On Windows, they use backslashes. If you build a path by joining strings, you get the wrong separator on the wrong OS.
use std::path::PathBuf;
/// Builds a file path using the OS-appropriate separator.
fn build_path(base: &str, file: &str) -> PathBuf {
// PathBuf knows the OS separator.
// join() inserts the right slash automatically.
let base_path = PathBuf::from(base);
base_path.join(file)
}
fn main() {
let path = build_path("data", "config.txt");
// On Linux: data/config.txt
// On Windows: data\config.txt
println!("{}", path.display());
}
PathBuf is the mutable version of a path. Path is the borrowed version. Both understand the platform's separator rules. join handles the logic. If the base already ends with a separator, join doesn't double it. If the file starts with a separator, join treats it as absolute and replaces the base. This behavior is consistent across platforms.
Never concatenate paths with string formatting. The compiler won't stop you, but Windows will.
Realistic example: finding config
Real tools need to find configuration files in user directories. Unix systems use $HOME. Windows uses %USERPROFILE%. If you check only HOME, your tool breaks on Windows. If you check only USERPROFILE, it breaks on Linux.
use std::env;
use std::path::PathBuf;
/// Locates the config file in the user's home directory.
/// Falls back to platform-specific environment variables.
fn find_config() -> PathBuf {
// Check for a custom config path first.
// var_os returns OsString, which is safe for non-UTF-8 values.
if let Ok(path) = env::var_os("MY_TOOL_CONFIG") {
return PathBuf::from(path);
}
// Fallback to home directory.
// HOME is standard on Unix. USERPROFILE is standard on Windows.
let home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.expect("Could not determine home directory");
// join handles the separator and directory structure.
PathBuf::from(home).join(".my_tool").join("config.toml")
}
Environment variables are the wild west of cross-platform code. They aren't guaranteed to be UTF-8 on all platforms. env::var returns a String and panics if the value isn't valid UTF-8. env::var_os returns an OsString, which can hold any bytes the OS allows. Use var_os when you're passing the value to the OS or constructing a path. Convert to String only when you need to parse the content as text.
Convention aside: the community prefers var_os for environment variables that feed into paths or system calls. It avoids panics on systems where environment variables contain legacy encodings.
Environment variables are the wild west of cross-platform code. Always prefer var_os unless you need a String.
Text files and line endings
Text files use different line endings. Unix uses \n. Windows uses \r\n. macOS historically used \r, though modern macOS uses \n. If you read a file as bytes and split on \n, Windows files leave trailing \r characters on every line. Your parsing logic breaks.
Rust's I/O traits handle this for you. BufReader::lines normalizes line endings. It strips \n, \r\n, and \r automatically. You get clean lines regardless of the platform that created the file.
use std::fs::File;
use std::io::{BufRead, BufReader};
/// Counts lines in a file, handling all line ending styles.
fn count_lines(path: &str) -> usize {
let file = File::open(path).expect("File not found");
let reader = BufReader::new(file);
// lines() handles \n, \r\n, and \r automatically.
// You don't need to trim or split manually.
reader.lines().count()
}
lines returns an iterator of Result<String, std::io::Error>. Each String has the line ending removed. If the file isn't valid UTF-8, lines returns an error. This is intentional. Text processing should fail fast on invalid encoding rather than silently corrupting data.
Rust's I/O traits normalize line endings for you. If you're manually splitting on \n, you're fighting the library.
Conditional compilation
Sometimes there is no abstraction. You need to call a system call that exists only on Linux, or link against a library that exists only on Windows. Rust provides cfg attributes to include or exclude code based on the target platform.
#[cfg(target_os = "linux")]
fn get_linux_info() {
// Linux-specific logic.
println!("Running on Linux");
}
#[cfg(target_os = "macos")]
fn get_macos_info() {
// macOS-specific logic.
println!("Running on macOS");
}
#[cfg(target_os = "windows")]
fn get_windows_info() {
// Windows-specific logic.
println!("Running on Windows");
}
// Fallback for other platforms.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn get_other_info() {
println!("Unknown platform");
}
fn main() {
// Call the appropriate function based on the platform.
#[cfg(target_os = "linux")]
get_linux_info();
#[cfg(target_os = "macos")]
get_macos_info();
#[cfg(target_os = "windows")]
get_windows_info();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
get_other_info();
}
cfg attributes are evaluated at compile time. Code guarded by a cfg that doesn't match the target is completely removed from the binary. This means you can call platform-specific functions without worrying about linker errors on other platforms. The compiler simply doesn't see the code.
Common cfg targets include target_os, target_arch, target_family, and feature. target_family groups platforms. unix matches Linux, macOS, FreeBSD, and others. windows matches all Windows targets. Use target_family = "unix" when you want code that works on all Unix-like systems.
Convention aside: the community convention is to test cross-platform code by building for other targets locally. Run rustup target add x86_64-pc-windows-gnu and cargo build --target x86_64-pc-windows-gnu to catch platform-specific compile errors without a Windows machine. This practice is called cross-compilation, and it's the standard way to verify portability.
Guard platform-specific code with cfg. The compiler is your travel agent; tell it where you're going.
Pitfalls and errors
Cross-platform code fails in subtle ways. Here are the common traps and how to avoid them.
Hardcoded paths are the number one cause of runtime failures. If you write "/etc/config" in your code, it works on Linux and crashes on Windows. The compiler won't warn you. The error appears at runtime as a file not found error. Use PathBuf and environment variables to locate files dynamically.
Line ending mismatches cause parsing errors. If you read a file as bytes and expect \n, Windows files break your logic. Use BufReader::lines or std::fs::read_to_string with text processing that handles normalization.
Permission models differ. Unix uses read/write/execute bits for user, group, and others. Windows uses Access Control Lists. std::fs::set_permissions works on both, but the semantics differ. On Windows, you can't set execute bits in the same way. If your code relies on execute permissions, test on Windows. The operation might succeed but have no effect, or it might fail with an std::io::Error with kind Unsupported.
Platform-specific APIs require unsafe. If you call a C library function directly, you need an unsafe block. Wrap the unsafe call in a safe function that validates inputs and outputs. Expose only the safe interface to the rest of your code.
#[cfg(target_os = "linux")]
fn get_pid() -> u32 {
// SAFETY: getpid is a standard POSIX syscall that always succeeds
// and returns a valid process ID. No arguments are passed,
// and the return value is always safe to interpret as u32.
unsafe { libc::getpid() as u32 }
}
If you forget to guard a platform-specific function with cfg, the linker complains with an "undefined reference" error when you build for the wrong target. The compiler sees the function call, but the symbol doesn't exist on that platform. Guard every platform-specific call.
Cross-compile early. A build failure on your machine is cheaper than a crash on your user's machine.
Decision matrix
Use std::path::PathBuf when constructing file paths so the OS handles separators and normalization. Use std::env::var_os when reading environment variables that might contain non-UTF-8 data or feed directly into system calls. Use #[cfg(target_os = "...")] when you have no choice but to invoke a platform-specific system call or link against a native library. Use the dirs crate when you need reliable access to user directories like home, config, or cache, because guessing environment variable names is error-prone. Use std::io::Result as your error type when wrapping I/O operations, since it carries platform-specific error codes without leaking them. Use BufReader::lines when processing text files to normalize line endings automatically. Use unsafe blocks only when you must call a platform-specific C API that has no safe Rust wrapper, and wrap it immediately in a safe interface.