# Rust Development Mac Tahoe Setup Adding Rust development on top of an Apple Silicon Mac already configured per [[General_Development_Mac_Tahoe_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the zsh setup all work identically. This guide only covers the Rust-specific additions. > [!tip] AI assistance is optional > The reference to Claude Code above assumes you completed Part 7 of the General guide. If you skipped it (which is fine — your dev environment works without any AI) or use a different AI tool (Copilot, Cursor, Continue, etc.), this guide's instructions still work — the language toolchain doesn't depend on any AI being present. > [!info] Coexistence > C, Python, Ruby, Go, and Rust can all live on the same system without interfering. Each language's toolchain installs to its own prefix: `uv` under `~/.local/share/uv/`, `rv` under `~/.rv/`, Go under `~/go/`, Rust under `~/.rustup/` and `~/.cargo/`, C/CMake under Homebrew. No conflicts. --- ## Install Rust via rustup **Use rustup, not Homebrew's `rust` formula.** The Rust team maintains rustup as the canonical toolchain manager; Homebrew's build lags behind and doesn't handle toolchain switching or component management. Everyone serious about Rust uses rustup. ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` The installer is interactive. Accept the default ("Proceed with standard installation") unless you have a specific reason to customize. It installs: - `rustc` — the compiler - `cargo` — the package manager and build tool - `rustup` — the toolchain manager (install multiple Rust versions, switch between stable/nightly) - `rustfmt` — the formatter - `clippy` — the linter - Native arm64 toolchain (Apple Silicon build) Everything lands in `~/.cargo/` and `~/.rustup/`. ### Add Cargo's bin directory to PATH The installer typically prompts you and handles this, but verify by adding to `~/.zshrc`: ```bash # Rust — make cargo-installed binaries callable . "$HOME/.cargo/env" ``` Reload with `source ~/.zshrc`. Verify: ```bash rustc --version cargo --version rustup --version ``` ### Updating Rust ```bash rustup update rustup update nightly rustup show ``` ### Installing additional toolchains For occasional use of nightly features (e.g., benchmarks, specific unstable features): ```bash rustup toolchain install nightly rustup run nightly cargo build rustup override set nightly ``` --- ## Global Rust tooling Install these once — they're used across all Rust projects: ```bash # Fast replacement for cargo-audit, watches files, reruns tests/build cargo install cargo-watch # Dependency security audit (checks against RustSec advisories) cargo install cargo-audit # Faster incremental builds cargo install cargo-nextest # Cargo extension that shows a dependency tree cargo install cargo-tree 2>/dev/null || true # cargo-edit — adds `cargo add`, `cargo rm`, `cargo upgrade` # NOTE: `cargo add` is built-in as of 1.62, but cargo-edit adds `upgrade` and more cargo install cargo-edit # cargo-outdated — shows outdated deps cargo install cargo-outdated ``` All of these land in `~/.cargo/bin/`, which is on your PATH from the `. "$HOME/.cargo/env"` line. --- ## VS Code extensions for Rust ```bash # The official rust-analyzer language server (formerly RLS, which is deprecated) code --install-extension rust-lang.rust-analyzer # CodeLLDB — debugger that works natively on Apple Silicon code --install-extension vadimcn.vscode-lldb # crates — shows latest versions of crates in Cargo.toml code --install-extension serayuzgur.crates # Even Better TOML (also useful for general dev — you may already have it) code --install-extension tamasfe.even-better-toml ``` > [!info] rust-analyzer vs "Rust" extension > The extension named just "Rust" (previously maintained by the Rust team via RLS) is deprecated. `rust-analyzer` replaced it years ago and is now the official choice — published by `rust-lang.rust-analyzer`. If you see tutorials recommending the "Rust" extension by rust-lang, they're outdated. --- ## VS Code settings Append these language-specific settings to your existing `settings.json` at `~/Library/Application Support/Code/User/settings.json`: ```json { // ── Rust ────────────────────────────────────────────────── "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true, "editor.tabSize": 4, "editor.rulers": [100] }, "rust-analyzer.check.command": "clippy", "rust-analyzer.cargo.features": "all", "rust-analyzer.inlayHints.chainingHints.enable": true, "rust-analyzer.inlayHints.parameterHints.enable": true, "rust-analyzer.inlayHints.typeHints.enable": true, "rust-analyzer.inlayHints.closureReturnTypeHints.enable": "always", "rust-analyzer.lens.enable": true, "rust-analyzer.lens.run.enable": true, "rust-analyzer.lens.debug.enable": true, "rust-analyzer.hover.actions.enable": true } ``` > [!info] `rust-analyzer.check.command: "clippy"` > By default, `rust-analyzer` uses `cargo check` for on-the-fly diagnostics. Switching to `clippy` runs the linter continuously, so you get style and correctness warnings (like "this could be simplified", "unused import", "collapsible if") inline as you type. --- ## Full demo: A calculator program This walks through building, testing, debugging, and publishing a four-function calculator from scratch. Parallel implementations exist in C, Python, Ruby, and Go. ### Create the project ```bash cd ~/projects cargo new --lib calc-rust cd calc-rust # Add a binary target for the CLI mkdir -p src/bin ``` `cargo new --lib` creates a library crate. Structure: ``` calc-rust/ ├── Cargo.toml ├── .gitignore └── src/ ├── lib.rs └── bin/ ``` ### `Cargo.toml` ```toml [package] name = "calc" version = "0.1.0" edition = "2021" [lib] name = "calc" path = "src/lib.rs" [[bin]] name = "calc" path = "src/bin/calc.rs" [[bin]] name = "calc-gui" path = "src/bin/calc_gui.rs" [dependencies] # (GUI dep added in the GUI section) [dev-dependencies] ``` ### `src/lib.rs` — the core library ```rust //! Four-function calculator library. use std::fmt; #[derive(Debug, PartialEq)] pub enum CalcError { DivideByZero, UnknownOperator(String), ParseError(String), } impl fmt::Display for CalcError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CalcError::DivideByZero => write!(f, "division by zero"), CalcError::UnknownOperator(op) => write!(f, "unknown operator '{op}'"), CalcError::ParseError(s) => write!(f, "cannot parse '{s}' as a number"), } } } impl std::error::Error for CalcError {} pub fn add(a: f64, b: f64) -> f64 { a + b } pub fn sub(a: f64, b: f64) -> f64 { a - b } pub fn mul(a: f64, b: f64) -> f64 { a * b } pub fn div(a: f64, b: f64) -> Result<f64, CalcError> { if b == 0.0 { Err(CalcError::DivideByZero) } else { Ok(a / b) } } /// Dispatch to the appropriate operation based on `op`. /// Supported operators: "+", "-", "*", "x", "/". pub fn calculate(a: f64, op: &str, b: f64) -> Result<f64, CalcError> { match op { "+" => Ok(add(a, b)), "-" => Ok(sub(a, b)), "*" | "x" => Ok(mul(a, b)), "/" => div(a, b), _ => Err(CalcError::UnknownOperator(op.to_string())), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_add() { assert_eq!(calculate(6.0, "+", 7.0), Ok(13.0)); } #[test] fn test_sub() { assert_eq!(calculate(10.0, "-", 3.0), Ok(7.0)); } #[test] fn test_mul_star() { assert_eq!(calculate(4.0, "*", 5.0), Ok(20.0)); } #[test] fn test_mul_x() { assert_eq!(calculate(4.0, "x", 5.0), Ok(20.0)); } #[test] fn test_div() { assert_eq!(calculate(20.0, "/", 4.0), Ok(5.0)); } #[test] fn test_div_by_zero() { assert_eq!(calculate(5.0, "/", 0.0), Err(CalcError::DivideByZero)); } #[test] fn test_unknown_operator() { assert!(matches!( calculate(1.0, "?", 2.0), Err(CalcError::UnknownOperator(_)) )); } // Parametrized-style: table-driven tests (a Rust idiom) #[test] fn test_operations_table() { let cases: &[(f64, &str, f64, f64)] = &[ (1.0, "+", 1.0, 2.0), (0.0, "+", 0.0, 0.0), (-1.0, "+", 1.0, 0.0), (1.5, "+", 2.5, 4.0), (10.0, "-", 20.0, -10.0), (3.0, "*", 4.0, 12.0), ]; for (a, op, b, expected) in cases { assert_eq!( calculate(*a, op, *b), Ok(*expected), "calculate({a}, {op:?}, {b}) should equal {expected}" ); } } } ``` ### `src/bin/calc.rs` — the CLI ```rust //! Command-line entry point for the four-function calculator. use std::env; use std::process::ExitCode; use calc::calculate; fn usage(prog: &str) { eprintln!("Usage: {prog} <number> <op> <number>"); eprintln!(" op: + - * /"); eprintln!("Example: {prog} 6 + 7"); } fn main() -> ExitCode { let args: Vec<String> = env::args().collect(); if args.len() != 4 { usage(&args[0]); return ExitCode::from(1); } let a: f64 = match args[1].parse() { Ok(v) => v, Err(_) => { eprintln!("Error: cannot parse '{}' as a number", args[1]); return ExitCode::from(1); } }; let b: f64 = match args[3].parse() { Ok(v) => v, Err(_) => { eprintln!("Error: cannot parse '{}' as a number", args[3]); return ExitCode::from(1); } }; match calculate(a, &args[2], b) { Ok(result) => { // Print as integer if it came out whole if result == (result as i64) as f64 { println!("{}", result as i64); } else { println!("{result}"); } ExitCode::SUCCESS } Err(e) => { eprintln!("Error: {e}"); ExitCode::from(1) } } } ``` ### `.gitignore` Cargo generates a minimal one. Append if needed: ``` target/ Cargo.lock .DS_Store ``` > [!info] `Cargo.lock` — commit it? > For **binary** crates (i.e., you distribute compiled executables), commit `Cargo.lock` for reproducible builds. For **library** crates (other projects depend on yours), don't commit it — downstream users need their own resolution. This project is a binary, so keep it in git. ### Build and run ```bash # Run the CLI (debug build, fast to compile) cargo run --bin calc -- 6 + 7 cargo run --bin calc -- 10 - 3 cargo run --bin calc -- 4 x 5 cargo run --bin calc -- 20 / 4 # Release build (optimized, slower to compile) cargo build --release ./target/release/calc 6 + 7 # Install to ~/.cargo/bin/ so `calc` is callable anywhere cargo install --path . --bin calc # Run tests cargo test # Or with nextest for faster parallel runs (if you installed cargo-nextest) cargo nextest run # Lint cargo clippy -- -D warnings # Format all files in place cargo fmt # Check formatting without modifying files cargo fmt --check # Full quality gate (common in CI) cargo fmt --check && cargo clippy -- -D warnings && cargo test ``` ### Debug in VS Code With `vadimcn.vscode-lldb` installed, CodeLens appears above each `fn main()` and each `#[test]` with "Run | Debug" buttons. Click **Debug** to step through. For the CLI with arguments, create `.vscode/launch.json`: ```json { "version": "0.2.0", "configurations": [ { "name": "Debug calc CLI", "type": "lldb", "request": "launch", "cargo": { "args": ["build", "--bin=calc"], "filter": { "name": "calc", "kind": "bin" } }, "args": ["6", "+", "7"], "cwd": "${workspaceFolder}" }, { "name": "Debug unit tests", "type": "lldb", "request": "launch", "cargo": { "args": ["test", "--no-run", "--lib"], "filter": { "name": "calc", "kind": "lib" } }, "args": [], "cwd": "${workspaceFolder}" } ] } ``` Set a breakpoint, hit **F5**, step through native arm64 code with full variable inspection. ### Publish to GitHub Same workflow as any project (see [[General_Development_Mac_Tahoe_Setup#Publishing a repo to GitHub]]): ```bash git init git add . git commit -m "Initial commit: four-function calculator" gh repo create calc-rust --private --source=. --remote=origin --push ``` ### `CLAUDE.md` for this project ```markdown # Project: calc-rust ## Purpose Four-function command-line calculator in Rust — pedagogical example. ## Conventions - Rust 2021 edition, stable toolchain - Library (src/lib.rs) + binaries (src/bin/) - Tests colocated with code via #[cfg(test)] mod tests - Format with rustfmt (default style) - Lint with clippy — treat warnings as errors in CI ## Commands - Run CLI: `cargo run --bin calc -- <a> <op> <b>` - Run GUI: `cargo run --bin calc-gui` - Build release: `cargo build --release` - Install: `cargo install --path . --bin calc` - Test: `cargo test` (or `cargo nextest run` for parallel) - Lint: `cargo clippy -- -D warnings` - Format: `cargo fmt` - Quality gate: `cargo fmt --check && cargo clippy -- -D warnings && cargo test` ## Style - 4-space indent, 100-column line length - snake_case for functions and variables, CamelCase for types - Return `Result<T, E>` with a concrete error enum for recoverable errors - Use `#[derive(Debug, PartialEq)]` on public types where possible - Doc comments (`///`) on every public item ``` --- ## Full demo: A calculator GUI Same calculator, wrapped in a graphical interface. This uses **iced**, a pure-Rust, Elm-inspired GUI toolkit — the most popular Rust GUI library in 2026. ### Why iced? | Option | Pros | Cons | |--------|------|------| | **iced** | Pure Rust, Elm architecture (predictable state), native-feeling, actively developed | Still pre-1.0 (API shifts between versions) | | **egui** | Immediate-mode, great for tools/debug UIs, very simple | Custom widget look — not native | | **Slint** | Declarative DSL, compiles to native, excellent perf | Custom language (`.slint` files) — not pure Rust | | **Tauri** | HTML/JS frontend + Rust backend | Bundles a webview | | **GTK-rs** | Native GTK widgets | Dated on macOS, heavy dependencies | For a pedagogical calculator in Rust, iced is the right default. The Elm architecture (Model + Update + View) makes the state machine obvious and unit-testable. ### Add iced as a dependency Add to `Cargo.toml`: ```toml [dependencies] iced = "0.13" ``` Or use `cargo-edit`: ```bash cargo add iced ``` ### `src/bin/calc_gui.rs` — the GUI ```rust //! Graphical four-function calculator using iced. use iced::widget::{button, column, container, row, text, text_input}; use iced::{Element, Length, Task, Theme}; use calc::{calculate, CalcError}; pub fn main() -> iced::Result { iced::application("Calculator", Calculator::update, Calculator::view) .theme(|_| Theme::Dark) .window_size((260.0, 360.0)) .run() } #[derive(Debug, Clone)] pub enum Message { Press(String), Clear, } #[derive(Default)] pub struct Calculator { current: String, stored: Option<f64>, pending_op: Option<String>, display: String, } impl Calculator { pub fn new() -> Self { Self { display: "0".to_string(), ..Default::default() } } pub fn update(&mut self, message: Message) -> Task<Message> { match message { Message::Press(label) => self.handle_press(&label), Message::Clear => self.clear(), } Task::none() } pub fn handle_press(&mut self, label: &str) { match label { d if d.chars().all(|c| c.is_ascii_digit() || c == '.') => { self.current.push_str(d); self.display = self.current.clone(); } "+" | "-" | "*" | "/" => { self.apply_pending(); self.pending_op = Some(label.to_string()); } "=" => { self.apply_pending(); self.pending_op = None; } _ => {} } } pub fn clear(&mut self) { self.current.clear(); self.stored = None; self.pending_op = None; self.display = "0".to_string(); } fn apply_pending(&mut self) { if self.current.is_empty() { return; } let value: f64 = match self.current.parse() { Ok(v) => v, Err(_) => { self.display = "Error".to_string(); self.reset_internal(); return; } }; let new_value = match (self.stored, self.pending_op.as_deref()) { (None, _) | (_, None) => value, (Some(s), Some(op)) => match calculate(s, op, value) { Ok(v) => v, Err(_) => { self.display = "Error".to_string(); self.reset_internal(); return; } }, }; self.stored = Some(new_value); // Format: integer if whole self.display = if new_value == (new_value as i64) as f64 { (new_value as i64).to_string() } else { format!("{new_value}") }; self.current.clear(); } fn reset_internal(&mut self) { self.current.clear(); self.stored = None; self.pending_op = None; } pub fn view(&self) -> Element<Message> { let display = text_input("0", &self.display) .size(28) .align_x(iced::alignment::Horizontal::Right); let btn = |label: &str| -> Element<Message> { button(text(label).size(18).center()) .width(Length::Fill) .padding(12) .on_press(Message::Press(label.to_string())) .into() }; let grid = column![ row![btn("7"), btn("8"), btn("9"), btn("/")].spacing(5), row![btn("4"), btn("5"), btn("6"), btn("*")].spacing(5), row![btn("1"), btn("2"), btn("3"), btn("-")].spacing(5), row![btn("0"), btn("."), btn("="), btn("+")].spacing(5), ] .spacing(5); let clear = button(text("Clear").center()) .width(Length::Fill) .padding(10) .on_press(Message::Clear); container( column![display, grid, clear] .spacing(10) .padding(10), ) .width(Length::Fill) .height(Length::Fill) .into() } } #[cfg(test)] mod tests { use super::*; fn press_sequence(presses: &[&str]) -> Calculator { let mut c = Calculator::new(); for p in presses { c.handle_press(p); } c } #[test] fn initial_display() { let c = Calculator::new(); assert_eq!(c.display, "0"); } #[test] fn single_digit() { let c = press_sequence(&["5"]); assert_eq!(c.display, "5"); } #[test] fn multi_digit() { let c = press_sequence(&["1", "2", "3"]); assert_eq!(c.display, "123"); } #[test] fn simple_addition() { let c = press_sequence(&["6", "+", "7", "="]); assert_eq!(c.display, "13"); } #[test] fn chained_operations() { // 2 + 3 * 4 → (2+3)*4 = 20 (left-to-right) let c = press_sequence(&["2", "+", "3", "*", "4", "="]); assert_eq!(c.display, "20"); } #[test] fn division() { let c = press_sequence(&["2", "0", "/", "4", "="]); assert_eq!(c.display, "5"); } #[test] fn division_by_zero() { let c = press_sequence(&["5", "/", "0", "="]); assert_eq!(c.display, "Error"); } #[test] fn clear_resets_state() { let mut c = press_sequence(&["5", "+", "3"]); c.clear(); assert_eq!(c.display, "0"); assert!(c.current.is_empty()); assert!(c.stored.is_none()); assert!(c.pending_op.is_none()); } #[test] fn decimal_input() { let c = press_sequence(&["1", ".", "5", "+", "2", ".", "5", "="]); assert_eq!(c.display, "4"); } } ``` ### Run everything ```bash # CLI cargo run --bin calc -- 6 + 7 # GUI (may take a while to compile the first time — iced pulls in winit, wgpu, etc.) cargo run --bin calc-gui # All tests (library + GUI state machine) cargo test # Release builds cargo build --release ./target/release/calc-gui ``` The first iced build is slow (several minutes) because it compiles the whole graphics stack — winit for windowing, wgpu for rendering. Subsequent builds are cached and fast. > [!info] Shipping iced apps > For a distributable macOS `.app` bundle with icon and code signing, use `cargo bundle`: > ```bash > cargo install cargo-bundle > cargo bundle --release > ``` > This produces `target/release/bundle/osx/Calculator.app` ready for `codesign` and `notarytool`. --- ## Starship prompt — Rust auto-detection The general Starship config you set up already includes the `[rust]` module. When you `cd` into this project, the prompt shows: ``` ~/projects/calc-rust main 1.83.0 ❯ ``` Reading left-to-right: directory, git branch, Rust version. --- ## Installing CLI tools from other Rust projects Cargo's `cargo install` is the equivalent of `uv tool install` or `go install` — it installs binaries globally (to `~/.cargo/bin/`) from any Rust project on crates.io or from a git URL: ```bash # Install ripgrep from crates.io (you may already have it via brew) cargo install ripgrep # Install bat (cat clone with syntax highlighting) cargo install bat # Install tokei (fast code counter) cargo install tokei # Install from a git repo cargo install --git https://github.com/you/your-tool.git ``` These land in `~/.cargo/bin/` and are callable by name anywhere. > [!info] Cargo vs Homebrew for Rust tools > Many popular Rust CLIs (ripgrep, bat, fd, eza) are available via both `brew` and `cargo install`. `brew` gives you precompiled binaries and faster installation; `cargo install` compiles from source each time but gives you the latest version with your local CPU's optimizations. For daily use, prefer `brew`. Use `cargo install` when Homebrew doesn't carry the tool, or when you want a specific version. --- ## Troubleshooting > [!warning] `rustc: command not found` after installing > The installer adds `. "$HOME/.cargo/env"` to your shell config. Verify with `grep cargo/env ~/.zshrc`. If missing, add it and open a fresh terminal. Don't `source` it manually in a running shell that was open before the install — just restart the terminal. > [!warning] Very slow first build of a GUI project > iced (and egui, and Slint) pull in substantial graphics dependencies (winit, wgpu, glyphon). The first `cargo build` for a GUI project typically takes 3–8 minutes. Subsequent builds are incremental and take seconds. This is normal — the whole graphics stack is compiled from source once and cached under `target/`. > [!warning] `rust-analyzer` seems unresponsive in VS Code > Open a terminal and run `cargo check` first. rust-analyzer uses cargo under the hood — if that's stuck indexing or building, VS Code can appear frozen. Also check the "Rust Analyzer Language Server" output panel in VS Code for errors. > [!warning] `cargo build` error: "linker `cc` not found" > You need the Xcode Command Line Tools (provides `cc`/`clang`). Run `xcode-select --install`. This is a prerequisite from the general setup and should already be installed. > [!warning] `cargo test` failing with "error: could not compile" > Read the first error carefully — Rust's compiler messages are famously good. Common causes: forgotten `&` on borrows, mismatched types, missing `use` statements, and lifetime issues. rust-analyzer in VS Code catches most of these before you hit `cargo test`. --- ## Summary — the one-shot Rust addition > [!warning] This is a checklist, not a script > The block below is **not a shell script you can save and run.** It's a sequence of commands to **copy and paste into your terminal one block at a time**, in order. The first step (`curl ... | sh`) **runs the interactive rustup installer** — you have to read its prompts and respond before the rest will work. The later `cargo install` commands compile from source and can take 1–3 minutes each. After running these, you also need to **append the recommended Rust settings block** from earlier in this guide to `~/Library/Application Support/Code/User/settings.json`. ```bash # Rust toolchain (interactive installer — accept defaults) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh . "$HOME/.cargo/env" # Global Cargo tooling cargo install cargo-watch cargo-audit cargo-nextest cargo-edit cargo-outdated # VS Code extensions code --install-extension rust-lang.rust-analyzer code --install-extension vadimcn.vscode-lldb code --install-extension serayuzgur.crates # Paste the Rust block into ~/Library/Application Support/Code/User/settings.json # Verify rustc --version && cargo --version && rustup --version ``` About five minutes of install time on top of the general setup (the `cargo install` calls compile from source). --- ## Related notes - [[General_Development_Mac_Tahoe_Setup]] - [[Python_Development_Mac_Tahoe_Setup]] - [[C_Development_Mac_Tahoe_Setup]] - [[Ruby_Development_Mac_Tahoe_Setup]] - [[Go_Development_Mac_Tahoe_Setup]] - [Cargo Cheat Sheet](https://doc.rust-lang.org/cargo/) - [iced Architecture Notes](https://book.iced.rs/architecture.html)