Cargo erwartet eine konventionelle Verzeichnisstruktur. Du kannst sie überschreiben, aber 99 % aller Rust-Projekte folgen denselben Regeln — was bedeutet, dass jeder, der zum ersten Mal in deine Codebase schaut, weiß, wo er nach welchem Code suchen muss. Dieser Artikel zeigt die Default-Struktur eines Binary- und eines Library-Crates, erklärt das Mapping zwischen mod-Deklarationen und Dateien, geht durch die Spezial-Ordner src/bin/, tests/, examples/ und benches/ und schließt mit einem Ausblick auf Workspaces für Multi-Crate-Setups.

Binary- vs. Library-Crate

Ein Crate ist die kleinste Einheit, die Cargo baut — entweder ein Binary (eine ausführbare Anwendung) oder eine Library (importierbar in anderen Crates).

  • Binary-Crate — hat src/main.rs als Einstiegspunkt. Das Ergebnis ist eine ausführbare Datei. cargo new legt standardmäßig ein Binary-Crate an.
  • Library-Crate — hat src/lib.rs als Einstiegspunkt. Das Ergebnis ist eine .rlib- (oder .so/.dylib/.dll-)Datei, die in andere Crates eingebunden wird. cargo new --lib legt eine Library an.

Ein Crate kann beides gleichzeitig sein — Library plus Binary. Das ist ein häufiges Pattern: die echte Logik lebt in src/lib.rs (testbar, wiederverwendbar), das src/main.rs ist ein dünner Wrapper, der die CLI parst und die Library aufruft.

Rust Crate mit beidem
meine-app/
├── Cargo.toml
└── src/
    ├── lib.rs       ← Library-Logik, testbar, wiederverwendbar
    └── main.rs      ← CLI-Glue, nutzt meine_app::*

In Cargo.toml müssen beide Einstiegspunkte nicht explizit deklariert werden — Cargo erkennt sie an den Dateinamen.

Die Default-Struktur eines Binary-Crates

cargo new meine-app legt an:

Rust meine-app/
meine-app/
├── .git/                 ← Git-Repo (mit cargo new --vcs none deaktivierbar)
├── .gitignore            ← enthält /target
├── Cargo.toml            ← Projekt-Manifest
├── Cargo.lock            ← entsteht beim ersten Build
├── src/
│   └── main.rs           ← Einstiegspunkt
└── target/               ← entsteht beim ersten Build, in .gitignore

Drei Dinge, die du committen solltest:

  • Cargo.toml — natürlich.
  • Cargo.lockbei Binary-Crates ja, bei Library-Crates typischerweise nein (siehe Cargo-Grundlagen-Artikel).
  • src/ — die Quellen.

Drei Dinge, die du nicht committen solltest:

  • target/ — generierte Build-Artefakte, mehrere GB groß.
  • IDE-spezifische Dateien wie .vscode/, .idea/ (außer du committest sie bewusst als Team-Konvention).
  • OS-Müll wie .DS_Store oder Thumbs.db.

Module und ihr Mapping auf Dateien

Rust-Code wird in Modulen organisiert. Ein Modul ist ein Namespace, der Funktionen, Typen und weitere Sub-Module enthält. Module werden mit mod deklariert.

Es gibt zwei Konventionen, wie ein Modul-Baum auf das Dateisystem abgebildet wird — die alte und die neue.

Variante A: Eine Datei pro Modul (modern, empfohlen)

Rust src/-Layout
src/
├── main.rs            ← deklariert: mod parser; mod renderer;
├── parser.rs          ← Modul `parser`
└── renderer.rs        ← Modul `renderer`
Rust src/main.rs
mod parser;
mod renderer;

fn main() {
    let ast = parser::parse("text");
    renderer::render(&ast);
}

mod parser; ist die Deklaration „suche eine Datei namens parser.rs (oder parser/mod.rs) im gleichen Verzeichnis und lade ihren Inhalt in das Modul parser".

Variante B: Verschachtelte Module mit Sub-Dateien

Wenn ein Modul selbst Sub-Module hat, gibt es zwei Möglichkeiten:

Rust Mit Ordner + mod.rs (alte Konvention)
src/
├── main.rs
└── parser/
    ├── mod.rs            ← Einstieg des Moduls `parser`
    ├── lexer.rs          ← Modul `parser::lexer`
    └── ast.rs            ← Modul `parser::ast`
Rust Mit Ordner + gleichnamiger Datei (moderne Konvention, seit 2018)
src/
├── main.rs
├── parser.rs             ← Einstieg des Moduls `parser`
└── parser/
    ├── lexer.rs          ← Modul `parser::lexer`
    └── ast.rs            ← Modul `parser::ast`

Die moderne Konvention (gleichnamige Datei + Ordner) gilt seit der 2018-Edition als idiomatisch. Sie löst eine alte Verwirrung: bei vielen mod.rs-Dateien in offenen Editor-Tabs siehst du nur „mod.rs, mod.rs, mod.rs" — bei der neuen Variante stehen parser.rs, lexer.rs und ast.rs direkt unter ihren Namen.

In parser.rs deklarierst du dann die Sub-Module:

Rust src/parser.rs
pub mod lexer;
pub mod ast;

pub fn parse(input: &str) -> ast::Node {
    let tokens = lexer::tokenize(input);
    // ...
}

pub mod macht das Sub-Modul auch außerhalb von parser sichtbar. Ohne pub ist es nur innerhalb von parser verwendbar.

Modul-Pfade

Innerhalb eines Crates referenzierst du Module über ihren Pfad:

Rust Pfad-Beispiele
// Absoluter Pfad ab Crate-Root
crate::parser::ast::Node

// Relativer Pfad ab dem aktuellen Modul
self::lexer::tokenize
super::ast::Node       // ein Modul aufwärts

// Über `use` kürzen
use crate::parser::ast::Node;
let node: Node = ...;

Die Details — pub(crate), pub(super), Re-Exports, Glob-Imports — folgen im Kapitel Module, Crates & Cargo.

Mehrere Binaries: src/bin/

Wenn ein Projekt mehrere ausführbare Programme liefern soll — z. B. einen Server und ein zugehöriges Admin-Tool — kommt der Ordner src/bin/ ins Spiel.

Rust Multi-Binary-Projekt
meine-app/
├── Cargo.toml
└── src/
    ├── lib.rs            ← Geteilte Logik
    ├── main.rs           ← Default-Binary (Name: meine-app)
    └── bin/
        ├── server.rs     ← Binary `server`
        └── admin.rs      ← Binary `admin`
Rust Ausführung
cargo run                       # Default-Binary aus src/main.rs
cargo run --bin server          # src/bin/server.rs
cargo run --bin admin           # src/bin/admin.rs

Alle Binaries können auf eine gemeinsame Library zugreifen, die in src/lib.rs lebt. Praktisches Pattern für CLIs mit mehreren Subkommandos, die jeweils ein eigenes Binary haben.

Falls ein Binary etwas komplexer ist, kannst du es auch als Unterordner mit eigener Struktur ablegen:

Rust Komplexes Binary
src/bin/
└── server/
    ├── main.rs
    └── http.rs

Tests: tests/

Rust unterscheidet Unit-Tests (in derselben Datei wie der getestete Code) und Integration-Tests (in einem separaten tests/-Ordner).

Rust Projekt mit Tests
meine-app/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   └── parser.rs
└── tests/
    ├── integration_basic.rs    ← Integration-Test 1
    └── integration_full.rs     ← Integration-Test 2
  • Unit-Tests stehen direkt unter dem Code, den sie testen — typischerweise in einem #[cfg(test)] mod tests { ... }-Block am Ende der jeweiligen Datei. Sie haben Zugriff auf private Items des Moduls.
  • Integration-Tests in tests/ werden jeweils als eigenständiges Crate gebaut. Sie können nur auf das öffentliche Interface deiner Library zugreifen — genau wie ein externer Benutzer.

Beide laufen mit cargo test. Cargo erkennt sie automatisch — keine Konfiguration in Cargo.toml nötig.

Beispiel für Unit-Tests in einer Datei:

Rust src/parser.rs
pub fn parse(input: &str) -> Result<Ast, ParseError> {
    // ...
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_empty_input() {
        assert!(parse("").is_ok());
    }
}

Das #[cfg(test)] sorgt dafür, dass dieser Block nur beim Test-Build kompiliert wird — im Release-Build bleibt er aussen vor und kostet nichts.

Beispiele: examples/

Für Library-Crates ist examples/ der Ort, an dem du Verwendungs-Demos ablegst.

Rust examples/
meine-lib/
├── Cargo.toml
├── src/lib.rs
└── examples/
    ├── basic.rs            ← cargo run --example basic
    └── advanced.rs

Jede Datei in examples/ wird als eigenständiges Binary mit Zugriff auf deine Library gebaut.

Rust Ausführung
cargo run --example basic
cargo build --examples              # Alle Beispiele bauen

Vorteil: Beispiele werden mit-getestet — bei cargo test versucht Cargo, alle Examples zu kompilieren. Wenn deine API sich ändert und ein Example bricht, fällt das sofort auf. Tokio, axum, serde — sie alle nutzen examples/ als zentrale Lern-Ressource.

Benchmarks: benches/

Performance-Messungen leben in benches/. Die Stdlib hat zwar ein #[bench]-Attribut, das ist aber unstable — in der Praxis nutzt das gesamte Ökosystem das Crate criterion:

Rust benches/parser_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use meine_lib::parse;

fn bench_parse(c: &mut Criterion) {
    c.bench_function("parse_small", |b| {
        b.iter(|| parse(black_box("some input")))
    });
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);
Rust Cargo.toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "parser_bench"
harness = false
Rust Ausführen
cargo bench

criterion liefert Median, Konfidenzintervalle und Vergleich mit vorherigen Runs — Standard im Rust-Ökosystem.

Workspaces: mehrere Crates in einem Projekt

Größere Projekte teilen sich in mehrere Crates auf:

Rust Workspace-Struktur
meine-suite/
├── Cargo.toml             ← Workspace-Root
├── backend/
│   ├── Cargo.toml
│   └── src/main.rs
├── frontend/
│   ├── Cargo.toml
│   └── src/main.rs
└── shared/
    ├── Cargo.toml
    └── src/lib.rs
Rust meine-suite/Cargo.toml
[workspace]
members = ["backend", "frontend", "shared"]
resolver = "2"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }

Vorteile:

  • Gemeinsamer target/-Cache für alle Member.
  • Ein Cargo.lock für die gesamte Suite — alle Crates nutzen exakt dieselben Dependency-Versionen.
  • workspace.dependencies als zentrale Versions-Quelle: Members schreiben nur serde = { workspace = true }, und alle Crates ziehen automatisch dieselbe Version.

Workspaces bekommen im Kapitel Module, Crates & Cargo einen eigenen Artikel mit Details zu path-Dependencies, Workspace-weiten Lints und Publish-Strategien.

Zusammenfassung der Konventionen

PfadZweck
Cargo.tomlManifest
Cargo.lockResolved Versionen (Binary: committen, Library: nicht)
src/main.rsDefault-Binary-Einstieg
src/lib.rsLibrary-Einstieg
src/<modul>.rs oder src/<modul>/mod.rsModul-Inhalt
src/bin/<name>.rsZusätzliches Binary
tests/<name>.rsIntegration-Test (eigenes Crate)
examples/<name>.rsBeispiel-Programm
benches/<name>.rsBenchmark
build.rsBuild-Skript (läuft vor dem eigentlichen Compile)
target/Build-Artefakte (in .gitignore)

Diese Struktur ist seit Rust 1.0 stabil. Wer ein Projekt nach diesem Schema baut, hat automatisch eine Codebase, die jeder Rust-Entwickler beim ersten Blick versteht.

Interessantes

Die mod.rs-Konvention war bis 2018 die einzige.

Vor Rust 1.30 (Oktober 2018) musstest du Sub-Module zwingend in <modul>/mod.rs ablegen. Mit der 2018-Edition wurde die <modul>.rs + <modul>/ Variante stabilisiert. Neue Codebases nutzen die moderne Variante; ältere Repos mischen oft beides. Beide sind unterstützt und auch in der 2024-Edition vollkommen gültig.

src/lib.rs und src/main.rs sind zwei verschiedene Crates.

Selbst in einem Projekt, das beides hat, sind Library und Binary aus Sicht des Compilers zwei unabhängige Crates. main.rs importiert die Library wie ein externer User über use meine_app::*; — der Name kommt aus package.name in Cargo.toml, wobei Bindestriche zu Unterstrichen werden. Das macht es einfach, die Library später aus dem Binary herauszulösen.

Die Library-Crate-Datei kann auch anders heißen.

Wenn du in Cargo.toml ein [lib]-Tabellen-Feld definierst, kannst du Name und Pfad überschreiben: [lib] name = "kern" path = "src/wurzel.rs". In 99 % der Fälle willst du das aber nicht — die Convention-over-Configuration ist hier ein Feature, kein Bug.

build.rs läuft vor dem Build deines Crates.

Liegt im Crate-Root (neben Cargo.toml, nicht im src/) eine Datei build.rs, wird sie als eigenes kleines Rust-Programm vor jedem Build kompiliert und ausgeführt. Typische Anwendung: Code-Generierung aus Schemas, Verlinkung mit C-Libraries, Einsetzen von Build-Zeit-Konstanten wie Git-Hash und Build-Datum.

Tests in tests/ sehen nur das öffentliche Interface.

Integration-Tests sind eigenständige Crates — sie können nur pub-markierte Items deiner Library nutzen. Das zwingt dazu, das öffentliche API beim Testen real auszuprobieren. Für Tests an privaten Funktionen nutzt du Unit-Tests in #[cfg(test)] mod tests direkt in der Quelldatei.

Ein tests/common/mod.rs ist eine bewusste Shared-Code-Lösung.

Tests in tests/ sind sonst jeweils ein eigenes Crate — was bedeutet, dass tests/util.rs selbst ein Test-Crate wäre. Mit der Konvention tests/common/mod.rs (im Ordner, nicht direkt im tests/-Root) erkennt Cargo, dass es geteilter Test-Code ist und nicht als eigener Test gebaut werden soll.

examples/ kompiliert Cargo bei cargo test still mit.

Auch wenn Examples keine Tests sind: bei cargo test versucht Cargo, sie zu bauen. Das ist eine eingebaute Schutzschicht — wenn deine API sich ändert und ein Example bricht, fällt der Test-Run um, bevor jemand die Doku liest. cargo test --no-fail-fast zeigt dir alle gleichzeitig kaputten Stellen auf einmal.

Workspaces brauchen seit 2021 den resolver = "2".

Der Default-Feature-Resolver (v1) kombiniert Features aller Workspace-Member in einer Weise, die in komplexen Setups zu unerwünschtem Feature-Bleed führt. resolver = "2" ist seit Edition 2021 für neue Projekte Default — bei Workspaces musst du ihn aber explizit im Root-Cargo.toml setzen, da die Edition pro Member-Crate gilt.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Grundlagen

Zur Übersicht