Working in Swift means juggling a handful of tools. SwiftPM (the Swift Package Manager) for dependencies and command-line builds, the swift CLI for the compiler itself, Xcode for the GUI workflow on Apple platforms, XCTest or Swift Testing for the tests, plus SwiftLint and swift-format on the side. Boundaries have shifted as the language matured, and where exactly you run your tests has historically depended on whether you were sitting in front of macOS or a Linux box.
Rust collapses all of that into one binary. Build, test, run, format, lint, doc, bench, publish: each is a cargo subcommand, and rustup installs the whole set in a single toolchain. Third-party subcommands drop in as cargo-<name> binaries and slot into the same surface (cargo audit, cargo expand, cargo nextest), so even when you reach outside the standard set, the muscle memory carries over.
Cargo and the manifest
Cargo's manifest is Cargo.toml, a declarative config file in TOML (Tom's Obvious Minimal Language). It carries the package metadata, the dependency list, per-profile build settings, optional features, and the workspace layout if there is one. Crucially, nothing in it executes; the parser reads the file and returns a struct.
[package]
name = "my-crate"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
proptest = "1"
[features]
default = ["std"]
std = []
Swift went the other way. Package.swift is a real Swift source file that constructs a Package value through a small DSL (Domain-Specific Language, a focused mini-language exposed through ordinary Swift syntax), and the file runs through the compiler at resolution time:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyPackage",
products: [
.library(name: "MyPackage", targets: ["MyPackage"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-collections", from: "1.0.0"),
],
targets: [
.target(
name: "MyPackage",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
]
),
.testTarget(name: "MyPackageTests", dependencies: ["MyPackage"]),
]
)
The two formats trade off in opposite directions. A declarative TOML file is something IDE tooling can read, validate, and complete without running anything, and one crate's manifest is independent of every other in the graph. An executable Package.swift can compute its own dependency list, branch on environment variables, or read files from disk. Useful when you need it, and at every other moment, slower and harder to reason about than a parsed config.
Dependencies are pinned by semver (MAJOR.MINOR.PATCH, breaking changes only on MAJOR). Cargo defaults to caret requirements ("1.2" means >=1.2.0, <2.0.0); SwiftPM uses from: "1.2.0" for the same shape. Both produce a lockfile recording the exact resolved version of every transitive dependency: Cargo.lock and Package.resolved. Both ship with the same advice: commit the lockfile in binaries, leave it out of libraries so downstream consumers pick their own resolution.
The registries diverge sharply. Rust's crates.io is a single centralised registry, names are first-come-first-served, versions are immutable once published, and the registry is the canonical lookup for the entire ecosystem. SwiftPM has nothing of the kind out of the box. Dependencies resolve by Git URL, and the Swift Package Index (community-run, on top of GitHub) fills the discovery role unofficially. Apple introduced a registry protocol in Swift 5.7 (2022), but adoption has been slow and most Package.swift files still list URLs. The split makes sense in context: the iOS and macOS world skews toward private organisation repositories, while the server-and-systems world Rust started in needs the kind of shared discovery a central registry provides.
A Cargo workspace is one Cargo.toml with members = [...], and each member is a crate with its own manifest. They share a single Cargo.lock, a single target/ build directory, and (since Rust 1.64, September 2022) a [workspace.dependencies] block where the root pins versions for every member to inherit:
[workspace]
members = ["api", "worker", "shared"]
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
A member crate then writes serde = { workspace = true } and the version lines up automatically. It is the kind of thing you barely notice until you have a six-crate repo and realise you only have one place to bump versions.
SwiftPM doesn't quite have that. The closest equivalent is a multi-target package: one Package.swift declares several targets (libraries, executables, test targets) inside a single package and shares dependencies through the package-level dependencies array. For genuinely separate packages in the same repo, the lever is local path dependencies (.package(path: "../other-package")). Workable, but flatter: there's no shared lockfile, no shared build cache, and no single place to pin versions.
| Concern | Cargo | SwiftPM |
|---|---|---|
| Manifest format | TOML, declarative | Swift source, executable |
| Lockfile | Cargo.lock | Package.resolved |
| Default registry | crates.io (centralised) | Git URLs (decentralised) |
| Multi-crate layout | Workspaces, shared lockfile and cache | Local path dependencies, per-package |
| Version inheritance | [workspace.dependencies] (1.64) | Per-package dependencies array |
Build, test, lint, doc
The day-to-day surface is where the unified-versus-split shape is most obvious. Cargo runs everything through one command. The Swift toolchain spreads the same workflows across swift (build, run, test), swift-format, SwiftLint, DocC (Apple's Documentation Compiler), XCTest or Swift Testing, and the Xcode GUI on Apple platforms.
Building and running line up cleanly. cargo build and swift build compile the package and drop artifacts in target/ and .build/ respectively. cargo run and swift run compile and execute. cargo test and swift test run the test suite.
The Rust side adds one thing Swift has no SwiftPM-level answer to: cargo check. It runs the compiler through type-checking and borrow-checking but stops short of code generation, which is the slow phase. The result is that editing a file and re-running cargo check returns errors several times faster than a full cargo build, and IDE integrations (rust-analyzer especially) lean on it for save-time feedback. Swift's incremental rebuild fills a similar role in practice; the discipline of a separate "just check the types, don't bother with codegen" subcommand never made it into SwiftPM.
Tests are file-and-attribute on each side. Rust uses #[test] and the tests/ directory:
#[test]
fn parses_an_empty_input() {
assert_eq!(parse(""), Ok(Document::empty()));
}
Swift's recent answer is Swift Testing, introduced in 2024 and shipped with Swift 6 as the successor to XCTest:
import Testing
@Test func parsesAnEmptyInput() {
#expect(parse("") == .empty)
}
Both run tests in parallel by default and discover them straight from the source layout, so there's nothing extra to wire up. XCTest will keep going for years in existing codebases; new packages reach for Swift Testing.
Formatting and linting are where Apple has caught up most visibly. Cargo has shipped cargo fmt (rustfmt) and cargo clippy (the official linter) as part of every rustup install since the early days. The Swift side has historically left these to the community: SwiftLint has been the de-facto linter for years, and swift-format was a separate Apple project before becoming the canonical formatter shipped with the toolchain in Swift 5.10 (2024). There's still no Apple-blessed linter; for that, SwiftLint remains the answer.
Documentation has converged on the same shape from different starts. cargo doc generates HTML from /// comments, including signatures, examples that double as compile-checked tests, and cross-links between items. DocC, originally part of the closed-source Apple developer documentation pipeline, was integrated into SwiftPM via plugin (swift package generate-documentation) starting in Swift 5.6 (2022). DocC has more polish for tutorials and articles; on the Rust side, cargo doc --open is something every Rust developer types reflexively.
Benchmarking is the soft spot on both sides. cargo bench is technically built into Cargo but its harness is nightly-only, so production benchmark suites lean on the third-party criterion crate. Swift has no first-party benchmark runner at all; the Swift Server Work Group's package-benchmark is the de-facto choice. Neither language has the kind of consensus tool that Go's testing.B is for Go.
| Task | Cargo | Swift toolchain |
|---|---|---|
| Build | cargo build | swift build |
| Run | cargo run | swift run |
| Typecheck only | cargo check | (no SwiftPM subcommand) |
| Test | cargo test | swift test (XCTest or Swift Testing) |
| Format | cargo fmt (rustfmt, in toolchain) | swift-format (in toolchain, since 5.10) |
| Lint | cargo clippy (in toolchain) | SwiftLint (third-party) |
| Doc | cargo doc | swift package generate-documentation (DocC) |
| Bench | cargo bench (nightly) or criterion | package-benchmark (third-party) |
| Publish | cargo publish (crates.io) | git tag (no central registry by default) |
Editions and conditional compilation
Both languages need to evolve syntax and semantics without breaking every published package, and both took the same general route: an opt-in version selector per package, with the older version still compiling fine in the same build. Rust calls them editions; Swift calls them language modes.
A Rust edition is declared per crate, in Cargo.toml:
[package]
name = "my-crate"
version = "0.1.0"
edition = "2024"
There have been four so far: 2015 (the implicit pre-1.0 baseline), 2018, 2021, and 2024. Each has been allowed to introduce changes that would otherwise have broken existing source. 2018 added the dyn Trait syntax (covered in the type-system post) and reworked module path resolution. 2021 changed how closures capture struct fields, doing it field by field rather than capturing the whole struct. 2024 tightened temporary-value lifetimes and reserved a handful of new keywords. The crucial property is that editions are mixable in one binary: if your crate is on 2024 and a dependency is still on 2018, the compiler reads each crate's edition independently and they coexist fine. Migration is mostly automated by cargo fix --edition.
Swift's parallel is the language mode, set per target via swiftLanguageVersions in Package.swift or -swift-version at the compiler level:
.target(
name: "MyTarget",
swiftSettings: [
.swiftLanguageMode(.v6)
]
)
The early Swift versions (1, 2, 3) each broke source on purpose. From Swift 4 onward, the version selector became the way to opt into stricter behaviour without forcing every downstream package to migrate at the same time. Swift 6 mode is the most consequential of those so far: strict Sendable checking by default (covered in the concurrency post), and several previously-warning conditions promoted to errors. A Swift 6 binary can depend on Swift 5 packages, the same way a Rust 2024 binary can depend on edition-2018 crates.
The two cadences differ. Editions ship roughly every three years and bundle several small changes; Swift modes line up with major language versions and tend to centre on one big shift at a time (concurrency in 6, the generics rework in 5.7).
Conditional compilation handles the other half: same source running through the parser, but with #[cfg(...)] attributes (Rust) or #if directives (Swift) selecting which code is included. Rust's cfg predicates check a small fixed set of keys (target_os, target_arch, target_pointer_width, feature and so on) and compose with any, all, and not:
#[cfg(target_os = "linux")]
fn platform_specific() { /* Linux implementation */ }
#[cfg(any(target_os = "macos", target_os = "ios"))]
fn platform_specific() { /* Apple implementation */ }
#[cfg(feature = "serde")]
impl serde::Serialize for MyType {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
// ...
}
}
Swift's #if covers the same ground with slightly different vocabulary:
#if os(Linux)
func platformSpecific() { /* Linux implementation */ }
#elseif os(macOS) || os(iOS)
func platformSpecific() { /* Apple implementation */ }
#endif
#if canImport(Foundation)
import Foundation
#endif
#if compiler(>=5.9)
// code that uses Swift 5.9 features
#endif
The one Swift idiom without a Rust equivalent is canImport, the "use this module if it happens to be available" check. Rust's dependency model is explicit enough that the question never comes up. A crate is either in the graph or it isn't.
The interesting axis is feature flags. Cargo features are a first-class part of the manifest:
[features]
default = ["std"]
std = ["alloc"]
alloc = []
serde = ["dep:serde"]
[dependencies]
serde = { version = "1", optional = true }
A consumer turns features on the same way they pin a dependency:
[dependencies]
my-crate = { version = "0.5", features = ["serde"] }
The crate then guards code with #[cfg(feature = "serde")]. Features are additive across the dependency graph: if any consumer enables serde, every consumer of the crate sees it enabled, which keeps the resolved build deterministic (and occasionally surprising, but that's a separate post).
Swift didn't have this for a long time. Until 2025 the only workarounds were splitting functionality across separate targets or packages, or leaning on #if compiler(...) and #if canImport(...) checks. Swift 6.1 brought package traits: opt-in capabilities a package declares and consumers enable, with the same additive resolution semantics as Cargo features. The mechanism is younger, and the ecosystem hasn't converged on it the way crates.io has converged on Cargo features, but the shape is the same.
| Concern | Rust | Swift |
|---|---|---|
| Per-unit language mode | Editions per crate (edition = "2024") | Language modes per target (swiftLanguageMode) |
| Mixable in one binary | Yes | Yes |
| Migration tooling | cargo fix --edition | Compiler diagnostics, manual fixes |
| Platform/arch checks | #[cfg(target_os = ...)] | #if os(...), #if arch(...) |
| Module-presence checks | (none; dependencies are explicit) | #if canImport(...) |
| Compile-time features | Cargo features (#[cfg(feature = "...")]) | SwiftPM package traits (since 6.1, 2025) |
C FFI
Sooner or later one language has to talk to native code, or to the other one, and C is where they meet. The umbrella term is FFI (Foreign Function Interface, the mechanism a language gives you for calling foreign code and being called back). The reason both languages route through C in particular is that neither has a stable ABI (Application Binary Interface, the machine-level contract for calling conventions and data layout) other languages can target. Rust deliberately reserves the right to reorder fields and pick conventions; Swift has a stable ABI on Apple platforms (Swift 5.0, March 2019) but not on Linux or Windows. C, meanwhile, has had a stable platform ABI on every operating system Rust and Swift target for decades, so when a Rust crate calls into a system library or a Swift app calls into a Rust crate, the call goes through a C-shaped interface.
Calling C from Rust uses an extern "C" block to declare the foreign signatures with the calling convention named explicitly:
use std::ffi::{c_char, c_int};
extern "C" {
fn printf(fmt: *const c_char, ...) -> c_int;
}
Structs that cross the boundary need #[repr(C)] so the compiler lays out fields in C's left-to-right declaration order with C's padding rules:
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
For real C libraries, hand-translating headers gets tedious fast. The standard tool is bindgen, a code generator that reads C headers and emits the extern "C" blocks, struct definitions, and constants needed to call them. It runs either as a build-script step (in build.rs) or as a one-shot CLI that produces a checked-in bindings file. When the C library ships its source rather than a precompiled binary, the cc crate invokes the system C compiler from build.rs to build it as part of the Rust build.
Going the other direction (exposing a Rust API as a C ABI) is the mirror image. You annotate functions with extern "C" and #[no_mangle] so the linker emits the symbol under its declared name rather than the mangled Rust form:
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
cbindgen is the standard generator for this direction: it reads Rust source and emits a C header file that matches the exposed extern "C" surface.
The Swift side splits along platform lines. On Apple platforms, C interop is part of the language proper: drop a header into the source tree, list it in a bridging header in mixed Swift/Objective-C/C projects, or expose it through a module map (a small text file describing how a set of headers should be imported as a Swift module), and Swift treats the C functions and types as if they were Swift declarations. Foundation and most of UIKit are surfaced this way under the hood.
Off Apple platforms, the same module-map approach works without Xcode managing it. A SwiftPM target can declare a C target (.target(name: "MyCLib", publicHeadersPath: "include")) with a module.modulemap exposing the headers, and a Swift target depending on it imports the module:
import MyCLib
let result = my_c_function(42)
To go in the other direction (exposing Swift functions as a C ABI for other languages to call), the relevant attribute is @_cdecl, which overrides Swift's name mangling and exports the function under a C-compatible symbol name:
@_cdecl("swift_add")
public func swiftAdd(_ a: Int32, _ b: Int32) -> Int32 {
return a + b
}
The leading underscore marks @_cdecl as a private compiler attribute, but it has been the standard way to export Swift functions to C for years and shows no sign of moving. Function-typed parameters that need to be C-callable use @convention(c) to declare the calling convention.
A full Rust-to-Swift round trip goes through the C waist on both sides. The Rust crate exports an extern "C" API and ships a header generated by cbindgen; the Swift package imports that header through a module map and calls the functions directly. The wrinkle is memory ownership: Rust's borrow checker and Swift's ARC both stop at the C boundary, so any pointer that crosses has to be paired with an explicit free or release on the other side.
Two higher-level tools take most of that work off the table. uniffi, maintained by Mozilla, generates idiomatic Swift, Kotlin, Python, and Ruby bindings from a Rust source file annotated with procedural macros. Write Rust, and uniffi emits the C ABI plus the per-language wrapper. swift-bridge is in the same spirit but Swift-specific, with a custom DSL inside Rust source that declares the Swift-visible surface. Both handle the memory question by generating the matching free/release calls automatically.
| Direction | Rust mechanism | Swift mechanism |
|---|---|---|
| Calling C functions | extern "C", #[repr(C)], bindgen | Bridging header (Apple) or module map (SwiftPM) |
| Compiling C in build | cc crate in build.rs | .target with C source files in SwiftPM |
| Exposing C ABI | extern "C" + #[no_mangle], cbindgen | @_cdecl, @convention(c) |
| Cross-language wrapper | uniffi, swift-bridge (Rust to Swift) | (consumes the C layer the Rust side generates) |
Macros, briefly
Rust ships two macro families. Declarative macros, written with macro_rules!, do pattern-based syntactic substitution: vec![1, 2, 3], println!(...), format!(...), and most of the well-known macros in the standard library are declarative. Procedural macros are compiler plugins compiled to native code, and they come in three forms: derive macros (#[derive(Serialize)] generates impl blocks for the annotated type), attribute macros (#[tokio::main] rewrites the annotated item), and function-like macros (sql!(SELECT * FROM users) consumes a token stream and produces another). Both families participate in the type system after expansion, both are hygienic with respect to local identifiers, and both let crates ship code generation as a normal dependency.
Swift arrived at the same capability through three steps. Property wrappers (Swift 5.1, 2019) turned @Published var foo: Int into a value wrapped in a generic type that intercepts get and set; @State, @Binding, and @Environment in SwiftUI are all property wrappers. Result builders (Swift 5.4, 2021) turned a closure body into a value-building DSL through a small set of static methods on a builder type, which is the machinery behind SwiftUI's View body and the if-inside-body pattern. Swift macros (Swift 5.9, September 2023) are the equivalent of Rust's proc macros: external compiler plugins that expand at parse time, declared with a role (@expression, @declaration, @attached(member)) and a typed signature, with the expansion visible inline in the IDE.
The end capability is the same; the surface still feels different. Swift macros are declarative-feeling because the role and signature constrain what an expansion can produce, and Xcode shows the generated code right there. Rust proc macros are looser, with the expansion invisible by default and inspected through cargo expand. More flexibility on one side, more observability on the other.
Where the differences come from
Rust started with no IDE, no platform, and no installed base; the team built Cargo first and made it the entry point for everything else. The Swift toolchain started inside Xcode on macOS, with the IDE doing the work that Cargo does on the Rust side, and SwiftPM and the cross-platform swift CLI grew out from there over the following decade. The shape of each toolchain still reflects which end was solved first.
The result is that for a Swift developer working on Linux, Cargo is roughly what they wish SwiftPM had been all along: one manifest, one lockfile, formatter and linter and documentation generator all installed by rustup and invoked through cargo. SwiftPM is converging on this shape (DocC integrated, swift-format adopted, Swift Testing replacing XCTest, package traits in 6.1) but the consolidation is recent and the ecosystem hasn't fully caught up.
The honest comparison runs the other way on the IDE. Xcode on Apple platforms ships an integrated debugger, an Instruments-based profiler, an asset pipeline, an interface builder, and a provisioning workflow that Rust has no equivalent of. rust-analyzer is excellent, lldb works fine, perf and samply cover profiling, and VS Code or RustRover handle the editor surface, but the parts come from different projects and integrate at the editor level rather than at the language-tooling level. For an iOS or macOS app developer, Xcode does work that no Rust equivalent really matches.
That closes the series. Six posts, one recurring theme: Swift bundles common patterns into language primitives (classes, actors, ARC, property wrappers); Rust composes them out of orthogonal pieces (ownership, traits, marker types, library primitives). Bundling is friendlier at the call site for the patterns it covers; composition expresses every pattern, including the ones that have no bundled name. A Swift developer coming to Rust starts with most of the type-system intuition already in place, has to learn one genuinely new idea (ownership, with lifetimes as its time-axis extension), and finds the rest of the language built out of pieces that map cleanly onto Swift mechanisms they already know.