use crate::models::common::enums::{Channel, Filetype, Provider};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "upstream")]
#[command(about = "A package manager for Github releases.")]
#[command(
long_about = "Upstream is a lightweight package manager that installs and manages \
applications directly from GitHub releases (and other providers).\n\n\
Install binaries, AppImages, and other artifacts with automatic updates, \
version pinning, and simple configuration management.\n\n\
EXAMPLES:\n \
upstream install nvim neovim/neovim --desktop\n \
upstream upgrade # Upgrade all packages\n \
upstream list # Show installed packages\n \
upstream config set github.api_token=ghp_xxx"
)]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
#[command(long_about = "Install a new package from a repository release.\n\n\
Downloads the specified file type from the latest release (or specified channel) \
and registers it under the given name for future updates.\n\n\
EXAMPLES:\n \
upstream install rg BurntSushi/ripgrep -k binary\n \
upstream install dust bootandy/dust -k archive\n \
upstream install rg BurntSushi/ripgrep --ignore-checksums")]
Install {
name: String,
repo_slug: String,
#[arg(short, long)]
tag: Option<String>,
#[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
kind: Filetype,
#[arg(short = 'p', long, default_value_t = Provider::Github)]
provider: Provider,
#[arg(long, requires = "provider")]
base_url: Option<String>,
#[arg(short, long, value_enum, default_value_t = Channel::Stable)]
channel: Channel,
#[arg(short = 'm', long, name = "match")]
match_pattern: Option<String>,
#[arg(short = 'e', long, name = "exclude")]
exclude_pattern: Option<String>,
#[arg(short, long, default_value_t = false)]
desktop: bool,
#[arg(long, default_value_t = false)]
ignore_checksums: bool,
},
#[command(
long_about = "Uninstall packages and optionally remove cached data.\n\n\
By default, removes the package binary/files but preserves cached release data. \
Use --purge to remove everything.\n\n\
EXAMPLES:\n \
upstream remove nvim\n \
upstream remove rg fd bat --purge"
)]
Remove {
names: Vec<String>,
#[arg(long, default_value_t = false)]
purge: bool,
},
#[command(long_about = "Check for and install updates to packages.\n\n\
Without arguments, upgrades all packages. Specify package names to upgrade \
only those packages. Use --check to preview available updates.\n\n\
EXAMPLES:\n \
upstream upgrade # Upgrade all\n \
upstream upgrade nvim rg # Upgrade specific packages\n \
upstream upgrade --check # Check for updates\n \
upstream upgrade --check --machine-readable # Script-friendly output\n \
upstream upgrade nvim --force # Force reinstall\n \
upstream upgrade --ignore-checksums")]
Upgrade {
names: Option<Vec<String>>,
#[arg(long, default_value_t = false)]
force: bool,
#[arg(long, default_value_t = false)]
check: bool,
#[arg(long, default_value_t = false, requires = "check")]
machine_readable: bool,
#[arg(long, default_value_t = false)]
ignore_checksums: bool,
},
#[command(long_about = "Display information about installed packages.\n\n\
Without arguments, shows a summary of all installed packages. \
Provide a package name to see detailed information.\n\n\
EXAMPLES:\n \
upstream list # List all packages\n \
upstream list nvim # Show details for nvim")]
List {
name: Option<String>,
},
#[command(long_about = "Probe a repository/source and show parsed releases.\n\n\
Useful for validating what upstream can see before installation.\n\n\
EXAMPLES:\n \
upstream probe neovim/neovim\n \
upstream probe https://ziglang.org/download/ -p scraper --limit 20\n \
upstream probe owner/repo --channel nightly --verbose")]
Probe {
repo_slug: String,
#[arg(short = 'p', long)]
provider: Option<Provider>,
#[arg(long)]
base_url: Option<String>,
#[arg(short, long, value_enum, default_value_t = Channel::Stable)]
channel: Channel,
#[arg(long, default_value_t = 10)]
limit: u32,
#[arg(long, default_value_t = false)]
verbose: bool,
},
#[command(long_about = "View and modify upstream's configuration.\n\n\
Configuration is stored in TOML format and includes settings like \
API tokens, default providers, and installation preferences.\n\n\
EXAMPLES:\n \
upstream config set github.api_token=ghp_xxx\n \
upstream config get github.api_token\n \
upstream config list\n \
upstream config edit")]
Config {
#[command(subcommand)]
action: ConfigAction,
},
#[command(
long_about = "Control package behavior and view internal metadata.\n\n\
Pin packages to prevent upgrades, view installation details, or manually \
adjust package metadata when needed.\n\n\
EXAMPLES:\n \
upstream package pin nvim\n \
upstream package metadata nvim\n \
upstream package get-key nvim install_path"
)]
Package {
#[command(subcommand)]
action: PackageAction,
},
#[command(long_about = "Set up upstream for first-time use.\n\n\
Adds upstream's bin directory to your PATH by modifying shell configuration \
files (.bashrc, .zshrc, etc.). Run this once after installation.\n\n\
EXAMPLES:\n \
upstream init\n \
upstream init --clean # Remove old hooks first")]
Init {
#[arg(long)]
clean: bool,
#[arg(long, default_value_t = false, conflicts_with = "clean")]
check: bool,
},
#[command(
long_about = "Import packages from a previously exported manifest or snapshot.\n\n\
Reads a manifest and reinstalls each package, or restores a full snapshot \
created with 'upstream export --full'. Packages that are already installed \
will be skipped.\n\n\
EXAMPLES:\n \
upstream import ./packages.json # Import from manifest\n \
upstream import ./backup.tar.gz # Restore full snapshot"
)]
Import {
path: std::path::PathBuf,
#[arg(long, default_value_t = false)]
skip_failed: bool,
},
#[command(long_about = "Export installed packages for backup or transfer.\n\n\
By default, writes a lightweight manifest containing just enough info to \
reinstall each package. Use --full to instead create a tarball of the entire \
upstream directory (a full snapshot).\n\n\
EXAMPLES:\n \
upstream export ./packages.json # Export manifest\n \
upstream export ./backup.tar.gz --full # Full snapshot")]
Export {
path: std::path::PathBuf,
#[arg(long, default_value_t = false)]
full: bool,
},
#[command(
long_about = "Inspect upstream installation health and package state.\n\n\
Checks package paths, symlinks, shell PATH integration, and desktop/icon files. \
Reports OK/WARN/FAIL with actionable hints.\n\n\
EXAMPLES:\n \
upstream doctor\n \
upstream doctor nvim ripgrep"
)]
Doctor {
names: Vec<String>,
},
}
impl Commands {
pub fn requires_lock(&self) -> bool {
match self {
Commands::List { .. } => false,
Commands::Doctor { .. } => false,
Commands::Init { check, .. } => !check,
Commands::Package { action } => !matches!(
action,
PackageAction::GetKey { .. } | PackageAction::Metadata { .. }
),
Commands::Config { action } => {
!matches!(action, ConfigAction::Get { .. } | ConfigAction::List)
}
Commands::Install { .. }
| Commands::Remove { .. }
| Commands::Upgrade { .. }
| Commands::Probe { .. }
| Commands::Import { .. }
| Commands::Export { .. } => true,
}
}
}
#[derive(Subcommand)]
pub enum ConfigAction {
#[command(long_about = "Set one or more configuration values.\n\n\
Use dot notation for nested keys. Multiple key=value pairs can be set at once.\n\n\
EXAMPLES:\n \
upstream config set github.api_token=ghp_xxx\n \
upstream config set gitlab.api_token=glpat_xxx")]
Set {
keys: Vec<String>,
},
#[command(long_about = "Retrieve one or more configuration values.\n\n\
Use dot notation to access nested keys.\n\n\
EXAMPLES:\n \
upstream config get github.api_token\n \
upstream config get github.api_token gitlab.api_token")]
Get {
keys: Vec<String>,
},
List,
Edit,
Reset,
}
#[derive(Subcommand)]
pub enum PackageAction {
#[command(long_about = "Prevent a package from being upgraded.\n\n\
Pinned packages are skipped during 'upstream upgrade' operations.\n\n\
EXAMPLE:\n \
upstream package pin nvim")]
Pin {
name: String,
},
#[command(long_about = "Remove version pin from a package.\n\n\
Unpinned packages will be included in future upgrade operations.\n\n\
EXAMPLE:\n \
upstream package unpin nvim")]
Unpin {
name: String,
},
#[command(long_about = "Retrieve raw metadata values for a package.\n\n\
Access internal package data like install paths, versions, and checksums.\n\n\
EXAMPLES:\n \
upstream package get-key nvim install_path\n \
upstream package get-key nvim version checksum")]
GetKey {
name: String,
keys: Vec<String>,
},
#[command(long_about = "Manually modify package metadata.\n\n\
Advanced operation - use with caution. Typically used for manual corrections \
or testing.\n\n\
EXAMPLE:\n \
upstream package set-key nvim is_pinned=false")]
SetKey {
name: String,
keys: Vec<String>,
},
#[command(long_about = "Rename the local alias of an installed package.\n\n\
This changes how upstream tracks the package and updates integration aliases \
(symlink/desktop entry) when possible.\n\n\
EXAMPLE:\n \
upstream package rename nvim neovim")]
Rename {
old_name: String,
new_name: String,
},
#[command(long_about = "Show complete package metadata in JSON format.\n\n\
Displays all internal data for the specified package including installation \
details, version info, and configuration.\n\n\
EXAMPLE:\n \
upstream package metadata nvim")]
Metadata {
name: String,
},
}
#[cfg(test)]
mod tests {
use super::{Cli, Commands, ConfigAction, PackageAction};
use clap::Parser;
#[test]
fn install_parses_ignore_checksums_flag() {
let cli = Cli::parse_from([
"upstream",
"install",
"rg",
"BurntSushi/ripgrep",
"--ignore-checksums",
]);
match cli.command {
Commands::Install {
ignore_checksums, ..
} => assert!(ignore_checksums),
other => panic!("unexpected command parsed: {}", other),
}
}
#[test]
fn upgrade_parses_ignore_checksums_flag() {
let cli = Cli::parse_from(["upstream", "upgrade", "--ignore-checksums"]);
match cli.command {
Commands::Upgrade {
ignore_checksums, ..
} => assert!(ignore_checksums),
other => panic!("unexpected command parsed: {}", other),
}
}
#[test]
fn requires_lock_skips_read_only_commands() {
assert!(!Commands::List { name: None }.requires_lock());
assert!(!Commands::Doctor { names: vec![] }.requires_lock());
assert!(
!Commands::Init {
clean: false,
check: true,
}
.requires_lock()
);
assert!(
!Commands::Package {
action: PackageAction::GetKey {
name: "ripgrep".to_string(),
keys: vec!["version".to_string()],
},
}
.requires_lock()
);
assert!(
!Commands::Package {
action: PackageAction::Metadata {
name: "ripgrep".to_string(),
},
}
.requires_lock()
);
assert!(
!Commands::Config {
action: ConfigAction::Get {
keys: vec!["github.api_token".to_string()],
},
}
.requires_lock()
);
assert!(
!Commands::Config {
action: ConfigAction::List,
}
.requires_lock()
);
}
#[test]
fn requires_lock_keeps_writing_and_side_effectful_commands_locked() {
assert!(
Commands::Install {
name: "ripgrep".to_string(),
repo_slug: "BurntSushi/ripgrep".to_string(),
tag: None,
kind: crate::models::common::enums::Filetype::Auto,
provider: crate::models::common::enums::Provider::Github,
base_url: None,
channel: crate::models::common::enums::Channel::Stable,
match_pattern: None,
exclude_pattern: None,
desktop: false,
ignore_checksums: false,
}
.requires_lock()
);
assert!(
Commands::Upgrade {
names: None,
force: false,
check: true,
machine_readable: false,
ignore_checksums: false,
}
.requires_lock()
);
assert!(
Commands::Config {
action: ConfigAction::Set {
keys: vec!["github.api_token=ghp_xxx".to_string()],
},
}
.requires_lock()
);
assert!(
Commands::Export {
path: "packages.json".into(),
full: false,
}
.requires_lock()
);
assert!(
Commands::Config {
action: ConfigAction::Edit,
}
.requires_lock()
);
assert!(
Commands::Config {
action: ConfigAction::Reset,
}
.requires_lock()
);
}
}