From c63b2917da5090497305d8f6317c946c4b24a957 Mon Sep 17 00:00:00 2001 From: stoorps Date: Fri, 20 Mar 2026 17:47:32 +0000 Subject: [PATCH] feat: redesign cli ux progress --- Cargo.lock | 51 +++++- Cargo.toml | 2 + README.md | 15 +- crates/aim-cli/Cargo.toml | 2 + crates/aim-cli/src/lib.rs | 48 +++++- crates/aim-cli/src/main.rs | 3 +- crates/aim-cli/src/ui/mod.rs | 2 + crates/aim-cli/src/ui/progress.rs | 154 +++++++++++++++++++ crates/aim-cli/src/ui/prompt.rs | 12 +- crates/aim-cli/src/ui/render.rs | 137 +++++++++++------ crates/aim-cli/src/ui/theme.rs | 22 +++ crates/aim-cli/tests/end_to_end_cli.rs | 139 +++++++++++++++-- crates/aim-cli/tests/ui_summary.rs | 50 +++++- crates/aim-core/src/app/add.rs | 104 ++++++++++++- crates/aim-core/src/app/mod.rs | 1 + crates/aim-core/src/app/progress.rs | 67 ++++++++ crates/aim-core/src/app/remove.rs | 39 ++++- crates/aim-core/src/app/update.rs | 77 ++++++++-- crates/aim-core/tests/install_integration.rs | 58 +++++++ crates/aim-core/tests/remove_flow.rs | 58 ++++++- crates/aim-core/tests/update_planning.rs | 52 ++++++- 21 files changed, 994 insertions(+), 99 deletions(-) create mode 100644 crates/aim-cli/src/ui/progress.rs create mode 100644 crates/aim-cli/src/ui/theme.rs create mode 100644 crates/aim-core/src/app/progress.rs diff --git a/Cargo.lock b/Cargo.lock index 7c88642..1b40bbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,9 @@ dependencies = [ "aim-core", "assert_cmd", "clap", + "console 0.16.3", "dialoguer", + "indicatif", "libc", "predicates", "tempfile", @@ -221,6 +223,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "console" version = "0.16.3" @@ -239,7 +254,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console", + "console 0.16.3", "shell-words", "tempfile", "zeroize", @@ -650,6 +665,19 @@ dependencies = [ "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]] name = "ipnet" version = "2.12.0" @@ -756,6 +784,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.4" @@ -786,6 +820,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1705,6 +1745,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 4f33171..288f947 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ version = "0.1.0" clap = { version = "4.5.32", features = ["derive"] } assert_cmd = "2.0.16" dialoguer = "0.12.0" +console = "0.16.3" +indicatif = "0.17.11" libc = "0.2.171" reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.219", features = ["derive"] } diff --git a/README.md b/README.md index 5f792ab..6db61ad 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,14 @@ By default `aim` auto-detects whether to use user or system scope. Override that ## Current Flow Shape -- `aim ` installs unambiguous apps, persists them into the registry after successful install, and renders review prompts when tracking needs confirmation -- bare `aim` and `aim update` build a review-first update plan -- `aim list` renders registered applications -- `aim remove ` resolves a registered application name before removal +- `aim ` 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` prints an `Update Review` without mutating the registry +- `aim update` executes the pending updates, streams live status on stderr, then prints an `Update Summary` +- `aim list` renders either `Installed Apps` or `No installed apps yet` +- `aim remove ` 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` diff --git a/crates/aim-cli/Cargo.toml b/crates/aim-cli/Cargo.toml index 87dc569..f4b23fc 100644 --- a/crates/aim-cli/Cargo.toml +++ b/crates/aim-cli/Cargo.toml @@ -14,6 +14,8 @@ path = "src/main.rs" [dependencies] clap.workspace = true dialoguer.workspace = true +console.workspace = true +indicatif.workspace = true libc.workspace = true aim-core = { path = "../aim-core" } diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 90da746..6312054 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -5,11 +5,12 @@ use std::env; use std::path::{Path, PathBuf}; 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::remove::{RemovalResult, remove_registered_app}; -use aim_core::app::update::{build_update_plan, execute_updates}; +use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter}; +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::update::{UpdateExecutionResult, UpdatePlan}; use aim_core::registry::model::Registry; @@ -22,6 +23,14 @@ pub fn parse() -> Cli { } pub fn dispatch(cli: Cli) -> Result { + let mut reporter = NoopReporter; + dispatch_with_reporter(cli, &mut reporter) +} + +pub fn dispatch_with_reporter( + cli: Cli, + reporter: &mut impl ProgressReporter, +) -> Result { let registry_path = registry_path(); let install_home = install_home(®istry_path); let store = RegistryStore::new(registry_path); @@ -36,21 +45,40 @@ pub fn dispatch(cli: Cli) -> Result { return match command { cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))), 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(); + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SaveRegistry, + message: "saving registry".to_owned(), + }); store.save(&Registry { version: registry.version, apps: remaining_apps, })?; + reporter.report(&OperationEvent::Finished { + summary: format!("removed {}", removal.removed.stable_id), + }); Ok(DispatchResult::Removed(Box::new(removal))) } 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(); + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SaveRegistry, + message: "saving registry".to_owned(), + }); store.save(&Registry { version: registry.version, apps: updated_apps, })?; + reporter.report(&OperationEvent::Finished { + summary: format!( + "updated {}, failed {}", + updates.updated_count(), + updates.failed_count() + ), + }); Ok(DispatchResult::Updated(Box::new(updates))) } }; @@ -68,13 +96,21 @@ pub fn dispatch(cli: Cli) -> Result { } } - 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(); upsert_app_record(&mut updated_apps, installed.record.clone()); + reporter.report(&OperationEvent::StageChanged { + stage: OperationStage::SaveRegistry, + message: "saving registry".to_owned(), + }); store.save(&Registry { version: registry.version, apps: updated_apps, })?; + reporter.report(&OperationEvent::Finished { + summary: format!("installed {}", installed.record.stable_id), + }); return Ok(DispatchResult::Added(Box::new(installed))); } diff --git a/crates/aim-cli/src/main.rs b/crates/aim-cli/src/main.rs index b17f3e1..414977b 100644 --- a/crates/aim-cli/src/main.rs +++ b/crates/aim-cli/src/main.rs @@ -1,6 +1,7 @@ fn main() { 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) => { let output = aim_cli::render(&result); if !output.is_empty() { diff --git a/crates/aim-cli/src/ui/mod.rs b/crates/aim-cli/src/ui/mod.rs index aca49d6..e22c24a 100644 --- a/crates/aim-cli/src/ui/mod.rs +++ b/crates/aim-cli/src/ui/mod.rs @@ -1,2 +1,4 @@ +pub mod progress; pub mod prompt; pub mod render; +pub mod theme; diff --git a/crates/aim-cli/src/ui/progress.rs b/crates/aim-cli/src/ui/progress.rs new file mode 100644 index 0000000..a5a5264 --- /dev/null +++ b/crates/aim-cli/src/ui/progress.rs @@ -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) -> 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 { + 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, + byte_total: Option, +} + +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) { + 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(), + } + } +} diff --git a/crates/aim-cli/src/ui/prompt.rs b/crates/aim-cli/src/ui/prompt.rs index 1dd53d8..c3edf41 100644 --- a/crates/aim-cli/src/ui/prompt.rs +++ b/crates/aim-cli/src/ui/prompt.rs @@ -3,24 +3,24 @@ use std::io::IsTerminal; use aim_core::app::add::{AddPlan, prefer_latest_tracking}; use aim_core::app::interaction::{InteractionKind, InteractionRequest}; -use dialoguer::{Select, theme::ColorfulTheme}; +use dialoguer::Select; const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE"; pub fn render_interaction(request: &InteractionRequest) -> String { match &request.kind { InteractionKind::SelectRegisteredApp { query, matches } => format!( - "multiple installed apps match '{query}': {}", + "Choose the installed app matching '{query}': {}", matches.join(", ") ), InteractionKind::ChooseTrackingPreference { requested_version, latest_version, } => format!( - "tracking preference required: requested {requested_version}, latest available {latest_version}", + "Choose update tracking: requested {requested_version}, latest available {latest_version}", ), 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!("Track the latest release after install ({latest_version})"), ]; - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Choose update tracking behavior") + let selection = Select::with_theme(&crate::ui::theme::dialog_theme()) + .with_prompt("Choose update tracking") .items(options) .default(1) .interact()?; diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index 2457055..d57028b 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -4,7 +4,13 @@ use aim_core::domain::update::UpdateExecutionStatus; use crate::DispatchResult; 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 { @@ -13,9 +19,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String { DispatchResult::List(rows) => render_list(rows), DispatchResult::PendingAdd(plan) => render_pending_add(plan), DispatchResult::Removed(removed) => render_removed_app(removed), - DispatchResult::UpdatePlan(plan) => { - render_update_summary(plan.items.len(), plan.items.len(), 0) - } + DispatchResult::UpdatePlan(plan) => render_update_plan(plan), DispatchResult::Updated(result) => render_updated_apps(result), DispatchResult::Noop => String::new(), } @@ -31,46 +35,68 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String { .warnings .iter() .chain(added.install_outcome.warnings.iter()) - .map(|warning| format!("warning: {warning}")) - .collect::>() - .join("\n"); + .map(|warning| format!("Warning: {warning}")) + .collect::>(); - let summary = format!( - "installing as {scope}\ninstalled app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]", - added.record.display_name, - added.record.stable_id, - added.source.kind.as_str(), - added.source.locator, - added.selected_artifact.url, - added.selected_artifact.selection_reason, - ); + let mut lines = vec![ + crate::ui::theme::heading("Installation Summary"), + format!( + "{} {} ({})", + crate::ui::theme::label("Application"), + added.record.display_name, + 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.locator, + ), + format!( + "{} {} [{}]", + crate::ui::theme::label("Selected artifact"), + added.selected_artifact.url, + added.selected_artifact.selection_reason, + ), + ]; - if warning_lines.is_empty() { - summary - } else { - format!("{summary}\n{warning_lines}") - } + lines.extend(warning_lines); + lines.join("\n") } fn render_pending_add(plan: &AddPlan) -> String { let prompts = crate::ui::prompt::render_interactions(&plan.interactions); - format!( - "resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}", - plan.resolution.source.kind.as_str(), - plan.resolution.source.locator, - plan.selected_artifact.url, - plan.selected_artifact.selection_reason, - ) + [ + 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: &[aim_core::app::list::ListRow]) -> String { 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 { - 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() } @@ -79,36 +105,38 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String let warning_lines = removed .warnings .iter() - .map(|warning| format!("warning: {warning}")) - .collect::>() - .join("\n"); - let summary = format!("removed: {}", removed.removed.display_name); - - if warning_lines.is_empty() { - summary - } else { - format!("{summary}\n{warning_lines}") - } + .map(|warning| format!("Warning: {warning}")) + .collect::>(); + let mut lines = vec![ + crate::ui::theme::heading("Removal Summary"), + format!( + "{} {}", + crate::ui::theme::label("Removed app"), + removed.removed.display_name, + ), + ]; + lines.extend(warning_lines); + lines.join("\n") } fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String { - let mut lines = vec![format!( - "updated apps: {}, failed: {}", - result.updated_count(), - result.failed_count() - )]; + 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: {} ({}) {} -> {}", + "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: {} ({}) {}", + "Failed: {} ({}) {}", item.display_name, item.stable_id, reason )), } @@ -116,3 +144,16 @@ fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) 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") +} diff --git a/crates/aim-cli/src/ui/theme.rs b/crates/aim-cli/src/ui/theme.rs new file mode 100644 index 0000000..78d7d7e --- /dev/null +++ b/crates/aim-cli/src/ui/theme.rs @@ -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}") +} diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index eba472b..4c58946 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -10,12 +10,15 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_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("aim").unwrap(); cmd.arg("list") + .env("AIM_REGISTRY_PATH", ®istry_path) .assert() .success() - .stdout(contains("installed")); + .stdout(contains("No installed apps yet")); } #[test] @@ -53,7 +56,8 @@ fn remove_command_removes_registered_app_from_registry_file() { .env("AIM_REGISTRY_PATH", ®istry_path) .assert() .success() - .stdout(contains("removed: Bat")); + .stdout(contains("Removal Summary")) + .stdout(contains("Removed app: Bat")); let contents = std::fs::read_to_string(®istry_path).unwrap(); assert!(!contents.contains("stable_id = \"bat\"")); @@ -86,7 +90,8 @@ fn remove_command_uninstalls_managed_files() { .env("AIM_REGISTRY_PATH", ®istry_path) .assert() .success() - .stdout(contains("removed: bat")); + .stdout(contains("Removal Summary")) + .stdout(contains("Removed app: bat")); assert!(!payload_path.exists()); assert!(!desktop_path.exists()); @@ -104,8 +109,8 @@ fn query_command_registers_unambiguous_app_in_registry_file() { .env(FIXTURE_MODE_ENV, "1") .assert() .success() - .stdout(contains("installing as user")) - .stdout(contains("installed app: bat (sharkdp-bat)")); + .stdout(contains("Installation Summary")) + .stdout(contains("Application: bat (sharkdp-bat)")); let contents = std::fs::read_to_string(®istry_path).unwrap(); 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") .assert() .success() - .stdout(contains("tracking preference required")) + .stdout(contains("Choose update tracking")) .stdout(contains("v0.0.11")) .stdout(contains("v0.0.12")); @@ -142,8 +147,9 @@ fn old_release_query_can_track_latest_and_register_app() { .env("AIM_TRACKING_PREFERENCE", "latest") .assert() .success() - .stdout(contains("installing as user")) - .stdout(contains("installed app: t3code (pingdotgg-t3code)")); + .stdout(contains("Installation Summary")) + .stdout(contains("Application: t3code (pingdotgg-t3code)")) + .stdout(contains("Install scope: user")); let contents = std::fs::read_to_string(®istry_path).unwrap(); assert!(contents.contains("stable_id = \"pingdotgg-t3code\"")); @@ -161,8 +167,80 @@ fn cli_add_installs_and_renders_resolved_mode() { .env(FIXTURE_MODE_ENV, "1") .assert() .success() - .stdout(contains("installing as user")) - .stdout(contains("installed app:")); + .stdout(contains("Installation Summary")) + .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] @@ -180,7 +258,8 @@ fn system_request_on_immutable_host_falls_back_to_user_install() { .env(FIXTURE_MODE_ENV, "1") .assert() .success() - .stdout(contains("installing as user")) + .stdout(contains("Installation Summary")) + .stdout(contains("Install scope: user")) .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!(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")); +} diff --git a/crates/aim-cli/tests/ui_summary.rs b/crates/aim-cli/tests/ui_summary.rs index d59a64c..91585d5 100644 --- a/crates/aim-cli/tests/ui_summary.rs +++ b/crates/aim-cli/tests/ui_summary.rs @@ -1,6 +1,8 @@ +use aim_cli::DispatchResult; 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::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan}; #[test] fn update_summary_mentions_selected_count() { @@ -8,6 +10,37 @@ fn update_summary_mentions_selected_count() { 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] fn tracking_prompt_mentions_requested_and_latest_versions() { 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.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")); +} diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 9a02fbc..6ae5b87 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -1,9 +1,13 @@ use std::env; +use std::io::Read; use std::path::{Path, PathBuf}; use crate::adapters::traits::AdapterResolution; use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity}; 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::scope::{ScopeOverride, resolve_install_scope_with_default}; use crate::domain::app::{AppRecord, InstallMetadata, InstallScope}; @@ -187,6 +191,27 @@ pub fn install_app( install_home: &Path, requested_scope: InstallScope, ) -> Result { + 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 { + reporter.report(&OperationEvent::Started { + kind: OperationKind::Add, + label: source_input.to_owned(), + }); let mut record = materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?; let (family, capabilities) = @@ -209,7 +234,12 @@ pub fn install_app( install_home, &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 desktop_owned = match policy.integration_mode { 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 { staging_root: &install_home.join(".local/share/aim/staging"), final_payload_path: &payload_path, @@ -235,6 +280,18 @@ pub fn install_app( }) .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 { scope: policy.scope, 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()), }); - Ok(InstalledApp { + let installed = InstalledApp { record, selected_artifact: plan.selected_artifact.clone(), source: plan.resolution.source.clone(), @@ -256,7 +313,13 @@ pub fn install_app( integration_mode: policy.integration_mode, install_outcome, warnings: policy.warnings, - }) + }; + + reporter.report(&OperationEvent::Finished { + summary: format!("installed {}", installed.record.stable_id), + }); + + Ok(installed) } #[derive(Debug, Eq, PartialEq)] @@ -287,21 +350,48 @@ pub enum InstallAppError { Materialize(MaterializeAddRecordError), Policy(String), Download(reqwest::Error), + DownloadIo(std::io::Error), HostProbe(std::io::Error), Install(crate::integration::install::PayloadInstallError), } -fn download_artifact_bytes(url: &str) -> Result, InstallAppError> { +fn download_artifact_bytes_with_reporter( + url: &str, + reporter: &mut impl ProgressReporter, +) -> Result, InstallAppError> { 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 = response .error_for_status() .map_err(InstallAppError::Download)?; - let bytes = response.bytes().map_err(InstallAppError::Download)?; - Ok(bytes.to_vec()) + let total = response.content_length(); + 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 { diff --git a/crates/aim-core/src/app/mod.rs b/crates/aim-core/src/app/mod.rs index 49521a1..e3ef15b 100644 --- a/crates/aim-core/src/app/mod.rs +++ b/crates/aim-core/src/app/mod.rs @@ -2,6 +2,7 @@ pub mod add; pub mod identity; pub mod interaction; pub mod list; +pub mod progress; pub mod query; pub mod remove; pub mod scope; diff --git a/crates/aim-core/src/app/progress.rs b/crates/aim-core/src/app/progress.rs new file mode 100644 index 0000000..6e3069f --- /dev/null +++ b/crates/aim-core/src/app/progress.rs @@ -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, + }, + Warning { + message: String, + }, + Finished { + summary: String, + }, + Failed { + stage: OperationStage, + reason: String, + }, +} + +pub trait ProgressReporter { + fn report(&mut self, event: &OperationEvent); +} + +impl 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) {} +} diff --git a/crates/aim-core/src/app/remove.rs b/crates/aim-core/src/app/remove.rs index 937bbec..5c77687 100644 --- a/crates/aim-core/src/app/remove.rs +++ b/crates/aim-core/src/app/remove.rs @@ -3,6 +3,9 @@ use std::io; use std::path::{Path, PathBuf}; use crate::app::interaction::{InteractionKind, InteractionRequest}; +use crate::app::progress::{ + NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter, +}; use crate::domain::app::{AppRecord, InstallScope}; use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path}; use crate::integration::refresh::refresh_integration; @@ -66,8 +69,30 @@ pub fn remove_registered_app( apps: &[AppRecord], install_home: &Path, ) -> Result { + 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 { + 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 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 remaining_apps = apps .iter() @@ -75,11 +100,21 @@ pub fn remove_registered_app( .cloned() .collect(); - Ok(RemovalResult { + let result = RemovalResult { removed: plan, remaining_apps, 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)] diff --git a/crates/aim-core/src/app/update.rs b/crates/aim-core/src/app/update.rs index 83ddbda..129fde1 100644 --- a/crates/aim-core/src/app/update.rs +++ b/crates/aim-core/src/app/update.rs @@ -1,6 +1,9 @@ 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::update::{ ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult, @@ -17,11 +20,28 @@ pub fn execute_updates( apps: &[AppRecord], install_home: &Path, ) -> Result { + 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 { + reporter.report(&OperationEvent::Started { + kind: OperationKind::UpdateBatch, + label: format!("{} apps", apps.len()), + }); let mut updated_apps = Vec::with_capacity(apps.len()); let mut items = Vec::with_capacity(apps.len()); 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) => { let warnings = updated .warnings @@ -39,6 +59,9 @@ pub fn execute_updates( status: UpdateExecutionStatus::Updated, }); updated_apps.push(record); + reporter.report(&OperationEvent::Finished { + summary: format!("updated {}", app.stable_id), + }); } Err(reason) => { items.push(ExecutedUpdate { @@ -54,10 +77,20 @@ pub fn execute_updates( } } - Ok(UpdateExecutionResult { + let result = UpdateExecutionResult { apps: updated_apps, items, - }) + }; + + reporter.report(&OperationEvent::Finished { + summary: format!( + "updated {}, failed {}", + result.updated_count(), + result.failed_count() + ), + }); + + Ok(result) } #[derive(Debug, Eq, PartialEq)] @@ -107,18 +140,44 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate { fn execute_update( app: &AppRecord, install_home: &Path, + reporter: &mut impl ProgressReporter, ) -> Result { - 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 .install .as_ref() .map(|install| install.scope) .unwrap_or(InstallScope::User); - let plan = build_add_plan(&query) - .map_err(|error| format!("failed to build update plan: {error:?}"))?; + let plan = build_add_plan(&query).map_err(|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) - .map_err(|error| format!("failed to install update: {error:?}")) + install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter).map_err( + |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 { diff --git a/crates/aim-core/tests/install_integration.rs b/crates/aim-core/tests/install_integration.rs index 2537ea3..b2933cf 100644 --- a/crates/aim-core/tests/install_integration.rs +++ b/crates/aim-core/tests/install_integration.rs @@ -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::platform::DesktopHelpers; +use aim_core::source::github::FixtureGitHubTransport; use std::fs; use std::os::unix::fs::PermissionsExt; 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") ); } + +#[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 = 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, + .. + } + ) + })); +} diff --git a/crates/aim-core/tests/remove_flow.rs b/crates/aim-core/tests/remove_flow.rs index 1da3fed..821c978 100644 --- a/crates/aim-core/tests/remove_flow.rs +++ b/crates/aim-core/tests/remove_flow.rs @@ -1,8 +1,12 @@ use aim_core::app::interaction::{InteractionKind, InteractionRequest}; 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 std::path::Path; +use tempfile::tempdir; #[test] 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 = 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, + .. + } + ) + })); +} diff --git a/crates/aim-core/tests/update_planning.rs b/crates/aim-core/tests/update_planning.rs index f3c7bae..e588713 100644 --- a/crates/aim-core/tests/update_planning.rs +++ b/crates/aim-core/tests/update_planning.rs @@ -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::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy}; use tempfile::tempdir; @@ -88,3 +89,52 @@ fn failed_update_keeps_previous_app_record() { assert_eq!(result.updated_count(), 0); 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 = 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, + .. + } + ) + })); +}