feat: finalize search UX and release hardening

This commit is contained in:
stoorps 2026-03-21 16:53:33 +00:00
parent c63b2917da
commit 34f9543a78
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
44 changed files with 4983 additions and 94 deletions

View file

@ -15,8 +15,12 @@ path = "src/main.rs"
clap.workspace = true
dialoguer.workspace = true
console.workspace = true
crossterm.workspace = true
indicatif.workspace = true
libc.workspace = true
ratatui.workspace = true
serde.workspace = true
toml.workspace = true
aim-core = { path = "../aim-core" }
[dev-dependencies]

View file

@ -26,5 +26,6 @@ impl Cli {
pub enum Command {
Remove { query: String },
List,
Search { query: String },
Update,
}

View file

@ -0,0 +1,130 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize)]
pub struct CliConfig {
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub theme: ThemeConfig,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
pub struct SearchConfig {
#[serde(default = "default_true")]
pub bottom_to_top: bool,
#[serde(default)]
pub skip_confirmation: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
bottom_to_top: true,
skip_confirmation: false,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
pub struct ThemeConfig {
#[serde(default = "default_accent")]
pub accent: String,
#[serde(default = "default_accent_secondary")]
pub accent_secondary: String,
#[serde(default = "default_dim")]
pub dim: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
accent: default_accent(),
accent_secondary: default_accent_secondary(),
dim: default_dim(),
}
}
}
pub fn load() -> Result<CliConfig, ConfigError> {
load_from_path(&default_path())
}
pub fn load_from_path(path: &Path) -> Result<CliConfig, ConfigError> {
match fs::read_to_string(path) {
Ok(contents) => toml::from_str(&contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
}),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()),
Err(source) => Err(ConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn default_path() -> PathBuf {
if let Some(path) = env::var_os("AIM_CONFIG_PATH") {
return PathBuf::from(path);
}
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
return PathBuf::from(config_home).join("aim/config.toml");
}
let home = env::var_os("HOME").unwrap_or_else(|| ".".into());
PathBuf::from(home).join(".config/aim/config.toml")
}
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
source: toml::de::Error,
},
}
fn default_true() -> bool {
true
}
fn default_accent() -> String {
"#b388ff".to_owned()
}
fn default_accent_secondary() -> String {
"#d5c2ff".to_owned()
}
fn default_dim() -> String {
"#7f7396".to_owned()
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read { path, source } => {
write!(
formatter,
"failed to read config {}: {source}",
path.display()
)
}
Self::Parse { path, source } => {
write!(
formatter,
"failed to parse config {}: {source}",
path.display()
)
}
}
}
}
impl std::error::Error for ConfigError {}

View file

@ -1,6 +1,8 @@
pub mod cli;
pub mod config;
pub mod ui;
use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
@ -8,12 +10,15 @@ use aim_core::app::add::{
AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope,
};
use aim_core::app::list::{ListRow, build_list_rows};
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
use aim_core::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
use aim_core::app::search::build_search_results;
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
use aim_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchQuery, SearchResults};
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
use aim_core::registry::model::Registry;
use aim_core::registry::store::RegistryStore;
pub use cli::args::Cli;
@ -47,30 +52,37 @@ pub fn dispatch_with_reporter(
cli::args::Command::Remove { query } => {
let removal =
remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?;
let remaining_apps = removal.remaining_apps.clone();
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: remaining_apps,
store.mutate_exclusive(|latest| {
remove_app_record(&mut latest.apps, &removal.removed.stable_id);
})?;
reporter.report(&OperationEvent::Finished {
summary: format!("removed {}", removal.removed.stable_id),
});
Ok(DispatchResult::Removed(Box::new(removal)))
}
cli::args::Command::Search { query } => {
reporter.report(&OperationEvent::Started {
kind: OperationKind::Search,
label: query.clone(),
});
let results = build_search_results(&SearchQuery::new(&query), &apps)?;
reporter.report(&OperationEvent::Finished {
summary: format!("search complete: {} remote hits", results.remote_hits.len()),
});
Ok(DispatchResult::Search(results))
}
cli::args::Command::Update => {
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
let updated_apps = updates.apps.clone();
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: updated_apps,
store.mutate_exclusive(|latest| {
merge_updated_app_records(&mut latest.apps, &apps, &updates.apps);
})?;
reporter.report(&OperationEvent::Finished {
summary: format!(
@ -98,15 +110,12 @@ pub fn dispatch_with_reporter(
let installed =
install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?;
let mut updated_apps = registry.apps.clone();
upsert_app_record(&mut updated_apps, installed.record.clone());
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SaveRegistry,
message: "saving registry".to_owned(),
});
store.save(&Registry {
version: registry.version,
apps: updated_apps,
store.mutate_exclusive(|latest| {
upsert_app_record(&mut latest.apps, installed.record.clone());
})?;
reporter.report(&OperationEvent::Finished {
summary: format!("installed {}", installed.record.stable_id),
@ -119,7 +128,11 @@ pub fn dispatch_with_reporter(
}
pub fn render(result: &DispatchResult) -> String {
ui::render::render_dispatch_result(result)
render_with_config(result, &config::CliConfig::default())
}
pub fn render_with_config(result: &DispatchResult, config: &config::CliConfig) -> String {
ui::render::render_dispatch_result_with_config(result, config)
}
fn registry_path() -> PathBuf {
@ -137,6 +150,7 @@ pub enum DispatchResult {
List(Vec<ListRow>),
PendingAdd(Box<AddPlan>),
Removed(Box<RemovalResult>),
Search(SearchResults),
UpdatePlan(UpdatePlan),
Updated(Box<UpdateExecutionResult>),
Noop,
@ -149,6 +163,7 @@ pub enum DispatchError {
Prompt(ui::prompt::PromptError),
RemovePlan(aim_core::app::remove::RemoveRegisteredAppError),
Registry(aim_core::registry::store::RegistryStoreError),
Search(aim_core::app::search::SearchError),
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
UpdateExecution(aim_core::app::update::ExecuteUpdatesError),
}
@ -195,6 +210,12 @@ impl From<aim_core::registry::store::RegistryStoreError> for DispatchError {
}
}
impl From<aim_core::app::search::SearchError> for DispatchError {
fn from(value: aim_core::app::search::SearchError) -> Self {
Self::Search(value)
}
}
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
if let Some(existing) = apps
.iter_mut()
@ -207,6 +228,33 @@ fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
apps.push(record);
}
fn remove_app_record(apps: &mut Vec<AppRecord>, stable_id: &str) {
apps.retain(|app| app.stable_id != stable_id);
}
fn merge_updated_app_records(
latest_apps: &mut [AppRecord],
original_apps: &[AppRecord],
updated_apps: &[AppRecord],
) {
let original_ids = original_apps
.iter()
.map(|app| app.stable_id.as_str())
.collect::<HashSet<_>>();
let updated_by_id = updated_apps
.iter()
.map(|app| (app.stable_id.as_str(), app.clone()))
.collect::<HashMap<_, _>>();
for app in latest_apps.iter_mut() {
if original_ids.contains(app.stable_id.as_str())
&& let Some(updated) = updated_by_id.get(app.stable_id.as_str())
{
*app = updated.clone();
}
}
}
fn install_home(registry_path: &Path) -> PathBuf {
if env::var_os("AIM_REGISTRY_PATH").is_some() {
return registry_path

View file

@ -1,9 +1,17 @@
fn main() {
let config = match aim_cli::config::load() {
Ok(config) => config,
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
};
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);
let output = aim_cli::render_with_config(&result, &config);
if !output.is_empty() {
println!("{output}");
}

View file

@ -1,4 +1,5 @@
pub mod progress;
pub mod prompt;
pub mod render;
pub mod search_browser;
pub mod theme;

View file

@ -23,6 +23,7 @@ pub fn byte_style() -> ProgressStyle {
pub fn operation_label(kind: OperationKind) -> &'static str {
match kind {
OperationKind::Add => "Installing",
OperationKind::Search => "Searching",
OperationKind::UpdateBatch => "Updating",
OperationKind::UpdateItem => "Updating",
OperationKind::Remove => "Removing",

View file

@ -1,7 +1,9 @@
use aim_core::app::add::AddPlan;
use aim_core::domain::search::SearchResults;
use aim_core::domain::update::UpdateExecutionStatus;
use crate::DispatchResult;
use crate::config::CliConfig;
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
[
@ -14,11 +16,16 @@ pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> St
}
pub fn render_dispatch_result(result: &DispatchResult) -> String {
render_dispatch_result_with_config(result, &CliConfig::default())
}
pub fn render_dispatch_result_with_config(result: &DispatchResult, config: &CliConfig) -> String {
match result {
DispatchResult::Added(added) => render_added_app(added),
DispatchResult::List(rows) => render_list(rows),
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
DispatchResult::Removed(removed) => render_removed_app(removed),
DispatchResult::Search(results) => render_search_results_with_config(results, config),
DispatchResult::UpdatePlan(plan) => render_update_plan(plan),
DispatchResult::Updated(result) => render_updated_apps(result),
DispatchResult::Noop => String::new(),
@ -119,6 +126,67 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
lines.join("\n")
}
fn render_search_results(results: &SearchResults) -> String {
let mut lines = vec![crate::ui::theme::heading("Search Results")];
lines.push(crate::ui::theme::heading("Remote Results"));
if results.remote_hits.is_empty() {
lines.push(crate::ui::theme::muted("No remote matches"));
} else {
for hit in &results.remote_hits {
lines.push(crate::ui::theme::bullet(&format!(
"[{}] {}",
hit.provider_id, hit.display_name
)));
lines.push(format!("Install query: {}", hit.install_query));
lines.push(format!("Source: {}", hit.source_locator));
if let Some(description) = &hit.description {
lines.push(format!("Description: {description}"));
}
}
}
lines.push(crate::ui::theme::heading("Installed Matches"));
if results.installed_matches.is_empty() {
lines.push(crate::ui::theme::muted("No installed matches"));
} else {
for app in &results.installed_matches {
lines.push(crate::ui::theme::bullet(&format!(
"{} ({})",
app.display_name, app.stable_id
)));
}
}
if !results.warnings.is_empty() {
lines.push(crate::ui::theme::heading("Warnings"));
for warning in &results.warnings {
match warning.provider_id.as_deref() {
Some(provider_id) => {
lines.push(format!("Warning: {provider_id}: {}", warning.message))
}
None => lines.push(format!("Warning: {}", warning.message)),
}
}
}
lines.join("\n")
}
fn render_search_results_with_config(results: &SearchResults, config: &CliConfig) -> String {
if crate::ui::search_browser::can_launch(results) {
match crate::ui::search_browser::run(results, config) {
Ok(Some(selection)) => {
return crate::ui::search_browser::render_confirmation_summary(&selection.rows);
}
Ok(None) => return String::new(),
Err(_) => {}
}
}
render_search_results(results)
}
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
let mut lines = vec![
crate::ui::theme::heading("Update Summary"),

View file

@ -0,0 +1,848 @@
use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::time::Duration;
use aim_core::domain::search::{SearchInstallStatus, SearchResult, SearchResults};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use crate::config::{CliConfig, SearchConfig};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BrowserPhase {
Browsing,
Confirming,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchRow {
pub status: SearchInstallStatus,
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub install_query: String,
pub version: Option<String>,
pub selectable: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchSelection {
pub rows: Vec<SearchRow>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubmitAction {
None,
Confirming,
Confirmed(SearchSelection),
}
pub struct SearchBrowserState {
rows: Vec<SearchRow>,
query_text: String,
selected: BTreeSet<usize>,
cursor: usize,
page_size: usize,
phase: BrowserPhase,
numeric_buffer: String,
status_message: Option<String>,
}
impl SearchBrowserState {
pub fn new(results: Vec<SearchResult>, config: SearchConfig, page_size: usize) -> Self {
Self::new_with_query(results, String::new(), config, page_size)
}
pub fn new_with_query(
results: Vec<SearchResult>,
query_text: String,
config: SearchConfig,
page_size: usize,
) -> Self {
let mut rows = results
.into_iter()
.map(|result| SearchRow {
selectable: !matches!(result.install_status, SearchInstallStatus::Installed { .. }),
status: result.install_status,
provider_id: result.provider_id,
display_name: result.display_name,
description: result.description,
install_query: result.install_query,
version: result.version,
})
.collect::<Vec<_>>();
if config.bottom_to_top {
rows.reverse();
}
Self {
rows,
query_text,
selected: BTreeSet::new(),
cursor: 0,
page_size: page_size.max(1),
phase: BrowserPhase::Browsing,
numeric_buffer: String::new(),
status_message: None,
}
}
pub fn ordered_rows(&self) -> &[SearchRow] {
&self.rows
}
pub fn query_text(&self) -> &str {
&self.query_text
}
pub fn selected_rows(&self) -> Vec<&SearchRow> {
self.selected
.iter()
.filter_map(|index| self.rows.get(*index))
.collect()
}
pub fn selected_rows_owned(&self) -> Vec<SearchRow> {
self.selected_rows().into_iter().cloned().collect()
}
pub fn selection_expression(&self) -> String {
compress_selection_ranges(
&self
.selected
.iter()
.map(|index| index + 1)
.collect::<Vec<_>>(),
)
}
pub fn selection_prompt_value(&self) -> String {
if self.numeric_buffer.is_empty() {
self.selection_expression()
} else {
self.numeric_buffer.clone()
}
}
pub fn phase(&self) -> BrowserPhase {
self.phase
}
pub fn cursor_position(&self) -> usize {
self.cursor
}
pub fn selection_count(&self) -> usize {
self.selected.len()
}
pub fn has_selection(&self) -> bool {
!self.selected.is_empty()
}
pub fn numeric_buffer(&self) -> &str {
&self.numeric_buffer
}
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn page_bounds(&self) -> (usize, usize) {
let start = (self.cursor / self.page_size) * self.page_size;
let end = (start + self.page_size).min(self.rows.len());
(start, end)
}
pub fn move_next(&mut self) {
if self.cursor + 1 < self.rows.len() {
self.cursor += 1;
}
}
pub fn move_previous(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_to_top(&mut self) {
self.cursor = 0;
}
pub fn move_to_bottom(&mut self) {
if !self.rows.is_empty() {
self.cursor = self.rows.len() - 1;
}
}
pub fn page_down(&mut self) {
if self.rows.is_empty() {
return;
}
let next_page = ((self.cursor / self.page_size) + 1) * self.page_size;
self.cursor = next_page.min(self.rows.len().saturating_sub(1));
}
pub fn page_up(&mut self) {
self.cursor = self.cursor.saturating_sub(self.cursor % self.page_size);
self.cursor = self.cursor.saturating_sub(self.page_size);
}
pub fn toggle_current_selection(&mut self) {
if self
.rows
.get(self.cursor)
.is_some_and(|row| !row.selectable)
{
self.set_status_message("installed result is not selectable");
return;
}
if !self.selected.insert(self.cursor) {
self.selected.remove(&self.cursor);
}
self.clear_status_message();
}
pub fn enter_confirmation(&mut self) -> bool {
if self.selected.is_empty() {
return false;
}
self.phase = BrowserPhase::Confirming;
true
}
pub fn cancel_confirmation(&mut self) {
self.phase = BrowserPhase::Browsing;
}
pub fn apply_numeric_selection(&mut self, input: &str) -> Result<(), String> {
let parsed = parse_selection(input, self.rows.len())?;
self.selected = parsed
.into_iter()
.filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable))
.collect();
Ok(())
}
pub fn submit_selection(&mut self, skip_confirmation: bool) -> SubmitAction {
if !self.has_selection() {
self.set_status_message("select at least one result");
return SubmitAction::None;
}
if skip_confirmation {
return SubmitAction::Confirmed(SearchSelection {
rows: self.selected_rows_owned(),
});
}
self.enter_confirmation();
SubmitAction::Confirming
}
pub fn push_numeric_input(&mut self, character: char) {
self.numeric_buffer.push(character);
self.refresh_selection_from_numeric_buffer();
}
pub fn pop_numeric_input(&mut self) {
self.numeric_buffer.pop();
self.refresh_selection_from_numeric_buffer();
}
pub fn clear_numeric_input(&mut self) {
self.numeric_buffer.clear();
}
pub fn set_status_message(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
}
pub fn clear_status_message(&mut self) {
self.status_message = None;
}
fn is_selected(&self, index: usize) -> bool {
self.selected.contains(&index)
}
fn refresh_selection_from_numeric_buffer(&mut self) {
let trimmed = self.numeric_buffer.trim();
if trimmed.is_empty() {
return;
}
if let Ok(parsed) = parse_selection(trimmed, self.rows.len()) {
self.selected = parsed
.into_iter()
.filter(|index| self.rows.get(*index).is_some_and(|row| row.selectable))
.collect();
}
}
}
#[derive(Debug)]
pub enum SearchBrowserError {
Terminal(std::io::Error),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HighlightSegment {
pub text: String,
pub is_match: bool,
}
pub fn can_launch(results: &SearchResults) -> bool {
!results.remote_hits.is_empty()
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
}
pub fn run(
results: &SearchResults,
config: &CliConfig,
) -> Result<Option<SearchSelection>, SearchBrowserError> {
let mut stdout = std::io::stdout();
enable_raw_mode().map_err(SearchBrowserError::Terminal)?;
execute!(stdout, EnterAlternateScreen).map_err(SearchBrowserError::Terminal)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(SearchBrowserError::Terminal)?;
let outcome = run_loop(&mut terminal, results, config);
let leave_screen = execute!(terminal.backend_mut(), LeaveAlternateScreen);
let show_cursor = terminal.show_cursor();
let disable_raw = disable_raw_mode();
if let Err(error) = leave_screen {
return Err(SearchBrowserError::Terminal(error));
}
if let Err(error) = show_cursor {
return Err(SearchBrowserError::Terminal(error));
}
if let Err(error) = disable_raw {
return Err(SearchBrowserError::Terminal(error));
}
outcome
}
pub fn format_search_row(
index: usize,
row: &SearchRow,
selected: bool,
active: bool,
width: usize,
) -> String {
let cursor = if active { ">" } else { " " };
let marker = if selected { "[*]" } else { "[ ]" };
let status = match &row.status {
SearchInstallStatus::Available => "",
SearchInstallStatus::Installed { .. } => "[installed] ",
SearchInstallStatus::UpdateAvailable { .. } => "[update] ",
};
let version = row
.version
.as_deref()
.map(|value| format!(" v{value}"))
.unwrap_or_default();
let first_line = format!(
"{cursor}{marker} {index:>2}. {status}{}{version}",
row.display_name
);
let second_line = match row.description.as_deref() {
Some(description) => format!("{} - {description}", row.provider_id),
None => row.provider_id.clone(),
};
format!(
"{}\n{}",
truncate_line(&first_line, width),
truncate_line(&format!(" {second_line}"), width)
)
}
pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
let normalized_query = query.trim().to_ascii_lowercase();
if normalized_query.is_empty() {
return vec![HighlightSegment {
text: text.to_owned(),
is_match: false,
}];
}
let normalized_text = text.to_ascii_lowercase();
let mut start = 0;
let mut segments = Vec::new();
while let Some(relative_match) = normalized_text[start..].find(&normalized_query) {
let match_start = start + relative_match;
let match_end = match_start + normalized_query.len();
if match_start > start {
segments.push(HighlightSegment {
text: text[start..match_start].to_owned(),
is_match: false,
});
}
segments.push(HighlightSegment {
text: text[match_start..match_end].to_owned(),
is_match: true,
});
start = match_end;
}
if start < text.len() {
segments.push(HighlightSegment {
text: text[start..].to_owned(),
is_match: false,
});
}
if segments.is_empty() {
segments.push(HighlightSegment {
text: text.to_owned(),
is_match: false,
});
}
segments
}
pub fn render_confirmation_summary(rows: &[SearchRow]) -> String {
let mut lines = vec![crate::ui::theme::heading("Confirm Search Selection")];
lines.push(format!("selected results: {}", rows.len()));
for row in rows {
lines.push(format!(
"{} [{}] {}",
crate::ui::theme::bullet(&row.display_name),
row.provider_id,
row.version
.as_deref()
.map(|value| format!("{} (v{value})", row.install_query))
.unwrap_or_else(|| row.install_query.clone())
));
}
lines.join("\n")
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
results: &SearchResults,
config: &CliConfig,
) -> Result<Option<SearchSelection>, SearchBrowserError> {
let mut state = SearchBrowserState::new_with_query(
results.remote_hits.clone(),
results.query_text.clone(),
config.search.clone(),
10,
);
loop {
terminal
.draw(|frame| draw_browser(frame, &state, results, config))
.map_err(SearchBrowserError::Terminal)?;
if !event::poll(Duration::from_millis(250)).map_err(SearchBrowserError::Terminal)? {
continue;
}
let Event::Key(key) = event::read().map_err(SearchBrowserError::Terminal)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if let Some(outcome) = handle_key_event(&mut state, key.code, key.modifiers, &config.search)
{
return Ok(outcome);
}
}
}
fn draw_browser(
frame: &mut Frame<'_>,
state: &SearchBrowserState,
_results: &SearchResults,
config: &CliConfig,
) {
let palette = crate::ui::theme::search_browser_palette(&config.theme);
if state.phase() == BrowserPhase::Confirming {
let area = centered_rect(frame.area(), 70, 40);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(render_confirmation_summary(&state.selected_rows_owned()))
.style(palette.text_style()),
area,
);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(5),
Constraint::Length(3),
])
.split(frame.area());
let header = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(layout[0]);
let header_top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(24)])
.split(header[0]);
let (start, end) = state.page_bounds();
frame.render_widget(
Paragraph::new(Line::styled("Search Results", palette.heading_style())),
header_top[0],
);
frame.render_widget(
Paragraph::new(vec![
Line::styled(
format!(
"Showing {}-{} of {}",
start + 1,
end,
state.ordered_rows().len()
),
palette.muted_style(),
),
Line::styled(
format!("Selected {}", state.selection_count()),
palette.muted_style(),
),
])
.alignment(Alignment::Right),
header_top[1],
);
frame.render_widget(
Paragraph::new(Line::styled(
"Enter confirm Space toggle j/k move PgUp/PgDn page g/G jump q cancel",
palette.hint_style(),
))
.wrap(Wrap { trim: true }),
header[1],
);
let width = layout[1].width as usize;
let items = state.ordered_rows()[start..end]
.iter()
.enumerate()
.map(|(offset, row)| {
let absolute = start + offset;
ListItem::new(render_search_row_lines(
absolute + 1,
row,
state.is_selected(absolute),
state.cursor_position() == absolute,
width,
palette,
state.query_text(),
))
})
.collect::<Vec<_>>();
frame.render_widget(List::new(items), layout[1]);
let status = state.status_message().unwrap_or("");
frame.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::styled("Apps to install: ", palette.text_style()),
Span::styled(state.selection_prompt_value(), palette.text_style()),
Span::styled(" eg. 1 2 3, 1-3", palette.hint_style()),
]),
Line::styled(status, palette.muted_style()),
])
.wrap(Wrap { trim: true }),
layout[2],
);
}
fn render_search_row_lines(
index: usize,
row: &SearchRow,
selected: bool,
active: bool,
width: usize,
palette: crate::ui::theme::SearchBrowserPalette,
query_text: &str,
) -> Vec<Line<'static>> {
let cursor = if active { ">" } else { " " };
let checkbox = if selected { "[*]" } else { "[ ]" };
let checkbox_style = if selected {
palette.checkbox_selected_style()
} else {
palette.checkbox_idle_style()
};
let name_style = if !row.selectable {
palette.disabled_style()
} else if active {
palette.active_name_style()
} else {
palette.text_style()
};
let index_style = if row.selectable {
palette.text_style()
} else {
palette.disabled_style()
};
let mut first_line = vec![
Span::styled(cursor.to_owned(), palette.cursor_style()),
Span::raw(" "),
Span::styled(checkbox.to_owned(), checkbox_style),
Span::styled(format!(" {index:>2}. "), index_style),
];
match row.status {
SearchInstallStatus::Available => {}
SearchInstallStatus::Installed { .. } => {
first_line.push(Span::styled(
"[installed] ".to_owned(),
name_style.add_modifier(Modifier::BOLD),
));
}
SearchInstallStatus::UpdateAvailable { .. } => {
first_line.push(Span::styled(
"[update] ".to_owned(),
name_style.add_modifier(Modifier::BOLD),
));
}
}
push_highlighted_spans(&mut first_line, &row.display_name, query_text, name_style);
if let Some(version) = &row.version {
first_line.push(Span::raw(" "));
first_line.push(Span::styled(format!("v{version}"), palette.version_style()));
}
let detail_text = match row.description.as_deref() {
Some(description) => format!("{} - {description}", row.provider_id),
None => row.provider_id.clone(),
};
let detail_text = truncate_line(&detail_text, width.saturating_sub(7));
let provider_len = row.provider_id.len().min(detail_text.len());
let (provider_text, remainder) = detail_text.split_at(provider_len);
let mut second_line = vec![Span::raw(" ")];
second_line.push(Span::styled(
provider_text.to_owned(),
palette.dim_style().add_modifier(Modifier::BOLD),
));
if !remainder.is_empty() {
push_highlighted_spans(&mut second_line, remainder, query_text, palette.dim_style());
}
vec![Line::from(first_line), Line::from(second_line)]
}
fn handle_key_event(
state: &mut SearchBrowserState,
code: KeyCode,
modifiers: KeyModifiers,
config: &SearchConfig,
) -> Option<Option<SearchSelection>> {
if state.phase() == BrowserPhase::Confirming {
return match code {
KeyCode::Enter | KeyCode::Char('y') => Some(Some(SearchSelection {
rows: state.selected_rows_owned(),
})),
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('n') => {
state.cancel_confirmation();
state.set_status_message("confirmation cancelled");
None
}
_ => None,
};
}
match code {
KeyCode::Up | KeyCode::Char('k') => {
state.move_previous();
state.clear_status_message();
}
KeyCode::Down | KeyCode::Char('j') => {
state.move_next();
state.clear_status_message();
}
KeyCode::PageDown => state.page_down(),
KeyCode::PageUp => state.page_up(),
KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => state.page_down(),
KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => state.page_up(),
KeyCode::Char('g') => state.move_to_top(),
KeyCode::Char('G') => state.move_to_bottom(),
KeyCode::Char(' ') => {
if state.numeric_buffer().is_empty() {
state.toggle_current_selection();
} else if !state.numeric_buffer().ends_with(' ') {
state.push_numeric_input(' ');
}
}
KeyCode::Char(character)
if character.is_ascii_digit() || character == ',' || character == '-' =>
{
state.push_numeric_input(character);
}
KeyCode::Backspace => state.pop_numeric_input(),
KeyCode::Enter => match state.submit_selection(config.skip_confirmation) {
SubmitAction::None | SubmitAction::Confirming => {}
SubmitAction::Confirmed(selection) => return Some(Some(selection)),
},
KeyCode::Esc | KeyCode::Char('q') => return Some(None),
_ => {}
}
None
}
fn parse_selection(input: &str, row_count: usize) -> Result<BTreeSet<usize>, String> {
let mut selected = BTreeSet::new();
for token in input
.split(|character: char| character == ',' || character.is_ascii_whitespace())
.map(str::trim)
.filter(|token| !token.is_empty())
{
if let Some((start, end)) = token.split_once('-') {
let start = parse_one_based(start, row_count, input)?;
let end = parse_one_based(end, row_count, input)?;
let (from, to) = if start <= end {
(start, end)
} else {
(end, start)
};
for index in from..=to {
selected.insert(index);
}
} else {
selected.insert(parse_one_based(token, row_count, input)?);
}
}
Ok(selected)
}
fn parse_one_based(token: &str, row_count: usize, original: &str) -> Result<usize, String> {
let parsed = token
.parse::<usize>()
.map_err(|_| format!("invalid selection '{original}'"))?;
if parsed == 0 || parsed > row_count {
return Err(format!("invalid selection '{original}'"));
}
Ok(parsed - 1)
}
fn push_highlighted_spans(
target: &mut Vec<Span<'static>>,
text: &str,
query: &str,
base_style: ratatui::style::Style,
) {
for segment in highlight_segments(text, query) {
let style = if segment.is_match {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
};
target.push(Span::styled(segment.text, style));
}
}
fn truncate_line(line: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let length = line.chars().count();
if length <= width {
return line.to_owned();
}
if width == 1 {
return ".".to_owned();
}
if width <= 3 {
return ".".repeat(width);
}
let mut truncated = line.chars().take(width - 3).collect::<String>();
truncated.push_str("...");
truncated
}
fn compress_selection_ranges(indices: &[usize]) -> String {
if indices.is_empty() {
return String::new();
}
let mut ranges = Vec::new();
let mut start = indices[0];
let mut end = indices[0];
for &index in &indices[1..] {
if index == end + 1 {
end = index;
continue;
}
ranges.push(format_range(start, end));
start = index;
end = index;
}
ranges.push(format_range(start, end));
ranges.join(",")
}
fn format_range(start: usize, end: usize) -> String {
if start == end {
start.to_string()
} else {
format!("{start}-{end}")
}
}
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}

View file

@ -1,5 +1,8 @@
use console::style;
use dialoguer::theme::ColorfulTheme;
use ratatui::style::{Color, Modifier, Style};
use crate::config::ThemeConfig;
pub fn dialog_theme() -> ColorfulTheme {
ColorfulTheme::default()
@ -20,3 +23,87 @@ pub fn muted(message: &str) -> String {
pub fn bullet(message: &str) -> String {
format!("- {message}")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SearchBrowserPalette {
accent: Color,
accent_secondary: Color,
dim: Color,
}
pub fn search_browser_palette(config: &ThemeConfig) -> SearchBrowserPalette {
SearchBrowserPalette {
accent: parse_color(&config.accent).unwrap_or(Color::Rgb(179, 136, 255)),
accent_secondary: parse_color(&config.accent_secondary)
.unwrap_or(Color::Rgb(213, 194, 255)),
dim: parse_color(&config.dim).unwrap_or(Color::Rgb(127, 115, 150)),
}
}
impl SearchBrowserPalette {
pub fn heading_style(self) -> Style {
Style::default()
.fg(self.accent)
.add_modifier(Modifier::BOLD)
}
pub fn hint_style(self) -> Style {
Style::default().fg(self.dim)
}
pub fn muted_style(self) -> Style {
Style::default().fg(self.dim)
}
pub fn text_style(self) -> Style {
Style::default().fg(Color::White)
}
pub fn dim_style(self) -> Style {
Style::default().fg(self.dim)
}
pub fn checkbox_selected_style(self) -> Style {
Style::default()
.fg(self.accent)
.add_modifier(Modifier::BOLD)
}
pub fn checkbox_idle_style(self) -> Style {
Style::default().fg(self.dim)
}
pub fn version_style(self) -> Style {
Style::default().fg(self.accent_secondary)
}
pub fn tag_style(self) -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
pub fn cursor_style(self) -> Style {
Style::default()
.fg(self.accent)
.add_modifier(Modifier::BOLD)
}
pub fn active_name_style(self) -> Style {
self.text_style().add_modifier(Modifier::BOLD)
}
pub fn disabled_style(self) -> Style {
self.dim_style()
}
}
fn parse_color(value: &str) -> Option<Color> {
let hex = value.trim().strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(red, green, blue))
}

View file

@ -7,6 +7,7 @@ fn help_lists_expected_commands() {
cmd.arg("--help")
.assert()
.success()
.stdout(contains("search"))
.stdout(contains("remove"))
.stdout(contains("list"))
.stdout(contains("update"));

View file

@ -0,0 +1,64 @@
use aim_cli::config::{CliConfig, ConfigError, SearchConfig, load_from_path};
use tempfile::tempdir;
#[test]
fn missing_config_file_returns_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = load_from_path(&path).unwrap();
assert_eq!(config, CliConfig::default());
assert_eq!(config.search, SearchConfig::default());
assert!(config.search.bottom_to_top);
assert!(!config.search.skip_confirmation);
assert_eq!(config.theme.accent, "#b388ff");
assert_eq!(config.theme.accent_secondary, "#d5c2ff");
assert_eq!(config.theme.dim, "#7f7396");
}
#[test]
fn search_section_overrides_defaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
)
.unwrap();
let config = load_from_path(&path).unwrap();
assert_eq!(
config,
CliConfig {
search: SearchConfig {
bottom_to_top: false,
skip_confirmation: true,
},
theme: aim_cli::config::ThemeConfig {
accent: "#9f6bff".to_owned(),
accent_secondary: "#efe7ff".to_owned(),
dim: "#6b6480".to_owned(),
},
}
);
}
#[test]
fn malformed_toml_returns_path_aware_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[search\nskip_confirmation = true\n").unwrap();
let error = load_from_path(&path).unwrap_err();
match error {
ConfigError::Parse {
path: error_path, ..
} => {
assert_eq!(error_path, path);
}
other => panic!("expected parse error, got {other:?}"),
}
}

View file

@ -0,0 +1,234 @@
use aim_cli::config::SearchConfig;
use aim_cli::ui::search_browser::{BrowserPhase, SearchBrowserState, SubmitAction};
use aim_core::domain::search::{SearchInstallStatus, SearchResult};
#[test]
fn browser_defaults_to_bottom_to_top_ordering() {
let state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
assert_eq!(
visible_names(&state),
vec!["charlie/app", "bravo/app", "alpha/app"]
);
}
#[test]
fn browser_moves_cursor_and_pages() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 2);
state.move_next();
assert_eq!(state.cursor_position(), 1);
state.page_down();
assert_eq!(state.cursor_position(), 2);
state.page_up();
assert_eq!(state.cursor_position(), 0);
}
#[test]
fn browser_supports_single_and_multiple_numeric_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1,3").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn browser_supports_numeric_ranges() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1-2").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "bravo/app"]);
}
#[test]
fn browser_supports_space_separated_numeric_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("1 3").unwrap();
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn typing_numeric_input_updates_selection_immediately() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.push_numeric_input('1');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
state.push_numeric_input(' ');
state.push_numeric_input('3');
assert_eq!(selected_names(&state), vec!["charlie/app", "alpha/app"]);
}
#[test]
fn invalid_numeric_input_keeps_last_good_selection_visible() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.push_numeric_input('1');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
state.push_numeric_input('-');
assert_eq!(selected_names(&state), vec!["charlie/app"]);
assert_eq!(state.numeric_buffer(), "1-");
}
#[test]
fn highlight_segments_marks_matching_query_fragments() {
let fragments = aim_cli::ui::search_browser::highlight_segments("pingdotgg/t3code", "dotgg");
assert_eq!(fragments.len(), 3);
assert_eq!(fragments[1].text, "dotgg");
assert!(fragments[1].is_match);
}
#[test]
fn invalid_numeric_selection_preserves_existing_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.apply_numeric_selection("2").unwrap();
let error = state.apply_numeric_selection("2-z").unwrap_err();
assert!(error.contains("2-z"));
assert_eq!(selected_names(&state), vec!["bravo/app"]);
}
#[test]
fn confirmation_requires_selection_before_transition() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
assert!(!state.enter_confirmation());
assert_eq!(state.phase(), BrowserPhase::Browsing);
state.toggle_current_selection();
assert!(state.enter_confirmation());
assert_eq!(state.phase(), BrowserPhase::Confirming);
state.cancel_confirmation();
assert_eq!(state.phase(), BrowserPhase::Browsing);
}
#[test]
fn submit_selection_can_skip_confirmation_from_config() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
let action = state.submit_selection(true);
assert_eq!(
action,
SubmitAction::Confirmed(aim_cli::ui::search_browser::SearchSelection {
rows: vec![aim_cli::ui::search_browser::SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "charlie/app".to_owned(),
description: None,
install_query: "charlie/app".to_owned(),
version: Some("1.0.0".to_owned()),
selectable: true,
}],
})
);
}
#[test]
fn installed_rows_are_visible_but_not_selectable() {
let mut state = SearchBrowserState::new(installed_first_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
assert!(state.selected_rows().is_empty());
assert_eq!(
state.status_message(),
Some("installed result is not selectable")
);
}
#[test]
fn update_rows_remain_selectable() {
let mut state = SearchBrowserState::new(update_first_results(), SearchConfig::default(), 3);
state.toggle_current_selection();
assert_eq!(selected_names(&state), vec!["charlie/app"]);
}
#[test]
fn selection_expression_prefills_from_checklist_selection() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
state.toggle_current_selection();
state.move_to_bottom();
state.toggle_current_selection();
assert_eq!(state.selection_expression(), "1,3");
}
#[test]
fn selection_expression_compacts_adjacent_ranges() {
let mut state = SearchBrowserState::new(sample_results(), SearchConfig::default(), 5);
state.apply_numeric_selection("1-3").unwrap();
assert_eq!(state.selection_expression(), "1-3");
}
fn sample_results() -> Vec<SearchResult> {
vec![
sample_result("alpha/app"),
sample_result("bravo/app"),
sample_result("charlie/app"),
]
}
fn sample_result(name: &str) -> SearchResult {
SearchResult {
provider_id: "github".to_owned(),
display_name: name.to_owned(),
description: None,
source_locator: name.to_owned(),
install_query: name.to_owned(),
canonical_locator: name.to_owned(),
version: Some("1.0.0".to_owned()),
install_status: SearchInstallStatus::Available,
}
}
fn installed_first_results() -> Vec<SearchResult> {
let mut results = sample_results();
results[2].install_status = SearchInstallStatus::Installed {
installed_version: Some("1.0.0".to_owned()),
};
results
}
fn update_first_results() -> Vec<SearchResult> {
let mut results = sample_results();
results[2].install_status = SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.9.0".to_owned()),
latest_version: Some("1.0.0".to_owned()),
};
results
}
fn visible_names(state: &SearchBrowserState) -> Vec<&str> {
state
.ordered_rows()
.iter()
.map(|row| row.display_name.as_str())
.collect()
}
fn selected_names(state: &SearchBrowserState) -> Vec<&str> {
state
.selected_rows()
.iter()
.map(|row| row.display_name.as_str())
.collect()
}

View file

@ -0,0 +1,153 @@
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use tempfile::tempdir;
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
#[test]
fn search_command_renders_remote_github_results() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("Remote Results"))
.stdout(contains("[github] sharkdp/bat"))
.stdout(contains("Install query: sharkdp/bat"));
}
#[test]
fn search_command_renders_local_matches_in_deterministic_order() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
std::fs::write(
&registry_path,
concat!(
"version = 1\n",
"[[apps]]\n",
"stable_id = \"bat\"\n",
"display_name = \"Bat\"\n",
"[[apps]]\n",
"stable_id = \"bat-tools\"\n",
"display_name = \"Bat Tools\"\n",
"[[apps]]\n",
"stable_id = \"acrobat-reader\"\n",
"display_name = \"Acrobat Reader\"\n",
"[[apps]]\n",
"stable_id = \"combat-viewer\"\n",
"display_name = \"Combat Viewer\"\n"
),
)
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Installed Matches"))
.stdout(
contains("- Bat (bat)")
.and(contains("- Bat Tools (bat-tools)"))
.and(contains("- Acrobat Reader (acrobat-reader)"))
.and(contains("- Combat Viewer (combat-viewer)")),
);
}
#[test]
fn search_command_is_read_only_for_registry_contents() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let original = "version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n";
std::fs::write(&registry_path, original).unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success();
let persisted = std::fs::read_to_string(&registry_path).unwrap();
assert_eq!(persisted, original);
}
#[test]
fn search_command_fails_fast_on_malformed_config() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[search\nskip_confirmation = true\n").unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env("AIM_CONFIG_PATH", &config_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.failure()
.stderr(contains(config_path.to_string_lossy().as_ref()));
}
#[test]
fn search_command_uses_plain_text_output_when_not_on_a_tty() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[search]\nbottom_to_top = false\nskip_confirmation = true\n",
)
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env("AIM_CONFIG_PATH", &config_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("Remote Results"))
.stdout(contains("[github] sharkdp/bat"));
}
#[test]
fn search_command_reports_loading_status_to_stderr() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "bat"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stderr(contains("Searching bat"));
}
#[test]
fn search_command_keeps_empty_results_in_plain_text_mode() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.args(["search", "no-such-app-image-query"])
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("Search Results"))
.stdout(contains("No remote matches"));
}

View file

@ -1,7 +1,9 @@
use aim_cli::DispatchResult;
use aim_cli::ui::prompt::render_interaction;
use aim_cli::ui::render::{render_dispatch_result, render_update_summary};
use aim_cli::ui::search_browser::{SearchRow, format_search_row, render_confirmation_summary};
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::domain::search::SearchInstallStatus;
use aim_core::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
#[test]
@ -68,3 +70,78 @@ fn tracking_prompt_uses_explicit_question_copy() {
assert!(output.contains("Choose update tracking"));
}
#[test]
fn search_browser_row_uses_status_tag_version_and_description_layout() {
let row = SearchRow {
status: SearchInstallStatus::Installed {
installed_version: Some("0.0.12".to_owned()),
},
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: false,
};
let output = format_search_row(1, &row, true, true, 120);
assert!(output.contains('\n'));
assert!(output.contains("[installed]"));
assert!(output.contains("v0.0.12"));
assert!(output.contains("pingdotgg/t3code"));
assert!(output.contains("github - The T3 desktop app."));
}
#[test]
fn search_browser_row_without_description_shows_provider_only() {
let row = SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: None,
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: true,
};
let output = format_search_row(1, &row, false, false, 120);
assert!(output.contains("github"));
assert!(!output.contains(" - "));
assert!(!output.contains("No description available"));
}
#[test]
fn search_confirmation_summary_lists_selected_rows() {
let rows = vec![
SearchRow {
status: SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.0.11".to_owned()),
latest_version: Some("0.0.12".to_owned()),
},
provider_id: "github".to_owned(),
display_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
install_query: "pingdotgg/t3code".to_owned(),
version: Some("0.0.12".to_owned()),
selectable: true,
},
SearchRow {
status: SearchInstallStatus::Available,
provider_id: "github".to_owned(),
display_name: "sharkdp/bat".to_owned(),
description: Some("A cat(1) clone with wings.".to_owned()),
install_query: "sharkdp/bat".to_owned(),
version: Some("1.0.0".to_owned()),
selectable: true,
},
];
let output = render_confirmation_summary(&rows);
assert!(output.contains("Confirm Search Selection"));
assert!(output.contains("pingdotgg/t3code"));
assert!(output.contains("sharkdp/bat"));
}

View file

@ -8,9 +8,12 @@ license.workspace = true
path = "src/lib.rs"
[dependencies]
base64.workspace = true
fs2.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
toml.workspace = true
[dev-dependencies]

View file

@ -1,4 +1,5 @@
use std::env;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
@ -13,12 +14,14 @@ use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
use crate::integration::install::{InstallOutcome, InstallRequest, execute_install};
use crate::integration::install::{
InstallOutcome, InstallRequest, execute_install, staged_appimage_path,
};
use crate::integration::policy::{IntegrationMode, resolve_install_policy};
use crate::metadata::parse_document;
use crate::platform::probe_live_host;
use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with,
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
@ -100,6 +103,7 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
url: source.locator.clone(),
version: "unresolved".to_owned(),
arch: None,
trusted_checksum: None,
selection_reason: "heuristic-match".to_owned(),
};
let strategy = UpdateStrategy {
@ -238,8 +242,13 @@ pub fn install_app_with_reporter(
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
});
let artifact_bytes =
download_artifact_bytes_with_reporter(&plan.selected_artifact.url, reporter)?;
let staging_root = install_home.join(".local/share/aim/staging");
let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id);
download_artifact_to_staged_path_with_reporter(
&plan.selected_artifact.url,
&staged_payload_path,
reporter,
)?;
let payload_exec = payload_path.clone();
let desktop_owned = match policy.integration_mode {
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
@ -265,9 +274,9 @@ pub fn install_app_with_reporter(
message: "staging payload".to_owned(),
});
let install_outcome = execute_install(&InstallRequest {
staging_root: &install_home.join(".local/share/aim/staging"),
staged_payload_path: &staged_payload_path,
final_payload_path: &payload_path,
artifact_bytes: &artifact_bytes,
trusted_checksum: plan.selected_artifact.trusted_checksum.as_deref(),
desktop: desktop_owned.as_ref().map(|(path, contents)| {
crate::integration::install::DesktopIntegrationRequest {
desktop_entry_path: path.as_path(),
@ -355,43 +364,117 @@ pub enum InstallAppError {
Install(crate::integration::install::PayloadInstallError),
}
fn download_artifact_bytes_with_reporter(
fn download_artifact_to_staged_path_with_reporter(
url: &str,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<Vec<u8>, InstallAppError> {
) -> Result<u64, InstallAppError> {
let policy = http_client_policy();
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec();
reporter.report(&OperationEvent::Progress {
current: bytes.len() as u64,
total: Some(bytes.len() as u64),
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82";
return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
Ok((
Box::new(std::io::Cursor::new(bytes.to_vec())) as Box<dyn Read>,
Some(bytes.len() as u64),
))
});
return Ok(bytes);
}
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
let client = reqwest::blocking::Client::builder()
.timeout(policy.timeout)
.build()
.map_err(InstallAppError::Download)?;
let total = response.content_length();
let mut response = response;
let mut bytes = Vec::new();
download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
let response = client.get(url).send().map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
.map_err(InstallAppError::Download)?;
let total = response.content_length();
Ok((Box::new(response) as Box<dyn Read>, total))
})
}
pub fn download_to_staged_path_with_retries(
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
policy: crate::source::github::HttpClientPolicy,
mut open_stream: impl FnMut() -> Result<(Box<dyn Read>, Option<u64>), InstallAppError>,
) -> Result<u64, InstallAppError> {
let mut last_error = None;
let attempts = policy.max_retries.max(1);
for attempt in 0..attempts {
match open_stream() {
Ok((mut reader, total)) => {
match stream_payload_to_staged_file_with_reporter(
&mut reader,
total,
staged_payload_path,
reporter,
) {
Ok(written) => return Ok(written),
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_error.unwrap_or_else(|| {
InstallAppError::DownloadIo(std::io::Error::other("download failed after retries"))
}))
}
pub fn stream_payload_to_staged_file_with_reporter<R: Read>(
reader: &mut R,
total: Option<u64>,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<u64, InstallAppError> {
if let Some(parent) = staged_payload_path.parent() {
fs::create_dir_all(parent).map_err(InstallAppError::DownloadIo)?;
}
let mut file = File::create(staged_payload_path).map_err(InstallAppError::DownloadIo)?;
let mut buffer = [0_u8; 16 * 1024];
let mut current = 0_u64;
loop {
let read = response
.read(&mut buffer)
.map_err(InstallAppError::DownloadIo)?;
let read = match reader.read(&mut buffer) {
Ok(read) => read,
Err(error) => {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
};
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
if let Err(error) = std::io::Write::write_all(&mut file, &buffer[..read]) {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
current += read as u64;
reporter.report(&OperationEvent::Progress { current, total });
}
Ok(bytes)
Ok(current)
}
fn is_retryable_download_error(error: &InstallAppError) -> bool {
matches!(
error,
InstallAppError::Download(_) | InstallAppError::DownloadIo(_)
)
}
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {

View file

@ -6,4 +6,5 @@ pub mod progress;
pub mod query;
pub mod remove;
pub mod scope;
pub mod search;
pub mod update;

View file

@ -1,6 +1,7 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
Add,
Search,
UpdateBatch,
UpdateItem,
Remove,

View file

@ -0,0 +1,322 @@
use crate::domain::app::AppRecord;
use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning,
};
use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with,
};
use std::collections::HashSet;
pub trait SearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchError {
ProviderFailures(Vec<SearchWarning>),
}
pub fn build_search_results(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
let transport = default_transport();
let provider = GitHubSearchProvider::new(transport.as_ref());
build_search_results_with(query, installed_apps, &[&provider])
}
pub fn build_search_results_with(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &[&dyn SearchProvider],
) -> Result<SearchResults, SearchError> {
let installed_matches = collect_installed_matches(query, installed_apps);
let mut remote_hits = Vec::new();
let mut warnings = Vec::new();
for provider in providers {
match provider.search(query) {
Ok(mut hits) => remote_hits.append(&mut hits),
Err(error) => warnings.push(SearchWarning {
provider_id: Some(error.provider_id),
message: error.message,
}),
}
}
annotate_remote_hits_with_install_status(&mut remote_hits, installed_apps);
if remote_hits.is_empty() && installed_matches.is_empty() && !warnings.is_empty() {
return Err(SearchError::ProviderFailures(warnings));
}
Ok(SearchResults {
query_text: query.text.clone(),
remote_hits,
installed_matches,
warnings,
})
}
pub struct GitHubSearchProvider<'a, T: GitHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text);
let mut ranked_hits =
search_github_repositories_with(&name_only_query, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
if ranked_hits.len() < query.remote_limit {
let mut seen = ranked_hits
.iter()
.map(|hit| hit.full_name.clone())
.collect::<HashSet<_>>();
let backfill =
search_github_repositories_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
for hit in backfill {
if ranked_hits.len() >= query.remote_limit {
break;
}
if seen.insert(hit.full_name.clone()) {
ranked_hits.push(hit);
}
}
}
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = ranked_hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
github_remote_match_rank(&normalized_query, &hit),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.filter_map(|(_, _, hit)| {
let full_name = hit.full_name;
let release = latest_appimage_release(self.transport, &full_name)?;
Some(SearchResult {
provider_id: "github".to_owned(),
display_name: full_name.clone(),
description: hit.description,
source_locator: hit.html_url,
install_query: full_name.clone(),
canonical_locator: full_name.clone(),
version: Some(release.tag.trim_start_matches('v').to_owned()),
install_status: SearchInstallStatus::Available,
})
})
.collect())
}
}
fn latest_appimage_release<T: GitHubTransport + ?Sized>(
transport: &T,
repo: &str,
) -> Option<TransportRelease> {
transport.fetch_releases(repo).ok().and_then(|releases| {
releases.into_iter().find(|release| {
release
.assets
.iter()
.any(|asset| asset.name.ends_with(".AppImage"))
})
})
}
fn collect_installed_matches(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Vec<InstalledSearchMatch> {
let normalized_query = normalize_lookup(&query.text);
let mut matches = installed_apps
.iter()
.filter_map(|app| {
match_rank(&normalized_query, &app.stable_id, &app.display_name).map(|rank| {
(
rank,
normalize_lookup(&app.stable_id),
InstalledSearchMatch {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
installed_version: app.installed_version.clone(),
},
)
})
})
.collect::<Vec<_>>();
matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
matches
.into_iter()
.map(|(_, _, installed_match)| installed_match)
.collect()
}
fn match_rank(query: &str, stable_id: &str, display_name: &str) -> Option<u8> {
let stable_id = normalize_lookup(stable_id);
let display_name = normalize_lookup(display_name);
[stable_id, display_name]
.into_iter()
.filter_map(|candidate| {
if candidate == query {
Some(0)
} else if candidate.starts_with(query) {
Some(1)
} else if candidate.contains(query) {
Some(2)
} else {
None
}
})
.min()
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn annotate_remote_hits_with_install_status(
remote_hits: &mut [SearchResult],
installed_apps: &[AppRecord],
) {
for hit in remote_hits.iter_mut() {
if let Some(installed) = installed_apps
.iter()
.find(|app| app_matches_remote_hit(app, hit))
{
if installed.installed_version == hit.version {
hit.install_status = SearchInstallStatus::Installed {
installed_version: installed.installed_version.clone(),
};
} else {
hit.install_status = SearchInstallStatus::UpdateAvailable {
installed_version: installed.installed_version.clone(),
latest_version: hit.version.clone(),
};
}
}
}
}
fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool {
let Some(locator) = app_search_locator(app) else {
return false;
};
locator == normalize_lookup(&hit.install_query)
|| locator == normalize_lookup(&hit.canonical_locator)
}
fn app_search_locator(app: &AppRecord) -> Option<String> {
if let Some(source) = &app.source
&& source.kind == crate::domain::source::SourceKind::GitHub
{
if let Some(locator) = source.canonical_locator.as_deref() {
return Some(normalize_lookup(locator));
}
return Some(normalize_lookup(&source.locator));
}
app.source_input.as_deref().and_then(|input| {
if input.contains('/') && !input.contains("://") {
Some(normalize_lookup(input))
} else {
None
}
})
}
fn github_remote_match_rank(
query: &str,
repository: &crate::source::github::TransportRepository,
) -> u8 {
let full_name = normalize_lookup(&repository.full_name);
let description = repository.description.as_deref().map(normalize_lookup);
let mut parts = full_name.split('/');
let owner = parts.next().unwrap_or_default();
let repo = parts.next().unwrap_or_default();
if full_name == query {
return 0;
}
if owner == query || repo == query {
return 1;
}
if full_name.starts_with(query) || owner.starts_with(query) || repo.starts_with(query) {
return 2;
}
if full_name.contains(query) || owner.contains(query) || repo.contains(query) {
return 3;
}
if description
.as_deref()
.map(|description| description.starts_with(query))
.unwrap_or(false)
{
return 4;
}
if description
.as_deref()
.map(|description| description.contains(query))
.unwrap_or(false)
{
return 5;
}
6
}
fn render_github_search_error(error: &GitHubSearchError) -> String {
match error {
GitHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -1,3 +1,4 @@
pub mod app;
pub mod search;
pub mod source;
pub mod update;

View file

@ -0,0 +1,68 @@
pub const DEFAULT_REMOTE_LIMIT: usize = 10;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchInstallStatus {
Available,
Installed {
installed_version: Option<String>,
},
UpdateAvailable {
installed_version: Option<String>,
latest_version: Option<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchQuery {
pub text: String,
pub remote_limit: usize,
}
impl SearchQuery {
pub fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
remote_limit: DEFAULT_REMOTE_LIMIT,
}
}
pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self {
Self {
text: text.to_owned(),
remote_limit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResult {
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub source_locator: String,
pub install_query: String,
pub canonical_locator: String,
pub version: Option<String>,
pub install_status: SearchInstallStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledSearchMatch {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchWarning {
pub provider_id: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResults {
pub query_text: String,
pub remote_hits: Vec<SearchResult>,
pub installed_matches: Vec<InstalledSearchMatch>,
pub warnings: Vec<SearchWarning>,
}

View file

@ -85,6 +85,7 @@ pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub selection_reason: String,
}

View file

@ -1,9 +1,13 @@
use std::fs;
use std::io;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::{error::Error, fmt};
use base64::Engine;
use sha2::{Digest, Sha512};
use crate::integration::desktop::{extract_icon_from_payload, write_desktop_integration};
use crate::integration::refresh::refresh_integration;
use crate::platform::DesktopHelpers;
@ -24,6 +28,8 @@ pub fn replacement_path(target: &Path) -> PathBuf {
#[derive(Debug)]
pub enum PayloadInstallError {
InvalidArtifact,
ChecksumMismatch,
InvalidTrustedChecksum,
Io(io::Error),
DesktopIntegration(io::Error),
}
@ -38,6 +44,8 @@ impl fmt::Display for PayloadInstallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidArtifact => write!(f, "artifact is not a valid AppImage"),
Self::ChecksumMismatch => write!(f, "artifact checksum did not match trusted metadata"),
Self::InvalidTrustedChecksum => write!(f, "trusted checksum metadata is malformed"),
Self::Io(error) => write!(f, "payload installation failed: {error}"),
Self::DesktopIntegration(error) => {
write!(f, "desktop integration failed: {error}")
@ -63,9 +71,9 @@ pub struct DesktopIntegrationRequest<'a> {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallRequest<'a> {
pub staging_root: &'a Path,
pub staged_payload_path: &'a Path,
pub final_payload_path: &'a Path,
pub artifact_bytes: &'a [u8],
pub trusted_checksum: Option<&'a str>,
pub desktop: Option<DesktopIntegrationRequest<'a>>,
pub helpers: DesktopHelpers,
}
@ -79,33 +87,25 @@ pub struct InstallOutcome {
}
pub fn stage_and_commit_payload(
staging_root: &Path,
staged_payload_path: &Path,
final_payload_path: &Path,
artifact_bytes: &[u8],
) -> Result<PayloadInstallOutcome, PayloadInstallError> {
if !is_appimage_payload(artifact_bytes) {
if !is_appimage_payload_path(staged_payload_path)? {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidArtifact);
}
let app_id = final_payload_path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("download");
let staged_path = staged_appimage_path(staging_root, app_id);
let replacement = replacement_path(final_payload_path);
fs::create_dir_all(staging_root)?;
fs::write(&staged_path, artifact_bytes)?;
let mut permissions = fs::metadata(&staged_path)?.permissions();
let mut permissions = fs::metadata(staged_payload_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&staged_path, permissions)?;
fs::set_permissions(staged_payload_path, permissions)?;
if let Some(parent) = final_payload_path.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&staged_path, &replacement)?;
fs::rename(staged_payload_path, &replacement)?;
fs::rename(&replacement, final_payload_path)?;
Ok(PayloadInstallOutcome {
@ -113,24 +113,25 @@ pub fn stage_and_commit_payload(
})
}
fn is_appimage_payload(bytes: &[u8]) -> bool {
bytes.starts_with(b"\x7fELF")
fn is_appimage_payload_path(path: &Path) -> Result<bool, io::Error> {
let mut file = fs::File::open(path)?;
let mut header = [0_u8; 4];
let read = file.read(&mut header)?;
Ok(read == header.len() && header == *b"\x7fELF")
}
pub fn execute_install(
request: &InstallRequest<'_>,
) -> Result<InstallOutcome, PayloadInstallError> {
let payload = stage_and_commit_payload(
request.staging_root,
request.final_payload_path,
request.artifact_bytes,
)?;
verify_trusted_checksum(request.staged_payload_path, request.trusted_checksum)?;
let payload =
stage_and_commit_payload(request.staged_payload_path, request.final_payload_path)?;
let mut desktop_entry_path = None;
let mut icon_path = None;
if let Some(desktop) = &request.desktop {
let extracted_icon = if desktop.icon_bytes.is_none() && desktop.icon_path.is_some() {
extract_icon_from_payload(request.artifact_bytes)
extract_icon_from_payload_path(&payload.final_payload_path)
} else {
None
};
@ -161,3 +162,38 @@ pub fn execute_install(
warnings,
})
}
fn extract_icon_from_payload_path(path: &Path) -> Option<Vec<u8>> {
fs::read(path)
.ok()
.and_then(|payload| extract_icon_from_payload(&payload))
}
fn verify_trusted_checksum(
staged_payload_path: &Path,
trusted_checksum: Option<&str>,
) -> Result<(), PayloadInstallError> {
let Some(trusted_checksum) = trusted_checksum.map(str::trim) else {
return Ok(());
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(trusted_checksum)
.map_err(|_| {
let _ = fs::remove_file(staged_payload_path);
PayloadInstallError::InvalidTrustedChecksum
})?;
if decoded.len() != 64 {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidTrustedChecksum);
}
let payload = fs::read(staged_payload_path)?;
let actual_checksum = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(payload));
if actual_checksum != trusted_checksum {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::ChecksumMismatch);
}
Ok(())
}

View file

@ -1,6 +1,8 @@
use std::fs;
use std::fs::{self, File, OpenOptions};
use std::path::PathBuf;
use fs2::FileExt;
use crate::registry::model::Registry;
pub struct RegistryStore {
@ -28,14 +30,71 @@ impl RegistryStore {
}
let contents = toml::to_string(registry)?;
fs::write(&self.path, contents)?;
let temporary_path = self.temporary_path();
fs::write(&temporary_path, contents)?;
fs::rename(&temporary_path, &self.path).map_err(|error| {
let _ = fs::remove_file(&temporary_path);
RegistryStoreError::Io(error)
})?;
Ok(())
}
pub fn lock_exclusive(&self) -> Result<RegistryLock, RegistryStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(self.lock_path())?;
match lock_file.try_lock_exclusive() {
Ok(()) => Ok(RegistryLock { file: lock_file }),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
Err(RegistryStoreError::LockUnavailable)
}
Err(error) => Err(RegistryStoreError::Io(error)),
}
}
pub fn mutate_exclusive<F>(&self, apply: F) -> Result<Registry, RegistryStoreError>
where
F: FnOnce(&mut Registry),
{
let _lock = self.lock_exclusive()?;
let mut registry = self.load()?;
apply(&mut registry);
self.save(&registry)?;
Ok(registry)
}
fn lock_path(&self) -> PathBuf {
self.path.with_extension("toml.lock")
}
fn temporary_path(&self) -> PathBuf {
self.path.with_extension("toml.tmp")
}
}
#[derive(Debug)]
pub struct RegistryLock {
file: File,
}
impl Drop for RegistryLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
#[derive(Debug)]
pub enum RegistryStoreError {
Io(std::io::Error),
LockUnavailable,
SerializeToml(toml::ser::Error),
Toml(toml::de::Error),
}

View file

@ -1,14 +1,36 @@
use std::env;
use std::time::Duration;
use crate::domain::source::{ResolvedRelease, SourceRef};
use crate::metadata::MetadataDocument;
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30;
const DEFAULT_HTTP_MAX_RETRIES: usize = 3;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpClientPolicy {
pub timeout: Duration,
pub max_retries: usize,
}
pub fn http_client_policy() -> HttpClientPolicy {
HttpClientPolicy {
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
max_retries: DEFAULT_HTTP_MAX_RETRIES,
}
}
pub trait GitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError>;
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError>;
fn fetch_document(
&self,
url: &str,
@ -30,6 +52,13 @@ pub struct TransportRelease {
pub assets: Vec<TransportAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRepository {
pub full_name: String,
pub description: Option<String>,
pub html_url: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubAsset {
pub name: String,
@ -130,6 +159,22 @@ pub fn discover_github_candidates_with<T: GitHubTransport + ?Sized>(
})
}
pub fn search_github_repositories(
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let transport = default_transport();
search_github_repositories_with(query, limit, transport.as_ref())
}
pub fn search_github_repositories_with<T: GitHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
transport.search_repositories(query, limit)
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureGitHubTransport)
@ -151,6 +196,7 @@ impl Default for ReqwestGitHubTransport {
impl ReqwestGitHubTransport {
pub fn new() -> Self {
let policy = http_client_policy();
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
@ -171,6 +217,7 @@ impl ReqwestGitHubTransport {
Self {
client: reqwest::blocking::Client::builder()
.default_headers(default_headers)
.timeout(policy.timeout)
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE")
@ -210,6 +257,34 @@ impl GitHubTransport for ReqwestGitHubTransport {
.collect())
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let url = format!("{}/search/repositories", self.api_base);
let response = self
.client
.get(url)
.query(&[("q", query), ("per_page", &limit.to_string())])
.send()
.map_err(GitHubSearchError::Transport)?
.error_for_status()
.map_err(GitHubSearchError::Transport)?
.json::<ApiRepositorySearchResponse>()
.map_err(GitHubSearchError::Transport)?;
Ok(response
.items
.into_iter()
.map(|repository| TransportRepository {
full_name: repository.full_name,
description: repository.description,
html_url: repository.html_url,
})
.collect())
}
fn fetch_document(
&self,
url: &str,
@ -246,6 +321,14 @@ impl GitHubTransport for FixtureGitHubTransport {
Ok(fixture_releases(repo))
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
Ok(fixture_repository_search(query, limit))
}
fn fetch_document(
&self,
url: &str,
@ -269,6 +352,11 @@ pub enum GitHubDiscoveryError {
Transport(reqwest::Error),
}
#[derive(Debug)]
pub enum GitHubSearchError {
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
@ -283,6 +371,18 @@ struct ApiAsset {
content_type: Option<String>,
}
#[derive(serde::Deserialize)]
struct ApiRepositorySearchResponse {
items: Vec<ApiRepository>,
}
#[derive(serde::Deserialize)]
struct ApiRepository {
full_name: String,
description: Option<String>,
html_url: String,
}
fn is_appimage_asset(name: &str) -> bool {
name.ends_with(".AppImage")
}
@ -308,6 +408,16 @@ fn fixture_releases(repo: &str) -> Vec<TransportRelease> {
fixture_release(repo, "v0.0.11", "T3-Code-0.0.11-x86_64.AppImage"),
],
"sharkdp/bat" => vec![fixture_release(repo, "v1.0.0", "Bat-1.0.0-x86_64.AppImage")],
"fero1xd/uploadstuff-server" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"uploadstuff-server-linux-x86_64.tar.gz",
)],
"Socialure/lawn" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"lawn-linux-x86_64.tar.gz",
)],
_ => {
let repo_name = repo.split('/').next_back().unwrap_or("app");
let title = title_case(repo_name);
@ -339,6 +449,25 @@ fn fixture_release(repo: &str, tag: &str, asset_name: &str) -> TransportRelease
}
}
fn fixture_release_without_appimage(repo: &str, tag: &str, asset_name: &str) -> TransportRelease {
TransportRelease {
tag: tag.to_owned(),
prerelease: false,
assets: vec![
TransportAsset {
name: asset_name.to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"),
content_type: Some("application/gzip".to_owned()),
},
TransportAsset {
name: "latest-linux.yml".to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"),
content_type: Some("application/yaml".to_owned()),
},
],
}
}
fn fixture_document(url: &str) -> Option<Vec<u8>> {
let tag = url.split("/releases/download/").nth(1)?.split('/').next()?;
let name = url.split('/').next_back()?;
@ -352,13 +481,85 @@ fn fixture_document(url: &str) -> Option<Vec<u8>> {
};
let version = tag.trim_start_matches('v');
Some(
format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(),
format!("version: {version}\npath: {appimage}\nsha512: ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==\n").into_bytes(),
)
}
_ => None,
}
}
fn fixture_repository_search(query: &str, limit: usize) -> Vec<TransportRepository> {
let (normalized_query, name_only) = parse_fixture_repository_query(query);
fixture_repository_catalog()
.into_iter()
.filter(|repository| {
let full_name_matches = repository
.full_name
.to_ascii_lowercase()
.contains(&normalized_query);
if name_only {
return full_name_matches;
}
full_name_matches
|| repository
.description
.as_deref()
.map(|description| description.to_ascii_lowercase().contains(&normalized_query))
.unwrap_or(false)
})
.take(limit)
.collect()
}
fn parse_fixture_repository_query(query: &str) -> (String, bool) {
let trimmed = query.trim();
if let Some(value) = trimmed.strip_suffix(" in:name") {
return (value.trim().to_ascii_lowercase(), true);
}
(trimmed.to_ascii_lowercase(), false)
}
fn fixture_repository_catalog() -> Vec<TransportRepository> {
vec![
TransportRepository {
full_name: "sharkdp/bat".to_owned(),
description: Some("A cat(1) clone with wings.".to_owned()),
html_url: "https://github.com/sharkdp/bat".to_owned(),
},
TransportRepository {
full_name: "astatine/bat".to_owned(),
description: Some("A small fixture repository for bat-shaped searches.".to_owned()),
html_url: "https://github.com/astatine/bat".to_owned(),
},
TransportRepository {
full_name: "eth-p/bat-extras".to_owned(),
description: Some("Bash scripts that integrate with bat.".to_owned()),
html_url: "https://github.com/eth-p/bat-extras".to_owned(),
},
TransportRepository {
full_name: "fero1xd/uploadstuff-server".to_owned(),
description: Some("Custom Server for UploadThing by pingdotgg".to_owned()),
html_url: "https://github.com/fero1xd/uploadstuff-server".to_owned(),
},
TransportRepository {
full_name: "Socialure/lawn".to_owned(),
description: Some(
"Video review for creative teams — Socialure-branded fork of pingdotgg/lawn"
.to_owned(),
),
html_url: "https://github.com/Socialure/lawn".to_owned(),
},
TransportRepository {
full_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
html_url: "https://github.com/pingdotgg/t3code".to_owned(),
},
]
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])

View file

@ -70,6 +70,7 @@ pub fn select_artifact(
.clone()
.unwrap_or_else(|| "latest".to_owned()),
arch: Some("x86_64".to_owned()),
trusted_checksum: hints.and_then(|value| value.checksum.clone()),
selection_reason: selection_reason.to_owned(),
}
}

View file

@ -0,0 +1,95 @@
use std::fs;
use aim_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use aim_core::platform::DesktopHelpers;
use tempfile::tempdir;
const VALID_FIXTURE_SHA512: &str =
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";
#[test]
fn install_succeeds_with_valid_trusted_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(
root.path(),
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert_eq!(outcome.final_payload_path, final_payload_path);
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_succeeds_without_trusted_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_fails_before_commit_when_trusted_checksum_mismatches() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::ChecksumMismatch));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
#[test]
fn malformed_trusted_checksum_fails_before_commit() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some("not-base64"),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::InvalidTrustedChecksum));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging/bat.download");
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, bytes).unwrap();
staged_path
}

View file

@ -0,0 +1,180 @@
use std::fs;
use std::io::{self, Cursor, Read};
use std::time::Duration;
use aim_core::app::add::{
InstallAppError, download_to_staged_path_with_retries,
stream_payload_to_staged_file_with_reporter,
};
use aim_core::app::progress::{NoopReporter, OperationEvent};
use aim_core::integration::install::{InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::HttpClientPolicy;
use tempfile::tempdir;
#[test]
fn payload_streaming_writes_staged_file_and_reports_progress() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let bytes = b"\x7fELFAppImage";
let mut reader = Cursor::new(bytes.as_slice());
let mut events = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let written = stream_payload_to_staged_file_with_reporter(
&mut reader,
Some(bytes.len() as u64),
&staged_path,
&mut reporter,
)
.unwrap();
assert_eq!(written, bytes.len() as u64);
assert_eq!(
fs::metadata(&staged_path).unwrap().len(),
bytes.len() as u64
);
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Progress {
current,
total: Some(total)
} if *current == bytes.len() as u64 && *total == bytes.len() as u64
)
}));
}
#[test]
fn install_commits_from_staged_payload_path() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let final_payload_path = root.path().join("payloads/bat.AppImage");
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert_eq!(outcome.final_payload_path, final_payload_path);
assert!(outcome.final_payload_path.exists());
assert!(!staged_path.exists());
}
#[test]
fn failed_streaming_download_removes_partial_staged_payload() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let mut reader = FailingReader::new(b"\x7fELFpartial".to_vec(), 4);
let mut reporter = NoopReporter;
let result = stream_payload_to_staged_file_with_reporter(
&mut reader,
Some(12),
&staged_path,
&mut reporter,
);
assert!(result.is_err());
assert!(!staged_path.exists());
}
#[test]
fn retry_policy_retries_transient_failures_before_success() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let bytes = b"\x7fELFAppImage";
let mut attempts = 0;
let written = download_to_staged_path_with_retries(
&staged_path,
&mut NoopReporter,
HttpClientPolicy {
timeout: Duration::from_secs(30),
max_retries: 3,
},
|| {
attempts += 1;
if attempts == 1 {
return Err(InstallAppError::DownloadIo(io::Error::other(
"transient failure",
)));
}
Ok((
Box::new(Cursor::new(bytes.to_vec())) as Box<dyn Read>,
Some(bytes.len() as u64),
))
},
)
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(written, bytes.len() as u64);
assert!(staged_path.exists());
}
#[test]
fn retry_exhaustion_returns_error_and_cleans_staged_payload() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let mut attempts = 0;
let result = download_to_staged_path_with_retries(
&staged_path,
&mut NoopReporter,
HttpClientPolicy {
timeout: Duration::from_secs(30),
max_retries: 2,
},
|| {
attempts += 1;
Ok((
Box::new(FailingReader::new(b"\x7fELFpartial".to_vec(), 4)) as Box<dyn Read>,
Some(12),
))
},
);
assert!(result.is_err());
assert_eq!(attempts, 2);
assert!(!staged_path.exists());
}
struct FailingReader {
bytes: Vec<u8>,
chunk_size: usize,
position: usize,
}
impl FailingReader {
fn new(bytes: Vec<u8>, chunk_size: usize) -> Self {
Self {
bytes,
chunk_size,
position: 0,
}
}
}
impl Read for FailingReader {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.position >= self.chunk_size {
return Err(io::Error::other("fixture read failure"));
}
let remaining = self.chunk_size - self.position;
let to_read = remaining
.min(buffer.len())
.min(self.bytes.len() - self.position);
buffer[..to_read].copy_from_slice(&self.bytes[self.position..self.position + to_read]);
self.position += to_read;
Ok(to_read)
}
}

View file

@ -1,5 +1,8 @@
use aim_core::app::query::resolve_query;
use aim_core::source::github::{FixtureGitHubTransport, discover_github_candidates_with};
use aim_core::source::github::{
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
};
use std::time::Duration;
#[test]
fn discovery_reports_appimage_assets_and_latest_linux_yml() {
@ -31,3 +34,11 @@ fn discovery_marks_explicit_older_release_against_latest_fixture_release() {
assert_eq!(discovery.releases[0].tag, "v0.0.12");
assert!(discovery.requested_is_older_release);
}
#[test]
fn github_http_policy_uses_explicit_timeout_and_retry_defaults() {
let policy = http_client_policy();
assert_eq!(policy.timeout, Duration::from_secs(30));
assert_eq!(policy.max_retries, 3);
}

View file

@ -13,13 +13,15 @@ fn integration_failure_removes_new_payload_and_generated_files() {
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
fs::write(&blocking_path, "blocker").unwrap();
let staged_path = staging_root.join("bat.download");
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage");
let desktop_entry_path = blocking_path.join("aim-bat.desktop");
let error = execute_install(&InstallRequest {
staging_root: &staging_root,
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
artifact_bytes: b"\x7fELFAppImage",
trusted_checksum: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_entry_path,
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",

View file

@ -8,21 +8,27 @@ use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging").join(format!("{name}.download"));
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, bytes).unwrap();
staged_path
}
#[test]
fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
let root = tempdir().unwrap();
let staging_root = root.path().join("staging");
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
let staged_path = write_staged_payload(root.path(), "bat", b"\x7fELFAppImage");
let outcome = execute_install(&InstallRequest {
staging_root: &staging_root,
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
artifact_bytes: b"\x7fELFAppImage",
trusted_checksum: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
@ -40,16 +46,19 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
#[test]
fn install_executes_refresh_helpers_when_available() {
let root = tempdir().unwrap();
let staging_root = root.path().join("staging");
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
let helper_root = root.path().join("helpers");
let log_path = root.path().join("helpers.log");
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
fs::create_dir(&helper_root).unwrap();
let staged_path = write_staged_payload(
root.path(),
"bat",
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let update_helper = helper_root.join("update-desktop-database");
let icon_helper = helper_root.join("gtk-update-icon-cache");
@ -70,9 +79,9 @@ fn install_executes_refresh_helpers_when_available() {
fs::create_dir_all(&icon_root).unwrap();
let outcome = execute_install(&InstallRequest {
staging_root: &staging_root,
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
trusted_checksum: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
@ -97,20 +106,23 @@ fn install_executes_refresh_helpers_when_available() {
#[test]
fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
let root = tempdir().unwrap();
let staging_root = root.path().join("staging");
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
let icon_root = root.path().join("icons/hicolor/256x256/apps");
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
fs::create_dir_all(&icon_root).unwrap();
let staged_path = write_staged_payload(
root.path(),
"bat",
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let outcome = execute_install(&InstallRequest {
staging_root: &staging_root,
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
trusted_checksum: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",

View file

@ -11,9 +11,10 @@ fn payload_commit_moves_staged_appimage_into_final_location() {
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
let staged_path = staging_root.join("bat.download");
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage");
let outcome =
stage_and_commit_payload(&staging_root, &final_payload_path, b"\x7fELFAppImage").unwrap();
let outcome = stage_and_commit_payload(&staged_path, &final_payload_path).unwrap();
assert_eq!(
outcome

View file

@ -101,3 +101,84 @@ fn registry_round_trips_install_metadata() {
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
);
}
#[test]
fn registry_save_is_atomic_and_cleans_up_temp_file() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let store = RegistryStore::new(registry_path.clone());
store
.save(&aim_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
}],
})
.unwrap();
assert!(registry_path.exists());
assert!(!dir.path().join("registry.toml.tmp").exists());
}
#[test]
fn registry_exclusive_lock_rejects_second_mutator() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let _guard = store.lock_exclusive().unwrap();
let error = store.lock_exclusive().unwrap_err();
assert!(matches!(
error,
aim_core::registry::store::RegistryStoreError::LockUnavailable
));
}
#[test]
fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
store
.save(&aim_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
}],
})
.unwrap();
store
.mutate_exclusive(|registry| {
registry.apps.push(aim_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
});
})
.unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.apps.len(), 2);
assert_eq!(loaded.apps[0].stable_id, "bat");
assert_eq!(loaded.apps[1].stable_id, "t3code");
}

View file

@ -0,0 +1,212 @@
use aim_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
};
use aim_core::domain::app::AppRecord;
use aim_core::domain::search::{SearchInstallStatus, SearchQuery};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
#[test]
fn github_fixtures_return_normalized_remote_hits() {
let query = SearchQuery::new("bat");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
assert_eq!(query.remote_limit, 10);
assert!(results.installed_matches.is_empty());
assert!(results.warnings.is_empty());
assert_eq!(results.remote_hits.len(), 3);
let first = &results.remote_hits[0];
assert_eq!(first.provider_id, "github");
assert_eq!(first.display_name, "sharkdp/bat");
assert_eq!(
first.description.as_deref(),
Some("A cat(1) clone with wings.")
);
assert_eq!(first.source_locator, "https://github.com/sharkdp/bat");
assert_eq!(first.install_query, "sharkdp/bat");
assert_eq!(first.canonical_locator, "sharkdp/bat");
assert_eq!(first.version.as_deref(), Some("1.0.0"));
assert_eq!(first.install_status, SearchInstallStatus::Available);
}
#[test]
fn github_search_respects_limit_and_fixture_order() {
let query = SearchQuery::with_remote_limit("bat", 2);
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["sharkdp/bat", "astatine/bat"]);
}
#[test]
fn github_search_ranks_full_name_matches_above_description_only_matches() {
let query = SearchQuery::new("pingdotgg");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators[0], "pingdotgg/t3code");
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn github_search_backfills_description_matches_after_name_matches() {
let query = SearchQuery::with_remote_limit("pingdotgg", 3);
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn github_search_only_returns_repositories_with_appimage_release_assets() {
let query = SearchQuery::new("pingdotgg");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
assert!(
results
.remote_hits
.iter()
.all(|hit| hit.canonical_locator == "pingdotgg/t3code")
);
}
#[test]
fn github_name_only_search_excludes_description_only_matches() {
let hits =
search_github_repositories_with("pingdotgg in:name", 10, &FixtureGitHubTransport).unwrap();
let locators = hits
.iter()
.map(|hit| hit.full_name.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn app_search_results_can_carry_local_matches_and_warnings() {
let query = SearchQuery::new("bat");
let installed = vec![AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("1.0.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let provider = FailingProvider;
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
assert!(results.remote_hits.is_empty());
assert_eq!(results.installed_matches.len(), 1);
assert_eq!(results.installed_matches[0].stable_id, "bat");
assert_eq!(results.installed_matches[0].display_name, "Bat");
assert_eq!(results.warnings.len(), 1);
assert_eq!(results.warnings[0].provider_id.as_deref(), Some("github"));
}
#[test]
fn github_search_marks_matching_current_install_as_installed() {
let query = SearchQuery::new("bat");
let installed = vec![installed_github_app("sharkdp/bat", "1.0.0")];
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
let bat = results
.remote_hits
.iter()
.find(|hit| hit.install_query == "sharkdp/bat")
.unwrap();
assert_eq!(
bat.install_status,
SearchInstallStatus::Installed {
installed_version: Some("1.0.0".to_owned()),
}
);
}
#[test]
fn github_search_marks_older_install_as_update_available() {
let query = SearchQuery::new("pingdotgg");
let installed = vec![installed_github_app("pingdotgg/t3code", "0.0.11")];
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
let t3code = results
.remote_hits
.iter()
.find(|hit| hit.install_query == "pingdotgg/t3code")
.unwrap();
assert_eq!(t3code.version.as_deref(), Some("0.0.12"));
assert_eq!(
t3code.install_status,
SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.0.11".to_owned()),
latest_version: Some("0.0.12".to_owned()),
}
);
}
fn installed_github_app(locator: &str, installed_version: &str) -> AppRecord {
AppRecord {
stable_id: locator.replace('/', "-"),
display_name: locator.split('/').next_back().unwrap().to_owned(),
source_input: Some(locator.to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: locator.to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some(locator.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some(installed_version.to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}
}
struct FailingProvider;
impl SearchProvider for FailingProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<aim_core::domain::search::SearchResult>, SearchProviderError> {
Err(SearchProviderError::new("github", "fixture rate limit"))
}
}