Skip to content

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

$ shot add luarocks
✔ Added 1 package(s) to `moonshot.toml`
  Run `shot install` to apply changes.

You can also pass a version constraint inline:

$ shot add "lua >=5.4"

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
use crate::manifest::Manifest;
use miette::IntoDiagnostic;
use std::path::PathBuf;

#project-struct
/// 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:

#project-impl
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
use clap::Parser;
use miette::{Context, IntoDiagnostic};
use rattler_conda_types::NamelessMatchSpec;

use crate::manifest::MANIFEST_FILENAME;
use crate::project::Project;

#add-args
#[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:

#add-execute [1]
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:

#add-execute [2]
    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

#split-spec
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 lua when lua = ">=5.4" already exists prints a warning and keeps >=5.4
  • shot 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 lua queries conda-forge and succeeds (lua exists)
  • shot add nonexistent-package-xyz fails with "Package not found in channels: ..."
  • shot add --offline lua skips 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 lua writes to [platform-dependencies.linux-64]
  • shot add --platform linux-64 validates 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 --platform flags add to each platform section

Summary

  • add modifies the manifest only; run shot install to 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.