Merge branch 'feat/update-execution-and-provider-contract'

This commit is contained in:
stoorps 2026-03-20 00:15:46 +00:00
commit d1b9754c0b
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
16 changed files with 407 additions and 79 deletions

View file

@ -18,8 +18,7 @@ pub struct Cli {
impl Cli {
pub fn is_review_update_flow(&self) -> bool {
matches!(self.command, Some(Command::Update))
|| (self.command.is_none() && self.query.is_none())
self.command.is_none() && self.query.is_none()
}
}

View file

@ -9,9 +9,9 @@ use aim_core::app::add::{
};
use aim_core::app::list::{ListRow, build_list_rows};
use aim_core::app::remove::{RemovalResult, remove_registered_app};
use aim_core::app::update::build_update_plan;
use aim_core::app::update::{build_update_plan, execute_updates};
use aim_core::domain::app::AppRecord;
use aim_core::domain::update::UpdatePlan;
use aim_core::domain::update::{UpdateExecutionResult, UpdatePlan};
use aim_core::registry::model::Registry;
use aim_core::registry::store::RegistryStore;
@ -44,7 +44,15 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
})?;
Ok(DispatchResult::Removed(Box::new(removal)))
}
cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)),
cli::args::Command::Update => {
let updates = execute_updates(&apps, &install_home)?;
let updated_apps = updates.apps.clone();
store.save(&Registry {
version: registry.version,
apps: updated_apps,
})?;
Ok(DispatchResult::Updated(Box::new(updates)))
}
};
}
@ -94,6 +102,7 @@ pub enum DispatchResult {
PendingAdd(Box<AddPlan>),
Removed(Box<RemovalResult>),
UpdatePlan(UpdatePlan),
Updated(Box<UpdateExecutionResult>),
Noop,
}
@ -105,6 +114,7 @@ pub enum DispatchError {
RemovePlan(aim_core::app::remove::RemoveRegisteredAppError),
Registry(aim_core::registry::store::RegistryStoreError),
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
UpdateExecution(aim_core::app::update::ExecuteUpdatesError),
}
impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
@ -131,6 +141,12 @@ impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
}
}
impl From<aim_core::app::update::ExecuteUpdatesError> for DispatchError {
fn from(value: aim_core::app::update::ExecuteUpdatesError) -> Self {
Self::UpdateExecution(value)
}
}
impl From<aim_core::app::remove::RemoveRegisteredAppError> for DispatchError {
fn from(value: aim_core::app::remove::RemoveRegisteredAppError) -> Self {
Self::RemovePlan(value)

View file

@ -1,4 +1,5 @@
use aim_core::app::add::AddPlan;
use aim_core::domain::update::UpdateExecutionStatus;
use crate::DispatchResult;
@ -15,6 +16,7 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
DispatchResult::UpdatePlan(plan) => {
render_update_summary(plan.items.len(), plan.items.len(), 0)
}
DispatchResult::Updated(result) => render_updated_apps(result),
DispatchResult::Noop => String::new(),
}
}
@ -88,3 +90,29 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
format!("{summary}\n{warning_lines}")
}
}
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
let mut lines = vec![format!(
"updated apps: {}, failed: {}",
result.updated_count(),
result.failed_count()
)];
for item in &result.items {
match &item.status {
UpdateExecutionStatus::Updated => lines.push(format!(
"updated: {} ({}) {} -> {}",
item.display_name,
item.stable_id,
item.from_version.as_deref().unwrap_or("unknown"),
item.to_version.as_deref().unwrap_or("unknown")
)),
UpdateExecutionStatus::Failed { reason } => lines.push(format!(
"failed: {} ({}) {}",
item.display_name, item.stable_id, reason
)),
}
}
lines.join("\n")
}

View file

@ -1,4 +1,8 @@
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::registry::model::Registry;
use aim_core::registry::store::RegistryStore;
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use tempfile::tempdir;
@ -179,3 +183,49 @@ fn system_request_on_immutable_host_falls_back_to_user_install() {
.stdout(contains("installing as user"))
.stdout(contains("downgraded to user scope"));
}
#[test]
fn update_command_applies_updates() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let payload_path = dir
.path()
.join("install-home/.local/lib/aim/appimages/pingdotgg-t3code.AppImage");
let store = RegistryStore::new(registry_path.clone());
store
.save(&Registry {
version: 1,
apps: vec![AppRecord {
stable_id: "pingdotgg-t3code".to_owned(),
display_name: "t3code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
}],
})
.unwrap();
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("update")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("updated apps: 1"))
.stdout(contains("updates found:").not());
let updated = store.load().unwrap();
assert_eq!(updated.apps.len(), 1);
assert_eq!(updated.apps[0].stable_id, "pingdotgg-t3code");
assert_eq!(updated.apps[0].installed_version.as_deref(), Some("0.0.12"));
assert!(payload_path.exists());
}

View file

@ -1,4 +1,7 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
pub struct CustomJsonAdapter;
@ -10,4 +13,12 @@ impl SourceAdapter for CustomJsonAdapter {
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -1,12 +1,32 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct DirectUrlAdapter;
impl DirectUrlAdapter {
pub fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, DirectUrlAdapterError> {
impl SourceAdapter for DirectUrlAdapter {
fn id(&self) -> &'static str {
"direct-url"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::DirectUrl {
return Err(DirectUrlAdapterError::UnsupportedSource);
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::DirectUrl {
return Err(AdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
@ -18,18 +38,3 @@ impl DirectUrlAdapter {
})
}
}
impl SourceAdapter for DirectUrlAdapter {
fn id(&self) -> &'static str {
"direct-url"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum DirectUrlAdapterError {
UnsupportedSource,
}

View file

@ -1,4 +1,6 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
@ -14,24 +16,6 @@ impl GitHubAdapter {
pub fn new() -> Self {
Self
}
pub fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, GitHubAdapterError> {
if source.kind != SourceKind::GitHub {
return Err(GitHubAdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
pub fn normalize(&self, query: &str) -> Result<SourceRef, GitHubAdapterError> {
resolve_query(query).map_err(|_| GitHubAdapterError::UnsupportedSource)
}
}
impl SourceAdapter for GitHubAdapter {
@ -45,9 +29,27 @@ impl SourceAdapter for GitHubAdapter {
supports_exact_resolution: true,
}
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::GitHub {
return Err(AdapterError::UnsupportedQuery);
}
#[derive(Debug, Eq, PartialEq)]
pub enum GitHubAdapterError {
UnsupportedSource,
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::GitHub {
return Err(AdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
}

View file

@ -1,24 +1,11 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct GitLabAdapter;
impl GitLabAdapter {
pub fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, GitLabAdapterError> {
if source.kind != SourceKind::GitLab {
return Err(GitLabAdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
}
impl SourceAdapter for GitLabAdapter {
fn id(&self) -> &'static str {
"gitlab"
@ -30,9 +17,27 @@ impl SourceAdapter for GitLabAdapter {
supports_exact_resolution: true,
}
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::GitLab {
return Err(AdapterError::UnsupportedQuery);
}
#[derive(Debug, Eq, PartialEq)]
pub enum GitLabAdapterError {
UnsupportedSource,
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::GitLab {
return Err(AdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
}

View file

@ -1,4 +1,7 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
pub struct SourceForgeAdapter;
@ -13,4 +16,12 @@ impl SourceAdapter for SourceForgeAdapter {
supports_exact_resolution: true,
}
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -1,5 +1,7 @@
use crate::adapters::traits::AdapterCapabilities;
use crate::adapters::traits::SourceAdapter;
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
#[derive(Debug)]
pub struct MockAdapter {
@ -24,4 +26,12 @@ impl SourceAdapter for MockAdapter {
fn capabilities(&self) -> AdapterCapabilities {
self.capabilities
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -22,8 +22,19 @@ pub struct AdapterResolution {
pub release: ResolvedRelease,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterError {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed(String),
}
pub trait SourceAdapter {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AdapterCapabilities;
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
}

View file

@ -1,4 +1,7 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
pub struct ZsyncAdapter;
@ -10,4 +13,12 @@ impl SourceAdapter for ZsyncAdapter {
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -1,5 +1,11 @@
use crate::domain::app::AppRecord;
use crate::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
use std::path::Path;
use crate::app::add::{build_add_plan, install_app};
use crate::domain::app::{AppRecord, InstallScope};
use crate::domain::update::{
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
UpdateExecutionStatus, UpdatePlan,
};
pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePlanError> {
Ok(UpdatePlan {
@ -7,9 +13,59 @@ pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePl
})
}
pub fn execute_updates(
apps: &[AppRecord],
install_home: &Path,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
let mut updated_apps = Vec::with_capacity(apps.len());
let mut items = Vec::with_capacity(apps.len());
for app in apps {
match execute_update(app, install_home) {
Ok(updated) => {
let warnings = updated
.warnings
.iter()
.chain(updated.install_outcome.warnings.iter())
.cloned()
.collect();
let record = updated.record;
items.push(ExecutedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
from_version: app.installed_version.clone(),
to_version: record.installed_version.clone(),
warnings,
status: UpdateExecutionStatus::Updated,
});
updated_apps.push(record);
}
Err(reason) => {
items.push(ExecutedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
from_version: app.installed_version.clone(),
to_version: app.installed_version.clone(),
warnings: Vec::new(),
status: UpdateExecutionStatus::Failed { reason },
});
updated_apps.push(app.clone());
}
}
}
Ok(UpdateExecutionResult {
apps: updated_apps,
items,
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildUpdatePlanError {}
#[derive(Debug, Eq, PartialEq)]
pub enum ExecuteUpdatesError {}
fn plan_update(app: &AppRecord) -> PlannedUpdate {
let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy {
if strategy.preferred.locator.contains("fail") {
@ -47,3 +103,31 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
selection_reason,
}
}
fn execute_update(
app: &AppRecord,
install_home: &Path,
) -> Result<crate::app::add::InstalledApp, String> {
let query = update_query(app).ok_or_else(|| "missing install source".to_owned())?;
let requested_scope = app
.install
.as_ref()
.map(|install| install.scope)
.unwrap_or(InstallScope::User);
let plan = build_add_plan(&query)
.map_err(|error| format!("failed to build update plan: {error:?}"))?;
install_app(&query, &plan, install_home, requested_scope)
.map_err(|error| format!("failed to install update: {error:?}"))
}
fn update_query(app: &AppRecord) -> Option<String> {
app.source_input.clone().or_else(|| {
app.source.as_ref().map(|source| {
source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone())
})
})
}

View file

@ -1,3 +1,5 @@
use crate::domain::app::AppRecord;
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
Unknown,
@ -98,3 +100,41 @@ pub struct PlannedUpdate {
pub selected_channel: ChannelPreference,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateExecutionResult {
pub apps: Vec<AppRecord>,
pub items: Vec<ExecutedUpdate>,
}
impl UpdateExecutionResult {
pub fn updated_count(&self) -> usize {
self.items
.iter()
.filter(|item| item.status == UpdateExecutionStatus::Updated)
.count()
}
pub fn failed_count(&self) -> usize {
self.items
.iter()
.filter(|item| matches!(item.status, UpdateExecutionStatus::Failed { .. }))
.count()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExecutedUpdate {
pub stable_id: String,
pub display_name: String,
pub from_version: Option<String>,
pub to_version: Option<String>,
pub warnings: Vec<String>,
pub status: UpdateExecutionStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateExecutionStatus {
Updated,
Failed { reason: String },
}

View file

@ -1,5 +1,6 @@
use aim_core::adapters::github::GitHubAdapter;
use aim_core::adapters::traits::AdapterCapabilities;
use aim_core::adapters::gitlab::GitLabAdapter;
use aim_core::adapters::traits::{AdapterCapabilities, SourceAdapter};
#[test]
fn adapter_capabilities_can_report_exact_resolution_only() {
@ -9,10 +10,27 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
#[test]
fn legacy_github_adapter_delegates_to_source_pipeline() {
let adapter = GitHubAdapter;
let adapter: &dyn SourceAdapter = &GitHubAdapter;
let result = adapter.normalize("sharkdp/bat").unwrap();
assert_eq!(result.normalized_kind.as_str(), "github-repository");
assert_eq!(result.canonical_locator.as_deref(), Some("sharkdp/bat"));
let resolution = adapter.resolve(&result).unwrap();
assert_eq!(resolution.release.version, "latest");
}
#[test]
fn gitlab_adapter_normalizes_and_resolves_through_trait() {
let adapter: &dyn SourceAdapter = &GitLabAdapter;
let result = adapter
.normalize("https://gitlab.com/example/team/app")
.unwrap();
assert_eq!(result.kind.as_str(), "gitlab");
let resolution = adapter.resolve(&result).unwrap();
assert_eq!(resolution.release.version, "latest");
}

View file

@ -1,6 +1,7 @@
use aim_core::app::update::build_update_plan;
use aim_core::domain::app::AppRecord;
use aim_core::app::update::{build_update_plan, execute_updates};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use tempfile::tempdir;
#[test]
fn empty_registry_produces_empty_plan() {
@ -61,3 +62,29 @@ fn update_plan_uses_alternate_channel_after_preferred_failure() {
);
assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed");
}
#[test]
fn failed_update_keeps_previous_app_record() {
let install_home = tempdir().unwrap();
let previous = AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("0.9.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.apps, vec![previous]);
assert_eq!(result.updated_count(), 0);
assert_eq!(result.failed_count(), 1);
}