1use crate::models::common::enums::{Channel, Filetype, Provider};
2use clap::{Parser, Subcommand};
3
4#[derive(Parser)]
5#[command(name = "upstream")]
6#[command(about = "A package manager for Github releases.")]
7#[command(
8 long_about = "Upstream is a lightweight package manager that installs and manages \
9 applications directly from GitHub releases (and other providers).\n\n\
10 Install binaries, AppImages, and other artifacts with automatic updates, \
11 version pinning, and simple configuration management.\n\n\
12 EXAMPLES:\n \
13 upstream install nvim neovim/neovim --desktop\n \
14 upstream upgrade # Upgrade all packages\n \
15 upstream list # Show installed packages\n \
16 upstream config set github.api_token=ghp_xxx"
17)]
18#[command(version)]
19pub struct Cli {
20 #[command(subcommand)]
21 pub command: Commands,
22}
23
24#[derive(Subcommand)]
25pub enum Commands {
26 #[command(long_about = "Install a new package from a repository release.\n\n\
28 Downloads the specified file type from the latest release (or specified channel) \
29 and registers it under the given name for future updates.\n\n\
30 EXAMPLES:\n \
31 upstream install rg BurntSushi/ripgrep -k binary\n \
32 upstream install dust bootandy/dust -k archive\n \
33 upstream install rg BurntSushi/ripgrep --ignore-checksums")]
34 Install {
35 name: String,
37
38 repo_slug: String,
40
41 #[arg(short, long)]
43 tag: Option<String>,
44
45 #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
47 kind: Filetype,
48
49 #[arg(short = 'p', long, default_value_t = Provider::Github)]
51 provider: Provider,
52
53 #[arg(long, requires = "provider")]
55 base_url: Option<String>,
56
57 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
59 channel: Channel,
60
61 #[arg(short = 'm', long, name = "match")]
63 match_pattern: Option<String>,
64
65 #[arg(short = 'e', long, name = "exclude")]
67 exclude_pattern: Option<String>,
68
69 #[arg(short, long, default_value_t = false)]
71 desktop: bool,
72
73 #[arg(long, default_value_t = false)]
75 ignore_checksums: bool,
76 },
77
78 #[command(
80 long_about = "Uninstall packages and optionally remove cached data.\n\n\
81 By default, removes the package binary/files but preserves cached release data. \
82 Use --purge to remove everything.\n\n\
83 EXAMPLES:\n \
84 upstream remove nvim\n \
85 upstream remove rg fd bat --purge"
86 )]
87 Remove {
88 names: Vec<String>,
90
91 #[arg(long, default_value_t = false)]
93 purge: bool,
94 },
95
96 #[command(long_about = "Check for and install updates to packages.\n\n\
98 Without arguments, upgrades all packages. Specify package names to upgrade \
99 only those packages. Use --check to preview available updates.\n\n\
100 EXAMPLES:\n \
101 upstream upgrade # Upgrade all\n \
102 upstream upgrade nvim rg # Upgrade specific packages\n \
103 upstream upgrade --check # Check for updates\n \
104 upstream upgrade --check --machine-readable # Script-friendly output\n \
105 upstream upgrade nvim --force # Force reinstall\n \
106 upstream upgrade --ignore-checksums")]
107 Upgrade {
108 names: Option<Vec<String>>,
110
111 #[arg(long, default_value_t = false)]
113 force: bool,
114
115 #[arg(long, default_value_t = false)]
117 check: bool,
118
119 #[arg(long, default_value_t = false, requires = "check")]
121 machine_readable: bool,
122
123 #[arg(long, default_value_t = false)]
125 ignore_checksums: bool,
126 },
127
128 #[command(long_about = "Display information about installed packages.\n\n\
130 Without arguments, shows a summary of all installed packages. \
131 Provide a package name to see detailed information.\n\n\
132 EXAMPLES:\n \
133 upstream list # List all packages\n \
134 upstream list nvim # Show details for nvim")]
135 List {
136 name: Option<String>,
138 },
139
140 #[command(long_about = "Probe a repository/source and show parsed releases.\n\n\
142 Useful for validating what upstream can see before installation.\n\n\
143 EXAMPLES:\n \
144 upstream probe neovim/neovim\n \
145 upstream probe https://ziglang.org/download/ -p scraper --limit 20\n \
146 upstream probe owner/repo --channel nightly --verbose")]
147 Probe {
148 repo_slug: String,
150
151 #[arg(short = 'p', long)]
153 provider: Option<Provider>,
154
155 #[arg(long)]
157 base_url: Option<String>,
158
159 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
161 channel: Channel,
162
163 #[arg(long, default_value_t = 10)]
165 limit: u32,
166
167 #[arg(long, default_value_t = false)]
169 verbose: bool,
170 },
171
172 #[command(long_about = "View and modify upstream's configuration.\n\n\
174 Configuration is stored in TOML format and includes settings like \
175 API tokens, default providers, and installation preferences.\n\n\
176 EXAMPLES:\n \
177 upstream config set github.api_token=ghp_xxx\n \
178 upstream config get github.api_token\n \
179 upstream config list\n \
180 upstream config edit")]
181 Config {
182 #[command(subcommand)]
183 action: ConfigAction,
184 },
185
186 #[command(
188 long_about = "Control package behavior and view internal metadata.\n\n\
189 Pin packages to prevent upgrades, view installation details, or manually \
190 adjust package metadata when needed.\n\n\
191 EXAMPLES:\n \
192 upstream package pin nvim\n \
193 upstream package metadata nvim\n \
194 upstream package get-key nvim install_path"
195 )]
196 Package {
197 #[command(subcommand)]
198 action: PackageAction,
199 },
200
201 #[command(long_about = "Set up upstream for first-time use.\n\n\
203 Adds upstream's bin directory to your PATH by modifying shell configuration \
204 files (.bashrc, .zshrc, etc.). Run this once after installation.\n\n\
205 EXAMPLES:\n \
206 upstream init\n \
207 upstream init --clean # Remove old hooks first")]
208 Init {
209 #[arg(long)]
211 clean: bool,
212
213 #[arg(long, default_value_t = false, conflicts_with = "clean")]
215 check: bool,
216 },
217
218 #[command(
220 long_about = "Import packages from a previously exported manifest or snapshot.\n\n\
221 Reads a manifest and reinstalls each package, or restores a full snapshot \
222 created with 'upstream export --full'. Packages that are already installed \
223 will be skipped.\n\n\
224 EXAMPLES:\n \
225 upstream import ./packages.json # Import from manifest\n \
226 upstream import ./backup.tar.gz # Restore full snapshot"
227 )]
228 Import {
229 path: std::path::PathBuf,
231
232 #[arg(long, default_value_t = false)]
234 skip_failed: bool,
235 },
236
237 #[command(long_about = "Export installed packages for backup or transfer.\n\n\
239 By default, writes a lightweight manifest containing just enough info to \
240 reinstall each package. Use --full to instead create a tarball of the entire \
241 upstream directory (a full snapshot).\n\n\
242 EXAMPLES:\n \
243 upstream export ./packages.json # Export manifest\n \
244 upstream export ./backup.tar.gz --full # Full snapshot")]
245 Export {
246 path: std::path::PathBuf,
248 #[arg(long, default_value_t = false)]
250 full: bool,
251 },
252
253 #[command(
255 long_about = "Inspect upstream installation health and package state.\n\n\
256 Checks package paths, symlinks, shell PATH integration, and desktop/icon files. \
257 Reports OK/WARN/FAIL with actionable hints.\n\n\
258 EXAMPLES:\n \
259 upstream doctor\n \
260 upstream doctor nvim ripgrep"
261 )]
262 Doctor {
263 names: Vec<String>,
265 },
266}
267
268impl Commands {
269 pub fn requires_lock(&self) -> bool {
270 match self {
271 Commands::List { .. } => false,
272 Commands::Doctor { .. } => false,
273 Commands::Init { check, .. } => !check,
274 Commands::Package { action } => !matches!(
275 action,
276 PackageAction::GetKey { .. } | PackageAction::Metadata { .. }
277 ),
278 Commands::Install { .. }
279 | Commands::Remove { .. }
280 | Commands::Upgrade { .. }
281 | Commands::Probe { .. }
282 | Commands::Import { .. }
283 | Commands::Export { .. }
284 | Commands::Config { .. } => true,
285 }
286 }
287}
288
289#[derive(Subcommand)]
290pub enum ConfigAction {
291 #[command(long_about = "Set one or more configuration values.\n\n\
293 Use dot notation for nested keys. Multiple key=value pairs can be set at once.\n\n\
294 EXAMPLES:\n \
295 upstream config set github.api_token=ghp_xxx\n \
296 upstream config set gitlab.api_token=glpat_xxx")]
297 Set {
298 keys: Vec<String>,
300 },
301
302 #[command(long_about = "Retrieve one or more configuration values.\n\n\
304 Use dot notation to access nested keys.\n\n\
305 EXAMPLES:\n \
306 upstream config get github.api_token\n \
307 upstream config get github.api_token gitlab.api_token")]
308 Get {
309 keys: Vec<String>,
311 },
312
313 List,
315
316 Edit,
318
319 Reset,
321}
322
323#[derive(Subcommand)]
324pub enum PackageAction {
325 #[command(long_about = "Prevent a package from being upgraded.\n\n\
327 Pinned packages are skipped during 'upstream upgrade' operations.\n\n\
328 EXAMPLE:\n \
329 upstream package pin nvim")]
330 Pin {
331 name: String,
333 },
334
335 #[command(long_about = "Remove version pin from a package.\n\n\
337 Unpinned packages will be included in future upgrade operations.\n\n\
338 EXAMPLE:\n \
339 upstream package unpin nvim")]
340 Unpin {
341 name: String,
343 },
344
345 #[command(long_about = "Retrieve raw metadata values for a package.\n\n\
347 Access internal package data like install paths, versions, and checksums.\n\n\
348 EXAMPLES:\n \
349 upstream package get-key nvim install_path\n \
350 upstream package get-key nvim version checksum")]
351 GetKey {
352 name: String,
354
355 keys: Vec<String>,
357 },
358
359 #[command(long_about = "Manually modify package metadata.\n\n\
361 Advanced operation - use with caution. Typically used for manual corrections \
362 or testing.\n\n\
363 EXAMPLE:\n \
364 upstream package set-key nvim is_pinned=false")]
365 SetKey {
366 name: String,
368
369 keys: Vec<String>,
371 },
372
373 #[command(long_about = "Rename the local alias of an installed package.\n\n\
375 This changes how upstream tracks the package and updates integration aliases \
376 (symlink/desktop entry) when possible.\n\n\
377 EXAMPLE:\n \
378 upstream package rename nvim neovim")]
379 Rename {
380 old_name: String,
382
383 new_name: String,
385 },
386
387 #[command(long_about = "Show complete package metadata in JSON format.\n\n\
389 Displays all internal data for the specified package including installation \
390 details, version info, and configuration.\n\n\
391 EXAMPLE:\n \
392 upstream package metadata nvim")]
393 Metadata {
394 name: String,
396 },
397}
398
399#[cfg(test)]
400mod tests {
401 use super::{Cli, Commands, ConfigAction, PackageAction};
402 use clap::Parser;
403
404 #[test]
405 fn install_parses_ignore_checksums_flag() {
406 let cli = Cli::parse_from([
407 "upstream",
408 "install",
409 "rg",
410 "BurntSushi/ripgrep",
411 "--ignore-checksums",
412 ]);
413
414 match cli.command {
415 Commands::Install {
416 ignore_checksums, ..
417 } => assert!(ignore_checksums),
418 other => panic!("unexpected command parsed: {}", other),
419 }
420 }
421
422 #[test]
423 fn upgrade_parses_ignore_checksums_flag() {
424 let cli = Cli::parse_from(["upstream", "upgrade", "--ignore-checksums"]);
425
426 match cli.command {
427 Commands::Upgrade {
428 ignore_checksums, ..
429 } => assert!(ignore_checksums),
430 other => panic!("unexpected command parsed: {}", other),
431 }
432 }
433
434 #[test]
435 fn requires_lock_skips_read_only_commands() {
436 assert!(!Commands::List { name: None }.requires_lock());
437 assert!(!Commands::Doctor { names: vec![] }.requires_lock());
438 assert!(
439 !Commands::Init {
440 clean: false,
441 check: true,
442 }
443 .requires_lock()
444 );
445 assert!(
446 !Commands::Package {
447 action: PackageAction::GetKey {
448 name: "ripgrep".to_string(),
449 keys: vec!["version".to_string()],
450 },
451 }
452 .requires_lock()
453 );
454 assert!(
455 !Commands::Package {
456 action: PackageAction::Metadata {
457 name: "ripgrep".to_string(),
458 },
459 }
460 .requires_lock()
461 );
462 }
463
464 #[test]
465 fn requires_lock_keeps_writing_and_side_effectful_commands_locked() {
466 assert!(
467 Commands::Install {
468 name: "ripgrep".to_string(),
469 repo_slug: "BurntSushi/ripgrep".to_string(),
470 tag: None,
471 kind: crate::models::common::enums::Filetype::Auto,
472 provider: crate::models::common::enums::Provider::Github,
473 base_url: None,
474 channel: crate::models::common::enums::Channel::Stable,
475 match_pattern: None,
476 exclude_pattern: None,
477 desktop: false,
478 ignore_checksums: false,
479 }
480 .requires_lock()
481 );
482 assert!(
483 Commands::Upgrade {
484 names: None,
485 force: false,
486 check: true,
487 machine_readable: false,
488 ignore_checksums: false,
489 }
490 .requires_lock()
491 );
492 assert!(
493 Commands::Config {
494 action: ConfigAction::Get {
495 keys: vec!["github.api_token".to_string()],
496 },
497 }
498 .requires_lock()
499 );
500 assert!(
501 Commands::Export {
502 path: "packages.json".into(),
503 full: false,
504 }
505 .requires_lock()
506 );
507 }
508}