pub mod cli; pub mod config; pub mod ui; use std::collections::{HashMap, HashSet}; use std::env; use std::path::{Path, PathBuf}; use aim_core::app::add::{ AddPlan, InstalledApp, build_add_plan_with_reporter, install_app_with_reporter, resolve_requested_scope, }; use aim_core::app::list::{ListRow, build_list_rows}; use aim_core::app::progress::{ NoopReporter, OperationEvent, 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::show::{build_installed_show_results, build_show_result}; 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::show::{InstalledShow, ShowResult}; use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan}; use aim_core::registry::store::RegistryStore; pub use cli::args::Cli; pub fn parse() -> Cli { ::parse() } pub fn dispatch(cli: Cli) -> Result { let mut reporter = NoopReporter; dispatch_with_reporter(cli, &mut reporter) } pub fn dispatch_with_reporter( cli: Cli, reporter: &mut impl ProgressReporter, ) -> Result { let registry_path = registry_path(); let install_home = install_home(®istry_path); let store = RegistryStore::new(registry_path); let registry = store.load()?; let apps = registry.apps.clone(); if cli.is_review_update_flow() { return Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)); } if let Some(command) = cli.command { return match command { cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))), cli::args::Command::Remove { query } => { let removal = remove_registered_app_with_reporter(&query, &apps, &install_home, reporter)?; reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); 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::Show { value } => match value { Some(value) => { let result = build_show_result(&value, &apps)?; Ok(DispatchResult::Show(Box::new(result))) } None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))), }, cli::args::Command::Update => { let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?; reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); store.mutate_exclusive(|latest| { merge_updated_app_records(&mut latest.apps, &apps, &updates.apps); })?; reporter.report(&OperationEvent::Finished { summary: format!( "updated {}, failed {}", updates.updated_count(), updates.failed_count() ), }); Ok(DispatchResult::Updated(Box::new(updates))) } }; } if let Some(query) = cli.query { let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root()); let transport = aim_core::source::github::default_transport(); let mut plan = build_add_plan_with_reporter(&query, transport.as_ref(), reporter)?; if !plan.interactions.is_empty() { match ui::prompt::resolve_add_plan_interactions(plan.clone())? { Some(resolved) => { plan = resolved; } None => return Ok(DispatchResult::PendingAdd(Box::new(plan))), } } let installed = install_app_with_reporter(&query, &plan, &install_home, requested_scope, reporter)?; reporter.report(&OperationEvent::StageChanged { stage: OperationStage::SaveRegistry, message: "saving registry".to_owned(), }); store.mutate_exclusive(|latest| { upsert_app_record(&mut latest.apps, installed.record.clone()); })?; reporter.report(&OperationEvent::Finished { summary: format!("installed {}", installed.record.stable_id), }); return Ok(DispatchResult::Added(Box::new(installed))); } Ok(DispatchResult::Noop) } pub fn render(result: &DispatchResult) -> String { 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 { if let Some(path) = env::var_os("AIM_REGISTRY_PATH") { return PathBuf::from(path); } let home = env::var_os("HOME").unwrap_or_else(|| ".".into()); PathBuf::from(home).join(".local/share/aim/registry.toml") } #[derive(Debug, Eq, PartialEq)] pub enum DispatchResult { Added(Box), List(Vec), PendingAdd(Box), Removed(Box), Search(SearchResults), Show(Box), ShowAll(Vec), UpdatePlan(UpdatePlan), Updated(Box), Noop, } #[derive(Debug)] pub enum DispatchError { AddPlan(aim_core::app::add::BuildAddPlanError), AddInstall(aim_core::app::add::InstallAppError), Prompt(ui::prompt::PromptError), RemovePlan(aim_core::app::remove::RemoveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), Search(aim_core::app::search::SearchError), Show(aim_core::domain::show::ShowResultError), UpdatePlan(aim_core::app::update::BuildUpdatePlanError), UpdateExecution(aim_core::app::update::ExecuteUpdatesError), } impl std::fmt::Display for DispatchError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::AddPlan(error) => match error { aim_core::app::add::BuildAddPlanError::Query( aim_core::app::query::ResolveQueryError::Unsupported, ) => write!(f, "unsupported source query"), aim_core::app::add::BuildAddPlanError::NoInstallableArtifact { source } => write!( f, "no installable artifact found for {} {}", source.kind.as_str(), source.locator ), aim_core::app::add::BuildAddPlanError::Adapter(id, error) => match error { aim_core::adapters::traits::AdapterError::UnsupportedQuery => { write!(f, "{id} does not support this query") } aim_core::adapters::traits::AdapterError::UnsupportedSource => { write!(f, "{id} does not support this source") } aim_core::adapters::traits::AdapterError::ResolutionFailed(reason) => { write!(f, "{id} resolution failed: {reason}") } }, aim_core::app::add::BuildAddPlanError::GitHubDiscovery(error) => { write!(f, "github discovery failed: {error:?}") } aim_core::app::add::BuildAddPlanError::NoCandidates => { write!(f, "no installable candidates found") } }, Self::AddInstall(error) => write!(f, "install failed: {error:?}"), Self::Prompt(error) => write!(f, "prompt failed: {error:?}"), Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"), Self::Registry(error) => write!(f, "registry failed: {error:?}"), Self::Search(error) => write!(f, "search failed: {error:?}"), Self::Show(error) => match error { aim_core::domain::show::ShowResultError::AmbiguousInstalledMatch { query, matches, } => write!( f, "multiple installed apps match {query}: {}", matches.join(", ") ), aim_core::domain::show::ShowResultError::UnsupportedQuery => { write!(f, "unsupported source query") } aim_core::domain::show::ShowResultError::NoInstallableArtifact { source } => { write!( f, "no installable artifact found for {} {}", source.kind.as_str(), source.locator ) } aim_core::domain::show::ShowResultError::AdapterResolutionFailed { adapter_id, kind, detail, } => match kind { aim_core::domain::show::AdapterFailureKind::UnsupportedQuery => { write!(f, "{adapter_id} does not support this query") } aim_core::domain::show::AdapterFailureKind::UnsupportedSource => { write!(f, "{adapter_id} does not support this source") } aim_core::domain::show::AdapterFailureKind::ResolutionFailed => { if let Some(detail) = detail { write!(f, "{adapter_id} resolution failed: {detail}") } else { write!(f, "{adapter_id} resolution failed") } } }, aim_core::domain::show::ShowResultError::GitHubDiscoveryFailed { kind, detail, } => match (kind, detail) { ( aim_core::domain::show::GitHubDiscoveryFailureKind::FixtureDocumentMissing, Some(detail), ) => write!(f, "github discovery failed: missing fixture document {detail}"), ( aim_core::domain::show::GitHubDiscoveryFailureKind::NoReleases, Some(detail), ) => write!(f, "github discovery failed: no releases for {detail}"), (aim_core::domain::show::GitHubDiscoveryFailureKind::Unsupported, _) => { write!(f, "github discovery failed: unsupported source") } (aim_core::domain::show::GitHubDiscoveryFailureKind::Transport, _) => { write!(f, "github discovery failed: transport error") } _ => write!(f, "github discovery failed"), }, aim_core::domain::show::ShowResultError::NoInstallableCandidates => { write!(f, "no installable candidates found") } }, Self::UpdatePlan(error) => write!(f, "update planning failed: {error:?}"), Self::UpdateExecution(error) => write!(f, "update execution failed: {error:?}"), } } } impl From for DispatchError { fn from(value: aim_core::app::add::BuildAddPlanError) -> Self { Self::AddPlan(value) } } impl From for DispatchError { fn from(value: aim_core::app::add::InstallAppError) -> Self { Self::AddInstall(value) } } impl From for DispatchError { fn from(value: ui::prompt::PromptError) -> Self { Self::Prompt(value) } } impl From for DispatchError { fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self { Self::UpdatePlan(value) } } impl From for DispatchError { fn from(value: aim_core::app::update::ExecuteUpdatesError) -> Self { Self::UpdateExecution(value) } } impl From for DispatchError { fn from(value: aim_core::app::remove::RemoveRegisteredAppError) -> Self { Self::RemovePlan(value) } } impl From for DispatchError { fn from(value: aim_core::registry::store::RegistryStoreError) -> Self { Self::Registry(value) } } impl From for DispatchError { fn from(value: aim_core::app::search::SearchError) -> Self { Self::Search(value) } } impl From for DispatchError { fn from(value: aim_core::domain::show::ShowResultError) -> Self { Self::Show(value) } } fn upsert_app_record(apps: &mut Vec, record: AppRecord) { if let Some(existing) = apps .iter_mut() .find(|item| item.stable_id == record.stable_id) { *existing = record; return; } apps.push(record); } fn remove_app_record(apps: &mut Vec, 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::>(); let updated_by_id = updated_apps .iter() .map(|app| (app.stable_id.as_str(), app.clone())) .collect::>(); 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 .parent() .unwrap_or_else(|| Path::new(".")) .join("install-home"); } let home = env::var_os("HOME").unwrap_or_else(|| ".".into()); PathBuf::from(home) } fn is_effective_root() -> bool { if let Some(value) = env::var_os("AIM_EFFECTIVE_ROOT") { let value = value.to_string_lossy(); return value == "1" || value.eq_ignore_ascii_case("true"); } #[cfg(unix)] unsafe { libc::geteuid() == 0 } #[cfg(not(unix))] { false } }