Skip to main content

upstream_rs/application/cli/
arguments.rs

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    /// Install a package from a GitHub release
27    #[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 to register the application under
36        name: String,
37
38        /// Repository identifier (e.g. `owner/repo`)
39        repo_slug: String,
40
41        /// Version tag to install (defaults to latest)
42        #[arg(short, long)]
43        tag: Option<String>,
44
45        /// File type to install
46        #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
47        kind: Filetype,
48
49        /// Source provider hosting the repository
50        #[arg(short = 'p', long, default_value_t = Provider::Github)]
51        provider: Provider,
52
53        /// Custom base URL. Defaults to provider's root
54        #[arg(long, requires = "provider")]
55        base_url: Option<String>,
56
57        /// Update channel to track
58        #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
59        channel: Channel,
60
61        /// Match pattern to use as a hint for which asset to prefer
62        #[arg(short = 'm', long, name = "match")]
63        match_pattern: Option<String>,
64
65        /// Exclude pattern to filter out unwanted assets (e.g., "rocm", "debug")
66        #[arg(short = 'e', long, name = "exclude")]
67        exclude_pattern: Option<String>,
68
69        /// Whether or not to create a .desktop entry for GUI applications
70        #[arg(short, long, default_value_t = false)]
71        desktop: bool,
72
73        /// Skip checksum verification for downloaded assets
74        #[arg(long, default_value_t = false)]
75        ignore_checksums: bool,
76    },
77
78    /// Remove one or more installed packages
79    #[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 of packages to remove
89        names: Vec<String>,
90
91        /// Remove all associated cached data
92        #[arg(long, default_value_t = false)]
93        purge: bool,
94    },
95
96    /// Upgrade installed packages to their latest versions
97    #[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        /// Packages to upgrade (upgrades all if omitted)
109        names: Option<Vec<String>>,
110
111        /// Force upgrade even if already up to date
112        #[arg(long, default_value_t = false)]
113        force: bool,
114
115        /// Check for available upgrades without applying them
116        #[arg(long, default_value_t = false)]
117        check: bool,
118
119        /// Use script-friendly check output: one line per update, "name oldver newver"
120        #[arg(long, default_value_t = false, requires = "check")]
121        machine_readable: bool,
122
123        /// Skip checksum verification for downloaded assets
124        #[arg(long, default_value_t = false)]
125        ignore_checksums: bool,
126    },
127
128    /// List installed packages and their metadata
129    #[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        /// Package name for detailed information
137        name: Option<String>,
138    },
139
140    /// Inspect releases visible from a provider without installing
141    #[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        /// Repository identifier or URL to probe
149        repo_slug: String,
150
151        /// Source provider (defaults to github, or scraper for URLs)
152        #[arg(short = 'p', long)]
153        provider: Option<Provider>,
154
155        /// Custom base URL for self-hosted providers
156        #[arg(long)]
157        base_url: Option<String>,
158
159        /// Channel view to display
160        #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
161        channel: Channel,
162
163        /// Maximum number of releases to display
164        #[arg(long, default_value_t = 10)]
165        limit: u32,
166
167        /// Include scored candidate assets for each release
168        #[arg(long, default_value_t = false)]
169        verbose: bool,
170    },
171
172    /// Manage upstream configuration
173    #[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    /// Manage package-specific settings and metadata
187    #[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    /// Initialize upstream by adding it to your shell PATH
202    #[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        /// Clean initialization (remove existing hooks first)
210        #[arg(long)]
211        clean: bool,
212
213        /// Check initialization status without making changes
214        #[arg(long, default_value_t = false, conflicts_with = "clean")]
215        check: bool,
216    },
217
218    /// Import packages from a manifest or full snapshot
219    #[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 to the manifest or snapshot archive
230        path: std::path::PathBuf,
231
232        /// Continue importing remaining packages when a package install/upgrade fails
233        #[arg(long, default_value_t = false)]
234        skip_failed: bool,
235    },
236
237    /// Export packages to a manifest or full snapshot
238    #[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        /// Output path for the manifest or snapshot archive
247        path: std::path::PathBuf,
248        /// Export a full snapshot of the upstream directory instead of a manifest
249        #[arg(long, default_value_t = false)]
250        full: bool,
251    },
252
253    /// Run diagnostics to detect installation and integration issues
254    #[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        /// Package names to check (all installed packages if omitted)
264        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    /// Set configuration values
292    #[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        /// Configuration assignments (format: key.path=value)
299        keys: Vec<String>,
300    },
301
302    /// Get configuration values
303    #[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        /// Configuration keys to retrieve (format: key.path)
310        keys: Vec<String>,
311    },
312
313    /// List all configuration keys
314    List,
315
316    /// Open configuration file in your default editor
317    Edit,
318
319    /// Reset configuration to defaults
320    Reset,
321}
322
323#[derive(Subcommand)]
324pub enum PackageAction {
325    /// Pin a package to its current version
326    #[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 of package to pin
332        name: String,
333    },
334
335    /// Unpin a package to allow updates
336    #[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 of package to unpin
342        name: String,
343    },
344
345    /// Get specific package metadata fields
346    #[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 of package
353        name: String,
354
355        /// Metadata keys to retrieve
356        keys: Vec<String>,
357    },
358
359    /// Manually set package metadata fields
360    #[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 of package
367        name: String,
368
369        /// Metadata assignments (format: key=value)
370        keys: Vec<String>,
371    },
372
373    /// Rename package alias without reinstalling
374    #[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        /// Existing package alias
381        old_name: String,
382
383        /// New package alias
384        new_name: String,
385    },
386
387    /// Display all metadata for a package
388    #[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 of package
395        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}