Chapter 5: The add Command¶
The add command updates the manifest with new dependencies from the command line. In pixi, we coupled this with an automatic install; package managers like npm or pnpm don't do this. We'll also skip that here to keep the code shorter.
Design¶
You can also pass a version constraint inline:
The command parses the spec into a NamelessMatchSpec, and
updates [dependencies] in moonshot.toml. If any spec is malformed the
command aborts without modifying the manifest. It does not install anything;
run shot install afterward to fetch and install the new packages.
Concepts¶
Minimal manifest changes¶
The add command tries to do the least amount of changes. If the package is already in [dependencies], add skips it (uses
entry().or_insert_with()). Running shot add lua twice does not create a
duplicate entry or change the existing version constraint.
Implementation¶
The add command is where we first use the Project struct, a small
abstraction that finds the manifest from the current directory and provides
helpers for saving changes back to disk. We introduce Project in its own
file.
src/project.rs¶
The Project struct centralises the boilerplate that every command repeats:
find the manifest, compute the project root, and derive paths from it. We
define it here and extend it with more methods in later chapters.
<<project-imports>>
<<project-struct>>
<<project-impl>>
/// A discovered project on disk.
///
/// Bundles the project root, manifest path, and parsed manifest into a
/// single value so that every command does not have to repeat the same
/// discovery boilerplate.
pub struct Project {
/// The directory that contains `moonshot.toml`.
pub root: PathBuf,
/// Absolute path to `moonshot.toml`.
pub manifest_path: PathBuf,
/// The parsed manifest.
pub manifest: Manifest,
}
discover() looks in the current directory for moonshot.toml
(using Manifest::find_in_dir from Chapter 3).
default_prefix() returns the .env/ directory relative to the project root,
and save() writes the manifest back after modifications like shot add:
impl Project {
/// Discover the project from the current working directory.
pub fn discover() -> miette::Result<Self> {
let cwd = std::env::current_dir().into_diagnostic()?;
let (manifest_path, manifest) = Manifest::find_in_dir(&cwd)?;
let root = manifest_path
.parent()
.expect("manifest path always has a parent")
.to_path_buf();
Ok(Self {
root,
manifest_path,
manifest,
})
}
/// The default prefix directory for the conda environment.
pub fn default_prefix(&self) -> PathBuf {
self.root.join(".env")
}
/// Persist the (possibly modified) manifest back to disk.
pub fn save(&self) -> miette::Result<()> {
self.manifest.write(&self.manifest_path)
}
}
src/commands/add.rs¶
<<add-imports>>
<<add-args>>
<<add-execute>> [1, 2]
<<split-spec>>
use clap::Parser;
use miette::{Context, IntoDiagnostic};
use rattler_conda_types::NamelessMatchSpec;
use crate::manifest::MANIFEST_FILENAME;
use crate::project::Project;
#[derive(Debug, Parser)]
pub struct Args {
/// Packages to add, e.g. `luarocks` or `"lua >=5.4"`.
#[clap(required = true)]
pub packages: Vec<String>,
}
The execute function validates all specs up front before modifying anything. This ensures a typo in the third package doesn't leave the first two already written to disk:
pub async fn execute(args: Args) -> miette::Result<()> {
let mut project = Project::discover()?;
// Validate all specs before modifying the manifest.
let parsed: Vec<(&str, NamelessMatchSpec)> = args
.packages
.iter()
.map(|pkg| {
let (name, version) = split_spec(pkg);
let spec: NamelessMatchSpec = version
.parse()
.into_diagnostic()
.with_context(|| format!("invalid dependency spec `{pkg}`"))?;
Ok((name, spec))
})
.collect::<miette::Result<_>>()?;
With all specs validated, we insert them into the manifest's dependency map and save:
let mut added = 0usize;
for (name, spec) in parsed {
let len_before = project.manifest.dependencies.len();
project
.manifest
.dependencies
.entry(name.to_string())
.or_insert(spec);
if project.manifest.dependencies.len() > len_before {
added += 1;
}
}
project.save()?;
println!(
"{} Added {added} package(s) to `{MANIFEST_FILENAME}`",
console::style("✔").green()
);
println!(" Run `shot install` to apply changes.");
Ok(())
}
Parsing package specs¶
fn split_spec(spec: &str) -> (&str, &str) {
// Split on first whitespace only; operator-prefixed versions require a space.
if let Some(pos) = spec.find(char::is_whitespace) {
let name = spec[..pos].trim();
let version = spec[pos..].trim();
(name, if version.is_empty() { "*" } else { version })
} else {
(spec.trim(), "*")
}
}
We split on whitespace only. Users write shot add lua ">=5.4" with a space between the name and the constraint. This avoids ambiguity with operator characters like >= and ==.
For example, given input "lua >=5.4", find locates the space at position 3. The name becomes "lua" and the version constraint becomes ">=5.4".
Running shot add¶
$ shot add luarocks
✔ Added 1 package(s) to `moonshot.toml`
Run `shot install` to apply changes.
$ cat moonshot.toml
[project]
name = "hello-lua"
channels = ["conda-forge"]
[dependencies]
lua = ">=5.4"
luarocks = "*"
Exercises¶
Warn on Version Constraint Change
Currently shot add "lua >=5.3" silently keeps the existing >=5.4 constraint because of or_insert_with. Change add so it warns the user when a package already exists with a different version constraint, and add a --force flag that overwrites the existing constraint.
- Acceptance criteria
-
shot add luawhenlua = ">=5.4"already exists prints a warning and keeps>=5.4shot add "lua >=5.3"prints "lua already has constraint>=5.4, skipping (use --force to overwrite)"shot add --force "lua >=5.3"replaces the constraint with>=5.3- Adding a truly new package still works as before
Validate Package Exists in Channel Before Adding
Make shot add query the repodata gateway by default to verify each package exists in the configured channels before adding it. If a package is not found, refuse to add it. Construct a Session, query with the parsed MatchSpec, and check that at least one matching record comes back. Add --offline to skip the check for users without network access.
- Acceptance criteria
-
shot add luaqueries conda-forge and succeeds (lua exists)shot add nonexistent-package-xyzfails with "Package not found in channels: ..."shot add --offline luaskips the gateway check and adds without validation- The manifest's configured channels are used for the query
Platform-Specific Dependencies
Implement shot add --platform linux-64 lua which adds the dependency to a platform-specific table [platform-dependencies.linux-64] instead of the global [dependencies]. This requires extending the Manifest struct with a platform_dependencies: HashMap<String, HashMap<String, String>> field, parsing the target platform with Platform::from_str, and optionally validating via the gateway for that specific platform.
- Acceptance criteria
-
shot add --platform linux-64 luawrites to[platform-dependencies.linux-64]shot add --platform linux-64validates the package exists for linux-64 specifically (gateway is on by default)- Without
--platform, behavior is unchanged (adds to[dependencies]) - Invalid platform strings produce a clear error
- Multiple
--platformflags add to each platform section
Summary¶
addmodifies the manifest only; runshot installto apply changes.- Manifest updates are idempotent: adding an existing package is a no-op.
In the next chapter we implement shot lock, which resolves the packages
listed in the manifest and records the exact solution. shot install follows
in Chapter 7.