Skip to content

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.

cargo new moonshot
cd moonshot

Dependencies

Open the Cargo.toml and add the following. We'll explain some of the crates as we use them.

file: Cargo.toml
<<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.

#cargo-header
[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.

#cargo-deps [1]
[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.

#cargo-deps [2]
# 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:

file: src/commands/mod.rs
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:

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.

#main-imports
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).

#main-cli-struct [1]
/// 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:

#main-cli-struct [2]
#[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).

#main-fn
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.

#main-async
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.