feat(cli): enhance install and removal UX with progress visibility and theming
- Introduced visible progress stages during installation, including source resolution and artifact selection. - Improved separation between live transcript output and final summaries, ensuring clarity. - Removed redundant recap text from installation summaries. - Centralized terminal styling using a configurable theme system, allowing for warm defaults and user overrides. - Added support for hex colors and named colors in the configuration. - Updated tests to verify new behaviors and configurations.
This commit is contained in:
parent
c63b2917da
commit
9d8ec1e4fd
17 changed files with 1277 additions and 74 deletions
|
|
@ -17,6 +17,8 @@ dialoguer.workspace = true
|
|||
console.workspace = true
|
||||
indicatif.workspace = true
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
aim-core = { path = "../aim-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
165
crates/aim-cli/src/cli/config.rs
Normal file
165
crates/aim-cli/src/cli/config.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct LoadedConfig {
|
||||
pub config: AppConfig,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct AppConfig {
|
||||
pub theme: ThemeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq)]
|
||||
pub struct ThemeConfig {
|
||||
pub heading: Option<String>,
|
||||
pub accent: Option<String>,
|
||||
pub muted: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub bullet: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub progress_spinner: Option<String>,
|
||||
pub progress_bar: Option<String>,
|
||||
pub progress_bar_unfilled: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct FileConfig {
|
||||
#[serde(default)]
|
||||
theme: FileThemeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct FileThemeConfig {
|
||||
heading: Option<String>,
|
||||
accent: Option<String>,
|
||||
muted: Option<String>,
|
||||
label: Option<String>,
|
||||
bullet: Option<String>,
|
||||
success: Option<String>,
|
||||
warning: Option<String>,
|
||||
error: Option<String>,
|
||||
progress_spinner: Option<String>,
|
||||
progress_bar: Option<String>,
|
||||
progress_bar_unfilled: Option<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> LoadedConfig {
|
||||
let system_path = Some(PathBuf::from("/etc/aim/config.toml"));
|
||||
let user_path = env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join(".config/aim/config.toml"));
|
||||
Self::load_from_paths(system_path.as_deref(), user_path.as_deref())
|
||||
}
|
||||
|
||||
pub fn load_from_paths(system_path: Option<&Path>, user_path: Option<&Path>) -> LoadedConfig {
|
||||
let mut loaded = LoadedConfig::default();
|
||||
|
||||
if let Some(path) = system_path {
|
||||
merge_file(path, &mut loaded);
|
||||
}
|
||||
|
||||
if let Some(path) = user_path {
|
||||
merge_file(path, &mut loaded);
|
||||
}
|
||||
|
||||
loaded
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_file(path: &Path, loaded: &mut LoadedConfig) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
loaded
|
||||
.warnings
|
||||
.push(format!("failed to read {}: {error}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: FileConfig = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
loaded
|
||||
.warnings
|
||||
.push(format!("failed to parse {}: {error}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
merge_theme(&mut loaded.config.theme, parsed.theme);
|
||||
}
|
||||
|
||||
fn merge_theme(theme: &mut ThemeConfig, update: FileThemeConfig) {
|
||||
merge_option(&mut theme.heading, update.heading);
|
||||
merge_option(&mut theme.accent, update.accent);
|
||||
merge_option(&mut theme.muted, update.muted);
|
||||
merge_option(&mut theme.label, update.label);
|
||||
merge_option(&mut theme.bullet, update.bullet);
|
||||
merge_option(&mut theme.success, update.success);
|
||||
merge_option(&mut theme.warning, update.warning);
|
||||
merge_option(&mut theme.error, update.error);
|
||||
merge_option(&mut theme.progress_spinner, update.progress_spinner);
|
||||
merge_option(&mut theme.progress_bar, update.progress_bar);
|
||||
merge_option(
|
||||
&mut theme.progress_bar_unfilled,
|
||||
update.progress_bar_unfilled,
|
||||
);
|
||||
}
|
||||
|
||||
fn merge_option(target: &mut Option<String>, update: Option<String>) {
|
||||
if let Some(value) = update {
|
||||
*target = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn user_config_overrides_system_theme_values() {
|
||||
let dir = tempdir().unwrap();
|
||||
let system_path = dir.path().join("system-config.toml");
|
||||
let user_path = dir.path().join("user-config.toml");
|
||||
|
||||
std::fs::write(
|
||||
&system_path,
|
||||
"[theme]\nheading = \"amber\"\naccent = \"teal\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&user_path, "[theme]\nheading = \"#d28b26\"\n").unwrap();
|
||||
|
||||
let loaded = AppConfig::load_from_paths(Some(&system_path), Some(&user_path));
|
||||
|
||||
assert_eq!(loaded.config.theme.heading.as_deref(), Some("#d28b26"));
|
||||
assert_eq!(loaded.config.theme.accent.as_deref(), Some("teal"));
|
||||
assert!(loaded.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_config_is_ignored_without_aborting_load() {
|
||||
let dir = tempdir().unwrap();
|
||||
let system_path = dir.path().join("system-config.toml");
|
||||
|
||||
std::fs::write(&system_path, "[theme\nheading = \"amber\"\n").unwrap();
|
||||
|
||||
let loaded = AppConfig::load_from_paths(Some(&system_path), None);
|
||||
|
||||
assert_eq!(loaded.config.theme.heading, None);
|
||||
assert!(!loaded.warnings.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
pub mod args;
|
||||
pub mod config;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use std::env;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aim_core::app::add::{
|
||||
AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope,
|
||||
AddPlan, InstalledApp, build_add_plan_with_reporter, install_app_with_reporter,
|
||||
resolve_requested_scope,
|
||||
};
|
||||
use aim_core::app::list::{ListRow, build_list_rows};
|
||||
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
|
||||
|
|
@ -86,7 +87,8 @@ pub fn dispatch_with_reporter(
|
|||
|
||||
if let Some(query) = cli.query {
|
||||
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
|
||||
let mut plan = build_add_plan(&query)?;
|
||||
let transport = aim_core::source::github::default_transport();
|
||||
let mut plan = build_add_plan_with_reporter(&query, transport.as_ref(), reporter)?;
|
||||
if !plan.interactions.is_empty() {
|
||||
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
||||
Some(resolved) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
fn main() {
|
||||
let loaded_config = aim_cli::cli::config::AppConfig::load();
|
||||
aim_cli::ui::theme::set_active_theme(aim_cli::ui::theme::resolve_theme(
|
||||
&loaded_config.config.theme,
|
||||
));
|
||||
for warning in loaded_config.warnings {
|
||||
eprintln!(
|
||||
"{}",
|
||||
aim_cli::ui::theme::warning_text(&format!("Config warning: {warning}"))
|
||||
);
|
||||
}
|
||||
|
||||
let cli = aim_cli::parse();
|
||||
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() {
|
||||
if reporter.emitted_output() {
|
||||
println!();
|
||||
}
|
||||
println!("{output}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,13 @@ pub fn spinner_style() -> ProgressStyle {
|
|||
}
|
||||
|
||||
pub fn byte_style() -> ProgressStyle {
|
||||
ProgressStyle::with_template("{bar:32.cyan/blue} {bytes}/{total_bytes} {msg}")
|
||||
.expect("byte progress template is valid")
|
||||
let theme = crate::ui::theme::current_theme();
|
||||
let filled = crate::ui::theme::indicatif_color_key(&theme.progress_bar);
|
||||
let unfilled = crate::ui::theme::indicatif_color_key(&theme.progress_bar_unfilled);
|
||||
ProgressStyle::with_template(&format!(
|
||||
"{{bar:32.{filled}/{unfilled}}} {{bytes}}/{{total_bytes}} {{msg}}"
|
||||
))
|
||||
.expect("byte progress template is valid")
|
||||
}
|
||||
|
||||
pub fn operation_label(kind: OperationKind) -> &'static str {
|
||||
|
|
@ -44,6 +49,27 @@ pub fn stage_label(stage: OperationStage) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
let mut value = bytes as f64;
|
||||
let mut unit_index = 0_usize;
|
||||
while value >= 1024.0 && unit_index < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit_index += 1;
|
||||
}
|
||||
|
||||
if unit_index == 0 {
|
||||
format!("{} {}", bytes, UNITS[unit_index])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit_index])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_completed_stage_line(token: &str) -> String {
|
||||
format!("{} {token}", crate::ui::theme::success("✓"))
|
||||
}
|
||||
|
||||
pub fn event_message(event: &OperationEvent) -> Option<String> {
|
||||
match event {
|
||||
OperationEvent::Started { kind, label } => {
|
||||
|
|
@ -70,6 +96,9 @@ pub struct TerminalProgressReporter {
|
|||
interactive: bool,
|
||||
progress_bar: Option<ProgressBar>,
|
||||
byte_total: Option<u64>,
|
||||
current_stage: Option<OperationStage>,
|
||||
last_progress_bytes: Option<u64>,
|
||||
emitted_output: bool,
|
||||
}
|
||||
|
||||
impl TerminalProgressReporter {
|
||||
|
|
@ -78,9 +107,16 @@ impl TerminalProgressReporter {
|
|||
interactive: std::io::stderr().is_terminal(),
|
||||
progress_bar: None,
|
||||
byte_total: None,
|
||||
current_stage: None,
|
||||
last_progress_bytes: None,
|
||||
emitted_output: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emitted_output(&self) -> bool {
|
||||
self.emitted_output
|
||||
}
|
||||
|
||||
fn clear_progress(&mut self) {
|
||||
if let Some(progress_bar) = self.progress_bar.take() {
|
||||
progress_bar.finish_and_clear();
|
||||
|
|
@ -88,23 +124,52 @@ impl TerminalProgressReporter {
|
|||
self.byte_total = None;
|
||||
}
|
||||
|
||||
fn emit_completed_stage_token(&mut self) {
|
||||
let token = match self.current_stage {
|
||||
Some(OperationStage::DownloadArtifact) => self
|
||||
.last_progress_bytes
|
||||
.map(|bytes| format!("{} Downloaded", format_bytes(bytes))),
|
||||
Some(OperationStage::StagePayload) => Some("Payload Staged".to_owned()),
|
||||
Some(OperationStage::WriteDesktopEntry) => Some("Desktop Entry Written".to_owned()),
|
||||
Some(OperationStage::ExtractIcon) => Some("Icon Extracted".to_owned()),
|
||||
Some(OperationStage::RefreshIntegration) => {
|
||||
Some("Desktop Integration Refreshed".to_owned())
|
||||
}
|
||||
Some(OperationStage::SaveRegistry) => Some("Registry Saved".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(token) = token {
|
||||
self.clear_progress();
|
||||
self.emitted_output = true;
|
||||
eprintln!("{}", format_completed_stage_line(&token));
|
||||
}
|
||||
}
|
||||
|
||||
fn show_spinner(&mut self, message: String) {
|
||||
if !self.interactive {
|
||||
eprintln!("{message}");
|
||||
self.emitted_output = true;
|
||||
eprintln!("{}", crate::ui::theme::accent(&message));
|
||||
return;
|
||||
}
|
||||
|
||||
if self.byte_total.is_some() {
|
||||
self.clear_progress();
|
||||
}
|
||||
|
||||
let progress_bar = self.progress_bar.get_or_insert_with(|| {
|
||||
let progress_bar = new_progress_bar(None);
|
||||
progress_bar.set_style(spinner_style());
|
||||
progress_bar.enable_steady_tick(Duration::from_millis(100));
|
||||
progress_bar
|
||||
});
|
||||
progress_bar.set_message(message);
|
||||
progress_bar.set_message(crate::ui::theme::accent(&message));
|
||||
self.byte_total = None;
|
||||
}
|
||||
|
||||
fn show_progress(&mut self, current: u64, total: Option<u64>) {
|
||||
self.last_progress_bytes = Some(current);
|
||||
|
||||
if !self.interactive {
|
||||
return;
|
||||
}
|
||||
|
|
@ -136,7 +201,14 @@ impl Default for TerminalProgressReporter {
|
|||
impl ProgressReporter for TerminalProgressReporter {
|
||||
fn report(&mut self, event: &OperationEvent) {
|
||||
match event {
|
||||
OperationEvent::Started { .. } | OperationEvent::StageChanged { .. } => {
|
||||
OperationEvent::Started { .. } => {
|
||||
if let Some(message) = event_message(event) {
|
||||
self.show_spinner(message);
|
||||
}
|
||||
}
|
||||
OperationEvent::StageChanged { stage, .. } => {
|
||||
self.emit_completed_stage_token();
|
||||
self.current_stage = Some(*stage);
|
||||
if let Some(message) = event_message(event) {
|
||||
self.show_spinner(message);
|
||||
}
|
||||
|
|
@ -145,10 +217,74 @@ impl ProgressReporter for TerminalProgressReporter {
|
|||
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
|
||||
self.clear_progress();
|
||||
if let Some(message) = event_message(event) {
|
||||
eprintln!("{message}");
|
||||
self.emitted_output = true;
|
||||
let styled = match event {
|
||||
OperationEvent::Warning { .. } => crate::ui::theme::warning_text(&message),
|
||||
OperationEvent::Failed { .. } => crate::ui::theme::error_text(&message),
|
||||
_ => message,
|
||||
};
|
||||
eprintln!("{styled}");
|
||||
}
|
||||
}
|
||||
OperationEvent::Finished { .. } => self.clear_progress(),
|
||||
OperationEvent::Finished { .. } => {
|
||||
self.emit_completed_stage_token();
|
||||
self.current_stage = None;
|
||||
self.last_progress_bytes = None;
|
||||
self.clear_progress();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TerminalProgressReporter;
|
||||
use crate::ui::progress::{ProgressReporter, format_completed_stage_line};
|
||||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||
|
||||
#[test]
|
||||
fn stage_change_resets_byte_progress_position() {
|
||||
let mut reporter = TerminalProgressReporter {
|
||||
interactive: true,
|
||||
progress_bar: None,
|
||||
byte_total: None,
|
||||
current_stage: None,
|
||||
last_progress_bytes: None,
|
||||
emitted_output: false,
|
||||
};
|
||||
|
||||
reporter.report(&OperationEvent::Progress {
|
||||
current: 98,
|
||||
total: Some(100),
|
||||
});
|
||||
|
||||
let byte_position = reporter
|
||||
.progress_bar
|
||||
.as_ref()
|
||||
.expect("progress bar created")
|
||||
.position();
|
||||
assert_eq!(byte_position, 98);
|
||||
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::StagePayload,
|
||||
message: "staging payload".to_owned(),
|
||||
});
|
||||
|
||||
let stage_position = reporter
|
||||
.progress_bar
|
||||
.as_ref()
|
||||
.expect("spinner bar retained")
|
||||
.position();
|
||||
assert_eq!(stage_position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_stage_lines_use_checklist_format() {
|
||||
let line = format_completed_stage_line("Payload Staged");
|
||||
|
||||
assert_eq!(
|
||||
line,
|
||||
format!("{} Payload Staged", crate::ui::theme::success("✓"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,14 +39,10 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
|
|||
.collect::<Vec<_>>();
|
||||
|
||||
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")),
|
||||
crate::ui::theme::heading(&format!(
|
||||
"Installed {} ({scope})",
|
||||
added.record.display_name
|
||||
)),
|
||||
format!(
|
||||
"{} {} {}",
|
||||
crate::ui::theme::label("Source"),
|
||||
|
|
@ -54,13 +50,22 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
|
|||
added.source.locator,
|
||||
),
|
||||
format!(
|
||||
"{} {} [{}]",
|
||||
crate::ui::theme::label("Selected artifact"),
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Artifact"),
|
||||
added.selected_artifact.url,
|
||||
added.selected_artifact.selection_reason,
|
||||
),
|
||||
];
|
||||
|
||||
let installed_files = install_file_paths(added);
|
||||
if !installed_files.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Installed files"));
|
||||
lines.extend(
|
||||
installed_files
|
||||
.iter()
|
||||
.map(|path| crate::ui::theme::bullet(path)),
|
||||
);
|
||||
}
|
||||
|
||||
lines.extend(warning_lines);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
|
@ -91,14 +96,65 @@ fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
|
|||
return crate::ui::theme::muted("No installed apps yet");
|
||||
}
|
||||
|
||||
let mut output = format!("{}\n", crate::ui::theme::heading("Installed Apps"));
|
||||
let name_width = rows
|
||||
.iter()
|
||||
.map(|row| row.display_name.len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("Name".len());
|
||||
let version_width = rows
|
||||
.iter()
|
||||
.map(|row| row.version.as_deref().unwrap_or("-").len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("Version".len());
|
||||
|
||||
let mut lines = vec![crate::ui::theme::heading("Installed Apps")];
|
||||
lines.push(format_list_row(
|
||||
"Name",
|
||||
"Version",
|
||||
"Source",
|
||||
name_width,
|
||||
version_width,
|
||||
true,
|
||||
));
|
||||
|
||||
for row in rows {
|
||||
output.push_str(&format!(
|
||||
"{}\n",
|
||||
crate::ui::theme::bullet(&format!("{} ({})", row.display_name, row.stable_id))
|
||||
lines.push(format_list_row(
|
||||
&row.display_name,
|
||||
row.version.as_deref().unwrap_or("-"),
|
||||
&row.source,
|
||||
name_width,
|
||||
version_width,
|
||||
false,
|
||||
));
|
||||
}
|
||||
output.trim_end().to_owned()
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_list_row(
|
||||
name: &str,
|
||||
version: &str,
|
||||
source: &str,
|
||||
name_width: usize,
|
||||
version_width: usize,
|
||||
is_header: bool,
|
||||
) -> String {
|
||||
let row = format!(
|
||||
"{name:<name_width$} {version:<version_width$} {source}",
|
||||
name = name,
|
||||
version = version,
|
||||
source = source,
|
||||
name_width = name_width,
|
||||
version_width = version_width,
|
||||
);
|
||||
|
||||
if is_header {
|
||||
crate::ui::theme::label(&row)
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String {
|
||||
|
|
@ -107,18 +163,50 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
|
|||
.iter()
|
||||
.map(|warning| format!("Warning: {warning}"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = vec![
|
||||
crate::ui::theme::heading("Removal Summary"),
|
||||
format!(
|
||||
"{} {}",
|
||||
crate::ui::theme::label("Removed app"),
|
||||
removed.removed.display_name,
|
||||
),
|
||||
];
|
||||
let mut lines = vec![crate::ui::theme::heading(&format!(
|
||||
"Removed {}",
|
||||
removed.removed.display_name,
|
||||
))];
|
||||
|
||||
if !removed.removed_paths.is_empty() {
|
||||
lines.push(crate::ui::theme::label("Removed files"));
|
||||
lines.extend(
|
||||
removed
|
||||
.removed_paths
|
||||
.iter()
|
||||
.map(|path| crate::ui::theme::bullet(path)),
|
||||
);
|
||||
}
|
||||
|
||||
lines.extend(warning_lines);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn install_file_paths(added: &aim_core::app::add::InstalledApp) -> Vec<String> {
|
||||
[
|
||||
Some(
|
||||
added
|
||||
.install_outcome
|
||||
.final_payload_path
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
added
|
||||
.install_outcome
|
||||
.desktop_entry_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
added
|
||||
.install_outcome
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
|
||||
let mut lines = vec![
|
||||
crate::ui::theme::heading("Update Summary"),
|
||||
|
|
|
|||
|
|
@ -1,22 +1,329 @@
|
|||
use console::style;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use console::{Style, true_colors_enabled};
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
|
||||
use crate::cli::config::ThemeConfig;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ColorValue {
|
||||
Named(String),
|
||||
Rgb(u8, u8, u8),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct StyleSpec {
|
||||
pub bold: bool,
|
||||
pub dim: bool,
|
||||
pub foreground: Option<ColorValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Theme {
|
||||
pub heading: StyleSpec,
|
||||
pub accent: StyleSpec,
|
||||
pub muted: StyleSpec,
|
||||
pub label: StyleSpec,
|
||||
pub bullet: StyleSpec,
|
||||
pub success: StyleSpec,
|
||||
pub warning: StyleSpec,
|
||||
pub error: StyleSpec,
|
||||
pub progress_spinner: StyleSpec,
|
||||
pub progress_bar: StyleSpec,
|
||||
pub progress_bar_unfilled: StyleSpec,
|
||||
}
|
||||
|
||||
static ACTIVE_THEME: OnceLock<Theme> = OnceLock::new();
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
heading: parse_style_spec("bold #7c3aed").expect("valid default heading style"),
|
||||
accent: parse_style_spec("#8b5cf6").expect("valid default accent style"),
|
||||
muted: parse_style_spec("dim #75658a").expect("valid default muted style"),
|
||||
label: parse_style_spec("bold #c4b5fd").expect("valid default label style"),
|
||||
bullet: StyleSpec::default(),
|
||||
success: parse_style_spec("green").expect("valid default success style"),
|
||||
warning: parse_style_spec("yellow").expect("valid default warning style"),
|
||||
error: parse_style_spec("red").expect("valid default error style"),
|
||||
progress_spinner: parse_style_spec("#8b5cf6").expect("valid default spinner style"),
|
||||
progress_bar: parse_style_spec("#8b5cf6").expect("valid default bar style"),
|
||||
progress_bar_unfilled: parse_style_spec("#75658a")
|
||||
.expect("valid default unfilled bar style"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_theme(config: &ThemeConfig) -> Theme {
|
||||
let mut theme = Theme::default();
|
||||
override_spec(&mut theme.heading, config.heading.as_deref());
|
||||
override_spec(&mut theme.accent, config.accent.as_deref());
|
||||
override_spec(&mut theme.muted, config.muted.as_deref());
|
||||
override_spec(&mut theme.label, config.label.as_deref());
|
||||
override_spec(&mut theme.bullet, config.bullet.as_deref());
|
||||
override_spec(&mut theme.success, config.success.as_deref());
|
||||
override_spec(&mut theme.warning, config.warning.as_deref());
|
||||
override_spec(&mut theme.error, config.error.as_deref());
|
||||
override_spec(
|
||||
&mut theme.progress_spinner,
|
||||
config.progress_spinner.as_deref(),
|
||||
);
|
||||
override_spec(&mut theme.progress_bar, config.progress_bar.as_deref());
|
||||
override_spec(
|
||||
&mut theme.progress_bar_unfilled,
|
||||
config.progress_bar_unfilled.as_deref(),
|
||||
);
|
||||
theme
|
||||
}
|
||||
|
||||
pub fn set_active_theme(theme: Theme) {
|
||||
let _ = ACTIVE_THEME.set(theme);
|
||||
}
|
||||
|
||||
pub fn current_theme() -> Theme {
|
||||
ACTIVE_THEME.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn dialog_theme() -> ColorfulTheme {
|
||||
ColorfulTheme::default()
|
||||
}
|
||||
|
||||
pub fn heading(title: &str) -> String {
|
||||
style(title).bold().to_string()
|
||||
apply_style_spec(title, ¤t_theme().heading)
|
||||
}
|
||||
|
||||
pub fn label(title: &str) -> String {
|
||||
style(format!("{title}:")).bold().to_string()
|
||||
apply_style_spec(&format!("{title}:"), ¤t_theme().label)
|
||||
}
|
||||
|
||||
pub fn muted(message: &str) -> String {
|
||||
style(message).dim().to_string()
|
||||
apply_style_spec(message, ¤t_theme().muted)
|
||||
}
|
||||
|
||||
pub fn bullet(message: &str) -> String {
|
||||
format!("- {message}")
|
||||
}
|
||||
|
||||
pub fn accent(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().accent)
|
||||
}
|
||||
|
||||
pub fn success(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().success)
|
||||
}
|
||||
|
||||
pub fn warning_text(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().warning)
|
||||
}
|
||||
|
||||
pub fn error_text(message: &str) -> String {
|
||||
apply_style_spec(message, ¤t_theme().error)
|
||||
}
|
||||
|
||||
pub fn indicatif_color_key(spec: &StyleSpec) -> &'static str {
|
||||
match spec.foreground.as_ref() {
|
||||
Some(ColorValue::Named(name)) => match name.as_str() {
|
||||
"black" | "stone" => "black",
|
||||
"red" => "red",
|
||||
"green" => "green",
|
||||
"yellow" | "amber" | "sand" => "yellow",
|
||||
"blue" => "blue",
|
||||
"magenta" => "magenta",
|
||||
"cyan" | "teal" => "cyan",
|
||||
"white" => "white",
|
||||
_ => "white",
|
||||
},
|
||||
Some(ColorValue::Rgb(red, green, blue)) => nearest_indicatif_color(*red, *green, *blue),
|
||||
None => "white",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_style_spec(input: &str) -> Result<StyleSpec, String> {
|
||||
let mut spec = StyleSpec::default();
|
||||
|
||||
for token in input.split_whitespace() {
|
||||
match token {
|
||||
"bold" => spec.bold = true,
|
||||
"dim" => spec.dim = true,
|
||||
color => spec.foreground = Some(parse_color_value(color)?),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub fn apply_style_spec(message: &str, spec: &StyleSpec) -> String {
|
||||
let mut style = Style::new();
|
||||
if spec.bold {
|
||||
style = style.bold();
|
||||
}
|
||||
if spec.dim {
|
||||
style = style.dim();
|
||||
}
|
||||
if let Some(color) = &spec.foreground {
|
||||
style = apply_color(style, color);
|
||||
}
|
||||
style.apply_to(message).to_string()
|
||||
}
|
||||
|
||||
fn override_spec(target: &mut StyleSpec, value: Option<&str>) {
|
||||
if let Some(value) = value
|
||||
&& let Ok(spec) = parse_style_spec(value)
|
||||
{
|
||||
*target = spec;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color_value(token: &str) -> Result<ColorValue, String> {
|
||||
if let Some(hex) = token.strip_prefix('#') {
|
||||
if hex.len() != 6 {
|
||||
return Err(format!("invalid hex color: {token}"));
|
||||
}
|
||||
|
||||
let red = u8::from_str_radix(&hex[0..2], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
let green = u8::from_str_radix(&hex[2..4], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
let blue = u8::from_str_radix(&hex[4..6], 16)
|
||||
.map_err(|_| format!("invalid hex color: {token}"))?;
|
||||
return Ok(ColorValue::Rgb(red, green, blue));
|
||||
}
|
||||
|
||||
if is_named_color(token) {
|
||||
return Ok(ColorValue::Named(token.to_owned()));
|
||||
}
|
||||
|
||||
Err(format!("unknown color token: {token}"))
|
||||
}
|
||||
|
||||
fn is_named_color(token: &str) -> bool {
|
||||
matches!(
|
||||
token,
|
||||
"black"
|
||||
| "red"
|
||||
| "green"
|
||||
| "yellow"
|
||||
| "blue"
|
||||
| "magenta"
|
||||
| "cyan"
|
||||
| "white"
|
||||
| "amber"
|
||||
| "teal"
|
||||
| "sand"
|
||||
| "stone"
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_color(style: Style, color: &ColorValue) -> Style {
|
||||
match color {
|
||||
ColorValue::Named(name) => apply_named_color(style, name),
|
||||
ColorValue::Rgb(red, green, blue) => {
|
||||
if true_colors_enabled() {
|
||||
style.true_color(*red, *green, *blue)
|
||||
} else {
|
||||
style.color256(rgb_to_ansi256(*red, *green, *blue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_named_color(style: Style, name: &str) -> Style {
|
||||
match name {
|
||||
"black" => style.black(),
|
||||
"red" => style.red(),
|
||||
"green" => style.green(),
|
||||
"yellow" => style.yellow(),
|
||||
"blue" => style.blue(),
|
||||
"magenta" => style.magenta(),
|
||||
"cyan" => style.cyan(),
|
||||
"white" => style.white(),
|
||||
"amber" => apply_color(style, &ColorValue::Rgb(210, 139, 38)),
|
||||
"teal" => apply_color(style, &ColorValue::Rgb(47, 142, 138)),
|
||||
"sand" => apply_color(style, &ColorValue::Rgb(231, 197, 138)),
|
||||
"stone" => apply_color(style, &ColorValue::Rgb(111, 98, 83)),
|
||||
_ => style,
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_to_ansi256(red: u8, green: u8, blue: u8) -> u8 {
|
||||
let red = ((red as f32 / 255.0) * 5.0).round() as u8;
|
||||
let green = ((green as f32 / 255.0) * 5.0).round() as u8;
|
||||
let blue = ((blue as f32 / 255.0) * 5.0).round() as u8;
|
||||
16 + (36 * red) + (6 * green) + blue
|
||||
}
|
||||
|
||||
fn nearest_indicatif_color(red: u8, green: u8, blue: u8) -> &'static str {
|
||||
const COLORS: [(&str, (u8, u8, u8)); 8] = [
|
||||
("black", (0, 0, 0)),
|
||||
("red", (205, 49, 49)),
|
||||
("green", (13, 188, 121)),
|
||||
("yellow", (229, 229, 16)),
|
||||
("blue", (36, 114, 200)),
|
||||
("magenta", (188, 63, 188)),
|
||||
("cyan", (17, 168, 205)),
|
||||
("white", (229, 229, 229)),
|
||||
];
|
||||
|
||||
COLORS
|
||||
.iter()
|
||||
.min_by_key(|(_, (target_red, target_green, target_blue))| {
|
||||
let red_distance = red as i32 - *target_red as i32;
|
||||
let green_distance = green as i32 - *target_green as i32;
|
||||
let blue_distance = blue as i32 - *target_blue as i32;
|
||||
red_distance * red_distance
|
||||
+ green_distance * green_distance
|
||||
+ blue_distance * blue_distance
|
||||
})
|
||||
.map(|(name, _)| *name)
|
||||
.unwrap_or("white")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_named_theme_value() {
|
||||
let spec = parse_style_spec("amber").unwrap();
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Named("amber".to_owned())));
|
||||
assert!(!spec.bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hex_theme_value() {
|
||||
let spec = parse_style_spec("#d28b26").unwrap();
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bold_hex_theme_value() {
|
||||
let spec = parse_style_spec("bold #d28b26").unwrap();
|
||||
assert!(spec.bold);
|
||||
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_override_falls_back_to_default_theme() {
|
||||
let theme = resolve_theme(&ThemeConfig {
|
||||
heading: Some("bogus".to_owned()),
|
||||
..ThemeConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(theme.heading, Theme::default().heading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_theme_uses_purple_led_palette() {
|
||||
let theme = Theme::default();
|
||||
|
||||
assert_eq!(theme.heading, parse_style_spec("bold #7c3aed").unwrap());
|
||||
assert_eq!(theme.accent, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(theme.label, parse_style_spec("bold #c4b5fd").unwrap());
|
||||
assert_eq!(theme.progress_spinner, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(theme.progress_bar, parse_style_spec("#8b5cf6").unwrap());
|
||||
assert_eq!(
|
||||
theme.progress_bar_unfilled,
|
||||
parse_style_spec("#75658a").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ fn list_command_reads_registered_apps_from_registry_file() {
|
|||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Bat (bat)"));
|
||||
.stdout(contains("Name"))
|
||||
.stdout(contains("Version"))
|
||||
.stdout(contains("Source"))
|
||||
.stdout(contains("Bat"))
|
||||
.stdout(contains("Bat (bat)").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -56,8 +60,9 @@ fn remove_command_removes_registered_app_from_registry_file() {
|
|||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Removal Summary"))
|
||||
.stdout(contains("Removed app: Bat"));
|
||||
.stdout(contains("Removed Bat"))
|
||||
.stdout(contains("Removal Summary").not())
|
||||
.stdout(contains("Removed app:").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(!contents.contains("stable_id = \"bat\""));
|
||||
|
|
@ -90,8 +95,14 @@ fn remove_command_uninstalls_managed_files() {
|
|||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Removal Summary"))
|
||||
.stdout(contains("Removed app: bat"));
|
||||
.stdout(contains("\nRemoved bat"))
|
||||
.stdout(contains("Removed bat"))
|
||||
.stdout(contains("Removal Summary").not())
|
||||
.stdout(contains("Removed app:").not())
|
||||
.stdout(contains("Removed files"))
|
||||
.stdout(contains("sharkdp-bat.AppImage"))
|
||||
.stdout(contains("aim-sharkdp-bat.desktop"))
|
||||
.stdout(contains("sharkdp-bat.png"));
|
||||
|
||||
assert!(!payload_path.exists());
|
||||
assert!(!desktop_path.exists());
|
||||
|
|
@ -109,8 +120,16 @@ fn query_command_registers_unambiguous_app_in_registry_file() {
|
|||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installation Summary"))
|
||||
.stdout(contains("Application: bat (sharkdp-bat)"));
|
||||
.stdout(contains("\nInstalled bat (user)"))
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("Installation Summary").not())
|
||||
.stdout(contains("Source: github sharkdp/bat"))
|
||||
.stdout(contains("Artifact:"))
|
||||
.stdout(contains("Selected artifact").not())
|
||||
.stdout(contains("metadata-guided").not())
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("sharkdp-bat.AppImage"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
||||
|
|
@ -147,9 +166,16 @@ fn old_release_query_can_track_latest_and_register_app() {
|
|||
.env("AIM_TRACKING_PREFERENCE", "latest")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installation Summary"))
|
||||
.stdout(contains("Application: t3code (pingdotgg-t3code)"))
|
||||
.stdout(contains("Install scope: user"));
|
||||
.stdout(contains("\nInstalled t3code (user)"))
|
||||
.stdout(contains("Installed t3code (user)"))
|
||||
.stdout(contains("Installation Summary").not())
|
||||
.stdout(contains("Source: github pingdotgg/t3code"))
|
||||
.stdout(contains("Artifact: T3-Code-0.0.12-x86_64.AppImage"))
|
||||
.stdout(contains("Selected artifact").not())
|
||||
.stdout(contains("metadata-guided").not())
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("pingdotgg-t3code.AppImage"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
||||
|
|
@ -167,8 +193,11 @@ fn cli_add_installs_and_renders_resolved_mode() {
|
|||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installation Summary"))
|
||||
.stdout(contains("Selected artifact"));
|
||||
.stdout(contains("\nInstalled bat (user)"))
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("Artifact:"))
|
||||
.stdout(contains("Installed files"))
|
||||
.stdout(contains("Completed steps").not());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -183,8 +212,16 @@ fn cli_add_emits_live_progress_to_stderr() {
|
|||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Installing sharkdp/bat"))
|
||||
.stderr(contains("Resolving source"))
|
||||
.stderr(contains("Discovering release"))
|
||||
.stderr(contains("Selecting artifact"))
|
||||
.stderr(contains("Downloading artifact"))
|
||||
.stderr(contains("Saving registry"));
|
||||
.stderr(contains("Downloaded"))
|
||||
.stderr(contains("Payload Staged"))
|
||||
.stderr(contains("Desktop Entry Written"))
|
||||
.stderr(contains("Icon Extracted"))
|
||||
.stderr(contains("Desktop Integration Refreshed"))
|
||||
.stderr(contains("Registry Saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -258,8 +295,7 @@ fn system_request_on_immutable_host_falls_back_to_user_install() {
|
|||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installation Summary"))
|
||||
.stdout(contains("Install scope: user"))
|
||||
.stdout(contains("Installed bat (user)"))
|
||||
.stdout(contains("downgraded to user scope"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
use aim_cli::DispatchResult;
|
||||
use aim_cli::ui::prompt::render_interaction;
|
||||
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
|
||||
use aim_core::app::add::InstalledApp;
|
||||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use aim_core::app::list::ListRow;
|
||||
use aim_core::app::remove::{RemovalPlan, RemovalResult};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::ArtifactCandidate;
|
||||
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
|
||||
use aim_core::integration::install::InstallOutcome;
|
||||
|
||||
#[test]
|
||||
fn update_summary_mentions_selected_count() {
|
||||
|
|
@ -22,6 +29,24 @@ fn list_empty_state_uses_friendlier_copy() {
|
|||
assert!(output.contains("No installed apps yet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_renders_table_with_name_version_and_source() {
|
||||
let output = render_dispatch_result(&DispatchResult::List(vec![ListRow {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
version: Some("0.25.0".to_owned()),
|
||||
source: "sharkdp/bat".to_owned(),
|
||||
}]));
|
||||
|
||||
assert!(output.contains("Name"));
|
||||
assert!(output.contains("Version"));
|
||||
assert!(output.contains("Source"));
|
||||
assert!(output.contains("Bat"));
|
||||
assert!(output.contains("0.25.0"));
|
||||
assert!(output.contains("sharkdp/bat"));
|
||||
assert!(!output.contains("Bat (bat)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_flow_uses_clearer_summary_labels() {
|
||||
let output = render_dispatch_result(&DispatchResult::UpdatePlan(UpdatePlan {
|
||||
|
|
@ -41,6 +66,30 @@ fn review_flow_uses_clearer_summary_labels() {
|
|||
assert!(output.contains("apps with updates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_summary_lists_removed_files() {
|
||||
let output = render_dispatch_result(&DispatchResult::Removed(Box::new(RemovalResult {
|
||||
removed: RemovalPlan {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
artifact_paths: vec![
|
||||
"/tmp/install-home/.local/lib/aim/appimages/bat.AppImage".to_owned(),
|
||||
"/tmp/install-home/.local/share/applications/aim-bat.desktop".to_owned(),
|
||||
],
|
||||
},
|
||||
removed_paths: vec![
|
||||
"/tmp/install-home/.local/lib/aim/appimages/bat.AppImage".to_owned(),
|
||||
"/tmp/install-home/.local/share/applications/aim-bat.desktop".to_owned(),
|
||||
],
|
||||
remaining_apps: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
})));
|
||||
|
||||
assert!(output.contains("Removed files"));
|
||||
assert!(output.contains("bat.AppImage"));
|
||||
assert!(output.contains("aim-bat.desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
||||
let output = render_interaction(&InteractionRequest {
|
||||
|
|
@ -68,3 +117,63 @@ fn tracking_prompt_uses_explicit_question_copy() {
|
|||
|
||||
assert!(output.contains("Choose update tracking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_summary_omits_completed_steps_recap() {
|
||||
let output = render_dispatch_result(&DispatchResult::Added(Box::new(InstalledApp {
|
||||
record: AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "bat".to_owned(),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.25.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(
|
||||
"/tmp/install-home/.local/lib/aim/appimages/sharkdp-bat.AppImage".to_owned(),
|
||||
),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/aim-sharkdp-bat.desktop"
|
||||
.to_owned(),
|
||||
),
|
||||
icon_path: None,
|
||||
}),
|
||||
},
|
||||
selected_artifact: ArtifactCandidate {
|
||||
url: "https://github.com/sharkdp/bat/releases/download/v0.25.0/bat-x86_64.AppImage"
|
||||
.to_owned(),
|
||||
version: "0.25.0".to_owned(),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
selection_reason: "heuristic-match".to_owned(),
|
||||
},
|
||||
artifact_size_bytes: 173_015_040,
|
||||
source: SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
},
|
||||
install_scope: InstallScope::User,
|
||||
integration_mode: aim_core::integration::policy::IntegrationMode::Full,
|
||||
install_outcome: InstallOutcome {
|
||||
final_payload_path: "/tmp/install-home/.local/lib/aim/appimages/sharkdp-bat.AppImage"
|
||||
.into(),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/aim-sharkdp-bat.desktop".into(),
|
||||
),
|
||||
icon_path: None,
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
warnings: Vec::new(),
|
||||
})));
|
||||
|
||||
assert!(output.contains("Installed bat (user)"));
|
||||
assert!(output.contains("Installed files"));
|
||||
assert!(!output.contains("Completed steps"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue