Chapter 2: Setting Up the Project¶
In this chapter we create the Rust project, add our dependencies, and build the
CLI skeleton with Clap. By the end of the chapter shot --help works, even
though none of the commands do anything yet.
Creating the project¶
Let's create a new Rust project first.
Dependencies¶
Open the Cargo.toml and add the following. We'll explain some of the crates as we use them.
<<cargo-header>>
<<cargo-deps>> [1, 2]
The header declares the package metadata, binary name, and TLS feature flags
that propagate rustls-tls through reqwest and the rattler crates.
[package]
name = "moonshot"
version = "0.1.0"
edition = "2021"
description = "A minimal Lua package manager built on rattler"
[[bin]]
name = "shot"
path = "src/main.rs"
[features]
default = ["rustls-tls"]
rustls-tls = [
"reqwest/rustls-tls",
"reqwest/rustls-tls-native-roots",
"rattler/rustls-tls",
"rattler_cache/rustls-tls",
"rattler_networking/rustls-tls",
"rattler_repodata_gateway/rustls-tls",
]
Cargo features are compile-time flags that enable optional functionality in a crate. Setting default-features = false turns off a crate's defaults so we can pick only what we need. The dependencies start with the core rattler crates.
[dependencies]
# Core rattler crates
rattler = { version = "0.40.0", default-features = false, features = ["indicatif"] }
rattler_cache = { version = "0.6.15", default-features = false }
rattler_conda_types = { version = "0.44.0", default-features = false }
rattler_digest = { version = "1.2.2", default-features = false }
rattler_networking = { version = "0.26.3", default-features = false, features = ["system-integration"] }
rattler_package_streaming = { version = "0.24.3", default-features = false }
rattler_repodata_gateway = { version = "0.27.0", default-features = false, features = ["gateway"] }
rattler_shell = { version = "0.26.3", default-features = false }
rattler_solve = { version = "5.0.0", default-features = false, features = ["resolvo"] }
rattler_index = { version = "0.27.17", default-features = false }
rattler_lock = { version = "0.27.1", default-features = false }
rattler_virtual_packages = { version = "2.3.12", default-features = false }
The remaining dependencies are general-purpose infrastructure: clap for CLI
parsing, tokio for async I/O, reqwest for HTTP, and so on.
# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process"] }
# CLI argument parsing
clap = { version = "4", features = ["derive", "color", "suggestions"] }
# Error handling: miette gives beautiful terminal diagnostics
miette = { version = "7", features = ["fancy"] }
thiserror = "2"
# fs-err wraps std::fs, adding the file path to every error message
fs-err = "3"
# Serialization: manifest I/O
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", default-features = false, features = ["macros"] }
toml = "0.9"
# HTTP client (versions must match what rattler expects)
reqwest = { version = "0.12", default-features = false, features = ["stream"] }
reqwest-middleware = "0.4"
# Progress bars
indicatif = "0.18"
# Console formatting
console = "0.16"
# Timestamps for package metadata
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
# SHA-256 for paths.json integrity data
sha2 = "0.10"
# JSON serialization used directly in build command
serde_json = "1"
# Temporary directory for build workspace
tempfile = "3"
# Directory walking for collecting build outputs
walkdir = "2"
# Logging / tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
The rattler crates (rattler, rattler_solve, rattler_shell, etc.) interact with the conda ecosystem; the rest is general-purpose infrastructure.
A package manager can surface errors from a lot of sources (network, filesystem, solver conflicts, malformed metadata), so we pull in miette with features = ["fancy"]. It renders structured diagnostics with source spans, which makes dependency conflicts and parse errors much easier to read than a plain error string. I can highly recommend it for most CLI applications.
Notice that we declare reqwest with features = ["stream"] for streaming downloads. TLS is handled at the crate level through the [features] section, where our rustls-tls feature propagates reqwest/rustls-tls and reqwest/rustls-tls-native-roots (along with matching features for the rattler crates). This gives us a pure-Rust TLS implementation via rustls, without linking against the system's OpenSSL.
The entry point: src/main.rs¶
Here is how the project is structured:
- src/
- main.rs CLI wiring, Tokio runtime
- client.rs shared HTTP client setup
- lock.rs moonshot.lock reader/writer
- manifest.rs moonshot.toml parser
- progress.rs spinner helpers
- project.rs project discovery
- session.rs networking + resolve
- environment.rs prefix activation
- build_backend.rs build trait
- commands/
- mod.rs module declarations
- init.rs
- search.rs
- add.rs
- install.rs
- lock.rs
- shell_hook.rs
- run.rs
- build.rs
Subcommand modules¶
The src/commands/mod.rs file declares all subcommand modules:
pub mod add;
pub mod build;
pub mod init;
pub mod install;
pub mod lock;
pub mod run;
pub mod search;
pub mod shell_hook;
The full main.rs is assembled from four named sections:
<<main-imports>>
<<main-cli-struct>> [1, 2]
<<main-fn>>
<<main-async>>
Let's take it section by section.
Imports and module declarations¶
The imports pull in Clap for argument parsing, miette for error reporting, and
the tracing filter types for log-level control. The mod declarations make the
rest of our crate visible, including the new project, session,
environment, and build_backend modules we will fill in over the coming
chapters.
use clap::Parser;
use miette::IntoDiagnostic;
use tracing_subscriber::{filter::LevelFilter, EnvFilter};
mod build_backend;
mod client;
mod commands;
mod environment;
mod lock;
mod manifest;
mod progress;
mod project;
mod session;
The CLI struct and subcommands¶
Clap's derive macros turn these struct and enum definitions into a full CLI
parser. Each variant of Command maps to a subcommand and carries its own
argument struct (defined in the corresponding module).
/// A minimal Lua package manager powered by rattler.
#[derive(Debug, Parser)]
#[clap(author, version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Command,
/// Enable verbose logging.
#[clap(short, long, global = true)]
verbose: bool,
}
The Command enum maps each subcommand to a variant that carries the
command's argument struct. We add one variant per chapter as we build out the
tool:
#[derive(Debug, clap::Subcommand)]
enum Command {
/// Create a new moonshot.toml in the current directory.
Init(commands::init::Args),
/// Search for packages in a channel.
Search(commands::search::Args),
/// Add one or more packages to the manifest.
Add(commands::add::Args),
/// Install (or update) all packages listed in moonshot.toml.
Install(commands::install::Args),
/// Resolve dependencies and write moonshot.lock.
Lock(commands::lock::Args),
/// Print a shell activation script.
ShellHook(commands::shell_hook::Args),
/// Run a command inside the activated environment.
Run(commands::run::Args),
/// Build a .conda package from the current project.
Build(commands::build::Args),
}
The synchronous entry point¶
Rust's async/await lets functions pause while waiting on slow operations like network requests. These async functions need a runtime to schedule and execute them. Tokio is the most widely used async runtime for Rust.
fn main builds a Tokio runtime and blocks on async_main. We configure two
thread pools: worker_threads for async work (HTTP requests, futures polling)
and max_blocking_threads for synchronous operations that would stall the async
scheduler (file I/O, archive extraction, solver runs).
Here miette::Result is a type alias for Result<T, miette::Report>. It works like the standard Result but carries structured error context (source locations, help text).
fn main() -> miette::Result<()> {
let num_cpus = std::thread::available_parallelism()
.map_or(2, std::num::NonZero::get)
.max(2);
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(num_cpus / 2)
.max_blocking_threads(num_cpus)
.enable_all()
.build()
.into_diagnostic()?;
runtime.block_on(async_main())
}
The async entry point¶
async_main parses the CLI arguments, sets up logging, and dispatches to the
right subcommand handler. We route all log output to stderr, leaving stdout
clean for machine-readable output (like shot shell-hook, which prints a shell
script). The --verbose flag raises the log level to DEBUG; users can also
set RUST_LOG=debug for more control.
async fn async_main() -> miette::Result<()> {
let cli = Cli::parse();
let default_level = if cli.verbose {
LevelFilter::DEBUG
} else {
LevelFilter::WARN
};
let env_filter = EnvFilter::builder()
.with_default_directive(default_level.into())
.from_env()
.into_diagnostic()?;
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.without_time()
.with_writer(std::io::stderr)
.init();
match cli.command {
Command::Init(args) => commands::init::execute(args).await,
Command::Search(args) => commands::search::execute(args).await,
Command::Add(args) => commands::add::execute(args).await,
Command::Install(args) => commands::install::execute(args).await,
Command::Lock(args) => commands::lock::execute(args).await,
Command::ShellHook(args) => commands::shell_hook::execute(args),
Command::Run(args) => commands::run::execute(args).await,
Command::Build(args) => commands::build::execute(args).await,
}
}
Summary¶
- We set up a Rust project with a clean module tree.
- Clap's derive macros turn struct definitions into a full CLI parser.
- Tokio provides the async runtime; we configure two thread pools.
In the next chapter we implement the simplest command: shot init, which
writes the project manifest.