What we are building

The goal is not to create a full backup product with compression, deduplication, or scheduling. Instead, we will build a focused utility that:

  • accepts a source and destination directory
  • recursively walks the source tree
  • copies regular files into the destination
  • creates missing directories as needed
  • reports useful errors instead of panicking

This is a good “getting started” project because it is realistic without being overwhelming. You will work with the standard library only, which keeps the example approachable.

Project setup

Create a new binary crate:

cargo new file_backup
cd file_backup

You can keep this project dependency-free. The standard library provides everything we need for a basic version.

A simple backup tool usually benefits from a clear command-line interface. For this first version, we will accept two positional arguments:

cargo run -- ./documents ./backup

The first path is the source directory. The second is the destination directory.

Designing the backup flow

Before writing code, it helps to define the algorithm:

  1. Validate that the source exists and is a directory.
  2. Create the destination directory if it does not exist.
  3. Recursively traverse the source tree.
  4. For each directory, create the matching directory in the destination.
  5. For each file, copy it to the corresponding destination path.
  6. Report any I/O failures with context.

This flow is intentionally simple. It avoids cleverness and makes failures easy to reason about.

Implementing the core logic

Replace src/main.rs with the following code:

use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

fn main() {
    if let Err(err) = run() {
        eprintln!("backup failed: {err}");
        std::process::exit(1);
    }
}

fn run() -> io::Result<()> {
    let mut args = env::args().skip(1);

    let source = args
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing source directory"))?;
    let destination = args
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing destination directory"))?;

    if args.next().is_some() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "too many arguments; expected: <source> <destination>",
        ));
    }

    let source = PathBuf::from(source);
    let destination = PathBuf::from(destination);

    validate_source(&source)?;
    fs::create_dir_all(&destination)?;

    copy_tree(&source, &destination, &source)
}

fn validate_source(source: &Path) -> io::Result<()> {
    let metadata = fs::metadata(source)?;
    if !metadata.is_dir() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "source path must be a directory",
        ));
    }
    Ok(())
}

fn copy_tree(source_root: &Path, destination_root: &Path, current: &Path) -> io::Result<()> {
    for entry in fs::read_dir(current)? {
        let entry = entry?;
        let path = entry.path();
        let relative = path
            .strip_prefix(source_root)
            .expect("path should always be inside source root");
        let target = destination_root.join(relative);

        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            fs::create_dir_all(&target)?;
            copy_tree(source_root, destination_root, &path)?;
        } else if file_type.is_file() {
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(&path, &target)?;
        }
    }

    Ok(())
}

This version is intentionally compact, but it already demonstrates several important Rust patterns:

  • Result-based error propagation with ?
  • path handling with Path and PathBuf
  • recursive traversal using read_dir
  • defensive argument validation

Understanding the structure

The main function delegates to run(), which returns io::Result<()>. This is a common Rust pattern for command-line tools because it keeps main small and lets you handle errors in one place.

Why use Path and PathBuf?

Rust distinguishes between borrowed path references and owned path values:

TypePurpose
PathBorrowed view into a filesystem path
PathBufOwned, mutable path buffer

In this example, we convert command-line strings into PathBuf values because the paths need to live for the duration of the program. Functions like validate_source and copy_tree accept &Path because they only need to inspect paths, not own them.

Why use strip_prefix?

The key to preserving directory structure is computing a relative path from the source root. For example:

  • source root: ./documents
  • current file: ./documents/reports/2024.txt
  • relative path: reports/2024.txt
  • destination file: ./backup/reports/2024.txt

strip_prefix gives you that relative path safely. This avoids manual string manipulation, which is error-prone and platform-specific.

Handling directories and files correctly

The backup logic checks the file type for each directory entry:

  • directories are recreated with create_dir_all
  • regular files are copied with fs::copy

This distinction matters because not every filesystem entry is a regular file. In a more advanced tool, you might also handle symlinks, sockets, or device files explicitly. For a beginner-friendly utility, ignoring unsupported entry types is acceptable, but you should be aware that the current code silently skips them.

If you want to be stricter, you can add an else branch that logs or returns an error for unsupported types.

Improving error messages

The current implementation uses standard I/O errors, which is enough for a first version. However, the quality of error messages strongly affects developer experience.

A few practical tips:

  • report which path failed
  • distinguish invalid input from runtime I/O failures
  • avoid panicking on expected conditions
  • exit with a non-zero status code on failure

For example, if the source directory does not exist, validate_source returns an InvalidInput error. If a file cannot be copied because of permissions, fs::copy returns a different I/O error automatically. This separation makes troubleshooting much easier.

Testing the utility

You can test the program manually with a small directory tree:

mkdir -p /tmp/source/reports
echo "alpha" > /tmp/source/a.txt
echo "beta" > /tmp/source/reports/b.txt

cargo run -- /tmp/source /tmp/backup

Then inspect the destination:

find /tmp/backup -type f -print -exec cat {} \;

You should see the same files and contents in the backup directory.

A quick verification checklist

CheckExpected result
Source directory existsProgram proceeds
Source is a file, not a directoryProgram returns an input error
Destination does not existProgram creates it
Nested directories existStructure is preserved
File copy fails due to permissionsProgram reports an I/O error

Practical enhancements you can add next

The utility is already useful, but real backup tools usually need a few more features. Here are some natural next steps.

1. Skip unchanged files

Copying everything every time is simple, but inefficient. You can compare file size and modification time before copying. If both match, skip the file.

This is useful when backing up large trees repeatedly, such as project directories or documents.

2. Add a dry-run mode

A dry-run mode prints what would be copied without changing the filesystem. This is valuable for safety, especially when users are backing up important data.

A typical interface might look like:

cargo run -- --dry-run ./documents ./backup

3. Preserve timestamps

fs::copy copies file contents and permissions on some platforms, but not all metadata. If you need exact preservation, you will need platform-specific APIs or a crate that handles metadata more thoroughly.

4. Support command-line flags

Positional arguments are fine for a prototype, but flags improve usability. A more polished interface might include:

FlagPurpose
--dry-runShow planned actions without copying
--verbosePrint each copied file
--excludeSkip matching paths
--overwriteReplace existing destination files

For a production tool, a crate such as clap can simplify argument parsing significantly.

Best practices for filesystem tools in Rust

Filesystem code has a few common pitfalls. Rust helps prevent memory bugs, but it cannot eliminate logical errors or platform differences. Keep these best practices in mind:

  • Prefer Path and PathBuf over string concatenation.
  • Treat errors as part of the design, not as exceptional afterthoughts.
  • Validate input early.
  • Keep the core traversal logic separate from argument parsing.
  • Be explicit about what kinds of filesystem entries you support.
  • Test on realistic directory trees, not just one or two files.

A particularly important habit is to keep side effects localized. In this tutorial, run() handles setup, while copy_tree() focuses on traversal and copying. That separation makes the code easier to extend later.

Where this project can go next

Once the basic utility works, you can evolve it in several directions:

  • add recursive exclusion patterns
  • generate a manifest of copied files
  • compare source and destination checksums
  • compress backups into an archive
  • run backups on a schedule with an external task runner

Each of these features builds naturally on the foundation you already have. The recursive copy logic remains useful even if the destination changes from a directory to a zip archive or remote storage backend.

Conclusion

A file backup utility is a practical first Rust project because it exercises core language and standard library skills in a realistic setting. You worked with command-line arguments, recursive directory traversal, path manipulation, and error handling, all while keeping the code small and understandable.

The main takeaway is that Rust encourages you to design for correctness from the beginning. By validating inputs, propagating errors, and using the filesystem APIs carefully, you can build a tool that behaves predictably and is easy to extend.

Learn more with useful resources