feat: redesign cli ux progress
This commit is contained in:
parent
ab60ee641f
commit
c63b2917da
21 changed files with 994 additions and 99 deletions
51
Cargo.lock
generated
51
Cargo.lock
generated
|
|
@ -18,7 +18,9 @@ dependencies = [
|
||||||
"aim-core",
|
"aim-core",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"clap",
|
"clap",
|
||||||
|
"console 0.16.3",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"predicates",
|
"predicates",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -221,6 +223,19 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "console"
|
||||||
|
version = "0.15.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||||
|
dependencies = [
|
||||||
|
"encode_unicode",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"unicode-width",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.16.3"
|
version = "0.16.3"
|
||||||
|
|
@ -239,7 +254,7 @@ version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
|
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console",
|
"console 0.16.3",
|
||||||
"shell-words",
|
"shell-words",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|
@ -650,6 +665,19 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indicatif"
|
||||||
|
version = "0.17.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
||||||
|
dependencies = [
|
||||||
|
"console 0.15.11",
|
||||||
|
"number_prefix",
|
||||||
|
"portable-atomic",
|
||||||
|
"unicode-width",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
|
|
@ -756,6 +784,12 @@ dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "number_prefix"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|
@ -786,6 +820,12 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|
@ -1705,6 +1745,15 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ version = "0.1.0"
|
||||||
clap = { version = "4.5.32", features = ["derive"] }
|
clap = { version = "4.5.32", features = ["derive"] }
|
||||||
assert_cmd = "2.0.16"
|
assert_cmd = "2.0.16"
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
|
console = "0.16.3"
|
||||||
|
indicatif = "0.17.11"
|
||||||
libc = "0.2.171"
|
libc = "0.2.171"
|
||||||
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -39,7 +39,14 @@ By default `aim` auto-detects whether to use user or system scope. Override that
|
||||||
|
|
||||||
## Current Flow Shape
|
## Current Flow Shape
|
||||||
|
|
||||||
- `aim <QUERY>` installs unambiguous apps, persists them into the registry after successful install, and renders review prompts when tracking needs confirmation
|
- `aim <QUERY>` installs unambiguous apps, shows live progress on stderr, prints an `Installation Summary` on stdout, and renders an `Installation Review` when tracking needs confirmation
|
||||||
- bare `aim` and `aim update` build a review-first update plan
|
- bare `aim` prints an `Update Review` without mutating the registry
|
||||||
- `aim list` renders registered applications
|
- `aim update` executes the pending updates, streams live status on stderr, then prints an `Update Summary`
|
||||||
- `aim remove <QUERY>` resolves a registered application name before removal
|
- `aim list` renders either `Installed Apps` or `No installed apps yet`
|
||||||
|
- `aim remove <QUERY>` resolves a registered application name, streams removal progress on stderr, then prints a `Removal Summary`
|
||||||
|
|
||||||
|
## Terminal UX
|
||||||
|
|
||||||
|
- prompts use `dialoguer`
|
||||||
|
- styled summaries use `console`
|
||||||
|
- live spinners and byte progress use `indicatif`
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dialoguer.workspace = true
|
dialoguer.workspace = true
|
||||||
|
console.workspace = true
|
||||||
|
indicatif.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
aim-core = { path = "../aim-core" }
|
aim-core = { path = "../aim-core" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ use std::env;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use aim_core::app::add::{
|
use aim_core::app::add::{
|
||||||
AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope,
|
AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope,
|
||||||
};
|
};
|
||||||
use aim_core::app::list::{ListRow, build_list_rows};
|
use aim_core::app::list::{ListRow, build_list_rows};
|
||||||
use aim_core::app::remove::{RemovalResult, remove_registered_app};
|
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
|
||||||
use aim_core::app::update::{build_update_plan, execute_updates};
|
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
|
||||||
|
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
|
||||||
use aim_core::domain::app::AppRecord;
|
use aim_core::domain::app::AppRecord;
|
||||||
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
|
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
|
||||||
use aim_core::registry::model::Registry;
|
use aim_core::registry::model::Registry;
|
||||||
|
|
@ -22,6 +23,14 @@ pub fn parse() -> Cli {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
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> {
|
||||||
let registry_path = registry_path();
|
let registry_path = registry_path();
|
||||||
let install_home = install_home(®istry_path);
|
let install_home = install_home(®istry_path);
|
||||||
let store = RegistryStore::new(registry_path);
|
let store = RegistryStore::new(registry_path);
|
||||||
|
|
@ -36,21 +45,40 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
return match command {
|
return match command {
|
||||||
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
||||||
cli::args::Command::Remove { query } => {
|
cli::args::Command::Remove { query } => {
|
||||||
let removal = remove_registered_app(&query, &apps, &install_home)?;
|
let removal =
|
||||||
|
remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?;
|
||||||
let remaining_apps = removal.remaining_apps.clone();
|
let remaining_apps = removal.remaining_apps.clone();
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::SaveRegistry,
|
||||||
|
message: "saving registry".to_owned(),
|
||||||
|
});
|
||||||
store.save(&Registry {
|
store.save(&Registry {
|
||||||
version: registry.version,
|
version: registry.version,
|
||||||
apps: remaining_apps,
|
apps: remaining_apps,
|
||||||
})?;
|
})?;
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!("removed {}", removal.removed.stable_id),
|
||||||
|
});
|
||||||
Ok(DispatchResult::Removed(Box::new(removal)))
|
Ok(DispatchResult::Removed(Box::new(removal)))
|
||||||
}
|
}
|
||||||
cli::args::Command::Update => {
|
cli::args::Command::Update => {
|
||||||
let updates = execute_updates(&apps, &install_home)?;
|
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
|
||||||
let updated_apps = updates.apps.clone();
|
let updated_apps = updates.apps.clone();
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::SaveRegistry,
|
||||||
|
message: "saving registry".to_owned(),
|
||||||
|
});
|
||||||
store.save(&Registry {
|
store.save(&Registry {
|
||||||
version: registry.version,
|
version: registry.version,
|
||||||
apps: updated_apps,
|
apps: updated_apps,
|
||||||
})?;
|
})?;
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!(
|
||||||
|
"updated {}, failed {}",
|
||||||
|
updates.updated_count(),
|
||||||
|
updates.failed_count()
|
||||||
|
),
|
||||||
|
});
|
||||||
Ok(DispatchResult::Updated(Box::new(updates)))
|
Ok(DispatchResult::Updated(Box::new(updates)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -68,13 +96,21 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let installed = install_app(&query, &plan, &install_home, requested_scope)?;
|
let installed =
|
||||||
|
install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?;
|
||||||
let mut updated_apps = registry.apps.clone();
|
let mut updated_apps = registry.apps.clone();
|
||||||
upsert_app_record(&mut updated_apps, installed.record.clone());
|
upsert_app_record(&mut updated_apps, installed.record.clone());
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::SaveRegistry,
|
||||||
|
message: "saving registry".to_owned(),
|
||||||
|
});
|
||||||
store.save(&Registry {
|
store.save(&Registry {
|
||||||
version: registry.version,
|
version: registry.version,
|
||||||
apps: updated_apps,
|
apps: updated_apps,
|
||||||
})?;
|
})?;
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!("installed {}", installed.record.stable_id),
|
||||||
|
});
|
||||||
|
|
||||||
return Ok(DispatchResult::Added(Box::new(installed)));
|
return Ok(DispatchResult::Added(Box::new(installed)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
fn main() {
|
fn main() {
|
||||||
let cli = aim_cli::parse();
|
let cli = aim_cli::parse();
|
||||||
match aim_cli::dispatch(cli) {
|
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
|
||||||
|
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let output = aim_cli::render(&result);
|
let output = aim_cli::render(&result);
|
||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
|
pub mod progress;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
pub mod theme;
|
||||||
|
|
|
||||||
154
crates/aim-cli/src/ui/progress.rs
Normal file
154
crates/aim-cli/src/ui/progress.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use aim_core::app::progress::{OperationEvent, OperationKind, OperationStage, ProgressReporter};
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ProgressStyle::with_template("{bar:32.cyan/blue} {bytes}/{total_bytes} {msg}")
|
||||||
|
.expect("byte progress template is valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation_label(kind: OperationKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
OperationKind::Add => "Installing",
|
||||||
|
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 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalProgressReporter {
|
||||||
|
pub fn stderr() -> Self {
|
||||||
|
Self {
|
||||||
|
interactive: std::io::stderr().is_terminal(),
|
||||||
|
progress_bar: None,
|
||||||
|
byte_total: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_progress(&mut self) {
|
||||||
|
if let Some(progress_bar) = self.progress_bar.take() {
|
||||||
|
progress_bar.finish_and_clear();
|
||||||
|
}
|
||||||
|
self.byte_total = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_spinner(&mut self, message: String) {
|
||||||
|
if !self.interactive {
|
||||||
|
eprintln!("{message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(message);
|
||||||
|
self.byte_total = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_progress(&mut self, current: u64, total: Option<u64>) {
|
||||||
|
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 { .. } | OperationEvent::StageChanged { .. } => {
|
||||||
|
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) {
|
||||||
|
eprintln!("{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OperationEvent::Finished { .. } => self.clear_progress(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,24 +3,24 @@ use std::io::IsTerminal;
|
||||||
|
|
||||||
use aim_core::app::add::{AddPlan, prefer_latest_tracking};
|
use aim_core::app::add::{AddPlan, prefer_latest_tracking};
|
||||||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use dialoguer::{Select, theme::ColorfulTheme};
|
use dialoguer::Select;
|
||||||
|
|
||||||
const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE";
|
const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE";
|
||||||
|
|
||||||
pub fn render_interaction(request: &InteractionRequest) -> String {
|
pub fn render_interaction(request: &InteractionRequest) -> String {
|
||||||
match &request.kind {
|
match &request.kind {
|
||||||
InteractionKind::SelectRegisteredApp { query, matches } => format!(
|
InteractionKind::SelectRegisteredApp { query, matches } => format!(
|
||||||
"multiple installed apps match '{query}': {}",
|
"Choose the installed app matching '{query}': {}",
|
||||||
matches.join(", ")
|
matches.join(", ")
|
||||||
),
|
),
|
||||||
InteractionKind::ChooseTrackingPreference {
|
InteractionKind::ChooseTrackingPreference {
|
||||||
requested_version,
|
requested_version,
|
||||||
latest_version,
|
latest_version,
|
||||||
} => format!(
|
} => format!(
|
||||||
"tracking preference required: requested {requested_version}, latest available {latest_version}",
|
"Choose update tracking: requested {requested_version}, latest available {latest_version}",
|
||||||
),
|
),
|
||||||
InteractionKind::SelectArtifact { candidates } => {
|
InteractionKind::SelectArtifact { candidates } => {
|
||||||
format!("artifact selection required: {}", candidates.join(", "))
|
format!("Choose an artifact: {}", candidates.join(", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,8 +100,8 @@ fn resolve_tracking_preference(
|
||||||
format!("Keep tracking the requested release lineage ({requested_version})"),
|
format!("Keep tracking the requested release lineage ({requested_version})"),
|
||||||
format!("Track the latest release after install ({latest_version})"),
|
format!("Track the latest release after install ({latest_version})"),
|
||||||
];
|
];
|
||||||
let selection = Select::with_theme(&ColorfulTheme::default())
|
let selection = Select::with_theme(&crate::ui::theme::dialog_theme())
|
||||||
.with_prompt("Choose update tracking behavior")
|
.with_prompt("Choose update tracking")
|
||||||
.items(options)
|
.items(options)
|
||||||
.default(1)
|
.default(1)
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,13 @@ use aim_core::domain::update::UpdateExecutionStatus;
|
||||||
use crate::DispatchResult;
|
use crate::DispatchResult;
|
||||||
|
|
||||||
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
|
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
|
||||||
format!("updates found: {total}, selected: {selected}, failed: {failed}",)
|
[
|
||||||
|
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 {
|
pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||||
|
|
@ -13,9 +19,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||||
DispatchResult::List(rows) => render_list(rows),
|
DispatchResult::List(rows) => render_list(rows),
|
||||||
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
|
||||||
DispatchResult::Removed(removed) => render_removed_app(removed),
|
DispatchResult::Removed(removed) => render_removed_app(removed),
|
||||||
DispatchResult::UpdatePlan(plan) => {
|
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
|
||||||
render_update_summary(plan.items.len(), plan.items.len(), 0)
|
|
||||||
}
|
|
||||||
DispatchResult::Updated(result) => render_updated_apps(result),
|
DispatchResult::Updated(result) => render_updated_apps(result),
|
||||||
DispatchResult::Noop => String::new(),
|
DispatchResult::Noop => String::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -31,46 +35,68 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
|
||||||
.warnings
|
.warnings
|
||||||
.iter()
|
.iter()
|
||||||
.chain(added.install_outcome.warnings.iter())
|
.chain(added.install_outcome.warnings.iter())
|
||||||
.map(|warning| format!("warning: {warning}"))
|
.map(|warning| format!("Warning: {warning}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
let summary = format!(
|
let mut lines = vec![
|
||||||
"installing as {scope}\ninstalled app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]",
|
crate::ui::theme::heading("Installation Summary"),
|
||||||
|
format!(
|
||||||
|
"{} {} ({})",
|
||||||
|
crate::ui::theme::label("Application"),
|
||||||
added.record.display_name,
|
added.record.display_name,
|
||||||
added.record.stable_id,
|
added.record.stable_id,
|
||||||
|
),
|
||||||
|
format!("{} {scope}", crate::ui::theme::label("Install scope")),
|
||||||
|
format!(
|
||||||
|
"{} {} {}",
|
||||||
|
crate::ui::theme::label("Source"),
|
||||||
added.source.kind.as_str(),
|
added.source.kind.as_str(),
|
||||||
added.source.locator,
|
added.source.locator,
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"{} {} [{}]",
|
||||||
|
crate::ui::theme::label("Selected artifact"),
|
||||||
added.selected_artifact.url,
|
added.selected_artifact.url,
|
||||||
added.selected_artifact.selection_reason,
|
added.selected_artifact.selection_reason,
|
||||||
);
|
),
|
||||||
|
];
|
||||||
|
|
||||||
if warning_lines.is_empty() {
|
lines.extend(warning_lines);
|
||||||
summary
|
lines.join("\n")
|
||||||
} else {
|
|
||||||
format!("{summary}\n{warning_lines}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_pending_add(plan: &AddPlan) -> String {
|
fn render_pending_add(plan: &AddPlan) -> String {
|
||||||
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
|
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
|
||||||
|
[
|
||||||
|
crate::ui::theme::heading("Installation Review"),
|
||||||
format!(
|
format!(
|
||||||
"resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}",
|
"{} {} {}",
|
||||||
|
crate::ui::theme::label("Resolved source"),
|
||||||
plan.resolution.source.kind.as_str(),
|
plan.resolution.source.kind.as_str(),
|
||||||
plan.resolution.source.locator,
|
plan.resolution.source.locator,
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"{} {} [{}]",
|
||||||
|
crate::ui::theme::label("Selected artifact"),
|
||||||
plan.selected_artifact.url,
|
plan.selected_artifact.url,
|
||||||
plan.selected_artifact.selection_reason,
|
plan.selected_artifact.selection_reason,
|
||||||
)
|
),
|
||||||
|
prompts,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
|
fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return "installed apps: none".to_owned();
|
return crate::ui::theme::muted("No installed apps yet");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut output = String::from("installed apps:\n");
|
let mut output = format!("{}\n", crate::ui::theme::heading("Installed Apps"));
|
||||||
for row in rows {
|
for row in rows {
|
||||||
output.push_str(&format!("- {} ({})\n", row.display_name, row.stable_id));
|
output.push_str(&format!(
|
||||||
|
"{}\n",
|
||||||
|
crate::ui::theme::bullet(&format!("{} ({})", row.display_name, row.stable_id))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
output.trim_end().to_owned()
|
output.trim_end().to_owned()
|
||||||
}
|
}
|
||||||
|
|
@ -79,36 +105,38 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
|
||||||
let warning_lines = removed
|
let warning_lines = removed
|
||||||
.warnings
|
.warnings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|warning| format!("warning: {warning}"))
|
.map(|warning| format!("Warning: {warning}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join("\n");
|
let mut lines = vec![
|
||||||
let summary = format!("removed: {}", removed.removed.display_name);
|
crate::ui::theme::heading("Removal Summary"),
|
||||||
|
format!(
|
||||||
if warning_lines.is_empty() {
|
"{} {}",
|
||||||
summary
|
crate::ui::theme::label("Removed app"),
|
||||||
} else {
|
removed.removed.display_name,
|
||||||
format!("{summary}\n{warning_lines}")
|
),
|
||||||
}
|
];
|
||||||
|
lines.extend(warning_lines);
|
||||||
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
|
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
|
||||||
let mut lines = vec![format!(
|
let mut lines = vec![
|
||||||
"updated apps: {}, failed: {}",
|
crate::ui::theme::heading("Update Summary"),
|
||||||
result.updated_count(),
|
format!("updated apps: {}", result.updated_count()),
|
||||||
result.failed_count()
|
format!("failed updates: {}", result.failed_count()),
|
||||||
)];
|
];
|
||||||
|
|
||||||
for item in &result.items {
|
for item in &result.items {
|
||||||
match &item.status {
|
match &item.status {
|
||||||
UpdateExecutionStatus::Updated => lines.push(format!(
|
UpdateExecutionStatus::Updated => lines.push(format!(
|
||||||
"updated: {} ({}) {} -> {}",
|
"Updated: {} ({}) {} -> {}",
|
||||||
item.display_name,
|
item.display_name,
|
||||||
item.stable_id,
|
item.stable_id,
|
||||||
item.from_version.as_deref().unwrap_or("unknown"),
|
item.from_version.as_deref().unwrap_or("unknown"),
|
||||||
item.to_version.as_deref().unwrap_or("unknown")
|
item.to_version.as_deref().unwrap_or("unknown")
|
||||||
)),
|
)),
|
||||||
UpdateExecutionStatus::Failed { reason } => lines.push(format!(
|
UpdateExecutionStatus::Failed { reason } => lines.push(format!(
|
||||||
"failed: {} ({}) {}",
|
"Failed: {} ({}) {}",
|
||||||
item.display_name, item.stable_id, reason
|
item.display_name, item.stable_id, reason
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
|
|
@ -116,3 +144,16 @@ fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult)
|
||||||
|
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_update_plan(plan: &aim_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")
|
||||||
|
}
|
||||||
|
|
|
||||||
22
crates/aim-cli/src/ui/theme.rs
Normal file
22
crates/aim-cli/src/ui/theme.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use console::style;
|
||||||
|
use dialoguer::theme::ColorfulTheme;
|
||||||
|
|
||||||
|
pub fn dialog_theme() -> ColorfulTheme {
|
||||||
|
ColorfulTheme::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn heading(title: &str) -> String {
|
||||||
|
style(title).bold().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(title: &str) -> String {
|
||||||
|
style(format!("{title}:")).bold().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn muted(message: &str) -> String {
|
||||||
|
style(message).dim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bullet(message: &str) -> String {
|
||||||
|
format!("- {message}")
|
||||||
|
}
|
||||||
|
|
@ -10,12 +10,15 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_command_runs_without_registry_entries() {
|
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("aim").unwrap();
|
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||||
|
|
||||||
cmd.arg("list")
|
cmd.arg("list")
|
||||||
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("installed"));
|
.stdout(contains("No installed apps yet"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -53,7 +56,8 @@ fn remove_command_removes_registered_app_from_registry_file() {
|
||||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("removed: Bat"));
|
.stdout(contains("Removal Summary"))
|
||||||
|
.stdout(contains("Removed app: Bat"));
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(!contents.contains("stable_id = \"bat\""));
|
assert!(!contents.contains("stable_id = \"bat\""));
|
||||||
|
|
@ -86,7 +90,8 @@ fn remove_command_uninstalls_managed_files() {
|
||||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("removed: bat"));
|
.stdout(contains("Removal Summary"))
|
||||||
|
.stdout(contains("Removed app: bat"));
|
||||||
|
|
||||||
assert!(!payload_path.exists());
|
assert!(!payload_path.exists());
|
||||||
assert!(!desktop_path.exists());
|
assert!(!desktop_path.exists());
|
||||||
|
|
@ -104,8 +109,8 @@ fn query_command_registers_unambiguous_app_in_registry_file() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("installing as user"))
|
.stdout(contains("Installation Summary"))
|
||||||
.stdout(contains("installed app: bat (sharkdp-bat)"));
|
.stdout(contains("Application: bat (sharkdp-bat)"));
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
||||||
|
|
@ -123,7 +128,7 @@ fn old_release_query_renders_tracking_prompt_without_writing_registry() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("tracking preference required"))
|
.stdout(contains("Choose update tracking"))
|
||||||
.stdout(contains("v0.0.11"))
|
.stdout(contains("v0.0.11"))
|
||||||
.stdout(contains("v0.0.12"));
|
.stdout(contains("v0.0.12"));
|
||||||
|
|
||||||
|
|
@ -142,8 +147,9 @@ fn old_release_query_can_track_latest_and_register_app() {
|
||||||
.env("AIM_TRACKING_PREFERENCE", "latest")
|
.env("AIM_TRACKING_PREFERENCE", "latest")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("installing as user"))
|
.stdout(contains("Installation Summary"))
|
||||||
.stdout(contains("installed app: t3code (pingdotgg-t3code)"));
|
.stdout(contains("Application: t3code (pingdotgg-t3code)"))
|
||||||
|
.stdout(contains("Install scope: user"));
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
||||||
|
|
@ -161,8 +167,80 @@ fn cli_add_installs_and_renders_resolved_mode() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("installing as user"))
|
.stdout(contains("Installation Summary"))
|
||||||
.stdout(contains("installed app:"));
|
.stdout(contains("Selected artifact"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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("aim").unwrap();
|
||||||
|
|
||||||
|
cmd.arg("sharkdp/bat")
|
||||||
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stderr(contains("Installing sharkdp/bat"))
|
||||||
|
.stderr(contains("Downloading artifact"))
|
||||||
|
.stderr(contains("Saving registry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_aim_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("aim").unwrap();
|
||||||
|
|
||||||
|
cmd.env("AIM_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("aim").unwrap();
|
||||||
|
|
||||||
|
cmd.args(["remove", "bat"])
|
||||||
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stderr(contains("Removing bat"))
|
||||||
|
.stderr(contains("Resolving source: resolving bat"))
|
||||||
|
.stderr(contains("Saving registry"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -180,7 +258,8 @@ fn system_request_on_immutable_host_falls_back_to_user_install() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("installing as user"))
|
.stdout(contains("Installation Summary"))
|
||||||
|
.stdout(contains("Install scope: user"))
|
||||||
.stdout(contains("downgraded to user scope"));
|
.stdout(contains("downgraded to user scope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,3 +308,41 @@ fn update_command_applies_updates() {
|
||||||
assert_eq!(updated.apps[0].installed_version.as_deref(), Some("0.0.12"));
|
assert_eq!(updated.apps[0].installed_version.as_deref(), Some("0.0.12"));
|
||||||
assert!(payload_path.exists());
|
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("aim").unwrap();
|
||||||
|
|
||||||
|
cmd.arg("update")
|
||||||
|
.env("AIM_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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
use aim_cli::DispatchResult;
|
||||||
use aim_cli::ui::prompt::render_interaction;
|
use aim_cli::ui::prompt::render_interaction;
|
||||||
use aim_cli::ui::render::render_update_summary;
|
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
|
||||||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
|
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_summary_mentions_selected_count() {
|
fn update_summary_mentions_selected_count() {
|
||||||
|
|
@ -8,6 +10,37 @@ fn update_summary_mentions_selected_count() {
|
||||||
assert!(output.contains("selected: 2"));
|
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 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]
|
#[test]
|
||||||
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
||||||
let output = render_interaction(&InteractionRequest {
|
let output = render_interaction(&InteractionRequest {
|
||||||
|
|
@ -18,7 +51,20 @@ fn tracking_prompt_mentions_requested_and_latest_versions() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(output.contains("tracking preference required"));
|
assert!(output.contains("Choose update tracking"));
|
||||||
assert!(output.contains("v0.0.11"));
|
assert!(output.contains("v0.0.11"));
|
||||||
assert!(output.contains("v0.0.12"));
|
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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::adapters::traits::AdapterResolution;
|
use crate::adapters::traits::AdapterResolution;
|
||||||
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
|
use crate::app::progress::{
|
||||||
|
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||||
|
};
|
||||||
use crate::app::query::{ResolveQueryError, resolve_query};
|
use crate::app::query::{ResolveQueryError, resolve_query};
|
||||||
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
|
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
|
||||||
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
|
|
@ -187,6 +191,27 @@ pub fn install_app(
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
requested_scope: InstallScope,
|
requested_scope: InstallScope,
|
||||||
) -> Result<InstalledApp, InstallAppError> {
|
) -> Result<InstalledApp, InstallAppError> {
|
||||||
|
let mut reporter = NoopReporter;
|
||||||
|
install_app_with_reporter(
|
||||||
|
source_input,
|
||||||
|
plan,
|
||||||
|
install_home,
|
||||||
|
requested_scope,
|
||||||
|
&mut reporter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_app_with_reporter(
|
||||||
|
source_input: &str,
|
||||||
|
plan: &AddPlan,
|
||||||
|
install_home: &Path,
|
||||||
|
requested_scope: InstallScope,
|
||||||
|
reporter: &mut impl ProgressReporter,
|
||||||
|
) -> Result<InstalledApp, InstallAppError> {
|
||||||
|
reporter.report(&OperationEvent::Started {
|
||||||
|
kind: OperationKind::Add,
|
||||||
|
label: source_input.to_owned(),
|
||||||
|
});
|
||||||
let mut record =
|
let mut record =
|
||||||
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
|
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
|
||||||
let (family, capabilities) =
|
let (family, capabilities) =
|
||||||
|
|
@ -209,7 +234,12 @@ pub fn install_app(
|
||||||
install_home,
|
install_home,
|
||||||
&policy.icon_root.join(format!("{}.png", record.stable_id)),
|
&policy.icon_root.join(format!("{}.png", record.stable_id)),
|
||||||
);
|
);
|
||||||
let artifact_bytes = download_artifact_bytes(&plan.selected_artifact.url)?;
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::DownloadArtifact,
|
||||||
|
message: "downloading artifact".to_owned(),
|
||||||
|
});
|
||||||
|
let artifact_bytes =
|
||||||
|
download_artifact_bytes_with_reporter(&plan.selected_artifact.url, reporter)?;
|
||||||
let payload_exec = payload_path.clone();
|
let payload_exec = payload_path.clone();
|
||||||
let desktop_owned = match policy.integration_mode {
|
let desktop_owned = match policy.integration_mode {
|
||||||
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
|
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
|
||||||
|
|
@ -219,6 +249,21 @@ pub fn install_app(
|
||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if desktop_owned.is_some() {
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::WriteDesktopEntry,
|
||||||
|
message: "writing desktop entry".to_owned(),
|
||||||
|
});
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::ExtractIcon,
|
||||||
|
message: "extracting icon".to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::StagePayload,
|
||||||
|
message: "staging payload".to_owned(),
|
||||||
|
});
|
||||||
let install_outcome = execute_install(&InstallRequest {
|
let install_outcome = execute_install(&InstallRequest {
|
||||||
staging_root: &install_home.join(".local/share/aim/staging"),
|
staging_root: &install_home.join(".local/share/aim/staging"),
|
||||||
final_payload_path: &payload_path,
|
final_payload_path: &payload_path,
|
||||||
|
|
@ -235,6 +280,18 @@ pub fn install_app(
|
||||||
})
|
})
|
||||||
.map_err(InstallAppError::Install)?;
|
.map_err(InstallAppError::Install)?;
|
||||||
|
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::RefreshIntegration,
|
||||||
|
message: "refreshing desktop integration".to_owned(),
|
||||||
|
});
|
||||||
|
if !install_outcome.warnings.is_empty() {
|
||||||
|
for warning in &install_outcome.warnings {
|
||||||
|
reporter.report(&OperationEvent::Warning {
|
||||||
|
message: warning.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
record.install = Some(InstallMetadata {
|
record.install = Some(InstallMetadata {
|
||||||
scope: policy.scope,
|
scope: policy.scope,
|
||||||
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
|
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
|
||||||
|
|
@ -248,7 +305,7 @@ pub fn install_app(
|
||||||
.map(|path| path.display().to_string()),
|
.map(|path| path.display().to_string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(InstalledApp {
|
let installed = InstalledApp {
|
||||||
record,
|
record,
|
||||||
selected_artifact: plan.selected_artifact.clone(),
|
selected_artifact: plan.selected_artifact.clone(),
|
||||||
source: plan.resolution.source.clone(),
|
source: plan.resolution.source.clone(),
|
||||||
|
|
@ -256,7 +313,13 @@ pub fn install_app(
|
||||||
integration_mode: policy.integration_mode,
|
integration_mode: policy.integration_mode,
|
||||||
install_outcome,
|
install_outcome,
|
||||||
warnings: policy.warnings,
|
warnings: policy.warnings,
|
||||||
})
|
};
|
||||||
|
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!("installed {}", installed.record.stable_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(installed)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
|
@ -287,21 +350,48 @@ pub enum InstallAppError {
|
||||||
Materialize(MaterializeAddRecordError),
|
Materialize(MaterializeAddRecordError),
|
||||||
Policy(String),
|
Policy(String),
|
||||||
Download(reqwest::Error),
|
Download(reqwest::Error),
|
||||||
|
DownloadIo(std::io::Error),
|
||||||
HostProbe(std::io::Error),
|
HostProbe(std::io::Error),
|
||||||
Install(crate::integration::install::PayloadInstallError),
|
Install(crate::integration::install::PayloadInstallError),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_artifact_bytes(url: &str) -> Result<Vec<u8>, InstallAppError> {
|
fn download_artifact_bytes_with_reporter(
|
||||||
|
url: &str,
|
||||||
|
reporter: &mut impl ProgressReporter,
|
||||||
|
) -> Result<Vec<u8>, InstallAppError> {
|
||||||
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
|
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
|
||||||
return Ok(b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec());
|
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec();
|
||||||
|
reporter.report(&OperationEvent::Progress {
|
||||||
|
current: bytes.len() as u64,
|
||||||
|
total: Some(bytes.len() as u64),
|
||||||
|
});
|
||||||
|
return Ok(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
|
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
|
||||||
let response = response
|
let response = response
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.map_err(InstallAppError::Download)?;
|
.map_err(InstallAppError::Download)?;
|
||||||
let bytes = response.bytes().map_err(InstallAppError::Download)?;
|
let total = response.content_length();
|
||||||
Ok(bytes.to_vec())
|
let mut response = response;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 16 * 1024];
|
||||||
|
let mut current = 0_u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let read = response
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(InstallAppError::DownloadIo)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.extend_from_slice(&buffer[..read]);
|
||||||
|
current += read as u64;
|
||||||
|
reporter.report(&OperationEvent::Progress { current, total });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {
|
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod add;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
pub mod progress;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod remove;
|
pub mod remove;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
|
|
|
||||||
67
crates/aim-core/src/app/progress.rs
Normal file
67
crates/aim-core/src/app/progress.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OperationKind {
|
||||||
|
Add,
|
||||||
|
UpdateBatch,
|
||||||
|
UpdateItem,
|
||||||
|
Remove,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OperationStage {
|
||||||
|
ResolveQuery,
|
||||||
|
DiscoverRelease,
|
||||||
|
SelectArtifact,
|
||||||
|
DownloadArtifact,
|
||||||
|
StagePayload,
|
||||||
|
WriteDesktopEntry,
|
||||||
|
ExtractIcon,
|
||||||
|
RefreshIntegration,
|
||||||
|
SaveRegistry,
|
||||||
|
Finalize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OperationEvent {
|
||||||
|
Started {
|
||||||
|
kind: OperationKind,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
StageChanged {
|
||||||
|
stage: OperationStage,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Progress {
|
||||||
|
current: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
},
|
||||||
|
Warning {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Finished {
|
||||||
|
summary: String,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
stage: OperationStage,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProgressReporter {
|
||||||
|
fn report(&mut self, event: &OperationEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F> ProgressReporter for F
|
||||||
|
where
|
||||||
|
F: FnMut(&OperationEvent),
|
||||||
|
{
|
||||||
|
fn report(&mut self, event: &OperationEvent) {
|
||||||
|
self(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct NoopReporter;
|
||||||
|
|
||||||
|
impl ProgressReporter for NoopReporter {
|
||||||
|
fn report(&mut self, _event: &OperationEvent) {}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,9 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
|
use crate::app::progress::{
|
||||||
|
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||||
|
};
|
||||||
use crate::domain::app::{AppRecord, InstallScope};
|
use crate::domain::app::{AppRecord, InstallScope};
|
||||||
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
|
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
|
||||||
use crate::integration::refresh::refresh_integration;
|
use crate::integration::refresh::refresh_integration;
|
||||||
|
|
@ -66,8 +69,30 @@ pub fn remove_registered_app(
|
||||||
apps: &[AppRecord],
|
apps: &[AppRecord],
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
) -> Result<RemovalResult, RemoveRegisteredAppError> {
|
) -> Result<RemovalResult, RemoveRegisteredAppError> {
|
||||||
|
let mut reporter = NoopReporter;
|
||||||
|
remove_registered_app_with_reporter(query, apps, install_home, &mut reporter)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_registered_app_with_reporter(
|
||||||
|
query: &str,
|
||||||
|
apps: &[AppRecord],
|
||||||
|
install_home: &Path,
|
||||||
|
reporter: &mut impl ProgressReporter,
|
||||||
|
) -> Result<RemovalResult, RemoveRegisteredAppError> {
|
||||||
|
reporter.report(&OperationEvent::Started {
|
||||||
|
kind: OperationKind::Remove,
|
||||||
|
label: query.to_owned(),
|
||||||
|
});
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
message: format!("resolving {query}"),
|
||||||
|
});
|
||||||
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
|
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
|
||||||
let plan = build_removal_plan(app, install_home);
|
let plan = build_removal_plan(app, install_home);
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::StagePayload,
|
||||||
|
message: "removing managed artifacts".to_owned(),
|
||||||
|
});
|
||||||
let warnings = delete_artifacts(&plan)?;
|
let warnings = delete_artifacts(&plan)?;
|
||||||
let remaining_apps = apps
|
let remaining_apps = apps
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -75,11 +100,21 @@ pub fn remove_registered_app(
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(RemovalResult {
|
let result = RemovalResult {
|
||||||
removed: plan,
|
removed: plan,
|
||||||
remaining_apps,
|
remaining_apps,
|
||||||
warnings,
|
warnings,
|
||||||
})
|
};
|
||||||
|
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::Finalize,
|
||||||
|
message: format!("removed {}", result.removed.stable_id),
|
||||||
|
});
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!("removed {}", result.removed.stable_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::app::add::{build_add_plan, install_app};
|
use crate::app::add::{build_add_plan, install_app_with_reporter};
|
||||||
|
use crate::app::progress::{
|
||||||
|
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||||
|
};
|
||||||
use crate::domain::app::{AppRecord, InstallScope};
|
use crate::domain::app::{AppRecord, InstallScope};
|
||||||
use crate::domain::update::{
|
use crate::domain::update::{
|
||||||
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
|
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
|
||||||
|
|
@ -17,11 +20,28 @@ pub fn execute_updates(
|
||||||
apps: &[AppRecord],
|
apps: &[AppRecord],
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||||
|
let mut reporter = NoopReporter;
|
||||||
|
execute_updates_with_reporter(apps, install_home, &mut reporter)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_updates_with_reporter(
|
||||||
|
apps: &[AppRecord],
|
||||||
|
install_home: &Path,
|
||||||
|
reporter: &mut impl ProgressReporter,
|
||||||
|
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||||
|
reporter.report(&OperationEvent::Started {
|
||||||
|
kind: OperationKind::UpdateBatch,
|
||||||
|
label: format!("{} apps", apps.len()),
|
||||||
|
});
|
||||||
let mut updated_apps = Vec::with_capacity(apps.len());
|
let mut updated_apps = Vec::with_capacity(apps.len());
|
||||||
let mut items = Vec::with_capacity(apps.len());
|
let mut items = Vec::with_capacity(apps.len());
|
||||||
|
|
||||||
for app in apps {
|
for app in apps {
|
||||||
match execute_update(app, install_home) {
|
reporter.report(&OperationEvent::Started {
|
||||||
|
kind: OperationKind::UpdateItem,
|
||||||
|
label: app.stable_id.clone(),
|
||||||
|
});
|
||||||
|
match execute_update(app, install_home, reporter) {
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
let warnings = updated
|
let warnings = updated
|
||||||
.warnings
|
.warnings
|
||||||
|
|
@ -39,6 +59,9 @@ pub fn execute_updates(
|
||||||
status: UpdateExecutionStatus::Updated,
|
status: UpdateExecutionStatus::Updated,
|
||||||
});
|
});
|
||||||
updated_apps.push(record);
|
updated_apps.push(record);
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!("updated {}", app.stable_id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
items.push(ExecutedUpdate {
|
items.push(ExecutedUpdate {
|
||||||
|
|
@ -54,10 +77,20 @@ pub fn execute_updates(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UpdateExecutionResult {
|
let result = UpdateExecutionResult {
|
||||||
apps: updated_apps,
|
apps: updated_apps,
|
||||||
items,
|
items,
|
||||||
})
|
};
|
||||||
|
|
||||||
|
reporter.report(&OperationEvent::Finished {
|
||||||
|
summary: format!(
|
||||||
|
"updated {}, failed {}",
|
||||||
|
result.updated_count(),
|
||||||
|
result.failed_count()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
|
|
@ -107,18 +140,44 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
|
||||||
fn execute_update(
|
fn execute_update(
|
||||||
app: &AppRecord,
|
app: &AppRecord,
|
||||||
install_home: &Path,
|
install_home: &Path,
|
||||||
|
reporter: &mut impl ProgressReporter,
|
||||||
) -> Result<crate::app::add::InstalledApp, String> {
|
) -> Result<crate::app::add::InstalledApp, String> {
|
||||||
let query = update_query(app).ok_or_else(|| "missing install source".to_owned())?;
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
message: format!("resolving {}", app.stable_id),
|
||||||
|
});
|
||||||
|
let query = update_query(app).ok_or_else(|| {
|
||||||
|
let reason = "missing install source".to_owned();
|
||||||
|
reporter.report(&OperationEvent::Failed {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
reason: reason.clone(),
|
||||||
|
});
|
||||||
|
reason
|
||||||
|
})?;
|
||||||
let requested_scope = app
|
let requested_scope = app
|
||||||
.install
|
.install
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|install| install.scope)
|
.map(|install| install.scope)
|
||||||
.unwrap_or(InstallScope::User);
|
.unwrap_or(InstallScope::User);
|
||||||
let plan = build_add_plan(&query)
|
let plan = build_add_plan(&query).map_err(|error| {
|
||||||
.map_err(|error| format!("failed to build update plan: {error:?}"))?;
|
let reason = format!("failed to build update plan: {error:?}");
|
||||||
|
reporter.report(&OperationEvent::Failed {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
reason: reason.clone(),
|
||||||
|
});
|
||||||
|
reason
|
||||||
|
})?;
|
||||||
|
|
||||||
install_app(&query, &plan, install_home, requested_scope)
|
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter).map_err(
|
||||||
.map_err(|error| format!("failed to install update: {error:?}"))
|
|error| {
|
||||||
|
let reason = format!("failed to install update: {error:?}");
|
||||||
|
reporter.report(&OperationEvent::Failed {
|
||||||
|
stage: OperationStage::Finalize,
|
||||||
|
reason: reason.clone(),
|
||||||
|
});
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_query(app: &AppRecord) -> Option<String> {
|
fn update_query(app: &AppRecord) -> Option<String> {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
use aim_core::app::add::{build_add_plan_with, install_app_with_reporter};
|
||||||
|
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||||
|
use aim_core::domain::app::InstallScope;
|
||||||
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||||
use aim_core::platform::DesktopHelpers;
|
use aim_core::platform::DesktopHelpers;
|
||||||
|
use aim_core::source::github::FixtureGitHubTransport;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
@ -125,3 +129,57 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
|
||||||
.starts_with(b"\x89PNG\r\n\x1a\n")
|
.starts_with(b"\x89PNG\r\n\x1a\n")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_app_reports_operation_stages_in_order() {
|
||||||
|
let root = tempdir().unwrap();
|
||||||
|
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
|
||||||
|
let mut events: Vec<OperationEvent> = Vec::new();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||||
|
|
||||||
|
let installed = install_app_with_reporter(
|
||||||
|
"sharkdp/bat",
|
||||||
|
&plan,
|
||||||
|
root.path(),
|
||||||
|
InstallScope::User,
|
||||||
|
&mut reporter,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(installed.record.stable_id, "sharkdp-bat");
|
||||||
|
assert!(events.contains(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::DownloadArtifact,
|
||||||
|
message: "downloading artifact".to_owned(),
|
||||||
|
}));
|
||||||
|
assert!(events.contains(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::StagePayload,
|
||||||
|
message: "staging payload".to_owned(),
|
||||||
|
}));
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::Progress {
|
||||||
|
current,
|
||||||
|
total: Some(total)
|
||||||
|
} if *current == *total
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(events.contains(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::WriteDesktopEntry,
|
||||||
|
message: "writing desktop entry".to_owned(),
|
||||||
|
}));
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::RefreshIntegration,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use aim_core::app::list::build_list_rows;
|
use aim_core::app::list::build_list_rows;
|
||||||
use aim_core::app::remove::{build_removal_plan, resolve_registered_app};
|
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||||
|
use aim_core::app::remove::{
|
||||||
|
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
|
||||||
|
};
|
||||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_flow_rejects_unknown_app_names() {
|
fn remove_flow_rejects_unknown_app_names() {
|
||||||
|
|
@ -125,3 +129,55 @@ fn removal_plan_falls_back_to_derived_managed_user_paths() {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_flow_reports_resolution_and_cleanup_events() {
|
||||||
|
let install_home = tempdir().unwrap();
|
||||||
|
let app = AppRecord {
|
||||||
|
stable_id: "bat".to_owned(),
|
||||||
|
display_name: "Bat".to_owned(),
|
||||||
|
source_input: None,
|
||||||
|
source: None,
|
||||||
|
installed_version: None,
|
||||||
|
update_strategy: None,
|
||||||
|
metadata: Vec::new(),
|
||||||
|
install: Some(InstallMetadata {
|
||||||
|
scope: InstallScope::User,
|
||||||
|
payload_path: Some(
|
||||||
|
install_home
|
||||||
|
.path()
|
||||||
|
.join(".local/lib/aim/appimages/bat.AppImage")
|
||||||
|
.display()
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
desktop_entry_path: None,
|
||||||
|
icon_path: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let mut events: Vec<OperationEvent> = Vec::new();
|
||||||
|
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||||
|
|
||||||
|
let result =
|
||||||
|
remove_registered_app_with_reporter("bat", &[app], install_home.path(), &mut reporter)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.removed.stable_id, "bat");
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::Finalize,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use aim_core::app::update::{build_update_plan, execute_updates};
|
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||||
|
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
|
||||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
@ -88,3 +89,52 @@ fn failed_update_keeps_previous_app_record() {
|
||||||
assert_eq!(result.updated_count(), 0);
|
assert_eq!(result.updated_count(), 0);
|
||||||
assert_eq!(result.failed_count(), 1);
|
assert_eq!(result.failed_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_execution_reports_per_app_lifecycle_events() {
|
||||||
|
let install_home = tempdir().unwrap();
|
||||||
|
let app = AppRecord {
|
||||||
|
stable_id: "legacy-bat".to_owned(),
|
||||||
|
display_name: "Legacy Bat".to_owned(),
|
||||||
|
source_input: None,
|
||||||
|
source: None,
|
||||||
|
installed_version: Some("0.9.0".to_owned()),
|
||||||
|
update_strategy: None,
|
||||||
|
metadata: Vec::new(),
|
||||||
|
install: Some(InstallMetadata {
|
||||||
|
scope: InstallScope::User,
|
||||||
|
payload_path: None,
|
||||||
|
desktop_entry_path: None,
|
||||||
|
icon_path: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let mut events: Vec<OperationEvent> = Vec::new();
|
||||||
|
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||||
|
|
||||||
|
let result = execute_updates_with_reporter(
|
||||||
|
std::slice::from_ref(&app),
|
||||||
|
install_home.path(),
|
||||||
|
&mut reporter,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.failed_count(), 1);
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
assert!(events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
OperationEvent::Failed {
|
||||||
|
stage: OperationStage::ResolveQuery,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue