Chapter 3: The init Command¶
Let's create our first command shot init. This command will create a
moonshot.toml, the manifest that describes which packages you want and
from which channels to fetch them. It's convenient to start with an init command, so that we can use it for testing and easily running the project.
Design¶
shot init creates a new manifest in the current directory:
$ shot init lumen-app
✔ Created `moonshot.toml` for project "lumen-app"
Add packages with: shot add <package>
Install them with: shot install
$ cat moonshot.toml
[project]
name = "lumen-app"
channels = ["conda-forge"]
[dependencies]
lua = ">=5.4"
We'll use this project throughout the book. In Chapter 10 we'll build a library that can process an image and install it here.
The command will accept an optional project name (defaults to the current directory
name) and one or more --channel flags. Pass --library to scaffold a
library project (adds a [build] section and version). If moonshot.toml
already exists, it will refuse to overwrite.
Configuration: moonshot.toml¶
[project]
name = "my-lua-app"
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64"]
[dependencies]
lua = ">=5.4"
luarocks = "*"
The [project] section will contain the metadata: [dependencies] maps package names
to version constraints. Version Requirements follow the conda MatchSpec mini-language, which evolved in conjunction with Python's Version and Requirements syntax. It does offer a couple of crazy features like matching on regexes, md5 hashes (with a regex even, never seen that being used!), and globs:
Some simple most-commonly used cases:
| Spec | Meaning |
|---|---|
"*" |
any version |
">=5.4" |
5.4 or newer |
"5.4.*" |
any 5.4.x release |
">=5.4,<6" |
5.4 series, exclusive upper bound |
The manifest will record what your intent is when requesting packages: which packages you want and from which channels. In this way it is distinct from a lock file, which records the exact versions the solver chose, including transitive dependencies.
The manifest says:
I want
lua >=5.4
A lock file says:
Install
lua 5.4.7 build h5eee18b_0from conda-forge, plus these 12 transitive dependencies at these exact versions.
We will implement both in moonshot: the manifest here and the lock file in Chapter 6.
Implementation¶
src/manifest.rs¶
Here is the full src/manifest.rs assembled from the pieces we'll walk through:
<<manifest-imports>>
<<manifest-filename-const>>
<<manifest-structs>> [1, 2]
<<manifest-impl>>
We begin with the standard imports for file handling, serialization, and conda types:
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use fs_err as fs;
use miette::{Context, IntoDiagnostic};
use rattler_conda_types::{MatchSpec, NamelessMatchSpec, PackageName, Platform};
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
A single constant keeps the filename consistent across all commands:
/// The file name we look for in the current directory.
pub const MANIFEST_FILENAME: &str = "moonshot.toml";
The core data structures map directly to the TOML layout. The struct below uses
serde_with, a companion crate to serde, for the dependencies field:
#[serde_as]on the struct enablesserde_with's custom (de)serialization.#[serde_as(as = "DisplayFromStr")]on a field tells serde to callFromStr::from_str()when reading andDisplay::fmtwhen writing.- Version strings like
">=5.4"in the TOML are automatically parsed into typedNamelessMatchSpecvalues at load time.
We use BTreeMap instead of HashMap so that dependencies serialize in
alphabetical order, producing stable diffs when the manifest changes.
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub project: ProjectMetadata,
#[serde_as(as = "BTreeMap<_, DisplayFromStr>")]
#[serde(default)]
pub dependencies: BTreeMap<String, NamelessMatchSpec>,
/// Present only for library projects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<BuildConfig>,
}
Serde for configuration files¶
We model the manifest as nested Rust structs that derive Serialize and
Deserialize. Serde handles the conversion between TOML text and Rust values
in both directions.
- Using
#[serde(default)]annotations make[dependencies]and others that use it optional. #[serde(default = "default_channels")]onchannelsfalls back to["conda-forge"]when omitted.
The platforms field lists which platforms to solve for. By default it includes
only the current platform, but you can add others (like linux-64 or
osx-arm64) to produce a lock file that works across machines.
serde_with and DisplayFromStr¶
The dependency values in TOML are plain strings like ">=5.4", but we want them
as typed NamelessMatchSpec values in Rust. The serde_with crate bridges this
gap with DisplayFromStr:
- On read: calls
NamelessMatchSpec::from_strfor each value - On write: calls
Display::fmtto turn it back into a string - Keys are left alone (they're already
Strings)
This follows "parse, don't validate": convert raw data into typed
values at the boundary, so the rest of your code can assume validity without
re-checking. The payoff is early error detection: a typo like lua = ">==5.4"
is caught during TOML deserialization itself. If the spec string is malformed,
toml::from_str returns an error before Manifest is ever constructed.
rattler_conda_types already uses serde_with internally, so I felt we should
introduce this here to get you familiar with it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub name: String,
#[serde(default = "default_channels")]
pub channels: Vec<String>,
#[serde(default = "default_platforms")]
pub platforms: Vec<Platform>,
/// Package version (required when [build] is present).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
/// SPDX license identifier, e.g. "MIT".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
/// One-line package description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn default_channels() -> Vec<String> {
vec!["conda-forge".to_string()]
}
pub(crate) fn default_platforms() -> Vec<Platform> {
vec![Platform::current()]
}
Manifest maps directly to the
top-level TOML, and ProjectMetadata maps to the [project] section. The
name field is used only for display; it does not affect resolution or
installation.
The version, license, and description fields are all optional. The
build field references BuildConfig, which we define in
Chapter 10 when we implement shot build. An application
project can leave all of these out.
We list channels in the manifest rather than in a global config file. Historically in conda and pip, a lot of config lives in your global configuration. One of the visions we have with pixi is that global config impedes reproducibility, so we try to put as much into the manifest as possible. With your own package manager you are free to decide of course.
The methods live in a single impl block:
impl Manifest {
<<manifest-from-path>>
<<manifest-write>>
<<manifest-find-in-dir>>
<<manifest-build-helpers>>
<<manifest-spec-helpers>> [1, 2]
}
Reading TOML:
pub fn from_path(path: &Path) -> miette::Result<Self> {
let content = fs::read_to_string(path)
.into_diagnostic()
.context("reading manifest")?;
// `DisplayFromStr` validates every dependency spec during
// deserialization, so a typo like `">==5.4"` fails here.
let manifest: Self = toml::from_str(&content)
.into_diagnostic()
.with_context(|| format!("parsing manifest at `{}`", path.display()))?;
Ok(manifest)
}
fs_err and Miette for error handling¶
We use fs_err instead of std::fs throughout this project, aliased
as fs with use fs_err as fs;. It is a drop-in replacement that wraps every
error with the file path that caused it, so you never see a bare "No such file
or directory" without knowing which file. We found this very useful, while working
on pixi so that the unknown directory or file at least surfaces.
fs::read_to_string returns Result<String, fs_err::Error>, which implements
std::error::Error. We convert that to miette::Result with
into_diagnostic(), then optionally attach extra context with .context().
Miette renders these as user-friendly error messages in the terminal.
Writing¶
Writing TOML:
pub fn write(&self, path: &Path) -> miette::Result<()> {
let content = toml::to_string_pretty(self)
.into_diagnostic()
.context("serializing manifest")?;
fs::write(path, content)
.into_diagnostic()
.context("writing manifest")
}
Commands other than init need to find the manifest, not create it, so lets have a method for it:
pub fn find_in_dir(dir: &Path) -> miette::Result<(PathBuf, Self)> {
let path = dir.join(MANIFEST_FILENAME);
if !path.exists() {
miette::bail!(
"No `{MANIFEST_FILENAME}` found in `{}`. \
Run `shot init` to create one.",
dir.display()
);
}
let manifest = Self::from_path(&path)?;
Ok((path, manifest))
}
It returns a tuple (PathBuf, Manifest) because callers sometimes need to
write back to the same path (e.g., shot add modifies the manifest before
installing).
Parsing dependencies as match specs¶
Both the resolver and the installer need to turn the name = "version" pairs
from [dependencies] into typed MatchSpec values. Rather than duplicating
that logic in every command, we add two helpers directly on Manifest.
match_specs combines each key-value pair into a full MatchSpec using
MatchSpec::from_nameless. Because the values are already parsed
NamelessMatchSpecs (thanks to DisplayFromStr), no string concatenation
or re-parsing is needed. dependency_strings formats them as
"name version" strings (the format conda's index.json uses for the
depends field).
/// Combine each `[dependencies]` entry into a full [`MatchSpec`].
///
/// The values are already parsed `NamelessMatchSpec`s, so this just
/// attaches the package name.
pub fn match_specs(&self) -> miette::Result<Vec<MatchSpec>> {
self.dependencies
.iter()
.map(|(name, spec)| {
let name = PackageName::from_str(name)
.into_diagnostic()
.with_context(|| format!("invalid package name `{name}`"))?;
Ok(MatchSpec::from_nameless(spec.clone(), name.into()))
})
.collect()
}
The second helper formats dependencies as "name version" strings for conda's
index.json. We use this in Chapter 10 when writing package
metadata.
/// Format dependencies as `"name version"` strings (or just `"name"`
/// when there is no version constraint).
///
/// This is the format expected by conda's `index.json` `depends` field.
pub fn dependency_strings(&self) -> Vec<String> {
self.dependencies
.iter()
.map(|(name, spec)| {
if spec.version.is_none() {
name.clone()
} else {
format!("{name} {spec}")
}
})
.collect()
}
src/commands/init.rs¶
<<init-imports>>
<<init-args>>
<<init-execute>> [1, 2, 3]
The imports pull in clap for argument parsing and the manifest types we just defined:
use std::collections::BTreeMap;
use clap::Parser;
use miette::IntoDiagnostic;
use rattler_conda_types::NamelessMatchSpec;
use crate::manifest::{
default_platforms, BuildConfig, Manifest, ProjectMetadata, MANIFEST_FILENAME,
};
The Args struct uses clap's derive macros. The --library flag controls
whether a [build] section is scaffolded:
#[derive(Debug, Parser)]
pub struct Args {
/// Name of the project. Defaults to the current directory name.
pub name: Option<String>,
/// Conda channels to search (can be repeated).
#[clap(short, long, default_value = "conda-forge")]
pub channel: Vec<String>,
/// Scaffold a library project (adds [build] section and version).
#[clap(long)]
pub library: bool,
}
The execute function first checks that no manifest exists yet, then resolves the project name (from the argument or the directory name):
pub async fn execute(args: Args) -> miette::Result<()> {
let cwd = std::env::current_dir().into_diagnostic()?;
let manifest_path = cwd.join(MANIFEST_FILENAME);
if manifest_path.exists() {
miette::bail!(
"`{MANIFEST_FILENAME}` already exists in `{}`. \
Delete it first if you want to re-initialise.",
cwd.display()
);
}
// Use the supplied name or fall back to the directory name.
let name = args.name.unwrap_or_else(|| {
cwd.file_name()
.and_then(|n| n.to_str())
.unwrap_or("my-lua-project")
.to_string()
});
With the name resolved, we construct a starter Manifest with Lua pre-filled
so the user has something to work with immediately. When --library is set we
also add a version and a default BuildConfig:
// Build a starter manifest with Lua pre-filled so the user has something
// to work with immediately.
let manifest = Manifest {
project: ProjectMetadata {
name: name.clone(),
channels: args.channel,
platforms: default_platforms(),
version: if args.library {
Some("0.1.0".to_string())
} else {
None
},
license: None,
description: None,
},
dependencies: BTreeMap::from([(
"lua".to_string(),
">=5.4".parse::<NamelessMatchSpec>().unwrap(),
)]),
build: if args.library {
Some(BuildConfig::default())
} else {
None
},
};
Finally we write the manifest and print a short guide for the user:
manifest.write(&manifest_path)?;
println!(
"{} Created `{MANIFEST_FILENAME}` for project \"{name}\"",
console::style("✔").green()
);
if args.library {
println!(" Build a package with: shot build");
}
println!(" Add packages with: shot add <package>");
println!(" Install them with: shot install");
Ok(())
}
We use the console crate to color terminal output. It degrades gracefully when stdout isn't a terminal (redirected to a file, CI, etc.), again another useful rust library.
Running shot init¶
At this point you can build and run the first command:
$ pixi run shot init lumen-app
✔ Created `moonshot.toml` for project "lumen-app"
Add packages with: shot add <package>
Install them with: shot install
$ cat moonshot.toml
[project]
name = "lumen-app"
channels = ["conda-forge"]
[dependencies]
lua = ">=5.4"
Summary¶
Manifestis a plain Rust struct derived fromSerialize/Deserialize.serde_with'sDisplayFromStrbridgesFromStr/Displaytypes to serde, giving us typed dependency values for free.Mietteprovides friendly error messages with context.
Exercises¶
Before we start, there is a small thing to take into account when starting:
Recurring patterns in exercises. Two patterns come up in many exercises throughout this book:
- TOML conventions use hyphens (
requires-lua), but Rust fields use underscores (requires_lua). Add#[serde(rename = "requires-lua")]to bridge the two whenever an exercise adds a hyphenated key tomoonshot.toml. - When you add a field to
ManifestorProjectMetadata, the compiler will point you to every place that constructs the struct. The most common one issrc/commands/init.rs. Later exercises will not always remind you of this; follow the compiler errors.
Add a requires-lua Field
Add a top-level requires-lua field to moonshot.toml (similar to requires-python in pyproject.toml). This field is more ergonomic than putting the Lua constraint in [dependencies] because it expresses the Lua version as a project-level requirement, not a regular dependency. Parse and validate it through MatchSpec::from_str. The shot init command gets a --lua-version flag to set it.
- Acceptance criteria
-
shot init --lua-version ">=5.1,<5.5"writesrequires-lua = ">=5.1,<5.5"to[project]shot init --lua-version "!!!invalid"fails with a parse error before creating any file- Default (no flag) writes
requires-lua = ">=5.4" Manifeststruct has arequires_lua: Option<String>field that round-trips through TOML
Detect and Record Virtual Packages
At init time, detect the system's virtual packages using VirtualPackage::detect() and print them to stdout. Write a [system] section into the manifest with the detected values (e.g., glibc = "2.31" on Linux, osx = "15.0" on macOS). This gives users visibility into what their build host provides, which matters when the project later resolves dependencies (Ch6) or builds platform-specific packages (Ch10).
- Acceptance criteria
-
shot initprints detected virtual packages (e.g.,Detected: __glibc=2.31, __archspec=1=x86_64)- Manifest contains
[system]with key-value pairs for detected packages - On macOS
__osxis recorded; on Linux__glibcis recorded [system]is omitted from serialization when empty
Init with Gateway Validation
Add a --validate flag to shot init that queries the configured channels to verify the Lua version constraint is satisfiable before writing the manifest. This requires constructing an HTTP client, creating a Gateway, and querying for a MatchSpec matching the requires-lua value. If no matching Lua packages exist in the channel, abort with a clear error.
Dependencies: Exercise 3.1 (uses the requires-lua field).
- Acceptance criteria
-
shot init --validatesucceeds whenlua >=5.4exists on conda-forgeshot init --validate --lua-version ">=99.0"fails with "No Lua packages matching >=99.0 found in channels"- Without
--validate, init works offline as before - The channels from
--channelflags (or the default) are used for the query
In the next chapter we'll implement shot search, which queries a channel for
available packages.