refactor: rename aim to upm and extract appimage module
This commit is contained in:
parent
af13e98eb3
commit
863c57e473
117 changed files with 2622 additions and 887 deletions
30
crates/upm/Cargo.toml
Normal file
30
crates/upm/Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "upm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "upm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
dialoguer.workspace = true
|
||||
console.workspace = true
|
||||
crossterm.workspace = true
|
||||
indicatif.workspace = true
|
||||
libc.workspace = true
|
||||
ratatui.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
upm-appimage = { path = "../upm-appimage" }
|
||||
upm-core = { path = "../upm-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates = "3.1.3"
|
||||
tempfile.workspace = true
|
||||
32
crates/upm/src/cli/args.rs
Normal file
32
crates/upm/src/cli/args.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "upm")]
|
||||
#[command(about = "Universal Package Manager")]
|
||||
pub struct Cli {
|
||||
#[arg(global = true, long = "system", conflicts_with = "user")]
|
||||
pub system: bool,
|
||||
|
||||
#[arg(global = true, long = "user", conflicts_with = "system")]
|
||||
pub user: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn is_review_update_flow(&self) -> bool {
|
||||
self.command.is_none() && self.query.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub enum Command {
|
||||
Remove { query: String },
|
||||
List,
|
||||
Search { query: String },
|
||||
Show { value: Option<String> },
|
||||
Update,
|
||||
}
|
||||
165
crates/upm/src/cli/config.rs
Normal file
165
crates/upm/src/cli/config.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct LoadedConfig {
|
||||
pub config: AppConfig,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct AppConfig {
|
||||
pub theme: ThemeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct ThemeConfig {
|
||||
pub heading: Option<String>,
|
||||
pub accent: Option<String>,
|
||||
pub muted: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub bullet: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub progress_spinner: Option<String>,
|
||||
pub progress_bar: Option<String>,
|
||||
pub progress_bar_unfilled: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct FileConfig {
|
||||
#[serde(default)]
|
||||
theme: FileThemeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct FileThemeConfig {
|
||||
heading: Option<String>,
|
||||
accent: Option<String>,
|
||||
muted: Option<String>,
|
||||
label: Option<String>,
|
||||
bullet: Option<String>,
|
||||
success: Option<String>,
|
||||
warning: Option<String>,
|
||||
error: Option<String>,
|
||||
progress_spinner: Option<String>,
|
||||
progress_bar: Option<String>,
|
||||
progress_bar_unfilled: Option<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> LoadedConfig {
|
||||
let system_path = Some(PathBuf::from("/etc/upm/config.toml"));
|
||||
let user_path = env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join(".config/upm/config.toml"));
|
||||
Self::load_from_paths(system_path.as_deref(), user_path.as_deref())
|
||||
}
|
||||
|
||||
pub fn load_from_paths(system_path: Option<&Path>, user_path: Option<&Path>) -> LoadedConfig {
|
||||
let mut loaded = LoadedConfig::default();
|
||||
|
||||
if let Some(path) = system_path {
|
||||
merge_file(path, &mut loaded);
|
||||
}
|
||||
|
||||
if let Some(path) = user_path {
|
||||
merge_file(path, &mut loaded);
|
||||
}
|
||||
|
||||
loaded
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_file(path: &Path, loaded: &mut LoadedConfig) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
loaded
|
||||
.warnings
|
||||
.push(format!("failed to read {}: {error}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: FileConfig = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
loaded
|
||||
.warnings
|
||||
.push(format!("failed to parse {}: {error}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
merge_theme(&mut loaded.config.theme, parsed.theme);
|
||||
}
|
||||
|
||||
fn merge_theme(theme: &mut ThemeConfig, update: FileThemeConfig) {
|
||||
merge_option(&mut theme.heading, update.heading);
|
||||
merge_option(&mut theme.accent, update.accent);
|
||||
merge_option(&mut theme.muted, update.muted);
|
||||
merge_option(&mut theme.label, update.label);
|
||||
merge_option(&mut theme.bullet, update.bullet);
|
||||
merge_option(&mut theme.success, update.success);
|
||||
merge_option(&mut theme.warning, update.warning);
|
||||
merge_option(&mut theme.error, update.error);
|
||||
merge_option(&mut theme.progress_spinner, update.progress_spinner);
|
||||
merge_option(&mut theme.progress_bar, update.progress_bar);
|
||||
merge_option(
|
||||
&mut theme.progress_bar_unfilled,
|
||||
update.progress_bar_unfilled,
|
||||
);
|
||||
}
|
||||
|
||||
fn merge_option(target: &mut Option<String>, update: Option<String>) {
|
||||
if let Some(value) = update {
|
||||
*target = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn user_config_overrides_system_theme_values() {
|
||||
let dir = tempdir().unwrap();
|
||||
let system_path = dir.path().join("system-config.toml");
|
||||
let user_path = dir.path().join("user-config.toml");
|
||||
|
||||
std::fs::write(
|
||||
&system_path,
|
||||
"[theme]\nheading = \"amber\"\naccent = \"teal\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&user_path, "[theme]\nheading = \"#d28b26\"\n").unwrap();
|
||||
|
||||
let loaded = AppConfig::load_from_paths(Some(&system_path), Some(&user_path));
|
||||
|
||||
assert_eq!(loaded.config.theme.heading.as_deref(), Some("#d28b26"));
|
||||
assert_eq!(loaded.config.theme.accent.as_deref(), Some("teal"));
|
||||
assert!(loaded.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_config_is_ignored_without_aborting_load() {
|
||||
let dir = tempdir().unwrap();
|
||||
let system_path = dir.path().join("system-config.toml");
|
||||
|
||||
std::fs::write(&system_path, "[theme\nheading = \"amber\"\n").unwrap();
|
||||
|
||||
let loaded = AppConfig::load_from_paths(Some(&system_path), None);
|
||||
|
||||
assert_eq!(loaded.config.theme.heading, None);
|
||||
assert!(!loaded.warnings.is_empty());
|
||||
}
|
||||
}
|
||||
2
crates/upm/src/cli/mod.rs
Normal file
2
crates/upm/src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod args;
|
||||
pub mod config;
|
||||
132
crates/upm/src/config.rs
Normal file
132
crates/upm/src/config.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize)]
|
||||
pub struct CliConfig {
|
||||
#[serde(default)]
|
||||
pub allow_http: bool,
|
||||
#[serde(default)]
|
||||
pub search: SearchConfig,
|
||||
#[serde(default)]
|
||||
pub theme: ThemeConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
|
||||
pub struct SearchConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub bottom_to_top: bool,
|
||||
#[serde(default)]
|
||||
pub skip_confirmation: bool,
|
||||
}
|
||||
|
||||
impl Default for SearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bottom_to_top: true,
|
||||
skip_confirmation: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
|
||||
pub struct ThemeConfig {
|
||||
#[serde(default = "default_accent")]
|
||||
pub accent: String,
|
||||
#[serde(default = "default_accent_secondary")]
|
||||
pub accent_secondary: String,
|
||||
#[serde(default = "default_dim")]
|
||||
pub dim: String,
|
||||
}
|
||||
|
||||
impl Default for ThemeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accent: default_accent(),
|
||||
accent_secondary: default_accent_secondary(),
|
||||
dim: default_dim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Result<CliConfig, ConfigError> {
|
||||
load_from_path(&default_path())
|
||||
}
|
||||
|
||||
pub fn load_from_path(path: &Path) -> Result<CliConfig, ConfigError> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => toml::from_str(&contents).map_err(|source| ConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()),
|
||||
Err(source) => Err(ConfigError::Read {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_path() -> PathBuf {
|
||||
if let Some(path) = env::var_os("UPM_CONFIG_PATH") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
||||
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
|
||||
return PathBuf::from(config_home).join("upm/config.toml");
|
||||
}
|
||||
|
||||
let home = env::var_os("HOME").unwrap_or_else(|| ".".into());
|
||||
PathBuf::from(home).join(".config/upm/config.toml")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
source: toml::de::Error,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_accent() -> String {
|
||||
"#b388ff".to_owned()
|
||||
}
|
||||
|
||||
fn default_accent_secondary() -> String {
|
||||
"#d5c2ff".to_owned()
|
||||
}
|
||||
|
||||
fn default_dim() -> String {
|
||||
"#7f7396".to_owned()
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Read { path, source } => {
|
||||
write!(
|
||||
formatter,
|
||||
"failed to read config {}: {source}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::Parse { path, source } => {
|
||||
write!(
|
||||
formatter,
|
||||
"failed to parse config {}: {source}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
492
crates/upm/src/lib.rs
Normal file
492
crates/upm/src/lib.rs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod providers;
|
||||
pub mod ui;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use upm_core::app::add::{
|
||||
AddPlan, AddSecurityPolicy, InstalledApp,
|
||||
build_add_plan_with_reporter_and_registered_providers, install_app_with_reporter,
|
||||
resolve_requested_scope,
|
||||
};
|
||||
use upm_core::app::list::{ListRow, build_list_rows};
|
||||
use upm_core::app::progress::{
|
||||
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||
};
|
||||
use upm_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
|
||||
use upm_core::app::search::build_search_results_with_registered_providers;
|
||||
use upm_core::app::show::{build_installed_show_results, build_show_result};
|
||||
use upm_core::app::update::{build_update_plan, execute_updates_with_reporter_and_policy};
|
||||
use upm_core::domain::app::AppRecord;
|
||||
use upm_core::domain::search::{SearchQuery, SearchResults};
|
||||
use upm_core::domain::show::{InstalledShow, ShowResult};
|
||||
use upm_core::domain::update::{UpdateExecutionResult, UpdatePlan};
|
||||
use upm_core::registry::store::RegistryStore;
|
||||
|
||||
pub use cli::args::Cli;
|
||||
|
||||
pub fn parse() -> Cli {
|
||||
<Cli as clap::Parser>::parse()
|
||||
}
|
||||
|
||||
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||
let mut reporter = NoopReporter;
|
||||
dispatch_with_reporter(cli, &mut reporter)
|
||||
}
|
||||
|
||||
pub fn dispatch_with_reporter(
|
||||
cli: Cli,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<DispatchResult, DispatchError> {
|
||||
dispatch_with_reporter_and_config(cli, &crate::config::CliConfig::default(), reporter)
|
||||
}
|
||||
|
||||
pub fn dispatch_with_reporter_and_config(
|
||||
cli: Cli,
|
||||
config: &crate::config::CliConfig,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<DispatchResult, DispatchError> {
|
||||
let registry_path = registry_path();
|
||||
let install_home = install_home(®istry_path);
|
||||
let store = RegistryStore::new(registry_path);
|
||||
let registry = store.load()?;
|
||||
let apps = registry.apps.clone();
|
||||
|
||||
if cli.is_review_update_flow() {
|
||||
return Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?));
|
||||
}
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
return match command {
|
||||
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
||||
cli::args::Command::Remove { query } => {
|
||||
let removal =
|
||||
remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?;
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SaveRegistry,
|
||||
message: "saving registry".to_owned(),
|
||||
});
|
||||
store.mutate_exclusive(|latest| {
|
||||
remove_app_record(&mut latest.apps, &removal.removed.stable_id);
|
||||
})?;
|
||||
reporter.report(&OperationEvent::Finished {
|
||||
summary: format!("removed {}", removal.removed.stable_id),
|
||||
});
|
||||
Ok(DispatchResult::Removed(Box::new(removal)))
|
||||
}
|
||||
cli::args::Command::Search { query } => {
|
||||
reporter.report(&OperationEvent::Started {
|
||||
kind: OperationKind::Search,
|
||||
label: query.clone(),
|
||||
});
|
||||
let results = providers::with_provider_registry(|providers| {
|
||||
build_search_results_with_registered_providers(
|
||||
&SearchQuery::new(&query),
|
||||
&apps,
|
||||
providers,
|
||||
)
|
||||
})?;
|
||||
reporter.report(&OperationEvent::Finished {
|
||||
summary: format!("search complete: {} remote hits", results.remote_hits.len()),
|
||||
});
|
||||
Ok(DispatchResult::Search(results))
|
||||
}
|
||||
cli::args::Command::Show { value } => match value {
|
||||
Some(value) => {
|
||||
let result = build_show_result(&value, &apps)?;
|
||||
Ok(DispatchResult::Show(Box::new(result)))
|
||||
}
|
||||
None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))),
|
||||
},
|
||||
cli::args::Command::Update => {
|
||||
let updates = execute_updates_with_reporter_and_policy(
|
||||
&apps,
|
||||
&install_home,
|
||||
reporter,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: config.allow_http,
|
||||
},
|
||||
)?;
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SaveRegistry,
|
||||
message: "saving registry".to_owned(),
|
||||
});
|
||||
store.mutate_exclusive(|latest| {
|
||||
merge_updated_app_records(&mut latest.apps, &apps, &updates.apps);
|
||||
})?;
|
||||
reporter.report(&OperationEvent::Finished {
|
||||
summary: format!(
|
||||
"updated {}, failed {}",
|
||||
updates.updated_count(),
|
||||
updates.failed_count()
|
||||
),
|
||||
});
|
||||
Ok(DispatchResult::Updated(Box::new(updates)))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(query) = cli.query {
|
||||
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
|
||||
let transport = upm_core::source::github::default_transport();
|
||||
let plan_result = providers::with_provider_registry(|providers| {
|
||||
build_add_plan_with_reporter_and_registered_providers(
|
||||
&query,
|
||||
transport.as_ref(),
|
||||
reporter,
|
||||
providers,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: config.allow_http,
|
||||
},
|
||||
)
|
||||
});
|
||||
let mut plan = match plan_result {
|
||||
Ok(plan) => plan,
|
||||
Err(
|
||||
upm_core::app::add::BuildAddPlanError::Query(
|
||||
upm_core::app::query::ResolveQueryError::Unsupported,
|
||||
)
|
||||
| upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. },
|
||||
) => {
|
||||
reporter.report(&OperationEvent::Started {
|
||||
kind: OperationKind::Search,
|
||||
label: query.clone(),
|
||||
});
|
||||
let results = providers::with_provider_registry(|providers| {
|
||||
build_search_results_with_registered_providers(
|
||||
&SearchQuery::new(&query),
|
||||
&apps,
|
||||
providers,
|
||||
)
|
||||
})?;
|
||||
reporter.report(&OperationEvent::Finished {
|
||||
summary: format!("search complete: {} remote hits", results.remote_hits.len()),
|
||||
});
|
||||
return Ok(DispatchResult::Search(results));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !plan.interactions.is_empty() {
|
||||
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
||||
Some(resolved) => {
|
||||
plan = resolved;
|
||||
}
|
||||
None => return Ok(DispatchResult::PendingAdd(Box::new(plan))),
|
||||
}
|
||||
}
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?;
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SaveRegistry,
|
||||
message: "saving registry".to_owned(),
|
||||
});
|
||||
store.mutate_exclusive(|latest| {
|
||||
upsert_app_record(&mut latest.apps, installed.record.clone());
|
||||
})?;
|
||||
reporter.report(&OperationEvent::Finished {
|
||||
summary: format!("installed {}", installed.record.stable_id),
|
||||
});
|
||||
|
||||
return Ok(DispatchResult::Added(Box::new(installed)));
|
||||
}
|
||||
|
||||
Ok(DispatchResult::Noop)
|
||||
}
|
||||
|
||||
pub fn render(result: &DispatchResult) -> String {
|
||||
render_with_config(result, &config::CliConfig::default())
|
||||
}
|
||||
|
||||
pub fn render_with_config(result: &DispatchResult, config: &config::CliConfig) -> String {
|
||||
ui::render::render_dispatch_result_with_config(result, config)
|
||||
}
|
||||
|
||||
pub fn default_registry_path() -> PathBuf {
|
||||
if let Some(path) = env::var_os("UPM_REGISTRY_PATH") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
||||
let home = env::var_os("HOME").unwrap_or_else(|| ".".into());
|
||||
PathBuf::from(home).join(".local/share/upm/registry.toml")
|
||||
}
|
||||
|
||||
fn registry_path() -> PathBuf {
|
||||
default_registry_path()
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum DispatchResult {
|
||||
Added(Box<InstalledApp>),
|
||||
List(Vec<ListRow>),
|
||||
PendingAdd(Box<AddPlan>),
|
||||
Removed(Box<RemovalResult>),
|
||||
Search(SearchResults),
|
||||
Show(Box<ShowResult>),
|
||||
ShowAll(Vec<InstalledShow>),
|
||||
UpdatePlan(UpdatePlan),
|
||||
Updated(Box<UpdateExecutionResult>),
|
||||
Noop,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DispatchError {
|
||||
AddPlan(upm_core::app::add::BuildAddPlanError),
|
||||
AddInstall(upm_core::app::add::InstallAppError),
|
||||
Prompt(ui::prompt::PromptError),
|
||||
RemovePlan(upm_core::app::remove::RemoveRegisteredAppError),
|
||||
Registry(upm_core::registry::store::RegistryStoreError),
|
||||
Search(upm_core::app::search::SearchError),
|
||||
Show(upm_core::domain::show::ShowResultError),
|
||||
UpdatePlan(upm_core::app::update::BuildUpdatePlanError),
|
||||
UpdateExecution(upm_core::app::update::ExecuteUpdatesError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DispatchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AddPlan(error) => match error {
|
||||
upm_core::app::add::BuildAddPlanError::Query(
|
||||
upm_core::app::query::ResolveQueryError::Unsupported,
|
||||
) => write!(f, "unsupported source query"),
|
||||
upm_core::app::add::BuildAddPlanError::InsecureHttpSource { .. } => write!(
|
||||
f,
|
||||
"insecure HTTP sources are disabled; set allow_http = true to permit them"
|
||||
),
|
||||
upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { source } => write!(
|
||||
f,
|
||||
"no installable artifact found for {} {}",
|
||||
source.kind.as_str(),
|
||||
source.locator
|
||||
),
|
||||
upm_core::app::add::BuildAddPlanError::Adapter(id, error) => match error {
|
||||
upm_core::adapters::traits::AdapterError::UnsupportedQuery => {
|
||||
write!(f, "{id} does not support this query")
|
||||
}
|
||||
upm_core::adapters::traits::AdapterError::UnsupportedSource => {
|
||||
write!(f, "{id} does not support this source")
|
||||
}
|
||||
upm_core::adapters::traits::AdapterError::ResolutionFailed(reason) => {
|
||||
write!(f, "{id} resolution failed: {reason}")
|
||||
}
|
||||
},
|
||||
upm_core::app::add::BuildAddPlanError::GitHubDiscovery(error) => {
|
||||
write!(f, "github discovery failed: {error:?}")
|
||||
}
|
||||
upm_core::app::add::BuildAddPlanError::NoCandidates => {
|
||||
write!(f, "no installable candidates found")
|
||||
}
|
||||
},
|
||||
Self::AddInstall(error) => write!(f, "install failed: {}", render_install_error(error)),
|
||||
Self::Prompt(error) => write!(f, "prompt failed: {error:?}"),
|
||||
Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"),
|
||||
Self::Registry(error) => write!(f, "registry failed: {error:?}"),
|
||||
Self::Search(error) => write!(f, "search failed: {error:?}"),
|
||||
Self::Show(error) => match error {
|
||||
upm_core::domain::show::ShowResultError::AmbiguousInstalledMatch {
|
||||
query,
|
||||
matches,
|
||||
} => write!(
|
||||
f,
|
||||
"multiple installed apps match {query}: {}",
|
||||
matches.join(", ")
|
||||
),
|
||||
upm_core::domain::show::ShowResultError::UnsupportedQuery => {
|
||||
write!(f, "unsupported source query")
|
||||
}
|
||||
upm_core::domain::show::ShowResultError::InsecureHttpSource => write!(
|
||||
f,
|
||||
"insecure HTTP sources are disabled; set allow_http = true to permit them"
|
||||
),
|
||||
upm_core::domain::show::ShowResultError::NoInstallableArtifact { source } => {
|
||||
write!(
|
||||
f,
|
||||
"no installable artifact found for {} {}",
|
||||
source.kind.as_str(),
|
||||
source.locator
|
||||
)
|
||||
}
|
||||
upm_core::domain::show::ShowResultError::AdapterResolutionFailed {
|
||||
adapter_id,
|
||||
kind,
|
||||
detail,
|
||||
} => match kind {
|
||||
upm_core::domain::show::AdapterFailureKind::UnsupportedQuery => {
|
||||
write!(f, "{adapter_id} does not support this query")
|
||||
}
|
||||
upm_core::domain::show::AdapterFailureKind::UnsupportedSource => {
|
||||
write!(f, "{adapter_id} does not support this source")
|
||||
}
|
||||
upm_core::domain::show::AdapterFailureKind::ResolutionFailed => {
|
||||
if let Some(detail) = detail {
|
||||
write!(f, "{adapter_id} resolution failed: {detail}")
|
||||
} else {
|
||||
write!(f, "{adapter_id} resolution failed")
|
||||
}
|
||||
}
|
||||
},
|
||||
upm_core::domain::show::ShowResultError::GitHubDiscoveryFailed {
|
||||
kind,
|
||||
detail,
|
||||
} => match (kind, detail) {
|
||||
(
|
||||
upm_core::domain::show::GitHubDiscoveryFailureKind::FixtureDocumentMissing,
|
||||
Some(detail),
|
||||
) => write!(f, "github discovery failed: missing fixture document {detail}"),
|
||||
(
|
||||
upm_core::domain::show::GitHubDiscoveryFailureKind::NoReleases,
|
||||
Some(detail),
|
||||
) => write!(f, "github discovery failed: no releases for {detail}"),
|
||||
(upm_core::domain::show::GitHubDiscoveryFailureKind::Unsupported, _) => {
|
||||
write!(f, "github discovery failed: unsupported source")
|
||||
}
|
||||
(upm_core::domain::show::GitHubDiscoveryFailureKind::Transport, _) => {
|
||||
write!(f, "github discovery failed: transport error")
|
||||
}
|
||||
_ => write!(f, "github discovery failed"),
|
||||
},
|
||||
upm_core::domain::show::ShowResultError::NoInstallableCandidates => {
|
||||
write!(f, "no installable candidates found")
|
||||
}
|
||||
},
|
||||
Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"),
|
||||
Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_install_error(error: &upm_core::app::add::InstallAppError) -> String {
|
||||
match error {
|
||||
upm_core::app::add::InstallAppError::Materialize(error) => format!("{error:?}"),
|
||||
upm_core::app::add::InstallAppError::Policy(error) => error.clone(),
|
||||
upm_core::app::add::InstallAppError::Download(error) => error.to_string(),
|
||||
upm_core::app::add::InstallAppError::DownloadIo(error) => error.to_string(),
|
||||
upm_core::app::add::InstallAppError::HostProbe(error) => error.to_string(),
|
||||
upm_core::app::add::InstallAppError::Install(error) => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::add::BuildAddPlanError> for DispatchError {
|
||||
fn from(value: upm_core::app::add::BuildAddPlanError) -> Self {
|
||||
Self::AddPlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::add::InstallAppError> for DispatchError {
|
||||
fn from(value: upm_core::app::add::InstallAppError) -> Self {
|
||||
Self::AddInstall(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ui::prompt::PromptError> for DispatchError {
|
||||
fn from(value: ui::prompt::PromptError) -> Self {
|
||||
Self::Prompt(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::update::BuildUpdatePlanError> for DispatchError {
|
||||
fn from(value: upm_core::app::update::BuildUpdatePlanError) -> Self {
|
||||
Self::UpdatePlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::update::ExecuteUpdatesError> for DispatchError {
|
||||
fn from(value: upm_core::app::update::ExecuteUpdatesError) -> Self {
|
||||
Self::UpdateExecution(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::remove::RemoveRegisteredAppError> for DispatchError {
|
||||
fn from(value: upm_core::app::remove::RemoveRegisteredAppError) -> Self {
|
||||
Self::RemovePlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::registry::store::RegistryStoreError> for DispatchError {
|
||||
fn from(value: upm_core::registry::store::RegistryStoreError) -> Self {
|
||||
Self::Registry(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::app::search::SearchError> for DispatchError {
|
||||
fn from(value: upm_core::app::search::SearchError) -> Self {
|
||||
Self::Search(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<upm_core::domain::show::ShowResultError> for DispatchError {
|
||||
fn from(value: upm_core::domain::show::ShowResultError) -> Self {
|
||||
Self::Show(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
|
||||
if let Some(existing) = apps
|
||||
.iter_mut()
|
||||
.find(|item| item.stable_id == record.stable_id)
|
||||
{
|
||||
*existing = record;
|
||||
return;
|
||||
}
|
||||
|
||||
apps.push(record);
|
||||
}
|
||||
|
||||
fn remove_app_record(apps: &mut Vec<AppRecord>, stable_id: &str) {
|
||||
apps.retain(|app| app.stable_id != stable_id);
|
||||
}
|
||||
|
||||
fn merge_updated_app_records(
|
||||
latest_apps: &mut [AppRecord],
|
||||
original_apps: &[AppRecord],
|
||||
updated_apps: &[AppRecord],
|
||||
) {
|
||||
let original_ids = original_apps
|
||||
.iter()
|
||||
.map(|app| app.stable_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let updated_by_id = updated_apps
|
||||
.iter()
|
||||
.map(|app| (app.stable_id.as_str(), app.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for app in latest_apps.iter_mut() {
|
||||
if original_ids.contains(app.stable_id.as_str())
|
||||
&& let Some(updated) = updated_by_id.get(app.stable_id.as_str())
|
||||
{
|
||||
*app = updated.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_home(registry_path: &Path) -> PathBuf {
|
||||
if env::var_os("UPM_REGISTRY_PATH").is_some() {
|
||||
return registry_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("install-home");
|
||||
}
|
||||
|
||||
let home = env::var_os("HOME").unwrap_or_else(|| ".".into());
|
||||
PathBuf::from(home)
|
||||
}
|
||||
|
||||
fn is_effective_root() -> bool {
|
||||
if let Some(value) = env::var_os("UPM_EFFECTIVE_ROOT") {
|
||||
let value = value.to_string_lossy();
|
||||
return value == "1" || value.eq_ignore_ascii_case("true");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::geteuid() == 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
38
crates/upm/src/main.rs
Normal file
38
crates/upm/src/main.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
fn main() {
|
||||
let loaded_theme_config = upm::cli::config::AppConfig::load();
|
||||
upm::ui::theme::set_active_theme(upm::ui::theme::resolve_theme(
|
||||
&loaded_theme_config.config.theme,
|
||||
));
|
||||
for warning in loaded_theme_config.warnings {
|
||||
eprintln!(
|
||||
"{}",
|
||||
upm::ui::theme::warning_text(&format!("Config warning: {warning}"))
|
||||
);
|
||||
}
|
||||
|
||||
let config = match upm::config::load() {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let cli = upm::parse();
|
||||
let mut reporter = upm::ui::progress::TerminalProgressReporter::stderr();
|
||||
match upm::dispatch_with_reporter_and_config(cli, &config, &mut reporter) {
|
||||
Ok(result) => {
|
||||
let output = upm::render_with_config(&result, &config);
|
||||
if !output.is_empty() {
|
||||
if reporter.emitted_output() {
|
||||
println!();
|
||||
}
|
||||
println!("{output}");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/upm/src/providers.rs
Normal file
16
crates/upm/src/providers.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use upm_appimage::AppImageHubAddProvider;
|
||||
use upm_appimage::AppImageHubSearchProvider;
|
||||
use upm_appimage::source::appimagehub;
|
||||
use upm_core::ProviderRegistry;
|
||||
|
||||
pub fn with_provider_registry<T>(build: impl FnOnce(&ProviderRegistry<'_>) -> T) -> T {
|
||||
let appimagehub_transport = appimagehub::default_transport();
|
||||
let appimagehub_search = AppImageHubSearchProvider::new(appimagehub_transport.as_ref());
|
||||
let appimagehub_add = AppImageHubAddProvider::new(appimagehub_transport.as_ref());
|
||||
let providers = ProviderRegistry {
|
||||
search_providers: vec![&appimagehub_search],
|
||||
external_add_providers: vec![&appimagehub_add],
|
||||
};
|
||||
|
||||
build(&providers)
|
||||
}
|
||||
5
crates/upm/src/ui/mod.rs
Normal file
5
crates/upm/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod progress;
|
||||
pub mod prompt;
|
||||
pub mod render;
|
||||
pub mod search_browser;
|
||||
pub mod theme;
|
||||
291
crates/upm/src/ui/progress.rs
Normal file
291
crates/upm/src/ui/progress.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
use std::io::IsTerminal;
|
||||
use std::time::Duration;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use upm_core::app::progress::{OperationEvent, OperationKind, OperationStage, ProgressReporter};
|
||||
|
||||
pub fn new_progress_bar(total: Option<u64>) -> ProgressBar {
|
||||
match total {
|
||||
Some(total) => ProgressBar::new(total),
|
||||
None => ProgressBar::new_spinner(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spinner_style() -> ProgressStyle {
|
||||
ProgressStyle::with_template("{spinner} {msg}").expect("spinner template is valid")
|
||||
}
|
||||
|
||||
pub fn byte_style() -> ProgressStyle {
|
||||
let theme = crate::ui::theme::current_theme();
|
||||
let filled = crate::ui::theme::indicatif_color_key(&theme.progress_bar);
|
||||
let unfilled = crate::ui::theme::indicatif_color_key(&theme.progress_bar_unfilled);
|
||||
ProgressStyle::with_template(&format!(
|
||||
"{{bar:32.{filled}/{unfilled}}} {{bytes}}/{{total_bytes}} {{msg}}"
|
||||
))
|
||||
.expect("byte progress template is valid")
|
||||
}
|
||||
|
||||
pub fn operation_label(kind: OperationKind) -> &'static str {
|
||||
match kind {
|
||||
OperationKind::Add => "Installing",
|
||||
OperationKind::Search => "Searching",
|
||||
OperationKind::UpdateBatch => "Updating",
|
||||
OperationKind::UpdateItem => "Updating",
|
||||
OperationKind::Remove => "Removing",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stage_label(stage: OperationStage) -> &'static str {
|
||||
match stage {
|
||||
OperationStage::ResolveQuery => "Resolving source",
|
||||
OperationStage::DiscoverRelease => "Discovering release",
|
||||
OperationStage::SelectArtifact => "Selecting artifact",
|
||||
OperationStage::DownloadArtifact => "Downloading artifact",
|
||||
OperationStage::StagePayload => "Staging payload",
|
||||
OperationStage::WriteDesktopEntry => "Writing desktop entry",
|
||||
OperationStage::ExtractIcon => "Extracting icon",
|
||||
OperationStage::RefreshIntegration => "Refreshing desktop integration",
|
||||
OperationStage::SaveRegistry => "Saving registry",
|
||||
OperationStage::Finalize => "Finalizing",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
let mut value = bytes as f64;
|
||||
let mut unit_index = 0_usize;
|
||||
while value >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
if unit_index == 0 {
|
||||
format!("{} {}", bytes, UNITS[unit_index])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit_index])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_completed_stage_line(token: &str) -> String {
|
||||
format!("{} {token}", crate::ui::theme::success("✓"))
|
||||
}
|
||||
|
||||
pub fn event_message(event: &OperationEvent) -> Option<String> {
|
||||
match event {
|
||||
OperationEvent::Started { kind, label } => {
|
||||
Some(format!("{} {label}", operation_label(*kind)))
|
||||
}
|
||||
OperationEvent::StageChanged { stage, message } => {
|
||||
let title = stage_label(*stage);
|
||||
if title.eq_ignore_ascii_case(message) {
|
||||
Some(title.to_owned())
|
||||
} else {
|
||||
Some(format!("{title}: {message}"))
|
||||
}
|
||||
}
|
||||
OperationEvent::Progress { .. } => None,
|
||||
OperationEvent::Warning { message } => Some(format!("Warning: {message}")),
|
||||
OperationEvent::Finished { summary } => Some(summary.clone()),
|
||||
OperationEvent::Failed { stage, reason } => {
|
||||
Some(format!("{} failed: {reason}", stage_label(*stage)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalProgressReporter {
|
||||
interactive: bool,
|
||||
progress_bar: Option<ProgressBar>,
|
||||
byte_total: Option<u64>,
|
||||
current_stage: Option<OperationStage>,
|
||||
last_progress_bytes: Option<u64>,
|
||||
emitted_output: bool,
|
||||
}
|
||||
|
||||
impl TerminalProgressReporter {
|
||||
pub fn stderr() -> Self {
|
||||
Self {
|
||||
interactive: std::io::stderr().is_terminal(),
|
||||
progress_bar: None,
|
||||
byte_total: None,
|
||||
current_stage: None,
|
||||
last_progress_bytes: None,
|
||||
emitted_output: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emitted_output(&self) -> bool {
|
||||
self.emitted_output
|
||||
}
|
||||
|
||||
fn clear_progress(&mut self) {
|
||||
if let Some(progress_bar) = self.progress_bar.take() {
|
||||
progress_bar.finish_and_clear();
|
||||
}
|
||||
self.byte_total = None;
|
||||
}
|
||||
|
||||
fn emit_completed_stage_token(&mut self) {
|
||||
let token = match self.current_stage {
|
||||
Some(OperationStage::DownloadArtifact) => self
|
||||
.last_progress_bytes
|
||||
.map(|bytes| format!("{} Downloaded", format_bytes(bytes))),
|
||||
Some(OperationStage::StagePayload) => Some("Payload Staged".to_owned()),
|
||||
Some(OperationStage::WriteDesktopEntry) => Some("Desktop Entry Written".to_owned()),
|
||||
Some(OperationStage::ExtractIcon) => Some("Icon Extracted".to_owned()),
|
||||
Some(OperationStage::RefreshIntegration) => {
|
||||
Some("Desktop Integration Refreshed".to_owned())
|
||||
}
|
||||
Some(OperationStage::SaveRegistry) => Some("Registry Saved".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(token) = token {
|
||||
self.clear_progress();
|
||||
self.emitted_output = true;
|
||||
eprintln!("{}", format_completed_stage_line(&token));
|
||||
}
|
||||
}
|
||||
|
||||
fn show_spinner(&mut self, message: String) {
|
||||
if !self.interactive {
|
||||
self.emitted_output = true;
|
||||
eprintln!("{}", crate::ui::theme::accent(&message));
|
||||
return;
|
||||
}
|
||||
|
||||
if self.byte_total.is_some() {
|
||||
self.clear_progress();
|
||||
}
|
||||
|
||||
let progress_bar = self.progress_bar.get_or_insert_with(|| {
|
||||
let progress_bar = new_progress_bar(None);
|
||||
progress_bar.set_style(spinner_style());
|
||||
progress_bar.enable_steady_tick(Duration::from_millis(100));
|
||||
progress_bar
|
||||
});
|
||||
progress_bar.set_message(crate::ui::theme::accent(&message));
|
||||
self.byte_total = None;
|
||||
}
|
||||
|
||||
fn show_progress(&mut self, current: u64, total: Option<u64>) {
|
||||
self.last_progress_bytes = Some(current);
|
||||
|
||||
if !self.interactive {
|
||||
return;
|
||||
}
|
||||
|
||||
let total = total.unwrap_or_else(|| current.max(1));
|
||||
let replace_progress = self.byte_total != Some(total);
|
||||
|
||||
if replace_progress {
|
||||
self.clear_progress();
|
||||
let progress_bar = new_progress_bar(Some(total));
|
||||
progress_bar.set_style(byte_style());
|
||||
self.progress_bar = Some(progress_bar);
|
||||
self.byte_total = Some(total);
|
||||
}
|
||||
|
||||
if let Some(progress_bar) = &self.progress_bar {
|
||||
progress_bar.set_length(total);
|
||||
progress_bar.set_position(current.min(total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TerminalProgressReporter {
|
||||
fn default() -> Self {
|
||||
Self::stderr()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressReporter for TerminalProgressReporter {
|
||||
fn report(&mut self, event: &OperationEvent) {
|
||||
match event {
|
||||
OperationEvent::Started { .. } => {
|
||||
if let Some(message) = event_message(event) {
|
||||
self.show_spinner(message);
|
||||
}
|
||||
}
|
||||
OperationEvent::StageChanged { stage, .. } => {
|
||||
self.emit_completed_stage_token();
|
||||
self.current_stage = Some(*stage);
|
||||
if let Some(message) = event_message(event) {
|
||||
self.show_spinner(message);
|
||||
}
|
||||
}
|
||||
OperationEvent::Progress { current, total } => self.show_progress(*current, *total),
|
||||
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
|
||||
self.clear_progress();
|
||||
if let Some(message) = event_message(event) {
|
||||
self.emitted_output = true;
|
||||
let styled = match event {
|
||||
OperationEvent::Warning { .. } => crate::ui::theme::warning_text(&message),
|
||||
OperationEvent::Failed { .. } => crate::ui::theme::error_text(&message),
|
||||
_ => message,
|
||||
};
|
||||
eprintln!("{styled}");
|
||||
}
|
||||
}
|
||||
OperationEvent::Finished { .. } => {
|
||||
self.emit_completed_stage_token();
|
||||
self.current_stage = None;
|
||||
self.last_progress_bytes = None;
|
||||
self.clear_progress();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TerminalProgressReporter;
|
||||
use crate::ui::progress::{ProgressReporter, format_completed_stage_line};
|
||||
use upm_core::app::progress::{OperationEvent, OperationStage};
|
||||
|
||||
#[test]
|
||||
fn stage_change_resets_byte_progress_position() {
|
||||
let mut reporter = TerminalProgressReporter {
|
||||
interactive: true,
|
||||
progress_bar: None,
|
||||
byte_total: None,
|
||||
current_stage: None,
|
||||
last_progress_bytes: None,
|
||||
emitted_output: false,
|
||||
};
|
||||
|
||||
reporter.report(&OperationEvent::Progress {
|
||||
current: 98,
|
||||
total: Some(100),
|
||||
});
|
||||
|
||||
let byte_position = reporter
|
||||
.progress_bar
|
||||
.as_ref()
|
||||
.expect("progress bar created")
|
||||
.position();
|
||||
assert_eq!(byte_position, 98);
|
||||
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::StagePayload,
|
||||
message: "staging payload".to_owned(),
|
||||
});
|
||||
|
||||
let stage_position = reporter
|
||||
.progress_bar
|
||||
.as_ref()
|
||||
.expect("spinner bar retained")
|
||||
.position();
|
||||
assert_eq!(stage_position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_stage_lines_use_checklist_format() {
|
||||
let line = format_completed_stage_line("Payload Staged");
|
||||
|
||||
assert_eq!(
|
||||
line,
|
||||
format!("{} Payload Staged", crate::ui::theme::success("✓"))
|
||||
);
|
||||
}
|
||||
}
|
||||
113
crates/upm/src/ui/prompt.rs
Normal file
113
crates/upm/src/ui/prompt.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use std::env;
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use dialoguer::Select;
|
||||
use upm_core::app::add::{AddPlan, prefer_latest_tracking};
|
||||
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||
|
||||
const TRACKING_PREFERENCE_ENV: &str = "UPM_TRACKING_PREFERENCE";
|
||||
|
||||
pub fn render_interaction(request: &InteractionRequest) -> String {
|
||||
match &request.kind {
|
||||
InteractionKind::SelectRegisteredApp { query, matches } => format!(
|
||||
"Choose the installed app matching '{query}': {}",
|
||||
matches.join(", ")
|
||||
),
|
||||
InteractionKind::ChooseTrackingPreference {
|
||||
requested_version,
|
||||
latest_version,
|
||||
} => format!(
|
||||
"Choose update tracking: requested {requested_version}, latest available {latest_version}",
|
||||
),
|
||||
InteractionKind::SelectArtifact { candidates } => {
|
||||
format!("Choose an artifact: {}", candidates.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_interactions(requests: &[InteractionRequest]) -> String {
|
||||
requests
|
||||
.iter()
|
||||
.map(render_interaction)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn resolve_add_plan_interactions(plan: AddPlan) -> Result<Option<AddPlan>, PromptError> {
|
||||
let mut resolved = plan;
|
||||
|
||||
for request in resolved.interactions.clone() {
|
||||
match &request.kind {
|
||||
InteractionKind::ChooseTrackingPreference {
|
||||
requested_version,
|
||||
latest_version,
|
||||
} => match resolve_tracking_preference(requested_version, latest_version)? {
|
||||
Some(TrackingPreference::Requested) => {
|
||||
resolved
|
||||
.interactions
|
||||
.retain(|item| item.key != "tracking-preference");
|
||||
}
|
||||
Some(TrackingPreference::Latest) => {
|
||||
resolved = prefer_latest_tracking(resolved);
|
||||
}
|
||||
None => return Ok(None),
|
||||
},
|
||||
InteractionKind::SelectRegisteredApp { .. }
|
||||
| InteractionKind::SelectArtifact { .. } => {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(resolved))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TrackingPreference {
|
||||
Requested,
|
||||
Latest,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptError {
|
||||
InvalidTrackingPreference(String),
|
||||
Dialoguer(dialoguer::Error),
|
||||
}
|
||||
|
||||
impl From<dialoguer::Error> for PromptError {
|
||||
fn from(value: dialoguer::Error) -> Self {
|
||||
Self::Dialoguer(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tracking_preference(
|
||||
requested_version: &str,
|
||||
latest_version: &str,
|
||||
) -> Result<Option<TrackingPreference>, PromptError> {
|
||||
if let Ok(value) = env::var(TRACKING_PREFERENCE_ENV) {
|
||||
return match value.trim().to_ascii_lowercase().as_str() {
|
||||
"requested" | "current" => Ok(Some(TrackingPreference::Requested)),
|
||||
"latest" => Ok(Some(TrackingPreference::Latest)),
|
||||
other => Err(PromptError::InvalidTrackingPreference(other.to_owned())),
|
||||
};
|
||||
}
|
||||
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let options = [
|
||||
format!("Keep tracking the requested release lineage ({requested_version})"),
|
||||
format!("Track the latest release after install ({latest_version})"),
|
||||
];
|
||||
let selection = Select::with_theme(&crate::ui::theme::dialog_theme())
|
||||
.with_prompt("Choose update tracking")
|
||||
.items(options)
|
||||
.default(1)
|
||||
.interact()?;
|
||||
|
||||
Ok(Some(match selection {
|
||||
0 => TrackingPreference::Requested,
|
||||
_ => TrackingPreference::Latest,
|
||||
}))
|
||||
}
|
||||
635
crates/upm/src/ui/render.rs
Normal file
635
crates/upm/src/ui/render.rs
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
use console::measure_text_width;
|
||||
use upm_core::app::add::AddPlan;
|
||||
use upm_core::domain::search::SearchResults;
|
||||
use upm_core::domain::show::{
|
||||
InstalledShow, MetadataSummary, RemoteInteractionSummary, RemoteShow, ShowResult, SourceSummary,
|
||||
};
|
||||
use upm_core::domain::update::UpdateExecutionStatus;
|
||||
|
||||
use crate::DispatchResult;
|
||||
use crate::config::CliConfig;
|
||||
|
||||
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
|
||||
[
|
||||
crate::ui::theme::heading("Update Review"),
|
||||
format!("apps with updates: {total}"),
|
||||
format!("selected: {selected}"),
|
||||
format!("failed: {failed}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||
render_dispatch_result_with_config(result, &CliConfig::default())
|
||||
}
|
||||
|
||||
pub fn render_dispatch_result_with_config(result: &DispatchResult, config: &CliConfig) -> String {
|
||||
match result {
|
||||
DispatchResult::Added(added) => render_added_app(added),
|
||||
DispatchResult::List(rows) => render_list(rows),
|
||||
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
||||
DispatchResult::Removed(removed) => render_removed_app(removed),
|
||||
DispatchResult::Search(results) => render_search_results_with_config(results, config),
|
||||
DispatchResult::Show(result) => render_show_result(result),
|
||||
DispatchResult::ShowAll(installed) => render_installed_show_list(installed),
|
||||
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
|
||||
DispatchResult::Updated(result) => render_updated_apps(result),
|
||||
DispatchResult::Noop => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_added_app(added: &upm_core::app::add::InstalledApp) -> String {
|
||||
let scope = match added.install_scope {
|
||||
upm_core::domain::app::InstallScope::User => "user",
|
||||
upm_core::domain::app::InstallScope::System => "system",
|
||||
};
|
||||
|
||||
let warning_lines = added
|
||||
.warnings
|
||||
.iter()
|
||||
.chain(added.install_outcome.warnings.iter())
|
||||
.map(|warning| format!("Warning: {warning}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut lines = vec![
|
||||
crate::ui::theme::heading(&format!(
|
||||
"Installed {} ({scope})",
|
||||
added.record.display_name
|
||||
)),
|
||||
format!(
|
||||
"{} {} {}",
|
||||
crate::ui::theme::label("Source"),
|
||||
added.source.kind.as_str(),
|
||||
added.source.locator,
|
||||
),
|
||||
format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Artifact"),
|
||||
added.selected_artifact.url,
|
||||
),
|
||||
];
|
||||
|
||||
let installed_files = install_file_paths(added);
|
||||
if !installed_files.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Installed files"));
|
||||
lines.extend(
|
||||
installed_files
|
||||
.iter()
|
||||
.map(|path| crate::ui::theme::bullet(path)),
|
||||
);
|
||||
}
|
||||
|
||||
lines.extend(warning_lines);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_pending_add(plan: &AddPlan) -> String {
|
||||
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
|
||||
[
|
||||
crate::ui::theme::heading("Installation Review"),
|
||||
format!(
|
||||
"{} {} {}",
|
||||
crate::ui::theme::label("Resolved source"),
|
||||
plan.resolution.source.kind.as_str(),
|
||||
plan.resolution.source.locator,
|
||||
),
|
||||
format!(
|
||||
"{} {} [{}]",
|
||||
crate::ui::theme::label("Selected artifact"),
|
||||
plan.selected_artifact.url,
|
||||
plan.selected_artifact.selection_reason,
|
||||
),
|
||||
prompts,
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn render_list(rows: &[upm_core::app::list::ListRow]) -> String {
|
||||
if rows.is_empty() {
|
||||
return crate::ui::theme::muted("No installed apps yet");
|
||||
}
|
||||
|
||||
let name_width = rows
|
||||
.iter()
|
||||
.map(|row| row.display_name.len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("Name".len());
|
||||
let version_width = rows
|
||||
.iter()
|
||||
.map(|row| row.version.as_deref().unwrap_or("-").len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("Version".len());
|
||||
|
||||
let mut lines = vec![crate::ui::theme::heading("Installed Apps")];
|
||||
lines.push(format_list_row(
|
||||
"Name",
|
||||
"Version",
|
||||
"Source",
|
||||
name_width,
|
||||
version_width,
|
||||
true,
|
||||
));
|
||||
|
||||
for row in rows {
|
||||
lines.push(format_list_row(
|
||||
&row.display_name,
|
||||
row.version.as_deref().unwrap_or("-"),
|
||||
&row.source,
|
||||
name_width,
|
||||
version_width,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_list_row(
|
||||
name: &str,
|
||||
version: &str,
|
||||
source: &str,
|
||||
name_width: usize,
|
||||
version_width: usize,
|
||||
is_header: bool,
|
||||
) -> String {
|
||||
let row = format!(
|
||||
"{name:<name_width$} {version:<version_width$} {source}",
|
||||
name = name,
|
||||
version = version,
|
||||
source = source,
|
||||
name_width = name_width,
|
||||
version_width = version_width,
|
||||
);
|
||||
|
||||
if is_header {
|
||||
crate::ui::theme::label(&row)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
fn render_removed_app(removed: &upm_core::app::remove::RemovalResult) -> String {
|
||||
let warning_lines = removed
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| format!("Warning: {warning}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = vec![crate::ui::theme::heading(&format!(
|
||||
"Removed {}",
|
||||
removed.removed.display_name,
|
||||
))];
|
||||
|
||||
if !removed.removed_paths.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Removed files"));
|
||||
lines.extend(
|
||||
removed
|
||||
.removed_paths
|
||||
.iter()
|
||||
.map(|path| crate::ui::theme::bullet(path)),
|
||||
);
|
||||
}
|
||||
|
||||
lines.extend(warning_lines);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_show_result(result: &ShowResult) -> String {
|
||||
match result {
|
||||
ShowResult::Installed(installed) => render_installed_show(installed),
|
||||
ShowResult::Remote(remote) => render_remote_show(remote),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_installed_show_list(installed: &[InstalledShow]) -> String {
|
||||
if installed.is_empty() {
|
||||
return crate::ui::theme::muted("No installed apps yet");
|
||||
}
|
||||
|
||||
installed
|
||||
.iter()
|
||||
.map(render_installed_show)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn render_installed_show(installed: &InstalledShow) -> String {
|
||||
let mut lines = installed_title_lines(installed);
|
||||
|
||||
if let Some(source_line) = installed_source_line(installed) {
|
||||
lines.push(source_line);
|
||||
}
|
||||
|
||||
if let Some(source_input) = installed.source_input.as_deref()
|
||||
&& should_render_requested_input(installed, source_input)
|
||||
{
|
||||
lines.push(format!(
|
||||
"{} {source_input}",
|
||||
crate::ui::theme::label("Requested")
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(current_metadata) = installed.metadata.first() {
|
||||
lines.extend(metadata_detail_lines(current_metadata));
|
||||
}
|
||||
|
||||
let tracked_paths = [
|
||||
installed.tracked_paths.payload_path.as_deref(),
|
||||
installed.tracked_paths.desktop_entry_path.as_deref(),
|
||||
installed.tracked_paths.icon_path.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if !tracked_paths.is_empty() {
|
||||
lines.push(installed_files_header(installed.install_scope));
|
||||
lines.extend(
|
||||
tracked_paths
|
||||
.into_iter()
|
||||
.map(|path| crate::ui::theme::muted(&format!(" {path}"))),
|
||||
);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn installed_title_lines(installed: &InstalledShow) -> Vec<String> {
|
||||
let left = crate::ui::theme::heading(&format!(
|
||||
"{} ({})",
|
||||
installed.display_name, installed.stable_id
|
||||
));
|
||||
let right = installed_right_summary(installed);
|
||||
|
||||
match terminal_width().filter(|width| *width > 0) {
|
||||
Some(width) => {
|
||||
let left_width = measure_text_width(&left);
|
||||
let right_width = measure_text_width(&right);
|
||||
if left_width + right_width + 2 <= width {
|
||||
vec![format!(
|
||||
"{left}{}{right}",
|
||||
" ".repeat(width - left_width - right_width)
|
||||
)]
|
||||
} else {
|
||||
vec![left, right]
|
||||
}
|
||||
}
|
||||
None => vec![left, right],
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_right_summary(installed: &InstalledShow) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(version) = installed.installed_version.as_deref() {
|
||||
parts.push(crate::ui::theme::accent(&format!("v{version}")));
|
||||
}
|
||||
|
||||
if let Some(tag) = installed_status_tag(installed) {
|
||||
parts.push(tag);
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn installed_status_tag(installed: &InstalledShow) -> Option<String> {
|
||||
let versions = ordered_metadata_versions(&installed.metadata);
|
||||
let latest_version = versions.first()?.clone();
|
||||
let installed_version = installed.installed_version.as_deref()?;
|
||||
|
||||
if installed_version == latest_version {
|
||||
Some(bold_muted("[up to date]"))
|
||||
} else {
|
||||
Some(crate::ui::theme::accent("[update available]"))
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_source_line(installed: &InstalledShow) -> Option<String> {
|
||||
let source = installed.source.as_ref()?;
|
||||
Some(labeled_detail_line(
|
||||
"Source",
|
||||
&format!(
|
||||
"{} - {}",
|
||||
source.kind.as_str(),
|
||||
display_source_locator(source)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn display_source_locator(source: &SourceSummary) -> &str {
|
||||
source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.unwrap_or(source.locator.as_str())
|
||||
}
|
||||
|
||||
fn should_render_requested_input(installed: &InstalledShow, source_input: &str) -> bool {
|
||||
let normalized_input = normalize_show_value(source_input);
|
||||
|
||||
if normalized_input == normalize_show_value(&installed.display_name)
|
||||
|| normalized_input == normalize_show_value(&installed.stable_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
installed.source.as_ref().is_none_or(|source| {
|
||||
normalized_input != normalize_show_value(&source.locator)
|
||||
&& source
|
||||
.canonical_locator
|
||||
.as_deref()
|
||||
.map(normalize_show_value)
|
||||
.is_none_or(|canonical| normalized_input != canonical)
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_width() -> Option<usize> {
|
||||
std::env::var("COLUMNS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.or_else(|| {
|
||||
crossterm::terminal::size()
|
||||
.ok()
|
||||
.map(|(cols, _)| cols as usize)
|
||||
})
|
||||
}
|
||||
|
||||
fn ordered_metadata_versions(metadata: &[MetadataSummary]) -> Vec<String> {
|
||||
let mut versions = Vec::new();
|
||||
|
||||
for version in metadata.iter().filter_map(|item| item.version.as_deref()) {
|
||||
if !versions.iter().any(|existing| existing == version) {
|
||||
versions.push(version.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
versions
|
||||
}
|
||||
|
||||
fn metadata_detail_lines(metadata: &MetadataSummary) -> Vec<String> {
|
||||
let mut lines = vec![labeled_detail_line(
|
||||
"Update Mechanism",
|
||||
metadata_kind_label(metadata.kind),
|
||||
)];
|
||||
|
||||
if let Some(architecture) = metadata.architecture.as_deref() {
|
||||
lines.push(labeled_detail_line("Architecture", architecture));
|
||||
}
|
||||
|
||||
if let Some(checksum) = metadata.checksum.as_deref() {
|
||||
lines.push(labeled_detail_line(
|
||||
"Checksum",
|
||||
&truncate_checksum(checksum),
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn installed_files_header(scope: Option<upm_core::domain::app::InstallScope>) -> String {
|
||||
let label = match scope {
|
||||
Some(upm_core::domain::app::InstallScope::User) => "Installed as User",
|
||||
Some(upm_core::domain::app::InstallScope::System) => "Installed as System",
|
||||
None => "Installed files",
|
||||
};
|
||||
|
||||
bold_muted_label(label)
|
||||
}
|
||||
|
||||
fn labeled_detail_line(label: &str, value: &str) -> String {
|
||||
format!(
|
||||
"{} {}",
|
||||
bold_muted_label(label),
|
||||
crate::ui::theme::muted(value)
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_checksum(checksum: &str) -> String {
|
||||
const PREFIX_CHARS: usize = 14;
|
||||
const SUFFIX_CHARS: usize = 6;
|
||||
const ELLIPSIS_CHARS: usize = 3;
|
||||
|
||||
let checksum_len = checksum.chars().count();
|
||||
|
||||
if checksum_len <= PREFIX_CHARS + SUFFIX_CHARS + ELLIPSIS_CHARS {
|
||||
checksum.to_owned()
|
||||
} else {
|
||||
let prefix = checksum.chars().take(PREFIX_CHARS).collect::<String>();
|
||||
let suffix = checksum
|
||||
.chars()
|
||||
.skip(checksum_len - SUFFIX_CHARS)
|
||||
.collect::<String>();
|
||||
format!("{prefix}...{suffix}",)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_kind_label(kind: upm_core::domain::update::ParsedMetadataKind) -> &'static str {
|
||||
match kind {
|
||||
upm_core::domain::update::ParsedMetadataKind::Unknown => "unknown",
|
||||
upm_core::domain::update::ParsedMetadataKind::ElectronBuilder => "electron-builder",
|
||||
upm_core::domain::update::ParsedMetadataKind::Zsync => "zsync",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_show_value(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn bold_muted(message: &str) -> String {
|
||||
let mut style = crate::ui::theme::current_theme().muted;
|
||||
style.bold = true;
|
||||
crate::ui::theme::apply_style_spec(message, &style)
|
||||
}
|
||||
|
||||
fn bold_muted_label(label: &str) -> String {
|
||||
bold_muted(&format!("{label}:"))
|
||||
}
|
||||
|
||||
fn render_remote_show(remote: &RemoteShow) -> String {
|
||||
let mut lines = vec![crate::ui::theme::heading("Resolved Source")];
|
||||
lines.push(format!(
|
||||
"{} {} {}",
|
||||
crate::ui::theme::label("Source"),
|
||||
remote.source.kind.as_str(),
|
||||
remote.source.locator,
|
||||
));
|
||||
if let Some(canonical_locator) = remote.source.canonical_locator.as_deref() {
|
||||
lines.push(format!(
|
||||
"{} {canonical_locator}",
|
||||
crate::ui::theme::label("Canonical")
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Artifact"),
|
||||
remote.artifact.url,
|
||||
));
|
||||
if let Some(version) = remote.artifact.version.as_deref() {
|
||||
lines.push(format!("{} {version}", crate::ui::theme::label("Version")));
|
||||
}
|
||||
if let Some(checksum) = remote.artifact.trusted_checksum.as_deref() {
|
||||
lines.push(format!(
|
||||
"{} {checksum}",
|
||||
crate::ui::theme::label("Checksum")
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Selection"),
|
||||
remote.artifact.selection_reason,
|
||||
));
|
||||
|
||||
if !remote.interactions.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Interactions"));
|
||||
for interaction in &remote.interactions {
|
||||
let text = match interaction {
|
||||
RemoteInteractionSummary::ChooseTrackingPreference {
|
||||
requested_version,
|
||||
latest_version,
|
||||
} => format!(
|
||||
"choose tracking preference: requested {requested_version}, latest {latest_version}"
|
||||
),
|
||||
RemoteInteractionSummary::SelectArtifact { candidate_count } => {
|
||||
format!("select artifact: {candidate_count} candidates")
|
||||
}
|
||||
};
|
||||
lines.push(crate::ui::theme::bullet(&text));
|
||||
}
|
||||
}
|
||||
|
||||
if !remote.warnings.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Warnings"));
|
||||
lines.extend(
|
||||
remote
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| format!("Warning: {warning}")),
|
||||
);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn install_file_paths(added: &upm_core::app::add::InstalledApp) -> Vec<String> {
|
||||
[
|
||||
Some(
|
||||
added
|
||||
.install_outcome
|
||||
.final_payload_path
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
added
|
||||
.install_outcome
|
||||
.desktop_entry_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
added
|
||||
.install_outcome
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_search_results(results: &SearchResults) -> String {
|
||||
let mut lines = vec![crate::ui::theme::heading("Search Results")];
|
||||
|
||||
lines.push(crate::ui::theme::heading("Remote Results"));
|
||||
if results.remote_hits.is_empty() {
|
||||
lines.push(crate::ui::theme::muted("No remote matches"));
|
||||
} else {
|
||||
for hit in &results.remote_hits {
|
||||
lines.push(crate::ui::theme::bullet(&format!(
|
||||
"[{}] {}",
|
||||
hit.provider_id, hit.display_name
|
||||
)));
|
||||
lines.push(format!("Install query: {}", hit.install_query));
|
||||
lines.push(format!("Source: {}", hit.source_locator));
|
||||
if let Some(description) = &hit.description {
|
||||
lines.push(format!("Description: {description}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(crate::ui::theme::heading("Installed Matches"));
|
||||
if results.installed_matches.is_empty() {
|
||||
lines.push(crate::ui::theme::muted("No installed matches"));
|
||||
} else {
|
||||
for app in &results.installed_matches {
|
||||
lines.push(crate::ui::theme::bullet(&format!(
|
||||
"{} ({})",
|
||||
app.display_name, app.stable_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
if !results.warnings.is_empty() {
|
||||
lines.push(crate::ui::theme::heading("Warnings"));
|
||||
for warning in &results.warnings {
|
||||
match warning.provider_id.as_deref() {
|
||||
Some(provider_id) => {
|
||||
lines.push(format!("Warning: {provider_id}: {}", warning.message))
|
||||
}
|
||||
None => lines.push(format!("Warning: {}", warning.message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_search_results_with_config(results: &SearchResults, config: &CliConfig) -> String {
|
||||
if crate::ui::search_browser::can_launch(results) {
|
||||
match crate::ui::search_browser::run(results, config) {
|
||||
Ok(Some(selection)) => {
|
||||
return crate::ui::search_browser::render_confirmation_summary(&selection.rows);
|
||||
}
|
||||
Ok(None) => return String::new(),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
render_search_results(results)
|
||||
}
|
||||
|
||||
fn render_updated_apps(result: &upm_core::domain::update::UpdateExecutionResult) -> String {
|
||||
let mut lines = vec![
|
||||
crate::ui::theme::heading("Update Summary"),
|
||||
format!("updated apps: {}", result.updated_count()),
|
||||
format!("failed updates: {}", result.failed_count()),
|
||||
];
|
||||
|
||||
for item in &result.items {
|
||||
match &item.status {
|
||||
UpdateExecutionStatus::Updated => lines.push(format!(
|
||||
"Updated: {} ({}) {} -> {}",
|
||||
item.display_name,
|
||||
item.stable_id,
|
||||
item.from_version.as_deref().unwrap_or("unknown"),
|
||||
item.to_version.as_deref().unwrap_or("unknown")
|
||||
)),
|
||||
UpdateExecutionStatus::Failed { reason } => lines.push(format!(
|
||||
"Failed: {} ({}) {}",
|
||||
item.display_name, item.stable_id, reason
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_update_plan(plan: &upm_core::domain::update::UpdatePlan) -> String {
|
||||
let mut lines = vec![render_update_summary(plan.items.len(), plan.items.len(), 0)];
|
||||
|
||||
for item in &plan.items {
|
||||
lines.push(format!(
|
||||
"{} ({}) via {}",
|
||||
item.display_name, item.stable_id, item.selected_channel.locator
|
||||
));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
848
crates/upm/src/ui/search_browser.rs
Normal file
848
crates/upm/src/ui/search_browser.rs
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::io::IsTerminal;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Clear, List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use upm_core::domain::search::{SearchInstallStatus, SearchResult, SearchResults};
|
||||
|
||||
use crate::config::{CliConfig, SearchConfig};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum BrowserPhase {
|
||||
Browsing,
|
||||
Confirming,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SearchRow {
|
||||
pub status: SearchInstallStatus,
|
||||
pub provider_id: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub install_query: String,
|
||||
pub version: Option<String>,
|
||||
pub selectable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SearchSelection {
|
||||
pub rows: Vec<SearchRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SubmitAction {
|
||||
None,
|
||||
Confirming,
|
||||
Confirmed(SearchSelection),
|
||||
}
|
||||
|
||||
pub struct SearchBrowserState {
|
||||
rows: Vec<SearchRow>,
|
||||
query_text: String,
|
||||
selected: BTreeSet<usize>,
|
||||
cursor: usize,
|
||||
page_size: usize,
|
||||
phase: BrowserPhase,
|
||||
numeric_buffer: String,
|
||||
status_message: Option<String>,
|
||||
}
|
||||
|
||||
impl SearchBrowserState {
|
||||
pub fn new(results: Vec<SearchResult>, config: SearchConfig, page_size: usize) -> Self {
|
||||
Self::new_with_query(results, String::new(), config, page_size)
|
||||
}
|
||||
|
||||
pub fn new_with_query(
|
||||
results: Vec<SearchResult>,
|
||||
query_text: String,
|
||||
config: SearchConfig,
|
||||
page_size: usize,
|
||||
) -> Self {
|
||||
let mut rows = results
|
||||
.into_iter()
|
||||
.map(|result| SearchRow {
|
||||
selectable: !matches!(result.install_status, SearchInstallStatus::Installed { .. }),
|
||||
status: result.install_status,
|
||||
provider_id: result.provider_id,
|
||||
display_name: result.display_name,
|
||||
description: result.description,
|
||||
install_query: result.install_query,
|
||||
version: result.version,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if config.bottom_to_top {
|
||||
rows.reverse();
|
||||
}
|
||||
|
||||
Self {
|
||||
rows,
|
||||
query_text,
|
||||
selected: BTreeSet::new(),
|
||||
cursor: 0,
|
||||
page_size: page_size.max(1),
|
||||
phase: BrowserPhase::Browsing,
|
||||
numeric_buffer: String::new(),
|
||||
status_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ordered_rows(&self) -> &[SearchRow] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn query_text(&self) -> &str {
|
||||
&self.query_text
|
||||
}
|
||||
|
||||
pub fn selected_rows(&self) -> Vec<&SearchRow> {
|
||||
self.selected
|
||||
.iter()
|
||||
.filter_map(|index| self.rows.get(*index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn selected_rows_owned(&self) -> Vec<SearchRow> {
|
||||
self.selected_rows().into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn selection_expression(&self) -> String {
|
||||
compress_selection_ranges(
|
||||
&self
|
||||
.selected
|
||||
.iter()
|
||||
.map(|index| index + 1)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn selection_prompt_value(&self) -> String {
|
||||
if self.numeric_buffer.is_empty() {
|
||||
self.selection_expression()
|
||||
} else {
|
||||
self.numeric_buffer.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> BrowserPhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
pub fn cursor_position(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn selection_count(&self) -> usize {
|
||||
self.selected.len()
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
!self.selected.is_empty()
|
||||
}
|
||||
|
||||
pub fn numeric_buffer(&self) -> &str {
|
||||
&self.numeric_buffer
|
||||
}
|
||||
|
||||
pub fn status_message(&self) -> Option<&str> {
|
||||
self.status_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn page_bounds(&self) -> (usize, usize) {
|
||||
let start = (self.cursor / self.page_size) * self.page_size;
|
||||
let end = (start + self.page_size).min(self.rows.len());
|
||||
(start, end)
|
||||
}
|
||||
|
||||
pub fn move_next(&mut self) {
|
||||
if self.cursor + 1 < self.rows.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_previous(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_to_top(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn move_to_bottom(&mut self) {
|
||||
if !self.rows.is_empty() {
|
||||
self.cursor = self.rows.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let next_page = ((self.cursor / self.page_size) + 1) * self.page_size;
|
||||
self.cursor = next_page.min(self.rows.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.cursor = self.cursor.saturating_sub(self.cursor % self.page_size);
|
||||
self.cursor = self.cursor.saturating_sub(self.page_size);
|
||||
}
|
||||
|
||||
pub fn toggle_current_selection(&mut self) {
|
||||
if self
|
||||
.rows
|
||||
.get(self.cursor)
|
||||
.is_some_and(|row| !row.selectable)
|
||||
{
|
||||
self.set_status_message("installed result is not selectable");
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.selected.insert(self.cursor) {
|
||||
self.selected.remove(&self.cursor);
|
||||
}
|
||||
|
||||
self.clear_status_message();
|
||||
}
|
||||
|
||||
pub fn enter_confirmation(&mut self) -> bool {
|
||||
if self.selected.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.phase = BrowserPhase::Confirming;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cancel_confirmation(&mut self) {
|
||||
self.phase = BrowserPhase::Browsing;
|
||||
}
|
||||
|
||||
pub fn apply_numeric_selection(&mut self, input: &str) -> Result<(), String> {
|
||||
let parsed = parse_selection(input, self.rows.len())?;
|
||||
self.selected = parsed
|
||||
.into_iter()
|
||||
.filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable))
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn submit_selection(&mut self, skip_confirmation: bool) -> SubmitAction {
|
||||
if !self.has_selection() {
|
||||
self.set_status_message("select at least one result");
|
||||
return SubmitAction::None;
|
||||
}
|
||||
|
||||
if skip_confirmation {
|
||||
return SubmitAction::Confirmed(SearchSelection {
|
||||
rows: self.selected_rows_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
self.enter_confirmation();
|
||||
SubmitAction::Confirming
|
||||
}
|
||||
|
||||
pub fn push_numeric_input(&mut self, character: char) {
|
||||
self.numeric_buffer.push(character);
|
||||
self.refresh_selection_from_numeric_buffer();
|
||||
}
|
||||
|
||||
pub fn pop_numeric_input(&mut self) {
|
||||
self.numeric_buffer.pop();
|
||||
self.refresh_selection_from_numeric_buffer();
|
||||
}
|
||||
|
||||
pub fn clear_numeric_input(&mut self) {
|
||||
self.numeric_buffer.clear();
|
||||
}
|
||||
|
||||
pub fn set_status_message(&mut self, message: impl Into<String>) {
|
||||
self.status_message = Some(message.into());
|
||||
}
|
||||
|
||||
pub fn clear_status_message(&mut self) {
|
||||
self.status_message = None;
|
||||
}
|
||||
|
||||
fn is_selected(&self, index: usize) -> bool {
|
||||
self.selected.contains(&index)
|
||||
}
|
||||
|
||||
fn refresh_selection_from_numeric_buffer(&mut self) {
|
||||
let trimmed = self.numeric_buffer.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(parsed) = parse_selection(trimmed, self.rows.len()) {
|
||||
self.selected = parsed
|
||||
.into_iter()
|
||||
.filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SearchBrowserError {
|
||||
Terminal(std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct HighlightSegment {
|
||||
pub text: String,
|
||||
pub is_match: bool,
|
||||
}
|
||||
|
||||
pub fn can_launch(results: &SearchResults) -> bool {
|
||||
!results.remote_hits.is_empty()
|
||||
&& std::io::stdin().is_terminal()
|
||||
&& std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
results: &SearchResults,
|
||||
config: &CliConfig,
|
||||
) -> Result<Option<SearchSelection>, SearchBrowserError> {
|
||||
let mut stdout = std::io::stdout();
|
||||
enable_raw_mode().map_err(SearchBrowserError::Terminal)?;
|
||||
execute!(stdout, EnterAlternateScreen).map_err(SearchBrowserError::Terminal)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend).map_err(SearchBrowserError::Terminal)?;
|
||||
let outcome = run_loop(&mut terminal, results, config);
|
||||
|
||||
let leave_screen = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
||||
let show_cursor = terminal.show_cursor();
|
||||
let disable_raw = disable_raw_mode();
|
||||
|
||||
if let Err(error) = leave_screen {
|
||||
return Err(SearchBrowserError::Terminal(error));
|
||||
}
|
||||
if let Err(error) = show_cursor {
|
||||
return Err(SearchBrowserError::Terminal(error));
|
||||
}
|
||||
if let Err(error) = disable_raw {
|
||||
return Err(SearchBrowserError::Terminal(error));
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
pub fn format_search_row(
|
||||
index: usize,
|
||||
row: &SearchRow,
|
||||
selected: bool,
|
||||
active: bool,
|
||||
width: usize,
|
||||
) -> String {
|
||||
let cursor = if active { ">" } else { " " };
|
||||
let marker = if selected { "[*]" } else { "[ ]" };
|
||||
let status = match &row.status {
|
||||
SearchInstallStatus::Available => "",
|
||||
SearchInstallStatus::Installed { .. } => "[installed] ",
|
||||
SearchInstallStatus::UpdateAvailable { .. } => "[update] ",
|
||||
};
|
||||
let version = row
|
||||
.version
|
||||
.as_deref()
|
||||
.map(|value| format!(" v{value}"))
|
||||
.unwrap_or_default();
|
||||
let first_line = format!(
|
||||
"{cursor}{marker} {index:>2}. {status}{}{version}",
|
||||
row.display_name
|
||||
);
|
||||
let second_line = match row.description.as_deref() {
|
||||
Some(description) => format!("{} - {description}", row.provider_id),
|
||||
None => row.provider_id.clone(),
|
||||
};
|
||||
format!(
|
||||
"{}\n{}",
|
||||
truncate_line(&first_line, width),
|
||||
truncate_line(&format!(" {second_line}"), width)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
|
||||
let normalized_query = query.trim().to_ascii_lowercase();
|
||||
if normalized_query.is_empty() {
|
||||
return vec![HighlightSegment {
|
||||
text: text.to_owned(),
|
||||
is_match: false,
|
||||
}];
|
||||
}
|
||||
|
||||
let normalized_text = text.to_ascii_lowercase();
|
||||
let mut start = 0;
|
||||
let mut segments = Vec::new();
|
||||
|
||||
while let Some(relative_match) = normalized_text[start..].find(&normalized_query) {
|
||||
let match_start = start + relative_match;
|
||||
let match_end = match_start + normalized_query.len();
|
||||
|
||||
if match_start > start {
|
||||
segments.push(HighlightSegment {
|
||||
text: text[start..match_start].to_owned(),
|
||||
is_match: false,
|
||||
});
|
||||
}
|
||||
|
||||
segments.push(HighlightSegment {
|
||||
text: text[match_start..match_end].to_owned(),
|
||||
is_match: true,
|
||||
});
|
||||
start = match_end;
|
||||
}
|
||||
|
||||
if start < text.len() {
|
||||
segments.push(HighlightSegment {
|
||||
text: text[start..].to_owned(),
|
||||
is_match: false,
|
||||
});
|
||||
}
|
||||
|
||||
if segments.is_empty() {
|
||||
segments.push(HighlightSegment {
|
||||
text: text.to_owned(),
|
||||
is_match: false,
|
||||
});
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
pub fn render_confirmation_summary(rows: &[SearchRow]) -> String {
|
||||
let mut lines = vec![crate::ui::theme::heading("Confirm Search Selection")];
|
||||
lines.push(format!("selected results: {}", rows.len()));
|
||||
for row in rows {
|
||||
lines.push(format!(
|
||||
"{} [{}] {}",
|
||||
crate::ui::theme::bullet(&row.display_name),
|
||||
row.provider_id,
|
||||
row.version
|
||||
.as_deref()
|
||||
.map(|value| format!("{} (v{value})", row.install_query))
|
||||
.unwrap_or_else(|| row.install_query.clone())
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
results: &SearchResults,
|
||||
config: &CliConfig,
|
||||
) -> Result<Option<SearchSelection>, SearchBrowserError> {
|
||||
let mut state = SearchBrowserState::new_with_query(
|
||||
results.remote_hits.clone(),
|
||||
results.query_text.clone(),
|
||||
config.search.clone(),
|
||||
10,
|
||||
);
|
||||
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| draw_browser(frame, &state, results, config))
|
||||
.map_err(SearchBrowserError::Terminal)?;
|
||||
|
||||
if !event::poll(Duration::from_millis(250)).map_err(SearchBrowserError::Terminal)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Event::Key(key) = event::read().map_err(SearchBrowserError::Terminal)? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if key.kind != KeyEventKind::Press {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(outcome) = handle_key_event(&mut state, key.code, key.modifiers, &config.search)
|
||||
{
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_browser(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &SearchBrowserState,
|
||||
_results: &SearchResults,
|
||||
config: &CliConfig,
|
||||
) {
|
||||
let palette = crate::ui::theme::search_browser_palette(&config.theme);
|
||||
|
||||
if state.phase() == BrowserPhase::Confirming {
|
||||
let area = centered_rect(frame.area(), 70, 40);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(render_confirmation_summary(&state.selected_rows_owned()))
|
||||
.style(palette.text_style()),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
let header = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(layout[0]);
|
||||
let header_top = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(10), Constraint::Length(24)])
|
||||
.split(header[0]);
|
||||
let (start, end) = state.page_bounds();
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled("Search Results", palette.heading_style())),
|
||||
header_top[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::styled(
|
||||
format!(
|
||||
"Showing {}-{} of {}",
|
||||
start + 1,
|
||||
end,
|
||||
state.ordered_rows().len()
|
||||
),
|
||||
palette.muted_style(),
|
||||
),
|
||||
Line::styled(
|
||||
format!("Selected {}", state.selection_count()),
|
||||
palette.muted_style(),
|
||||
),
|
||||
])
|
||||
.alignment(Alignment::Right),
|
||||
header_top[1],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::styled(
|
||||
"Enter confirm Space toggle j/k move PgUp/PgDn page g/G jump q cancel",
|
||||
palette.hint_style(),
|
||||
))
|
||||
.wrap(Wrap { trim: true }),
|
||||
header[1],
|
||||
);
|
||||
|
||||
let width = layout[1].width as usize;
|
||||
let items = state.ordered_rows()[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(offset, row)| {
|
||||
let absolute = start + offset;
|
||||
ListItem::new(render_search_row_lines(
|
||||
absolute + 1,
|
||||
row,
|
||||
state.is_selected(absolute),
|
||||
state.cursor_position() == absolute,
|
||||
width,
|
||||
palette,
|
||||
state.query_text(),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
frame.render_widget(List::new(items), layout[1]);
|
||||
|
||||
let status = state.status_message().unwrap_or("");
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Apps to install: ", palette.text_style()),
|
||||
Span::styled(state.selection_prompt_value(), palette.text_style()),
|
||||
Span::styled(" eg. 1 2 3, 1-3", palette.hint_style()),
|
||||
]),
|
||||
Line::styled(status, palette.muted_style()),
|
||||
])
|
||||
.wrap(Wrap { trim: true }),
|
||||
layout[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_search_row_lines(
|
||||
index: usize,
|
||||
row: &SearchRow,
|
||||
selected: bool,
|
||||
active: bool,
|
||||
width: usize,
|
||||
palette: crate::ui::theme::SearchBrowserPalette,
|
||||
query_text: &str,
|
||||
) -> Vec<Line<'static>> {
|
||||
let cursor = if active { ">" } else { " " };
|
||||
let checkbox = if selected { "[*]" } else { "[ ]" };
|
||||
let checkbox_style = if selected {
|
||||
palette.checkbox_selected_style()
|
||||
} else {
|
||||
palette.checkbox_idle_style()
|
||||
};
|
||||
let name_style = if !row.selectable {
|
||||
palette.disabled_style()
|
||||
} else if active {
|
||||
palette.active_name_style()
|
||||
} else {
|
||||
palette.text_style()
|
||||
};
|
||||
let index_style = if row.selectable {
|
||||
palette.text_style()
|
||||
} else {
|
||||
palette.disabled_style()
|
||||
};
|
||||
|
||||
let mut first_line = vec![
|
||||
Span::styled(cursor.to_owned(), palette.cursor_style()),
|
||||
Span::raw(" "),
|
||||
Span::styled(checkbox.to_owned(), checkbox_style),
|
||||
Span::styled(format!(" {index:>2}. "), index_style),
|
||||
];
|
||||
|
||||
match row.status {
|
||||
SearchInstallStatus::Available => {}
|
||||
SearchInstallStatus::Installed { .. } => {
|
||||
first_line.push(Span::styled(
|
||||
"[installed] ".to_owned(),
|
||||
name_style.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
SearchInstallStatus::UpdateAvailable { .. } => {
|
||||
first_line.push(Span::styled(
|
||||
"[update] ".to_owned(),
|
||||
name_style.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
push_highlighted_spans(&mut first_line, &row.display_name, query_text, name_style);
|
||||
if let Some(version) = &row.version {
|
||||
first_line.push(Span::raw(" "));
|
||||
first_line.push(Span::styled(format!("v{version}"), palette.version_style()));
|
||||
}
|
||||
|
||||
let detail_text = match row.description.as_deref() {
|
||||
Some(description) => format!("{} - {description}", row.provider_id),
|
||||
None => row.provider_id.clone(),
|
||||
};
|
||||
let detail_text = truncate_line(&detail_text, width.saturating_sub(7));
|
||||
let provider_len = row.provider_id.len().min(detail_text.len());
|
||||
let (provider_text, remainder) = detail_text.split_at(provider_len);
|
||||
let mut second_line = vec![Span::raw(" ")];
|
||||
second_line.push(Span::styled(
|
||||
provider_text.to_owned(),
|
||||
palette.dim_style().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if !remainder.is_empty() {
|
||||
push_highlighted_spans(&mut second_line, remainder, query_text, palette.dim_style());
|
||||
}
|
||||
|
||||
vec![Line::from(first_line), Line::from(second_line)]
|
||||
}
|
||||
|
||||
fn handle_key_event(
|
||||
state: &mut SearchBrowserState,
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
config: &SearchConfig,
|
||||
) -> Option<Option<SearchSelection>> {
|
||||
if state.phase() == BrowserPhase::Confirming {
|
||||
return match code {
|
||||
KeyCode::Enter | KeyCode::Char('y') => Some(Some(SearchSelection {
|
||||
rows: state.selected_rows_owned(),
|
||||
})),
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('n') => {
|
||||
state.cancel_confirmation();
|
||||
state.set_status_message("confirmation cancelled");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
state.move_previous();
|
||||
state.clear_status_message();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
state.move_next();
|
||||
state.clear_status_message();
|
||||
}
|
||||
KeyCode::PageDown => state.page_down(),
|
||||
KeyCode::PageUp => state.page_up(),
|
||||
KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => state.page_down(),
|
||||
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => state.page_up(),
|
||||
KeyCode::Char('g') => state.move_to_top(),
|
||||
KeyCode::Char('G') => state.move_to_bottom(),
|
||||
KeyCode::Char(' ') => {
|
||||
if state.numeric_buffer().is_empty() {
|
||||
state.toggle_current_selection();
|
||||
} else if !state.numeric_buffer().ends_with(' ') {
|
||||
state.push_numeric_input(' ');
|
||||
}
|
||||
}
|
||||
KeyCode::Char(character)
|
||||
if character.is_ascii_digit() || character == ',' || character == '-' =>
|
||||
{
|
||||
state.push_numeric_input(character);
|
||||
}
|
||||
KeyCode::Backspace => state.pop_numeric_input(),
|
||||
KeyCode::Enter => match state.submit_selection(config.skip_confirmation) {
|
||||
SubmitAction::None | SubmitAction::Confirming => {}
|
||||
SubmitAction::Confirmed(selection) => return Some(Some(selection)),
|
||||
},
|
||||
KeyCode::Esc | KeyCode::Char('q') => return Some(None),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_selection(input: &str, row_count: usize) -> Result<BTreeSet<usize>, String> {
|
||||
let mut selected = BTreeSet::new();
|
||||
|
||||
for token in input
|
||||
.split(|character: char| character == ',' || character.is_ascii_whitespace())
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
{
|
||||
if let Some((start, end)) = token.split_once('-') {
|
||||
let start = parse_one_based(start, row_count, input)?;
|
||||
let end = parse_one_based(end, row_count, input)?;
|
||||
let (from, to) = if start <= end {
|
||||
(start, end)
|
||||
} else {
|
||||
(end, start)
|
||||
};
|
||||
for index in from..=to {
|
||||
selected.insert(index);
|
||||
}
|
||||
} else {
|
||||
selected.insert(parse_one_based(token, row_count, input)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
fn parse_one_based(token: &str, row_count: usize, original: &str) -> Result<usize, String> {
|
||||
let parsed = token
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid selection '{original}'"))?;
|
||||
|
||||
if parsed == 0 || parsed > row_count {
|
||||
return Err(format!("invalid selection '{original}'"));
|
||||
}
|
||||
|
||||
Ok(parsed - 1)
|
||||
}
|
||||
|
||||
fn push_highlighted_spans(
|
||||
target: &mut Vec<Span<'static>>,
|
||||
text: &str,
|
||||
query: &str,
|
||||
base_style: ratatui::style::Style,
|
||||
) {
|
||||
for segment in highlight_segments(text, query) {
|
||||
let style = if segment.is_match {
|
||||
base_style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base_style
|
||||
};
|
||||
target.push(Span::styled(segment.text, style));
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_line(line: &str, width: usize) -> String {
|
||||
if width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let length = line.chars().count();
|
||||
if length <= width {
|
||||
return line.to_owned();
|
||||
}
|
||||
|
||||
if width == 1 {
|
||||
return ".".to_owned();
|
||||
}
|
||||
|
||||
if width <= 3 {
|
||||
return ".".repeat(width);
|
||||
}
|
||||
|
||||
let mut truncated = line.chars().take(width - 3).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
|
||||
fn compress_selection_ranges(indices: &[usize]) -> String {
|
||||
if indices.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
let mut start = indices[0];
|
||||
let mut end = indices[0];
|
||||
|
||||
for &index in &indices[1..] {
|
||||
if index == end + 1 {
|
||||
end = index;
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push(format_range(start, end));
|
||||
start = index;
|
||||
end = index;
|
||||
}
|
||||
|
||||
ranges.push(format_range(start, end));
|
||||
ranges.join(",")
|
||||
}
|
||||
|
||||
fn format_range(start: usize, end: usize) -> String {
|
||||
if start == end {
|
||||
start.to_string()
|
||||
} else {
|
||||
format!("{start}-{end}")
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
Constraint::Percentage(height_percent),
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
Constraint::Percentage(width_percent),
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
415
crates/upm/src/ui/theme.rs
Normal file
415
crates/upm/src/ui/theme.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
use std::sync::OnceLock;
|
||||
|
||||
use console::{Style as ConsoleStyle, true_colors_enabled};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use ratatui::style::{Color, Modifier, Style as TuiStyle};
|
||||
|
||||
use crate::cli::config::ThemeConfig as AppThemeConfig;
|
||||
use crate::config::ThemeConfig as SearchThemeConfig;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ColorValue {
|
||||
Named(String),
|
||||
Rgb(u8, u8, u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct StyleSpec {
|
||||
pub bold: bool,
|
||||
pub dim: bool,
|
||||
pub foreground: Option<ColorValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Theme {
|
||||
pub heading: StyleSpec,
|
||||
pub accent: StyleSpec,
|
||||
pub muted: StyleSpec,
|
||||
pub label: StyleSpec,
|
||||
pub bullet: StyleSpec,
|
||||
pub success: StyleSpec,
|
||||
pub warning: StyleSpec,
|
||||
pub error: StyleSpec,
|
||||
pub progress_spinner: StyleSpec,
|
||||
pub progress_bar: StyleSpec,
|
||||
pub progress_bar_unfilled: StyleSpec,
|
||||
}
|
||||
|
||||
static ACTIVE_THEME: OnceLock<Theme> = OnceLock::new();
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
heading: parse_style_spec("bold #7c3aed").expect("valid default heading style"),
|
||||
accent: parse_style_spec("#8b5cf6").expect("valid default accent style"),
|
||||
muted: parse_style_spec("dim #75658a").expect("valid default muted style"),
|
||||
label: parse_style_spec("bold #c4b5fd").expect("valid default label style"),
|
||||
bullet: StyleSpec::default(),
|
||||
success: parse_style_spec("green").expect("valid default success style"),
|
||||
warning: parse_style_spec("yellow").expect("valid default warning style"),
|
||||
error: parse_style_spec("red").expect("valid default error style"),
|
||||
progress_spinner: parse_style_spec("#8b5cf6").expect("valid default spinner style"),
|
||||
progress_bar: parse_style_spec("#8b5cf6").expect("valid default bar style"),
|
||||
progress_bar_unfilled: parse_style_spec("#75658a")
|
||||
.expect("valid default unfilled bar style"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_theme(config: &AppThemeConfig) -> Theme {
|
||||
let mut theme = Theme::default();
|
||||
override_spec(&mut theme.heading, config.heading.as_deref());
|
||||
override_spec(&mut theme.accent, config.accent.as_deref());
|
||||
override_spec(&mut theme.muted, config.muted.as_deref());
|
||||
override_spec(&mut theme.label, config.label.as_deref());
|
||||
override_spec(&mut theme.bullet, config.bullet.as_deref());
|
||||
override_spec(&mut theme.success, config.success.as_deref());
|
||||
override_spec(&mut theme.warning, config.warning.as_deref());
|
||||
override_spec(&mut theme.error, config.error.as_deref());
|
||||
override_spec(
|
||||
&mut theme.progress_spinner,
|
||||
config.progress_spinner.as_deref(),
|
||||
);
|
||||
override_spec(&mut theme.progress_bar, config.progress_bar.as_deref());
|
||||
override_spec(
|
||||
&mut theme.progress_bar_unfilled,
|
||||
config.progress_bar_unfilled.as_deref(),
|
||||
);
|
||||
theme
|
||||
}
|
||||
|
||||
pub fn set_active_theme(theme: Theme) {
|
||||
let _ = ACTIVE_THEME.set(theme);
|
||||
}
|
||||
|
||||
pub fn current_theme() -> Theme {
|
||||
ACTIVE_THEME.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn dialog_theme() -> ColorfulTheme {
|
||||
ColorfulTheme::default()
|
||||
}
|
||||
|
||||
pub fn heading(title: &str) -> String {
|
||||
apply_style_spec(title, ¤t_theme().heading)
|
||||
}
|
||||
|
||||
pub fn label(title: &str) -> String {
|
||||
apply_style_spec(&format!("{title}:"), ¤t_theme().label)
|
||||
}
|
||||
|
||||
pub fn muted(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().muted)
|
||||
}
|
||||
|
||||
pub fn bullet(message: &str) -> String {
|
||||
format!("- {message}")
|
||||
}
|
||||
|
||||
pub fn accent(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().accent)
|
||||
}
|
||||
|
||||
pub fn success(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().success)
|
||||
}
|
||||
|
||||
pub fn warning_text(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().warning)
|
||||
}
|
||||
|
||||
pub fn error_text(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().error)
|
||||
}
|
||||
|
||||
pub fn indicatif_color_key(spec: &StyleSpec) -> &'static str {
|
||||
match spec.foreground.as_ref() {
|
||||
Some(ColorValue::Named(name)) => match name.as_str() {
|
||||
"black" | "stone" => "black",
|
||||
"red" => "red",
|
||||
"green" => "green",
|
||||
"yellow" | "amber" | "sand" => "yellow",
|
||||
"blue" => "blue",
|
||||
"magenta" => "magenta",
|
||||
"cyan" | "teal" => "cyan",
|
||||
"white" => "white",
|
||||
_ => "white",
|
||||
},
|
||||
Some(ColorValue::Rgb(red, green, blue)) => nearest_indicatif_color(*red, *green, *blue),
|
||||
None => "white",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_style_spec(input: &str) -> Result<StyleSpec, String> {
|
||||
let mut spec = StyleSpec::default();
|
||||
|
||||
for token in input.split_whitespace() {
|
||||
match token {
|
||||
"bold" => spec.bold = true,
|
||||
"dim" => spec.dim = true,
|
||||
color => spec.foreground = Some(parse_color_value(color)?),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub fn apply_style_spec(message: &str, spec: &StyleSpec) -> String {
|
||||
let mut style = ConsoleStyle::new();
|
||||
if spec.bold {
|
||||
style = style.bold();
|
||||
}
|
||||
if spec.dim {
|
||||
style = style.dim();
|
||||
}
|
||||
if let Some(color) = &spec.foreground {
|
||||
style = apply_color(style, color);
|
||||
}
|
||||
style.apply_to(message).to_string()
|
||||
}
|
||||
|
||||
fn override_spec(target: &mut StyleSpec, value: Option<&str>) {
|
||||
if let Some(value) = value
|
||||
&& let Ok(spec) = parse_style_spec(value)
|
||||
{
|
||||
*target = spec;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color_value(token: &str) -> Result<ColorValue, String> {
|
||||
if let Some(hex) = token.strip_prefix('#') {
|
||||
if hex.len() != 6 {
|
||||
return Err(format!("invalid hex color: {token}"));
|
||||
}
|
||||
|
||||
let red = u8::from_str_radix(&hex[0..2], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
let green = u8::from_str_radix(&hex[2..4], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
let blue = u8::from_str_radix(&hex[4..6], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
return Ok(ColorValue::Rgb(red, green, blue));
|
||||
}
|
||||
|
||||
if is_named_color(token) {
|
||||
return Ok(ColorValue::Named(token.to_owned()));
|
||||
}
|
||||
|
||||
Err(format!("unknown color token: {token}"))
|
||||
}
|
||||
|
||||
fn is_named_color(token: &str) -> bool {
|
||||
matches!(
|
||||
token,
|
||||
"black"
|
||||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "blue"
|
||||
| "magenta"
|
||||
| "cyan"
|
||||
| "white"
|
||||
| "amber"
|
||||
| "teal"
|
||||
| "sand"
|
||||
| "stone"
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_color(style: ConsoleStyle, color: &ColorValue) -> ConsoleStyle {
|
||||
match color {
|
||||
ColorValue::Named(name) => apply_named_color(style, name),
|
||||
ColorValue::Rgb(red, green, blue) => {
|
||||
if true_colors_enabled() {
|
||||
style.true_color(*red, *green, *blue)
|
||||
} else {
|
||||
style.color256(rgb_to_ansi256(*red, *green, *blue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_named_color(style: ConsoleStyle, name: &str) -> ConsoleStyle {
|
||||
match name {
|
||||
"black" => style.black(),
|
||||
"red" => style.red(),
|
||||
"green" => style.green(),
|
||||
"yellow" => style.yellow(),
|
||||
"blue" => style.blue(),
|
||||
"magenta" => style.magenta(),
|
||||
"cyan" => style.cyan(),
|
||||
"white" => style.white(),
|
||||
"amber" => apply_color(style, &ColorValue::Rgb(210, 139, 38)),
|
||||
"teal" => apply_color(style, &ColorValue::Rgb(47, 142, 138)),
|
||||
"sand" => apply_color(style, &ColorValue::Rgb(231, 197, 138)),
|
||||
"stone" => apply_color(style, &ColorValue::Rgb(111, 98, 83)),
|
||||
_ => style,
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_to_ansi256(red: u8, green: u8, blue: u8) -> u8 {
|
||||
let red = ((red as f32 / 255.0) * 5.0).round() as u8;
|
||||
let green = ((green as f32 / 255.0) * 5.0).round() as u8;
|
||||
let blue = ((blue as f32 / 255.0) * 5.0).round() as u8;
|
||||
16 + (36 * red) + (6 * green) + blue
|
||||
}
|
||||
|
||||
fn nearest_indicatif_color(red: u8, green: u8, blue: u8) -> &'static str {
|
||||
const COLORS: [(&str, (u8, u8, u8)); 8] = [
|
||||
("black", (0, 0, 0)),
|
||||
("red", (205, 49, 49)),
|
||||
("green", (13, 188, 121)),
|
||||
("yellow", (229, 229, 16)),
|
||||
("blue", (36, 114, 200)),
|
||||
("magenta", (188, 63, 188)),
|
||||
("cyan", (17, 168, 205)),
|
||||
("white", (229, 229, 229)),
|
||||
];
|
||||
|
||||
COLORS
|
||||
.iter()
|
||||
.min_by_key(|(_, (target_red, target_green, target_blue))| {
|
||||
let red_distance = red as i32 - *target_red as i32;
|
||||
let green_distance = green as i32 - *target_green as i32;
|
||||
let blue_distance = blue as i32 - *target_blue as i32;
|
||||
red_distance * red_distance
|
||||
+ green_distance * green_distance
|
||||
+ blue_distance * blue_distance
|
||||
})
|
||||
.map(|(name, _)| *name)
|
||||
.unwrap_or("white")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_named_theme_value() {
|
||||
let spec = parse_style_spec("amber").unwrap();
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Named("amber".to_owned())));
|
||||
assert!(!spec.bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hex_theme_value() {
|
||||
let spec = parse_style_spec("#d28b26").unwrap();
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bold_hex_theme_value() {
|
||||
let spec = parse_style_spec("bold #d28b26").unwrap();
|
||||
assert!(spec.bold);
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_override_falls_back_to_default_theme() {
|
||||
let theme = resolve_theme(&AppThemeConfig {
|
||||
heading: Some("bogus".to_owned()),
|
||||
..AppThemeConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(theme.heading, Theme::default().heading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_theme_uses_purple_led_palette() {
|
||||
let theme = Theme::default();
|
||||
|
||||
assert_eq!(theme.heading, parse_style_spec("bold #7c3aed").unwrap());
|
||||
assert_eq!(theme.accent, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(theme.label, parse_style_spec("bold #c4b5fd").unwrap());
|
||||
assert_eq!(theme.progress_spinner, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(theme.progress_bar, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(
|
||||
theme.progress_bar_unfilled,
|
||||
parse_style_spec("#75658a").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SearchBrowserPalette {
|
||||
accent: Color,
|
||||
accent_secondary: Color,
|
||||
dim: Color,
|
||||
}
|
||||
|
||||
pub fn search_browser_palette(config: &SearchThemeConfig) -> SearchBrowserPalette {
|
||||
SearchBrowserPalette {
|
||||
accent: parse_color(&config.accent).unwrap_or(Color::Rgb(179, 136, 255)),
|
||||
accent_secondary: parse_color(&config.accent_secondary)
|
||||
.unwrap_or(Color::Rgb(213, 194, 255)),
|
||||
dim: parse_color(&config.dim).unwrap_or(Color::Rgb(127, 115, 150)),
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchBrowserPalette {
|
||||
pub fn heading_style(self) -> TuiStyle {
|
||||
TuiStyle::default()
|
||||
.fg(self.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn hint_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(self.dim)
|
||||
}
|
||||
|
||||
pub fn muted_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(self.dim)
|
||||
}
|
||||
|
||||
pub fn text_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(Color::White)
|
||||
}
|
||||
|
||||
pub fn dim_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(self.dim)
|
||||
}
|
||||
|
||||
pub fn checkbox_selected_style(self) -> TuiStyle {
|
||||
TuiStyle::default()
|
||||
.fg(self.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn checkbox_idle_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(self.dim)
|
||||
}
|
||||
|
||||
pub fn version_style(self) -> TuiStyle {
|
||||
TuiStyle::default().fg(self.accent_secondary)
|
||||
}
|
||||
|
||||
pub fn tag_style(self) -> TuiStyle {
|
||||
TuiStyle::default().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn cursor_style(self) -> TuiStyle {
|
||||
TuiStyle::default()
|
||||
.fg(self.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn active_name_style(self) -> TuiStyle {
|
||||
self.text_style().add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
pub fn disabled_style(self) -> TuiStyle {
|
||||
self.dim_style()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(value: &str) -> Option<Color> {
|
||||
let hex = value.trim().strip_prefix('#')?;
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(Color::Rgb(red, green, blue))
|
||||
}
|
||||
75
crates/upm/tests/cli_commands.rs
Normal file
75
crates/upm/tests/cli_commands.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::str::contains;
|
||||
|
||||
use clap::Parser;
|
||||
use upm::cli::args::Command as UpmCommand;
|
||||
use upm::{Cli, DispatchError};
|
||||
use upm_core::domain::show::{ShowResultError, SourceSummary};
|
||||
use upm_core::domain::source::SourceKind;
|
||||
|
||||
#[test]
|
||||
fn help_lists_expected_commands() {
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
cmd.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("search"))
|
||||
.stdout(contains("show"))
|
||||
.stdout(contains("remove"))
|
||||
.stdout(contains("list"))
|
||||
.stdout(contains("update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_show_subcommand() {
|
||||
let cli = Cli::try_parse_from(["upm", "show", "legacy-bat"]).unwrap();
|
||||
|
||||
match cli.command {
|
||||
Some(UpmCommand::Show { value }) => assert_eq!(value.as_deref(), Some("legacy-bat")),
|
||||
other => panic!("expected show command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_bare_show_subcommand() {
|
||||
let cli = Cli::try_parse_from(["upm", "show"]).unwrap();
|
||||
|
||||
match cli.command {
|
||||
Some(UpmCommand::Show { value }) => assert_eq!(value, None),
|
||||
other => panic!("expected bare show command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_ambiguity_error_is_readable() {
|
||||
let error = DispatchError::Show(ShowResultError::AmbiguousInstalledMatch {
|
||||
query: "bat".to_owned(),
|
||||
matches: vec![
|
||||
"Bat (bat)".to_owned(),
|
||||
"Bat Preview (legacy-bat)".to_owned(),
|
||||
],
|
||||
});
|
||||
|
||||
let rendered = error.to_string();
|
||||
|
||||
assert!(rendered.contains("multiple installed apps match bat"));
|
||||
assert!(rendered.contains("Bat (bat)"));
|
||||
assert!(rendered.contains("Bat Preview (legacy-bat)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_no_installable_artifact_error_is_readable() {
|
||||
let error = DispatchError::Show(ShowResultError::NoInstallableArtifact {
|
||||
source: SourceSummary {
|
||||
kind: SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/".to_owned(),
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
},
|
||||
});
|
||||
|
||||
let rendered = error.to_string();
|
||||
|
||||
assert!(rendered.contains("no installable artifact found"));
|
||||
assert!(rendered.contains("sourceforge"));
|
||||
assert!(rendered.contains("https://sourceforge.net/projects/team-app/"));
|
||||
}
|
||||
7
crates/upm/tests/cli_smoke.rs
Normal file
7
crates/upm/tests/cli_smoke.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use assert_cmd::Command;
|
||||
|
||||
#[test]
|
||||
fn cli_shows_help() {
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
cmd.arg("--help").assert().success();
|
||||
}
|
||||
167
crates/upm/tests/config_loading.rs
Normal file
167
crates/upm/tests/config_loading.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use std::sync::Mutex;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use upm::config::{
|
||||
CliConfig, ConfigError, SearchConfig, ThemeConfig, default_path, load_from_path,
|
||||
};
|
||||
use upm::default_registry_path;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.original {
|
||||
Some(value) => unsafe {
|
||||
std::env::set_var(self.key, value);
|
||||
},
|
||||
None => unsafe {
|
||||
std::env::remove_var(self.key);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_file_returns_defaults() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
let config = load_from_path(&path).unwrap();
|
||||
|
||||
assert_eq!(config, CliConfig::default());
|
||||
assert_eq!(config.search, SearchConfig::default());
|
||||
assert!(!config.allow_http);
|
||||
assert!(config.search.bottom_to_top);
|
||||
assert!(!config.search.skip_confirmation);
|
||||
assert_eq!(config.theme.accent, "#b388ff");
|
||||
assert_eq!(config.theme.accent_secondary, "#d5c2ff");
|
||||
assert_eq!(config.theme.dim, "#7f7396");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_section_overrides_defaults() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"allow_http = true\n\n[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_from_path(&path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config,
|
||||
CliConfig {
|
||||
allow_http: true,
|
||||
search: SearchConfig {
|
||||
bottom_to_top: false,
|
||||
skip_confirmation: true,
|
||||
},
|
||||
theme: ThemeConfig {
|
||||
accent: "#9f6bff".to_owned(),
|
||||
accent_secondary: "#efe7ff".to_owned(),
|
||||
dim: "#6b6480".to_owned(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_toml_returns_path_aware_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap();
|
||||
|
||||
let error = load_from_path(&path).unwrap_err();
|
||||
|
||||
match error {
|
||||
ConfigError::Parse {
|
||||
path: error_path, ..
|
||||
} => {
|
||||
assert_eq!(error_path, path);
|
||||
}
|
||||
other => panic!("expected parse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_path_uses_upm_directory() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let _config_path = EnvGuard::remove("UPM_CONFIG_PATH");
|
||||
let _xdg_config_home = EnvGuard::remove("XDG_CONFIG_HOME");
|
||||
let _home = EnvGuard::set("HOME", dir.path());
|
||||
|
||||
let path = default_path();
|
||||
|
||||
assert_eq!(path, dir.path().join(".config/upm/config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_path_ignores_legacy_aim_override() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let dir = tempdir().unwrap();
|
||||
let legacy_path = dir.path().join("aim-config.toml");
|
||||
|
||||
let _legacy_config_path = EnvGuard::set("AIM_CONFIG_PATH", &legacy_path);
|
||||
let _config_path = EnvGuard::remove("UPM_CONFIG_PATH");
|
||||
let _xdg_config_home = EnvGuard::remove("XDG_CONFIG_HOME");
|
||||
let _home = EnvGuard::set("HOME", dir.path());
|
||||
|
||||
let path = default_path();
|
||||
|
||||
assert_eq!(path, dir.path().join(".config/upm/config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_registry_path_uses_upm_directory() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
let _registry_path = EnvGuard::remove("UPM_REGISTRY_PATH");
|
||||
let _home = EnvGuard::set("HOME", dir.path());
|
||||
|
||||
let path = default_registry_path();
|
||||
|
||||
assert_eq!(path, dir.path().join(".local/share/upm/registry.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_registry_path_ignores_legacy_aim_override() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let dir = tempdir().unwrap();
|
||||
let legacy_path = dir.path().join("aim-registry.toml");
|
||||
|
||||
let _legacy_registry_path = EnvGuard::set("AIM_REGISTRY_PATH", &legacy_path);
|
||||
let _registry_path = EnvGuard::remove("UPM_REGISTRY_PATH");
|
||||
let _home = EnvGuard::set("HOME", dir.path());
|
||||
|
||||
let path = default_registry_path();
|
||||
|
||||
assert_eq!(path, dir.path().join(".local/share/upm/registry.toml"));
|
||||
}
|
||||
755
crates/upm/tests/end_to_end_cli.rs
Normal file
755
crates/upm/tests/end_to_end_cli.rs
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::registry::model::Registry;
|
||||
use upm_core::registry::store::RegistryStore;
|
||||
|
||||
const FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
|
||||
|
||||
#[test]
|
||||
fn list_command_runs_without_registry_entries() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("list")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("No installed apps yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_command_reads_registered_apps_from_registry_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
"version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("list")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Name"))
|
||||
.stdout(contains("Version"))
|
||||
.stdout(contains("Source"))
|
||||
.stdout(contains("Bat"))
|
||||
.stdout(contains("Bat (bat)").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_command_removes_registered_app_from_registry_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
"version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["remove", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Removed Bat"))
|
||||
.stdout(contains("Removal Summary").not())
|
||||
.stdout(contains("Removed app:").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(!contents.contains("stable_id = \"bat\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_command_uninstalls_managed_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let install_home = dir.path().join("install-home");
|
||||
let payload_path = install_home.join(".local/lib/upm/appimages/sharkdp-bat.AppImage");
|
||||
let desktop_path = install_home.join(".local/share/applications/upm-sharkdp-bat.desktop");
|
||||
let icon_path = install_home.join(".local/share/icons/hicolor/256x256/apps/sharkdp-bat.png");
|
||||
|
||||
let mut add_cmd = Command::cargo_bin("upm").unwrap();
|
||||
add_cmd
|
||||
.arg("sharkdp/bat")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(payload_path.exists());
|
||||
assert!(desktop_path.exists());
|
||||
assert!(icon_path.exists());
|
||||
|
||||
let mut remove_cmd = Command::cargo_bin("upm").unwrap();
|
||||
remove_cmd
|
||||
.args(["remove", "sharkdp-bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\nRemoved bat"))
|
||||
.stdout(contains("Removed bat"))
|
||||
.stdout(contains("Removal Summary").not())
|
||||
.stdout(contains("Removed app:").not())
|
||||
.stdout(contains("Removed files"))
|
||||
.stdout(contains("sharkdp-bat.AppImage"))
|
||||
.stdout(contains("upm-sharkdp-bat.desktop"))
|
||||
.stdout(contains("sharkdp-bat.png"));
|
||||
|
||||
assert!(!payload_path.exists());
|
||||
assert!(!desktop_path.exists());
|
||||
assert!(!icon_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_command_registers_unambiguous_app_in_registry_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("sharkdp/bat")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\nInstalled bat (user)"))
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("Installation Summary").not())
|
||||
.stdout(contains("Source: github sharkdp/bat"))
|
||||
.stdout(contains("Artifact:"))
|
||||
.stdout(contains("Selected artifact").not())
|
||||
.stdout(contains("metadata-guided").not())
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("sharkdp-bat.AppImage"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
||||
assert!(contents.contains("source_input = \"sharkdp/bat\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_release_query_renders_tracking_prompt_without_writing_registry() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Choose update tracking"))
|
||||
.stdout(contains("v0.0.11"))
|
||||
.stdout(contains("v0.0.12"));
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_release_query_can_track_latest_and_register_app() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("UPM_TRACKING_PREFERENCE", "latest")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\nInstalled t3code (user)"))
|
||||
.stdout(contains("Installed t3code (user)"))
|
||||
.stdout(contains("Installation Summary").not())
|
||||
.stdout(contains("Source: github pingdotgg/t3code"))
|
||||
.stdout(contains("Artifact: T3-Code-0.0.12-x86_64.AppImage"))
|
||||
.stdout(contains("Selected artifact").not())
|
||||
.stdout(contains("metadata-guided").not())
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("pingdotgg-t3code.AppImage"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
||||
assert!(contents.contains("locator = \"pingdotgg/t3code\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_release_query_ignores_legacy_tracking_preference_env() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("AIM_TRACKING_PREFERENCE", "latest")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Choose update tracking"))
|
||||
.stdout(contains("v0.0.11"))
|
||||
.stdout(contains("v0.0.12"))
|
||||
.stdout(contains("Installed t3code").not());
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_and_renders_resolved_mode() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("sharkdp/bat")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("\nInstalled bat (user)"))
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("Artifact:"))
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_query_falls_back_to_search_for_plain_name_queries() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("firefox")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains(
|
||||
"[appimagehub] Firefox by Mozilla - Official AppImage Edition",
|
||||
))
|
||||
.stdout(contains("Install query: appimagehub/2338455"))
|
||||
.stdout(contains("Installed firefox").not())
|
||||
.stdout(contains("unsupported source query").not());
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_query_falls_back_to_empty_search_when_direct_item_has_no_appimage() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2337998")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("No remote matches"))
|
||||
.stdout(contains("No installed matches"))
|
||||
.stdout(contains("unsupported source query").not())
|
||||
.stdout(contains("no installable artifact").not());
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_appimagehub_source_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2338455")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains(
|
||||
"Installed Firefox by Mozilla - Official AppImage Edition (user)",
|
||||
))
|
||||
.stdout(contains(
|
||||
"Source: appimagehub https://www.appimagehub.com/p/2338455",
|
||||
))
|
||||
.stdout(contains(
|
||||
"Artifact: https://files06.pling.com/api/files/download/firefox-x86-64.AppImage",
|
||||
));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("display_name = \"Firefox by Mozilla - Official AppImage Edition\""));
|
||||
assert!(contents.contains("kind = \"AppImageHub\""));
|
||||
assert!(contents.contains("canonical_locator = \"2338455\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_gitlab_source_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://gitlab.com/example/team-app")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains("Source: gitlab https://gitlab.com/example/team-app"))
|
||||
.stdout(contains(
|
||||
"Artifact: https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage",
|
||||
));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("source_input = \"https://gitlab.com/example/team-app\""));
|
||||
assert!(contents.contains("kind = \"GitLab\""));
|
||||
assert!(contents.contains("locator = \"https://gitlab.com/example/team-app\""));
|
||||
assert!(contents.contains("canonical_locator = \"example/team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_preserves_direct_url_origin_for_provider_like_downloads() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed "))
|
||||
.stdout(contains(format!("Source: direct-url {query}")))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"DirectUrl\""));
|
||||
assert!(contents.contains(&format!("locator = \"{query}\"")));
|
||||
assert!(!contents.contains("kind = \"SourceForge\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains(format!("Source: sourceforge {query}")))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"SourceForge\""));
|
||||
assert!(contents.contains(&format!("locator = \"{query}\"")));
|
||||
assert!(contents.contains("canonical_locator = \"team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_insecure_http_direct_urls_by_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("http://example.com/team-app.AppImage")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("insecure HTTP sources are disabled"));
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_allows_insecure_http_direct_urls_when_config_enables_it() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(&config_path, "allow_http = true\n").unwrap();
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("http://example.com/team-app.AppImage")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env("UPM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed"))
|
||||
.stdout(contains(
|
||||
"Source: direct-url http://example.com/team-app.AppImage",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_insecure_appimagehub_download_urls_even_when_http_is_allowed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(&config_path, "allow_http = true\n").unwrap();
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2338455")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env("UPM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP", "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("insecure appimagehub download url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_appimagehub_install_when_md5_does_not_match() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2338455")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5", "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("weak provider checksum did not match"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_sourceforge_release_folder_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download";
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains(format!("Source: sourceforge {query}")))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"SourceForge\""));
|
||||
assert!(contents.contains(&format!("locator = \"{query}\"")));
|
||||
assert!(contents.contains("canonical_locator = \"team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_file_like_sourceforge_release_download_stores_releases_root_and_preserves_artifact() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let query =
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download";
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg(query)
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed team-app (user)"))
|
||||
.stdout(contains(
|
||||
"Source: sourceforge https://sourceforge.net/projects/team-app/files/releases",
|
||||
))
|
||||
.stdout(contains(format!("Artifact: {query}")));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains(&format!("source_input = \"{query}\"")));
|
||||
assert!(contents.contains("kind = \"SourceForge\""));
|
||||
assert!(
|
||||
contents.contains("locator = \"https://sourceforge.net/projects/team-app/files/releases\"")
|
||||
);
|
||||
assert!(contents.contains("requested_asset_name = \"team-app-1.0.0.AppImage\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_reports_unsupported_source_queries_distinctly() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://gitlab.com/example")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("No remote matches"))
|
||||
.stdout(contains("No installed matches"))
|
||||
.stderr(contains("unsupported source query").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_reports_supported_sources_without_installable_artifacts_distinctly() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("https://sourceforge.net/projects/team-app/")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("No remote matches"))
|
||||
.stdout(contains("No installed matches"))
|
||||
.stderr(contains("no installable artifact found").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_emits_live_progress_to_stderr() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("sharkdp/bat")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Installing sharkdp/bat"))
|
||||
.stderr(contains("Resolving source"))
|
||||
.stderr(contains("Discovering release"))
|
||||
.stderr(contains("Selecting artifact"))
|
||||
.stderr(contains("Downloading artifact"))
|
||||
.stderr(contains("Downloaded"))
|
||||
.stderr(contains("Payload Staged"))
|
||||
.stderr(contains("Desktop Entry Written"))
|
||||
.stderr(contains("Icon Extracted"))
|
||||
.stderr(contains("Desktop Integration Refreshed"))
|
||||
.stderr(contains("Registry Saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_upm_review_renders_review_heading() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
store
|
||||
.save(&Registry {
|
||||
version: 1,
|
||||
apps: vec![AppRecord {
|
||||
stable_id: "pingdotgg-t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Update Review"))
|
||||
.stdout(contains("apps with updates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_command_emits_live_progress_to_stderr() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
"version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["remove", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Removing bat"))
|
||||
.stderr(contains("Resolving source: resolving bat"))
|
||||
.stderr(contains("Saving registry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_request_on_immutable_host_falls_back_to_user_install() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let os_release_path = dir.path().join("os-release");
|
||||
std::fs::write(&os_release_path, "ID=fedora\nVARIANT_ID=silverblue\n").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["--system", "sharkdp/bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env("UPM_OS_RELEASE_PATH", &os_release_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("downgraded to user scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_applies_updates() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let payload_path = dir
|
||||
.path()
|
||||
.join("install-home/.local/lib/upm/appimages/pingdotgg-t3code.AppImage");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
store
|
||||
.save(&Registry {
|
||||
version: 1,
|
||||
apps: vec![AppRecord {
|
||||
stable_id: "pingdotgg-t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("update")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("updated apps: 1"))
|
||||
.stdout(contains("updates found:").not());
|
||||
|
||||
let updated = store.load().unwrap();
|
||||
assert_eq!(updated.apps.len(), 1);
|
||||
assert_eq!(updated.apps[0].stable_id, "pingdotgg-t3code");
|
||||
assert_eq!(updated.apps[0].installed_version.as_deref(), Some("0.0.12"));
|
||||
assert!(payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_emits_live_progress_to_stderr() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
store
|
||||
.save(&Registry {
|
||||
version: 1,
|
||||
apps: vec![AppRecord {
|
||||
stable_id: "pingdotgg-t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("update")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Updating 1 apps"))
|
||||
.stderr(contains("Resolving source: resolving pingdotgg-t3code"))
|
||||
.stderr(contains("Saving registry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_reports_when_previous_installation_is_restored() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let install_home = dir.path().join("install-home");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
let stable_id = "url-example.com-downloads-team-app.appimage";
|
||||
let payload_path = install_home.join(format!(".local/lib/upm/appimages/{stable_id}.AppImage"));
|
||||
|
||||
std::fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&payload_path, b"previous-payload").unwrap();
|
||||
std::fs::create_dir_all(install_home.join(".local/share")).unwrap();
|
||||
std::fs::write(install_home.join(".local/share/applications"), b"blocker").unwrap();
|
||||
|
||||
store
|
||||
.save(&Registry {
|
||||
version: 1,
|
||||
apps: vec![AppRecord {
|
||||
stable_id: stable_id.to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(upm_core::domain::source::SourceRef {
|
||||
kind: upm_core::domain::source::SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: upm_core::domain::source::SourceInputKind::DirectUrl,
|
||||
normalized_kind: upm_core::domain::source::NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(payload_path.display().to_string()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.arg("update")
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("DISPLAY", ":99")
|
||||
.env("XDG_CURRENT_DESKTOP", "test")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Failed:"))
|
||||
.stdout(contains("restored previous installation"));
|
||||
|
||||
assert_eq!(std::fs::read(&payload_path).unwrap(), b"previous-payload");
|
||||
}
|
||||
234
crates/upm/tests/search_browser.rs
Normal file
234
crates/upm/tests/search_browser.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
use upm::config::SearchConfig;
|
||||
use upm::ui::search_browser::{BrowserPhase, SearchBrowserState, SubmitAction};
|
||||
use upm_core::domain::search::{SearchInstallStatus, SearchResult};
|
||||
|
||||
#[test]
|
||||
fn browser_defaults_to_bottom_to_top_ordering() {
|
||||
let state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
assert_eq!(
|
||||
visible_names(&state),
|
||||
vec!["charlie/app", "bravo/app", "alpha/app"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_moves_cursor_and_pages() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 2);
|
||||
|
||||
state.move_next();
|
||||
assert_eq!(state.cursor_position(), 1);
|
||||
|
||||
state.page_down();
|
||||
assert_eq!(state.cursor_position(), 2);
|
||||
|
||||
state.page_up();
|
||||
assert_eq!(state.cursor_position(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_supports_single_and_multiple_numeric_selection() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.apply_numeric_selection("1,3").unwrap();
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_supports_numeric_ranges() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.apply_numeric_selection("1-2").unwrap();
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app", "bravo/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_supports_space_separated_numeric_selection() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.apply_numeric_selection("1 3").unwrap();
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_numeric_input_updates_selection_immediately() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.push_numeric_input('1');
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app"]);
|
||||
|
||||
state.push_numeric_input(' ');
|
||||
state.push_numeric_input('3');
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_numeric_input_keeps_last_good_selection_visible() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.push_numeric_input('1');
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app"]);
|
||||
|
||||
state.push_numeric_input('-');
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app"]);
|
||||
assert_eq!(state.numeric_buffer(), "1-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_segments_marks_matching_query_fragments() {
|
||||
let fragments = upm::ui::search_browser::highlight_segments("pingdotgg/t3code", "dotgg");
|
||||
|
||||
assert_eq!(fragments.len(), 3);
|
||||
assert_eq!(fragments[1].text, "dotgg");
|
||||
assert!(fragments[1].is_match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_numeric_selection_preserves_existing_selection() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
state.apply_numeric_selection("2").unwrap();
|
||||
|
||||
let error = state.apply_numeric_selection("2-z").unwrap_err();
|
||||
|
||||
assert!(error.contains("2-z"));
|
||||
assert_eq!(selected_names(&state), vec!["bravo/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_requires_selection_before_transition() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
|
||||
assert!(!state.enter_confirmation());
|
||||
assert_eq!(state.phase(), BrowserPhase::Browsing);
|
||||
|
||||
state.toggle_current_selection();
|
||||
assert!(state.enter_confirmation());
|
||||
assert_eq!(state.phase(), BrowserPhase::Confirming);
|
||||
|
||||
state.cancel_confirmation();
|
||||
assert_eq!(state.phase(), BrowserPhase::Browsing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_selection_can_skip_confirmation_from_config() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
|
||||
state.toggle_current_selection();
|
||||
|
||||
let action = state.submit_selection(true);
|
||||
|
||||
assert_eq!(
|
||||
action,
|
||||
SubmitAction::Confirmed(upm::ui::search_browser::SearchSelection {
|
||||
rows: vec![upm::ui::search_browser::SearchRow {
|
||||
status: SearchInstallStatus::Available,
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "charlie/app".to_owned(),
|
||||
description: None,
|
||||
install_query: "charlie/app".to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
selectable: true,
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_rows_are_visible_but_not_selectable() {
|
||||
let mut state = SearchBrowserState::new(installed_first_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.toggle_current_selection();
|
||||
|
||||
assert!(state.selected_rows().is_empty());
|
||||
assert_eq!(
|
||||
state.status_message(),
|
||||
Some("installed result is not selectable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rows_remain_selectable() {
|
||||
let mut state = SearchBrowserState::new(update_first_results(), SearchConfig::default(), 3);
|
||||
|
||||
state.toggle_current_selection();
|
||||
|
||||
assert_eq!(selected_names(&state), vec!["charlie/app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_expression_prefills_from_checklist_selection() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
|
||||
|
||||
state.toggle_current_selection();
|
||||
state.move_to_bottom();
|
||||
state.toggle_current_selection();
|
||||
|
||||
assert_eq!(state.selection_expression(), "1,3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_expression_compacts_adjacent_ranges() {
|
||||
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
|
||||
|
||||
state.apply_numeric_selection("1-3").unwrap();
|
||||
|
||||
assert_eq!(state.selection_expression(), "1-3");
|
||||
}
|
||||
|
||||
fn sample_results() -> Vec<SearchResult> {
|
||||
vec![
|
||||
sample_result("alpha/app"),
|
||||
sample_result("bravo/app"),
|
||||
sample_result("charlie/app"),
|
||||
]
|
||||
}
|
||||
|
||||
fn sample_result(name: &str) -> SearchResult {
|
||||
SearchResult {
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: name.to_owned(),
|
||||
description: None,
|
||||
source_locator: name.to_owned(),
|
||||
install_query: name.to_owned(),
|
||||
canonical_locator: name.to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
install_status: SearchInstallStatus::Available,
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_first_results() -> Vec<SearchResult> {
|
||||
let mut results = sample_results();
|
||||
results[2].install_status = SearchInstallStatus::Installed {
|
||||
installed_version: Some("1.0.0".to_owned()),
|
||||
};
|
||||
results
|
||||
}
|
||||
|
||||
fn update_first_results() -> Vec<SearchResult> {
|
||||
let mut results = sample_results();
|
||||
results[2].install_status = SearchInstallStatus::UpdateAvailable {
|
||||
installed_version: Some("0.9.0".to_owned()),
|
||||
latest_version: Some("1.0.0".to_owned()),
|
||||
};
|
||||
results
|
||||
}
|
||||
|
||||
fn visible_names(state: &SearchBrowserState) -> Vec<&str> {
|
||||
state
|
||||
.ordered_rows()
|
||||
.iter()
|
||||
.map(|row| row.display_name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected_names(state: &SearchBrowserState) -> Vec<&str> {
|
||||
state
|
||||
.selected_rows()
|
||||
.iter()
|
||||
.map(|row| row.display_name.as_str())
|
||||
.collect()
|
||||
}
|
||||
171
crates/upm/tests/search_cli.rs
Normal file
171
crates/upm/tests/search_cli.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
|
||||
|
||||
#[test]
|
||||
fn search_command_renders_remote_github_results() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("Remote Results"))
|
||||
.stdout(contains("[github] sharkdp/bat"))
|
||||
.stdout(contains("Install query: sharkdp/bat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_renders_local_matches_in_deterministic_order() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
concat!(
|
||||
"version = 1\n",
|
||||
"[[apps]]\n",
|
||||
"stable_id = \"bat\"\n",
|
||||
"display_name = \"Bat\"\n",
|
||||
"[[apps]]\n",
|
||||
"stable_id = \"bat-tools\"\n",
|
||||
"display_name = \"Bat Tools\"\n",
|
||||
"[[apps]]\n",
|
||||
"stable_id = \"acrobat-reader\"\n",
|
||||
"display_name = \"Acrobat Reader\"\n",
|
||||
"[[apps]]\n",
|
||||
"stable_id = \"combat-viewer\"\n",
|
||||
"display_name = \"Combat Viewer\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed Matches"))
|
||||
.stdout(
|
||||
contains("- Bat (bat)")
|
||||
.and(contains("- Bat Tools (bat-tools)"))
|
||||
.and(contains("- Acrobat Reader (acrobat-reader)"))
|
||||
.and(contains("- Combat Viewer (combat-viewer)")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_is_read_only_for_registry_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let original = "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n";
|
||||
std::fs::write(®istry_path, original).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let persisted = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert_eq!(persisted, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_fails_fast_on_malformed_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(&config_path, "[search\nskip_confirmation = true\n").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env("UPM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(config_path.to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_uses_plain_text_output_when_not_on_a_tty() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
"[search]\nbottom_to_top = false\nskip_confirmation = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env("UPM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("Remote Results"))
|
||||
.stdout(contains("[github] sharkdp/bat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_reports_loading_status_to_stderr() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "bat"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Searching bat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_keeps_empty_results_in_plain_text_mode() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "no-such-app-image-query"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains("No remote matches"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_command_renders_appimagehub_results() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("upm").unwrap();
|
||||
|
||||
cmd.args(["search", "firefox"])
|
||||
.env("UPM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Search Results"))
|
||||
.stdout(contains(
|
||||
"[appimagehub] Firefox by Mozilla - Official AppImage Edition",
|
||||
))
|
||||
.stdout(contains("Install query: appimagehub/2338455"));
|
||||
}
|
||||
553
crates/upm/tests/ui_summary.rs
Normal file
553
crates/upm/tests/ui_summary.rs
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
use upm::DispatchResult;
|
||||
use upm::ui::prompt::render_interaction;
|
||||
use upm::ui::render::{render_dispatch_result, render_update_summary};
|
||||
use upm::ui::search_browser::{SearchRow, format_search_row, render_confirmation_summary};
|
||||
use upm_core::app::add::InstalledApp;
|
||||
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use upm_core::app::list::ListRow;
|
||||
use upm_core::app::remove::{RemovalPlan, RemovalResult};
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::domain::search::SearchInstallStatus;
|
||||
use upm_core::domain::show::{
|
||||
InstalledShow, MetadataSummary, RemoteArtifactSummary, RemoteShow, ShowResult, SourceSummary,
|
||||
TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
|
||||
};
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use upm_core::domain::update::ArtifactCandidate;
|
||||
use upm_core::domain::update::{
|
||||
ChannelPreference, ParsedMetadataKind, PlannedUpdate, UpdateChannelKind, UpdatePlan,
|
||||
};
|
||||
use upm_core::integration::install::InstallOutcome;
|
||||
|
||||
fn muted_bold_label(title: &str) -> String {
|
||||
let mut style = upm::ui::theme::current_theme().muted;
|
||||
style.bold = true;
|
||||
upm::ui::theme::apply_style_spec(&format!("{title}:"), &style)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_summary_mentions_selected_count() {
|
||||
let output = render_update_summary(3, 2, 1);
|
||||
assert!(output.contains("selected: 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_summary_uses_review_heading() {
|
||||
let output = render_update_summary(3, 2, 1);
|
||||
assert!(output.contains("Update Review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_empty_state_uses_friendlier_copy() {
|
||||
let output = render_dispatch_result(&DispatchResult::List(Vec::new()));
|
||||
assert!(output.contains("No installed apps yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_renders_table_with_name_version_and_source() {
|
||||
let output = render_dispatch_result(&DispatchResult::List(vec![ListRow {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
version: Some("0.25.0".to_owned()),
|
||||
source: "sharkdp/bat".to_owned(),
|
||||
}]));
|
||||
|
||||
assert!(output.contains("Name"));
|
||||
assert!(output.contains("Version"));
|
||||
assert!(output.contains("Source"));
|
||||
assert!(output.contains("Bat"));
|
||||
assert!(output.contains("0.25.0"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
assert!(!output.contains("Bat (bat)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_flow_uses_clearer_summary_labels() {
|
||||
let output = render_dispatch_result(&DispatchResult::UpdatePlan(UpdatePlan {
|
||||
items: vec![PlannedUpdate {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
selected_channel: ChannelPreference {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
selection_reason: "install-origin-match".to_owned(),
|
||||
}],
|
||||
}));
|
||||
|
||||
assert!(output.contains("Update Review"));
|
||||
assert!(output.contains("apps with updates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_summary_lists_removed_files() {
|
||||
let output = render_dispatch_result(&DispatchResult::Removed(Box::new(RemovalResult {
|
||||
removed: RemovalPlan {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
artifact_paths: vec![
|
||||
"/tmp/install-home/.local/lib/upm/appimages/bat.AppImage".to_owned(),
|
||||
"/tmp/install-home/.local/share/applications/upm-bat.desktop".to_owned(),
|
||||
],
|
||||
},
|
||||
removed_paths: vec![
|
||||
"/tmp/install-home/.local/lib/upm/appimages/bat.AppImage".to_owned(),
|
||||
"/tmp/install-home/.local/share/applications/upm-bat.desktop".to_owned(),
|
||||
],
|
||||
remaining_apps: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
})));
|
||||
|
||||
assert!(output.contains("Removed files"));
|
||||
assert!(output.contains("bat.AppImage"));
|
||||
assert!(output.contains("upm-bat.desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
||||
let output = render_interaction(&InteractionRequest {
|
||||
key: "tracking-preference".to_owned(),
|
||||
kind: InteractionKind::ChooseTrackingPreference {
|
||||
requested_version: "v0.0.11".to_owned(),
|
||||
latest_version: "v0.0.12".to_owned(),
|
||||
},
|
||||
});
|
||||
|
||||
assert!(output.contains("Choose update tracking"));
|
||||
assert!(output.contains("v0.0.11"));
|
||||
assert!(output.contains("v0.0.12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_prompt_uses_explicit_question_copy() {
|
||||
let output = render_interaction(&InteractionRequest {
|
||||
key: "tracking-preference".to_owned(),
|
||||
kind: InteractionKind::ChooseTrackingPreference {
|
||||
requested_version: "v0.0.11".to_owned(),
|
||||
latest_version: "v0.0.12".to_owned(),
|
||||
},
|
||||
});
|
||||
|
||||
assert!(output.contains("Choose update tracking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_summary_omits_completed_steps_recap() {
|
||||
let output = render_dispatch_result(&DispatchResult::Added(Box::new(InstalledApp {
|
||||
record: AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "bat".to_owned(),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.25.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(
|
||||
"/tmp/install-home/.local/lib/upm/appimages/sharkdp-bat.AppImage".to_owned(),
|
||||
),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/upm-sharkdp-bat.desktop"
|
||||
.to_owned(),
|
||||
),
|
||||
icon_path: None,
|
||||
}),
|
||||
},
|
||||
selected_artifact: ArtifactCandidate {
|
||||
url: "https://github.com/sharkdp/bat/releases/download/v0.25.0/bat-x86_64.AppImage"
|
||||
.to_owned(),
|
||||
version: "0.25.0".to_owned(),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "heuristic-match".to_owned(),
|
||||
},
|
||||
artifact_size_bytes: 173_015_040,
|
||||
source: SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
},
|
||||
install_scope: InstallScope::User,
|
||||
integration_mode: upm_core::integration::policy::IntegrationMode::Full,
|
||||
install_outcome: InstallOutcome {
|
||||
final_payload_path: "/tmp/install-home/.local/lib/upm/appimages/sharkdp-bat.AppImage"
|
||||
.into(),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/upm-sharkdp-bat.desktop".into(),
|
||||
),
|
||||
icon_path: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
warnings: Vec::new(),
|
||||
})));
|
||||
|
||||
assert!(output.contains("Installed bat (user)"));
|
||||
assert!(output.contains("Installed files"));
|
||||
assert!(!output.contains("Completed steps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_browser_row_uses_status_tag_version_and_description_layout() {
|
||||
let row = SearchRow {
|
||||
status: SearchInstallStatus::Installed {
|
||||
installed_version: Some("0.0.12".to_owned()),
|
||||
},
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "pingdotgg/t3code".to_owned(),
|
||||
description: Some("The T3 desktop app.".to_owned()),
|
||||
install_query: "pingdotgg/t3code".to_owned(),
|
||||
version: Some("0.0.12".to_owned()),
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
let output = format_search_row(1, &row, true, true, 120);
|
||||
|
||||
assert!(output.contains('\n'));
|
||||
assert!(output.contains("[installed]"));
|
||||
assert!(output.contains("v0.0.12"));
|
||||
assert!(output.contains("pingdotgg/t3code"));
|
||||
assert!(output.contains("github - The T3 desktop app."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_browser_row_without_description_shows_provider_only() {
|
||||
let row = SearchRow {
|
||||
status: SearchInstallStatus::Available,
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "pingdotgg/t3code".to_owned(),
|
||||
description: None,
|
||||
install_query: "pingdotgg/t3code".to_owned(),
|
||||
version: Some("0.0.12".to_owned()),
|
||||
selectable: true,
|
||||
};
|
||||
|
||||
let output = format_search_row(1, &row, false, false, 120);
|
||||
|
||||
assert!(output.contains("github"));
|
||||
assert!(!output.contains(" - "));
|
||||
assert!(!output.contains("No description available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_confirmation_summary_lists_selected_rows() {
|
||||
let rows = vec![
|
||||
SearchRow {
|
||||
status: SearchInstallStatus::UpdateAvailable {
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
latest_version: Some("0.0.12".to_owned()),
|
||||
},
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "pingdotgg/t3code".to_owned(),
|
||||
description: Some("The T3 desktop app.".to_owned()),
|
||||
install_query: "pingdotgg/t3code".to_owned(),
|
||||
version: Some("0.0.12".to_owned()),
|
||||
selectable: true,
|
||||
},
|
||||
SearchRow {
|
||||
status: SearchInstallStatus::Available,
|
||||
provider_id: "github".to_owned(),
|
||||
display_name: "sharkdp/bat".to_owned(),
|
||||
description: Some("A cat(1) clone with wings.".to_owned()),
|
||||
install_query: "sharkdp/bat".to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
selectable: true,
|
||||
},
|
||||
];
|
||||
|
||||
let output = render_confirmation_summary(&rows);
|
||||
|
||||
assert!(output.contains("Confirm Search Selection"));
|
||||
assert!(output.contains("pingdotgg/t3code"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_summary_renders_source_scope_and_paths() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
|
||||
InstalledShow {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/tmp/upm-bat.desktop".to_owned()),
|
||||
icon_path: Some("/tmp/upm-bat.png".to_owned()),
|
||||
},
|
||||
update_strategy: Some(UpdateStrategySummary {
|
||||
preferred: UpdateChannelSummary {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: vec![
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.24.0".to_owned()),
|
||||
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
|
||||
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.23.0".to_owned()),
|
||||
primary_download: Some("https://example.test/bat-0.23.0.AppImage".to_owned()),
|
||||
checksum: Some("sha256:efgh".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("Legacy Bat (legacy-bat)"));
|
||||
assert!(output.contains("v0.24.0"));
|
||||
assert!(output.contains("[up to date]"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
upm::ui::theme::muted("github - sharkdp/bat")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Update Mechanism"),
|
||||
upm::ui::theme::muted("electron-builder")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Architecture"),
|
||||
upm::ui::theme::muted("x86_64")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Checksum"),
|
||||
upm::ui::theme::muted("sha256:abcdefg...456789")
|
||||
)));
|
||||
assert!(output.contains(&muted_bold_label("Installed as User")));
|
||||
assert!(output.contains("/tmp/bat.AppImage"));
|
||||
assert!(output.contains("/tmp/upm-bat.desktop"));
|
||||
assert!(!output.contains("[up to date] User"));
|
||||
assert!(!output.contains("past version"));
|
||||
assert!(!output.contains(&upm::ui::theme::label("Metadata")));
|
||||
assert!(!output.contains(&upm::ui::theme::label("Files")));
|
||||
assert!(!output.contains("abcdefghijklmnopqrstuvwxyz0123456789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_summary_reports_when_newer_versions_are_available() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Installed(
|
||||
InstalledShow {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
installed_version: Some("0.0.13".to_owned()),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "pingdotgg/t3code".to_owned(),
|
||||
canonical_locator: Some("pingdotgg/t3code".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: Some(UpdateStrategySummary {
|
||||
preferred: UpdateChannelSummary {
|
||||
kind: UpdateChannelKind::ElectronBuilder,
|
||||
locator: "https://github.com/pingdotgg/t3code/releases/download/v0.0.16/latest-linux.yml"
|
||||
.to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: vec![
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.16".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.15".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.14".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.13".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: Some("latest".to_owned()),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("t3code (t3code)"));
|
||||
assert!(output.contains("v0.0.13"));
|
||||
assert!(output.contains("[update available]"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
upm::ui::theme::muted("github - pingdotgg/t3code")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Update Mechanism"),
|
||||
upm::ui::theme::muted("electron-builder")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Architecture"),
|
||||
upm::ui::theme::muted("x86_64")
|
||||
)));
|
||||
assert!(output.contains(&muted_bold_label("Installed as User")));
|
||||
assert!(!output.contains("[update available] User"));
|
||||
assert!(!output.contains("past versions"));
|
||||
assert!(!output.contains("latest v0.0.16"));
|
||||
assert!(!output.contains(&upm::ui::theme::label("Metadata")));
|
||||
assert!(!output.contains(&upm::ui::theme::label("Files")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_show_list_renders_each_app_using_singular_show_format() {
|
||||
let output = render_dispatch_result(&DispatchResult::ShowAll(vec![
|
||||
InstalledShow {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/tmp/upm-bat.desktop".to_owned()),
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: None,
|
||||
metadata: vec![MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.24.0".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: Some("sha256:abcdefghijklmnopqrstuvwxyz0123456789".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
}],
|
||||
},
|
||||
InstalledShow {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "t3code".to_owned(),
|
||||
installed_version: Some("0.0.13".to_owned()),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: Some(SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "pingdotgg/t3code".to_owned(),
|
||||
canonical_locator: Some("pingdotgg/t3code".to_owned()),
|
||||
}),
|
||||
install_scope: Some(InstallScope::User),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: Some("/tmp/t3code.AppImage".to_owned()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
},
|
||||
update_strategy: None,
|
||||
metadata: vec![MetadataSummary {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
version: Some("0.0.16".to_owned()),
|
||||
primary_download: None,
|
||||
checksum: None,
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
warnings: Vec::new(),
|
||||
}],
|
||||
},
|
||||
]));
|
||||
|
||||
assert!(output.contains("Legacy Bat (legacy-bat)"));
|
||||
assert!(output.contains("t3code (t3code)"));
|
||||
assert!(output.contains("\n\n"));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
upm::ui::theme::muted("github - sharkdp/bat")
|
||||
)));
|
||||
assert!(output.contains(&format!(
|
||||
"{} {}",
|
||||
muted_bold_label("Source"),
|
||||
upm::ui::theme::muted("github - pingdotgg/t3code")
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_show_summary_renders_source_artifact_and_reason() {
|
||||
let output = render_dispatch_result(&DispatchResult::Show(Box::new(ShowResult::Remote(
|
||||
RemoteShow {
|
||||
source: SourceSummary {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
},
|
||||
artifact: RemoteArtifactSummary {
|
||||
url: "https://github.com/sharkdp/bat/releases/download/v1.0.0/Bat-1.0.0-x86_64.AppImage"
|
||||
.to_owned(),
|
||||
version: Some("1.0.0".to_owned()),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: Some("sha512:abcd".to_owned()),
|
||||
selection_reason: "metadata-guided".to_owned(),
|
||||
},
|
||||
interactions: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
))));
|
||||
|
||||
assert!(output.contains("Resolved Source"));
|
||||
assert!(output.contains("github"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
assert!(output.contains("Bat-1.0.0-x86_64.AppImage"));
|
||||
assert!(output.contains("1.0.0"));
|
||||
assert!(output.contains("metadata-guided"));
|
||||
assert!(output.contains("sha512:abcd"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue