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:
stoorps 2026-03-20 19:44:04 +00:00
parent c63b2917da
commit 9d8ec1e4fd
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
17 changed files with 1277 additions and 74 deletions

View file

@ -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]

View 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());
}
}

View file

@ -1 +1,2 @@
pub mod args;
pub mod config;

View file

@ -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) => {

View file

@ -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}");
}
}

View file

@ -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(""))
);
}
}

View file

@ -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"),

View file

@ -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, &current_theme().heading)
}
pub fn label(title: &str) -> String {
style(format!("{title}:")).bold().to_string()
apply_style_spec(&format!("{title}:"), &current_theme().label)
}
pub fn muted(message: &str) -> String {
style(message).dim().to_string()
apply_style_spec(message, &current_theme().muted)
}
pub fn bullet(message: &str) -> String {
format!("- {message}")
}
pub fn accent(message: &str) -> String {
apply_style_spec(message, &current_theme().accent)
}
pub fn success(message: &str) -> String {
apply_style_spec(message, &current_theme().success)
}
pub fn warning_text(message: &str) -> String {
apply_style_spec(message, &current_theme().warning)
}
pub fn error_text(message: &str) -> String {
apply_style_spec(message, &current_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()
);
}
}

View file

@ -37,7 +37,11 @@ fn list_command_reads_registered_apps_from_registry_file() {
.env("AIM_REGISTRY_PATH", &registry_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", &registry_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(&registry_path).unwrap();
assert!(!contents.contains("stable_id = \"bat\""));
@ -90,8 +95,14 @@ fn remove_command_uninstalls_managed_files() {
.env("AIM_REGISTRY_PATH", &registry_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(&registry_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(&registry_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"));
}

View file

@ -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"));
}

View file

@ -27,19 +27,37 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
build_add_plan_with(query, transport.as_ref())
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport.as_ref(), &mut reporter)
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter(query, transport, &mut reporter)
}
pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
});
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
@ -60,6 +78,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
@ -89,6 +111,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
)
}
_ => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
@ -249,17 +275,6 @@ pub fn install_app_with_reporter(
)),
};
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(),
@ -280,6 +295,20 @@ pub fn install_app_with_reporter(
})
.map_err(InstallAppError::Install)?;
if install_outcome.desktop_entry_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
}
if install_outcome.icon_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
message: "refreshing desktop integration".to_owned(),
@ -308,6 +337,7 @@ pub fn install_app_with_reporter(
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
artifact_size_bytes: artifact_bytes.len() as u64,
source: plan.resolution.source.clone(),
install_scope: policy.scope,
integration_mode: policy.integration_mode,
@ -326,6 +356,7 @@ pub fn install_app_with_reporter(
pub struct InstalledApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub artifact_size_bytes: u64,
pub source: crate::domain::source::SourceRef,
pub install_scope: InstallScope,
pub integration_mode: IntegrationMode,

View file

@ -4,6 +4,8 @@ use crate::domain::app::AppRecord;
pub struct ListRow {
pub stable_id: String,
pub display_name: String,
pub version: Option<String>,
pub source: String,
}
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
@ -11,6 +13,13 @@ pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
.map(|app| ListRow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
version: app.installed_version.clone(),
source: app
.source
.as_ref()
.map(|source| source.locator.clone())
.or_else(|| app.source_input.clone())
.unwrap_or_else(|| "-".to_owned()),
})
.collect()
}

View file

@ -93,7 +93,7 @@ pub fn remove_registered_app_with_reporter(
stage: OperationStage::StagePayload,
message: "removing managed artifacts".to_owned(),
});
let warnings = delete_artifacts(&plan)?;
let deletion = delete_artifacts(&plan)?;
let remaining_apps = apps
.iter()
.filter(|candidate| candidate.stable_id != app.stable_id)
@ -102,8 +102,9 @@ pub fn remove_registered_app_with_reporter(
let result = RemovalResult {
removed: plan,
removed_paths: deletion.removed_paths,
remaining_apps,
warnings,
warnings: deletion.warnings,
};
reporter.report(&OperationEvent::StageChanged {
@ -120,6 +121,7 @@ pub fn remove_registered_app_with_reporter(
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalResult {
pub removed: RemovalPlan,
pub removed_paths: Vec<String>,
pub remaining_apps: Vec<AppRecord>,
pub warnings: Vec<String>,
}
@ -161,13 +163,19 @@ fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf>
]
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredAppError> {
struct DeletionOutcome {
removed_paths: Vec<String>,
warnings: Vec<String>,
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<DeletionOutcome, RemoveRegisteredAppError> {
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
let mut removed_paths = Vec::new();
for artifact_path in &plan.artifact_paths {
match fs::remove_file(artifact_path) {
Ok(()) => {}
Ok(()) => removed_paths.push(artifact_path.clone()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
}
@ -182,5 +190,8 @@ fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredA
));
}
Ok(warnings)
Ok(DeletionOutcome {
removed_paths,
warnings,
})
}

View file

@ -1,4 +1,4 @@
use aim_core::app::add::{build_add_plan_with, install_app_with_reporter};
use aim_core::app::add::{build_add_plan_with_reporter, 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};
@ -133,7 +133,6 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
#[test]
fn install_app_reports_operation_stages_in_order() {
let root = tempdir().unwrap();
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
let mut events: Vec<OperationEvent> = Vec::new();
unsafe {
@ -142,6 +141,9 @@ fn install_app_reports_operation_stages_in_order() {
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let plan = build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut reporter)
.unwrap();
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
@ -152,6 +154,18 @@ fn install_app_reports_operation_stages_in_order() {
.unwrap();
assert_eq!(installed.record.stable_id, "sharkdp-bat");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
@ -182,4 +196,33 @@ fn install_app_reports_operation_stages_in_order() {
}
)
}));
let stage_order = events
.iter()
.filter_map(|event| match event {
OperationEvent::StageChanged { stage, .. } => Some(*stage),
_ => None,
})
.collect::<Vec<_>>();
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::ResolveQuery,
OperationStage::DiscoverRelease,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::DiscoverRelease,
OperationStage::SelectArtifact,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::SelectArtifact,
OperationStage::DownloadArtifact,
]
}));
}

View file

@ -5,6 +5,7 @@ use aim_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use std::path::Path;
use tempfile::tempdir;
@ -20,9 +21,18 @@ fn list_flow_returns_display_rows_for_registered_apps() {
let rows = build_list_rows(&[AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
source_input: Some("sharkdp/bat".to_owned()),
source: Some(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,
}),
installed_version: Some("0.25.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
@ -31,6 +41,8 @@ fn list_flow_returns_display_rows_for_registered_apps() {
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].stable_id, "bat");
assert_eq!(rows[0].display_name, "Bat");
assert_eq!(rows[0].version.as_deref(), Some("0.25.0"));
assert_eq!(rows[0].source, "sharkdp/bat");
}
#[test]
@ -162,6 +174,7 @@ fn remove_flow_reports_resolution_and_cleanup_events() {
.unwrap();
assert_eq!(result.removed.stable_id, "bat");
assert_eq!(result.removed_paths.len(), 0);
assert!(events.iter().any(|event| {
matches!(
event,