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
|
|
@ -0,0 +1,234 @@
|
||||||
|
# CLI Theme And Preflight Progress Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Polish the CLI install and removal experience so progress stays visibly active before downloads start, transcript output and summaries are better separated, redundant install recap text is removed, and terminal styling is driven by a configurable warm theme instead of ad hoc color choices.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current CLI progress work fixed the large functional gaps, but four UX issues remain:
|
||||||
|
|
||||||
|
- install has a silent pre-download period while source resolution and release selection happen
|
||||||
|
- final install and removal summaries run directly into the transcript without enough visual separation
|
||||||
|
- the install summary repeats work that the transcript already showed via `Completed steps`
|
||||||
|
- styling is still thin and hardcoded, with no clear config surface for future CLI presentation settings
|
||||||
|
|
||||||
|
The user also wants the styling system to be future-proof:
|
||||||
|
|
||||||
|
- coded warm defaults
|
||||||
|
- external overrides loaded from `config.toml`
|
||||||
|
- room to extend the same config file beyond theming later
|
||||||
|
- optional support for hex colors where the terminal supports truecolor
|
||||||
|
|
||||||
|
## Design Goals
|
||||||
|
|
||||||
|
- show visible work as soon as install begins, not only once bytes start downloading
|
||||||
|
- keep `stderr` as the live transcript surface and `stdout` as the final summary surface
|
||||||
|
- ensure a clear blank-line separation between transcript output and final summaries
|
||||||
|
- remove redundant install recap text from the final summary
|
||||||
|
- centralize terminal styling behind semantic theme tokens
|
||||||
|
- support config-driven theme overrides from app-specific system and user paths
|
||||||
|
- accept both named colors and hex colors while degrading cleanly on limited terminals
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- a full TUI redesign
|
||||||
|
- migrating the application to async
|
||||||
|
- a cross-platform config search redesign beyond the agreed Linux app-specific paths
|
||||||
|
- full arbitrary terminal capability negotiation beyond practical truecolor or fallback detection
|
||||||
|
- broader non-theme config features in this slice beyond reserving the config surface for future use
|
||||||
|
|
||||||
|
## Approved UX Shape
|
||||||
|
|
||||||
|
### Transcript versus summary
|
||||||
|
|
||||||
|
- `stderr` remains the place for live progress output
|
||||||
|
- `stdout` remains the place for final success or completion summaries
|
||||||
|
- if any transcript lines were emitted, the renderer inserts exactly one blank line before printing the final summary
|
||||||
|
- install no longer prints a `Completed steps` section in the final summary
|
||||||
|
- remove keeps the compact summary and removed file list, with the same blank-line separation rule
|
||||||
|
|
||||||
|
### Install lifecycle visibility
|
||||||
|
|
||||||
|
Install should emit visible progress through the full lifecycle, including the current silent period before download:
|
||||||
|
|
||||||
|
- resolving source
|
||||||
|
- discovering release
|
||||||
|
- selecting artifact
|
||||||
|
- downloading artifact
|
||||||
|
- staging payload
|
||||||
|
- writing desktop entry
|
||||||
|
- extracting icon
|
||||||
|
- refreshing desktop integration
|
||||||
|
- saving registry
|
||||||
|
|
||||||
|
When download byte totals are known, the reporter should continue using the byte progress bar. When byte totals are unavailable, it should still show staged progress honestly.
|
||||||
|
|
||||||
|
### Summary shape
|
||||||
|
|
||||||
|
Install summary remains compact:
|
||||||
|
|
||||||
|
- bold `Installed <name> (<scope>)`
|
||||||
|
- `Source: ...`
|
||||||
|
- `Artifact: ...`
|
||||||
|
- `Installed files:` list when files exist
|
||||||
|
|
||||||
|
Remove summary remains compact:
|
||||||
|
|
||||||
|
- bold `Removed <name>`
|
||||||
|
- `Removed files:` list when files exist
|
||||||
|
|
||||||
|
The transcript already carries the step-by-step operational recap, so the final install summary should not repeat it.
|
||||||
|
|
||||||
|
## Architectural Decision
|
||||||
|
|
||||||
|
Keep the existing event-driven split, but make two targeted improvements:
|
||||||
|
|
||||||
|
1. extend `aim-core` install reporting so pre-download work emits stages instead of happening silently
|
||||||
|
2. move CLI presentation onto a theme token layer backed by a loadable config file
|
||||||
|
|
||||||
|
This preserves the intended boundary:
|
||||||
|
|
||||||
|
- `aim-core` owns workflow semantics and event emission
|
||||||
|
- `aim-cli` owns config discovery, theme resolution, terminal capability handling, spacing, and rendering
|
||||||
|
|
||||||
|
## Theme And Config Model
|
||||||
|
|
||||||
|
### Config file locations
|
||||||
|
|
||||||
|
The CLI should load configuration from app-specific Linux paths in this order:
|
||||||
|
|
||||||
|
- `/etc/aim/config.toml`
|
||||||
|
- `~/.config/aim/config.toml`
|
||||||
|
|
||||||
|
User config overrides system config.
|
||||||
|
|
||||||
|
### Config file shape
|
||||||
|
|
||||||
|
The file is intentionally broader than theming so it can grow later without another migration. This slice only consumes the `[theme]` table.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme]
|
||||||
|
heading = "#d28b26"
|
||||||
|
accent = "teal"
|
||||||
|
muted = "dim"
|
||||||
|
label = "bold #e7c58a"
|
||||||
|
success = "green"
|
||||||
|
warning = "yellow"
|
||||||
|
error = "red"
|
||||||
|
progress_spinner = "#d28b26"
|
||||||
|
progress_bar = "#d28b26"
|
||||||
|
progress_bar_unfilled = "#6f6253"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme token layer
|
||||||
|
|
||||||
|
Renderers should consume semantic tokens, not direct color selections. The token set should cover at least:
|
||||||
|
|
||||||
|
- `heading`
|
||||||
|
- `accent`
|
||||||
|
- `muted`
|
||||||
|
- `label`
|
||||||
|
- `bullet`
|
||||||
|
- `success`
|
||||||
|
- `warning`
|
||||||
|
- `error`
|
||||||
|
- `progress_spinner`
|
||||||
|
- `progress_bar`
|
||||||
|
- `progress_bar_unfilled`
|
||||||
|
|
||||||
|
The built-in default theme is warm:
|
||||||
|
|
||||||
|
- amber or gold for headings and active progress
|
||||||
|
- teal as secondary accent
|
||||||
|
- warm gray or sand for muted and supporting text
|
||||||
|
- semantic success, warning, and error colors reserved for status meaning
|
||||||
|
|
||||||
|
### Named colors and hex colors
|
||||||
|
|
||||||
|
`config.toml` should accept either:
|
||||||
|
|
||||||
|
- named values like `amber`, `teal`, `green`, `dim`
|
||||||
|
- hex values like `#d28b26`
|
||||||
|
- style combinations such as `bold amber` or `bold #d28b26`
|
||||||
|
|
||||||
|
Internally, theme values should normalize into a small style model that can render as:
|
||||||
|
|
||||||
|
- plain text
|
||||||
|
- ANSI basic or 256-style fallback
|
||||||
|
- truecolor RGB when supported
|
||||||
|
|
||||||
|
## Terminal Capability Strategy
|
||||||
|
|
||||||
|
Modern terminals can render hex-configured colors by converting them into 24-bit ANSI sequences, but support is not universal. The CLI should therefore:
|
||||||
|
|
||||||
|
- use truecolor when terminal capability is available
|
||||||
|
- fall back to a nearest named or ANSI-safe color when truecolor is unavailable
|
||||||
|
- fall back to plain text when color is disabled or unsupported
|
||||||
|
|
||||||
|
Color should improve presentation, not become a hard dependency for readability.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Config parsing must be non-fatal.
|
||||||
|
|
||||||
|
- missing config files: ignore and use defaults
|
||||||
|
- partial config: merge valid fields, use defaults for the rest
|
||||||
|
- invalid values: ignore the bad value, keep defaults, optionally emit a warning
|
||||||
|
- config load failure must never block installs, updates, listing, or removals
|
||||||
|
|
||||||
|
This keeps the CLI robust while still making misconfiguration visible.
|
||||||
|
|
||||||
|
## Implementation Boundaries
|
||||||
|
|
||||||
|
### `aim-core`
|
||||||
|
|
||||||
|
- emit `ResolveQuery`, `DiscoverRelease`, and `SelectArtifact` during install preflight
|
||||||
|
- keep terminal decisions out of core logic
|
||||||
|
|
||||||
|
### `aim-cli`
|
||||||
|
|
||||||
|
- load and merge config from agreed paths
|
||||||
|
- resolve theme tokens from defaults plus config overrides
|
||||||
|
- detect terminal color capability pragmatically
|
||||||
|
- render transcript and summaries with a single semantic theme layer
|
||||||
|
- track whether transcript output occurred so the final summary spacing rule is applied once
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Core tests
|
||||||
|
|
||||||
|
- verify install emits the new early stages before download begins
|
||||||
|
- verify event ordering remains coherent across the full install flow
|
||||||
|
|
||||||
|
### CLI renderer tests
|
||||||
|
|
||||||
|
- verify exactly one blank line separates transcript output from final summaries
|
||||||
|
- verify install no longer renders `Completed steps`
|
||||||
|
- verify compact install and removal summaries remain intact
|
||||||
|
|
||||||
|
### Config tests
|
||||||
|
|
||||||
|
- system config loads successfully
|
||||||
|
- user config overrides system config
|
||||||
|
- invalid config values fall back to defaults without aborting commands
|
||||||
|
- named colors and hex colors both parse
|
||||||
|
|
||||||
|
### Progress tests
|
||||||
|
|
||||||
|
- pre-download transcript lines appear before byte download output
|
||||||
|
- non-interactive mode still records final byte counts correctly
|
||||||
|
- truecolor-capable styling degrades safely when color support is limited or disabled
|
||||||
|
|
||||||
|
## Rollout Order
|
||||||
|
|
||||||
|
Implement in this order:
|
||||||
|
|
||||||
|
1. config and theme token model in `aim-cli`
|
||||||
|
2. failing tests for spacing, non-redundant summaries, config loading, and early install stages
|
||||||
|
3. early install stage emission in `aim-core`
|
||||||
|
4. progress reporter updates for transcript spacing and themed styling
|
||||||
|
5. final renderer cleanup and summary simplification
|
||||||
|
6. documentation refresh if needed once output is final
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -23,7 +23,9 @@ dependencies = [
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"predicates",
|
"predicates",
|
||||||
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ dialoguer.workspace = true
|
||||||
console.workspace = true
|
console.workspace = true
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
aim-core = { path = "../aim-core" }
|
aim-core = { path = "../aim-core" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[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 args;
|
||||||
|
pub mod config;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ use std::env;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use aim_core::app::add::{
|
use aim_core::app::add::{
|
||||||
AddPlan, InstalledApp, build_add_plan, install_app_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::list::{ListRow, build_list_rows};
|
||||||
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
|
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
|
||||||
|
|
@ -86,7 +87,8 @@ pub fn dispatch_with_reporter(
|
||||||
|
|
||||||
if let Some(query) = cli.query {
|
if let Some(query) = cli.query {
|
||||||
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
|
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() {
|
if !plan.interactions.is_empty() {
|
||||||
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
||||||
Some(resolved) => {
|
Some(resolved) => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
fn main() {
|
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 cli = aim_cli::parse();
|
||||||
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
|
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
|
||||||
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
|
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let output = aim_cli::render(&result);
|
let output = aim_cli::render(&result);
|
||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
|
if reporter.emitted_output() {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
println!("{output}");
|
println!("{output}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,13 @@ pub fn spinner_style() -> ProgressStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn byte_style() -> ProgressStyle {
|
pub fn byte_style() -> ProgressStyle {
|
||||||
ProgressStyle::with_template("{bar:32.cyan/blue} {bytes}/{total_bytes} {msg}")
|
let theme = crate::ui::theme::current_theme();
|
||||||
.expect("byte progress template is valid")
|
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 {
|
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> {
|
pub fn event_message(event: &OperationEvent) -> Option<String> {
|
||||||
match event {
|
match event {
|
||||||
OperationEvent::Started { kind, label } => {
|
OperationEvent::Started { kind, label } => {
|
||||||
|
|
@ -70,6 +96,9 @@ pub struct TerminalProgressReporter {
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
progress_bar: Option<ProgressBar>,
|
progress_bar: Option<ProgressBar>,
|
||||||
byte_total: Option<u64>,
|
byte_total: Option<u64>,
|
||||||
|
current_stage: Option<OperationStage>,
|
||||||
|
last_progress_bytes: Option<u64>,
|
||||||
|
emitted_output: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalProgressReporter {
|
impl TerminalProgressReporter {
|
||||||
|
|
@ -78,9 +107,16 @@ impl TerminalProgressReporter {
|
||||||
interactive: std::io::stderr().is_terminal(),
|
interactive: std::io::stderr().is_terminal(),
|
||||||
progress_bar: None,
|
progress_bar: None,
|
||||||
byte_total: 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) {
|
fn clear_progress(&mut self) {
|
||||||
if let Some(progress_bar) = self.progress_bar.take() {
|
if let Some(progress_bar) = self.progress_bar.take() {
|
||||||
progress_bar.finish_and_clear();
|
progress_bar.finish_and_clear();
|
||||||
|
|
@ -88,23 +124,52 @@ impl TerminalProgressReporter {
|
||||||
self.byte_total = None;
|
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) {
|
fn show_spinner(&mut self, message: String) {
|
||||||
if !self.interactive {
|
if !self.interactive {
|
||||||
eprintln!("{message}");
|
self.emitted_output = true;
|
||||||
|
eprintln!("{}", crate::ui::theme::accent(&message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.byte_total.is_some() {
|
||||||
|
self.clear_progress();
|
||||||
|
}
|
||||||
|
|
||||||
let progress_bar = self.progress_bar.get_or_insert_with(|| {
|
let progress_bar = self.progress_bar.get_or_insert_with(|| {
|
||||||
let progress_bar = new_progress_bar(None);
|
let progress_bar = new_progress_bar(None);
|
||||||
progress_bar.set_style(spinner_style());
|
progress_bar.set_style(spinner_style());
|
||||||
progress_bar.enable_steady_tick(Duration::from_millis(100));
|
progress_bar.enable_steady_tick(Duration::from_millis(100));
|
||||||
progress_bar
|
progress_bar
|
||||||
});
|
});
|
||||||
progress_bar.set_message(message);
|
progress_bar.set_message(crate::ui::theme::accent(&message));
|
||||||
self.byte_total = None;
|
self.byte_total = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_progress(&mut self, current: u64, total: Option<u64>) {
|
fn show_progress(&mut self, current: u64, total: Option<u64>) {
|
||||||
|
self.last_progress_bytes = Some(current);
|
||||||
|
|
||||||
if !self.interactive {
|
if !self.interactive {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +201,14 @@ impl Default for TerminalProgressReporter {
|
||||||
impl ProgressReporter for TerminalProgressReporter {
|
impl ProgressReporter for TerminalProgressReporter {
|
||||||
fn report(&mut self, event: &OperationEvent) {
|
fn report(&mut self, event: &OperationEvent) {
|
||||||
match event {
|
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) {
|
if let Some(message) = event_message(event) {
|
||||||
self.show_spinner(message);
|
self.show_spinner(message);
|
||||||
}
|
}
|
||||||
|
|
@ -145,10 +217,74 @@ impl ProgressReporter for TerminalProgressReporter {
|
||||||
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
|
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
|
||||||
self.clear_progress();
|
self.clear_progress();
|
||||||
if let Some(message) = event_message(event) {
|
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<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
crate::ui::theme::heading("Installation Summary"),
|
crate::ui::theme::heading(&format!(
|
||||||
format!(
|
"Installed {} ({scope})",
|
||||||
"{} {} ({})",
|
added.record.display_name
|
||||||
crate::ui::theme::label("Application"),
|
)),
|
||||||
added.record.display_name,
|
|
||||||
added.record.stable_id,
|
|
||||||
),
|
|
||||||
format!("{} {scope}", crate::ui::theme::label("Install scope")),
|
|
||||||
format!(
|
format!(
|
||||||
"{} {} {}",
|
"{} {} {}",
|
||||||
crate::ui::theme::label("Source"),
|
crate::ui::theme::label("Source"),
|
||||||
|
|
@ -54,13 +50,22 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
|
||||||
added.source.locator,
|
added.source.locator,
|
||||||
),
|
),
|
||||||
format!(
|
format!(
|
||||||
"{} {} [{}]",
|
"{} {}",
|
||||||
crate::ui::theme::label("Selected artifact"),
|
crate::ui::theme::label("Artifact"),
|
||||||
added.selected_artifact.url,
|
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.extend(warning_lines);
|
||||||
lines.join("\n")
|
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");
|
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 {
|
for row in rows {
|
||||||
output.push_str(&format!(
|
lines.push(format_list_row(
|
||||||
"{}\n",
|
&row.display_name,
|
||||||
crate::ui::theme::bullet(&format!("{} ({})", row.display_name, row.stable_id))
|
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 {
|
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()
|
.iter()
|
||||||
.map(|warning| format!("Warning: {warning}"))
|
.map(|warning| format!("Warning: {warning}"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut lines = vec![
|
let mut lines = vec![crate::ui::theme::heading(&format!(
|
||||||
crate::ui::theme::heading("Removal Summary"),
|
"Removed {}",
|
||||||
format!(
|
removed.removed.display_name,
|
||||||
"{} {}",
|
))];
|
||||||
crate::ui::theme::label("Removed app"),
|
|
||||||
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.extend(warning_lines);
|
||||||
lines.join("\n")
|
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 {
|
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
crate::ui::theme::heading("Update Summary"),
|
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 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 {
|
pub fn dialog_theme() -> ColorfulTheme {
|
||||||
ColorfulTheme::default()
|
ColorfulTheme::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn heading(title: &str) -> String {
|
pub fn heading(title: &str) -> String {
|
||||||
style(title).bold().to_string()
|
apply_style_spec(title, ¤t_theme().heading)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn label(title: &str) -> String {
|
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 {
|
pub fn muted(message: &str) -> String {
|
||||||
style(message).dim().to_string()
|
apply_style_spec(message, ¤t_theme().muted)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bullet(message: &str) -> String {
|
pub fn bullet(message: &str) -> String {
|
||||||
format!("- {message}")
|
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)
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Bat (bat)"));
|
.stdout(contains("Name"))
|
||||||
|
.stdout(contains("Version"))
|
||||||
|
.stdout(contains("Source"))
|
||||||
|
.stdout(contains("Bat"))
|
||||||
|
.stdout(contains("Bat (bat)").not());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -56,8 +60,9 @@ fn remove_command_removes_registered_app_from_registry_file() {
|
||||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Removal Summary"))
|
.stdout(contains("Removed Bat"))
|
||||||
.stdout(contains("Removed app: Bat"));
|
.stdout(contains("Removal Summary").not())
|
||||||
|
.stdout(contains("Removed app:").not());
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(!contents.contains("stable_id = \"bat\""));
|
assert!(!contents.contains("stable_id = \"bat\""));
|
||||||
|
|
@ -90,8 +95,14 @@ fn remove_command_uninstalls_managed_files() {
|
||||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Removal Summary"))
|
.stdout(contains("\nRemoved bat"))
|
||||||
.stdout(contains("Removed app: 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!(!payload_path.exists());
|
||||||
assert!(!desktop_path.exists());
|
assert!(!desktop_path.exists());
|
||||||
|
|
@ -109,8 +120,16 @@ fn query_command_registers_unambiguous_app_in_registry_file() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Installation Summary"))
|
.stdout(contains("\nInstalled bat (user)"))
|
||||||
.stdout(contains("Application: bat (sharkdp-bat)"));
|
.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();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
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")
|
.env("AIM_TRACKING_PREFERENCE", "latest")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Installation Summary"))
|
.stdout(contains("\nInstalled t3code (user)"))
|
||||||
.stdout(contains("Application: t3code (pingdotgg-t3code)"))
|
.stdout(contains("Installed t3code (user)"))
|
||||||
.stdout(contains("Install scope: 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();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
||||||
|
|
@ -167,8 +193,11 @@ fn cli_add_installs_and_renders_resolved_mode() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Installation Summary"))
|
.stdout(contains("\nInstalled bat (user)"))
|
||||||
.stdout(contains("Selected artifact"));
|
.stdout(contains("Installed bat (user)"))
|
||||||
|
.stdout(contains("Artifact:"))
|
||||||
|
.stdout(contains("Installed files"))
|
||||||
|
.stdout(contains("Completed steps").not());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -183,8 +212,16 @@ fn cli_add_emits_live_progress_to_stderr() {
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stderr(contains("Installing sharkdp/bat"))
|
.stderr(contains("Installing sharkdp/bat"))
|
||||||
|
.stderr(contains("Resolving source"))
|
||||||
|
.stderr(contains("Discovering release"))
|
||||||
|
.stderr(contains("Selecting artifact"))
|
||||||
.stderr(contains("Downloading 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]
|
#[test]
|
||||||
|
|
@ -258,8 +295,7 @@ fn system_request_on_immutable_host_falls_back_to_user_install() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(contains("Installation Summary"))
|
.stdout(contains("Installed bat (user)"))
|
||||||
.stdout(contains("Install scope: user"))
|
|
||||||
.stdout(contains("downgraded to user scope"));
|
.stdout(contains("downgraded to user scope"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
use aim_cli::DispatchResult;
|
use aim_cli::DispatchResult;
|
||||||
use aim_cli::ui::prompt::render_interaction;
|
use aim_cli::ui::prompt::render_interaction;
|
||||||
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
|
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::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::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
|
||||||
|
use aim_core::integration::install::InstallOutcome;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_summary_mentions_selected_count() {
|
fn update_summary_mentions_selected_count() {
|
||||||
|
|
@ -22,6 +29,24 @@ fn list_empty_state_uses_friendlier_copy() {
|
||||||
assert!(output.contains("No installed apps yet"));
|
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]
|
#[test]
|
||||||
fn review_flow_uses_clearer_summary_labels() {
|
fn review_flow_uses_clearer_summary_labels() {
|
||||||
let output = render_dispatch_result(&DispatchResult::UpdatePlan(UpdatePlan {
|
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"));
|
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]
|
#[test]
|
||||||
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
fn tracking_prompt_mentions_requested_and_latest_versions() {
|
||||||
let output = render_interaction(&InteractionRequest {
|
let output = render_interaction(&InteractionRequest {
|
||||||
|
|
@ -68,3 +117,63 @@ fn tracking_prompt_uses_explicit_question_copy() {
|
||||||
|
|
||||||
assert!(output.contains("Choose update tracking"));
|
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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,37 @@ const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
|
||||||
|
|
||||||
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
|
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
|
||||||
let transport = crate::source::github::default_transport();
|
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>(
|
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
||||||
query: &str,
|
query: &str,
|
||||||
transport: &T,
|
transport: &T,
|
||||||
) -> Result<AddPlan, BuildAddPlanError> {
|
) -> 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 source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
|
||||||
|
|
||||||
let mut interactions = Vec::new();
|
let mut interactions = Vec::new();
|
||||||
let mut parsed_metadata = Vec::new();
|
let mut parsed_metadata = Vec::new();
|
||||||
let (resolution, selected_artifact, update_strategy) = match source.kind {
|
let (resolution, selected_artifact, update_strategy) = match source.kind {
|
||||||
SourceKind::GitHub => {
|
SourceKind::GitHub => {
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::DiscoverRelease,
|
||||||
|
message: "discovering release".to_owned(),
|
||||||
|
});
|
||||||
let discovery = discover_github_candidates_with(&source, transport)
|
let discovery = discover_github_candidates_with(&source, transport)
|
||||||
.map_err(BuildAddPlanError::GitHubDiscovery)?;
|
.map_err(BuildAddPlanError::GitHubDiscovery)?;
|
||||||
for document in &discovery.metadata_documents {
|
for document in &discovery.metadata_documents {
|
||||||
|
|
@ -60,6 +78,10 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
||||||
.iter()
|
.iter()
|
||||||
.find(|item| item.hints.primary_download.is_some())
|
.find(|item| item.hints.primary_download.is_some())
|
||||||
.map(|item| &item.hints);
|
.map(|item| &item.hints);
|
||||||
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
stage: OperationStage::SelectArtifact,
|
||||||
|
message: "selecting artifact".to_owned(),
|
||||||
|
});
|
||||||
let artifact = select_artifact(&preferred, metadata_hints);
|
let artifact = select_artifact(&preferred, metadata_hints);
|
||||||
|
|
||||||
if discovery.requested_is_older_release {
|
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 {
|
let resolution = AdapterResolution {
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
release: ResolvedRelease {
|
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 {
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
stage: OperationStage::StagePayload,
|
stage: OperationStage::StagePayload,
|
||||||
message: "staging payload".to_owned(),
|
message: "staging payload".to_owned(),
|
||||||
|
|
@ -280,6 +295,20 @@ pub fn install_app_with_reporter(
|
||||||
})
|
})
|
||||||
.map_err(InstallAppError::Install)?;
|
.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 {
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
stage: OperationStage::RefreshIntegration,
|
stage: OperationStage::RefreshIntegration,
|
||||||
message: "refreshing desktop integration".to_owned(),
|
message: "refreshing desktop integration".to_owned(),
|
||||||
|
|
@ -308,6 +337,7 @@ pub fn install_app_with_reporter(
|
||||||
let installed = InstalledApp {
|
let installed = InstalledApp {
|
||||||
record,
|
record,
|
||||||
selected_artifact: plan.selected_artifact.clone(),
|
selected_artifact: plan.selected_artifact.clone(),
|
||||||
|
artifact_size_bytes: artifact_bytes.len() as u64,
|
||||||
source: plan.resolution.source.clone(),
|
source: plan.resolution.source.clone(),
|
||||||
install_scope: policy.scope,
|
install_scope: policy.scope,
|
||||||
integration_mode: policy.integration_mode,
|
integration_mode: policy.integration_mode,
|
||||||
|
|
@ -326,6 +356,7 @@ pub fn install_app_with_reporter(
|
||||||
pub struct InstalledApp {
|
pub struct InstalledApp {
|
||||||
pub record: AppRecord,
|
pub record: AppRecord,
|
||||||
pub selected_artifact: ArtifactCandidate,
|
pub selected_artifact: ArtifactCandidate,
|
||||||
|
pub artifact_size_bytes: u64,
|
||||||
pub source: crate::domain::source::SourceRef,
|
pub source: crate::domain::source::SourceRef,
|
||||||
pub install_scope: InstallScope,
|
pub install_scope: InstallScope,
|
||||||
pub integration_mode: IntegrationMode,
|
pub integration_mode: IntegrationMode,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ use crate::domain::app::AppRecord;
|
||||||
pub struct ListRow {
|
pub struct ListRow {
|
||||||
pub stable_id: String,
|
pub stable_id: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
|
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 {
|
.map(|app| ListRow {
|
||||||
stable_id: app.stable_id.clone(),
|
stable_id: app.stable_id.clone(),
|
||||||
display_name: app.display_name.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ pub fn remove_registered_app_with_reporter(
|
||||||
stage: OperationStage::StagePayload,
|
stage: OperationStage::StagePayload,
|
||||||
message: "removing managed artifacts".to_owned(),
|
message: "removing managed artifacts".to_owned(),
|
||||||
});
|
});
|
||||||
let warnings = delete_artifacts(&plan)?;
|
let deletion = delete_artifacts(&plan)?;
|
||||||
let remaining_apps = apps
|
let remaining_apps = apps
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|candidate| candidate.stable_id != app.stable_id)
|
.filter(|candidate| candidate.stable_id != app.stable_id)
|
||||||
|
|
@ -102,8 +102,9 @@ pub fn remove_registered_app_with_reporter(
|
||||||
|
|
||||||
let result = RemovalResult {
|
let result = RemovalResult {
|
||||||
removed: plan,
|
removed: plan,
|
||||||
|
removed_paths: deletion.removed_paths,
|
||||||
remaining_apps,
|
remaining_apps,
|
||||||
warnings,
|
warnings: deletion.warnings,
|
||||||
};
|
};
|
||||||
|
|
||||||
reporter.report(&OperationEvent::StageChanged {
|
reporter.report(&OperationEvent::StageChanged {
|
||||||
|
|
@ -120,6 +121,7 @@ pub fn remove_registered_app_with_reporter(
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
pub struct RemovalResult {
|
pub struct RemovalResult {
|
||||||
pub removed: RemovalPlan,
|
pub removed: RemovalPlan,
|
||||||
|
pub removed_paths: Vec<String>,
|
||||||
pub remaining_apps: Vec<AppRecord>,
|
pub remaining_apps: Vec<AppRecord>,
|
||||||
pub warnings: Vec<String>,
|
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 desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
|
||||||
let icon_path = plan.artifact_paths.get(2).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 {
|
for artifact_path in &plan.artifact_paths {
|
||||||
match fs::remove_file(artifact_path) {
|
match fs::remove_file(artifact_path) {
|
||||||
Ok(()) => {}
|
Ok(()) => removed_paths.push(artifact_path.clone()),
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::app::progress::{OperationEvent, OperationStage};
|
||||||
use aim_core::domain::app::InstallScope;
|
use aim_core::domain::app::InstallScope;
|
||||||
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
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]
|
#[test]
|
||||||
fn install_app_reports_operation_stages_in_order() {
|
fn install_app_reports_operation_stages_in_order() {
|
||||||
let root = tempdir().unwrap();
|
let root = tempdir().unwrap();
|
||||||
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
|
|
||||||
let mut events: Vec<OperationEvent> = Vec::new();
|
let mut events: Vec<OperationEvent> = Vec::new();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
@ -142,6 +141,9 @@ fn install_app_reports_operation_stages_in_order() {
|
||||||
|
|
||||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
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(
|
let installed = install_app_with_reporter(
|
||||||
"sharkdp/bat",
|
"sharkdp/bat",
|
||||||
&plan,
|
&plan,
|
||||||
|
|
@ -152,6 +154,18 @@ fn install_app_reports_operation_stages_in_order() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(installed.record.stable_id, "sharkdp-bat");
|
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 {
|
assert!(events.contains(&OperationEvent::StageChanged {
|
||||||
stage: OperationStage::DownloadArtifact,
|
stage: OperationStage::DownloadArtifact,
|
||||||
message: "downloading artifact".to_owned(),
|
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,
|
||||||
|
]
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use aim_core::app::remove::{
|
||||||
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
|
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
|
||||||
};
|
};
|
||||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||||
|
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
|
@ -20,9 +21,18 @@ fn list_flow_returns_display_rows_for_registered_apps() {
|
||||||
let rows = build_list_rows(&[AppRecord {
|
let rows = build_list_rows(&[AppRecord {
|
||||||
stable_id: "bat".to_owned(),
|
stable_id: "bat".to_owned(),
|
||||||
display_name: "Bat".to_owned(),
|
display_name: "Bat".to_owned(),
|
||||||
source_input: None,
|
source_input: Some("sharkdp/bat".to_owned()),
|
||||||
source: None,
|
source: Some(SourceRef {
|
||||||
installed_version: None,
|
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,
|
update_strategy: None,
|
||||||
metadata: Vec::new(),
|
metadata: Vec::new(),
|
||||||
install: None,
|
install: None,
|
||||||
|
|
@ -31,6 +41,8 @@ fn list_flow_returns_display_rows_for_registered_apps() {
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].stable_id, "bat");
|
assert_eq!(rows[0].stable_id, "bat");
|
||||||
assert_eq!(rows[0].display_name, "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]
|
#[test]
|
||||||
|
|
@ -162,6 +174,7 @@ fn remove_flow_reports_resolution_and_cleanup_events() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.removed.stable_id, "bat");
|
assert_eq!(result.removed.stable_id, "bat");
|
||||||
|
assert_eq!(result.removed_paths.len(), 0);
|
||||||
assert!(events.iter().any(|event| {
|
assert!(events.iter().any(|event| {
|
||||||
matches!(
|
matches!(
|
||||||
event,
|
event,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue