initial skeleton

This commit is contained in:
stoorps 2026-03-19 18:46:50 +00:00
parent dc79fa2448
commit 71f89dde9c
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
60 changed files with 3480 additions and 0 deletions

View file

@ -0,0 +1,15 @@
[package]
name = "aim-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
serde.workspace = true
toml.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,13 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
pub struct CustomJsonAdapter;
impl SourceAdapter for CustomJsonAdapter {
fn id(&self) -> &'static str {
"custom-json"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
}

View file

@ -0,0 +1,37 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct DirectUrlAdapter;
impl DirectUrlAdapter {
pub fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, DirectUrlAdapterError> {
if source.kind != SourceKind::DirectUrl {
return Err(DirectUrlAdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::DirectUrl,
locator: source.locator.clone(),
},
release: ResolvedRelease {
version: "unresolved".to_owned(),
},
})
}
}
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

@ -0,0 +1,50 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct GitHubAdapter;
impl Default for GitHubAdapter {
fn default() -> Self {
Self::new()
}
}
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: SourceRef {
kind: SourceKind::GitHub,
locator: source.locator.clone(),
},
release: ResolvedRelease {
version: "latest".to_owned(),
},
})
}
}
impl SourceAdapter for GitHubAdapter {
fn id(&self) -> &'static str {
"github"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum GitHubAdapterError {
UnsupportedSource,
}

View file

@ -0,0 +1,40 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
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: SourceRef {
kind: SourceKind::GitLab,
locator: source.locator.clone(),
},
release: ResolvedRelease {
version: "latest".to_owned(),
},
})
}
}
impl SourceAdapter for GitLabAdapter {
fn id(&self) -> &'static str {
"gitlab"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum GitLabAdapterError {
UnsupportedSource,
}

View file

@ -0,0 +1,19 @@
pub mod custom_json;
pub mod direct_url;
pub mod github;
pub mod gitlab;
pub mod sourceforge;
pub mod test_support;
pub mod traits;
pub mod zsync;
pub fn all_adapter_kinds() -> Vec<&'static str> {
vec![
"github",
"gitlab",
"direct-url",
"zsync",
"sourceforge",
"custom-json",
]
}

View file

@ -0,0 +1,16 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
pub struct SourceForgeAdapter;
impl SourceAdapter for SourceForgeAdapter {
fn id(&self) -> &'static str {
"sourceforge"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
}

View file

@ -0,0 +1,27 @@
use crate::adapters::traits::AdapterCapabilities;
use crate::adapters::traits::SourceAdapter;
#[derive(Debug)]
pub struct MockAdapter {
id: &'static str,
capabilities: AdapterCapabilities,
}
impl MockAdapter {
pub fn exact_resolution_only() -> Self {
Self {
id: "mock",
capabilities: AdapterCapabilities::exact_resolution_only(),
}
}
}
impl SourceAdapter for MockAdapter {
fn id(&self) -> &'static str {
self.id
}
fn capabilities(&self) -> AdapterCapabilities {
self.capabilities
}
}

View file

@ -0,0 +1,29 @@
use crate::domain::source::ResolvedRelease;
use crate::domain::source::SourceRef;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AdapterCapabilities {
pub supports_search: bool,
pub supports_exact_resolution: bool,
}
impl AdapterCapabilities {
pub fn exact_resolution_only() -> Self {
Self {
supports_search: false,
supports_exact_resolution: true,
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,
}
pub trait SourceAdapter {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AdapterCapabilities;
}

View file

@ -0,0 +1,13 @@
use crate::adapters::traits::{AdapterCapabilities, SourceAdapter};
pub struct ZsyncAdapter;
impl SourceAdapter for ZsyncAdapter {
fn id(&self) -> &'static str {
"zsync"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
}

View file

@ -0,0 +1,36 @@
use crate::adapters::github::{GitHubAdapter, GitHubAdapterError};
use crate::adapters::traits::AdapterResolution;
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::domain::source::{SourceKind, SourceRef};
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
let resolution = match source.kind {
SourceKind::GitHub => GitHubAdapter::new()
.resolve(&source)
.map_err(BuildAddPlanError::GitHub)?,
_ => AdapterResolution {
source: SourceRef {
kind: source.kind,
locator: source.locator.clone(),
},
release: crate::domain::source::ResolvedRelease {
version: "unresolved".to_owned(),
},
},
};
Ok(AddPlan { resolution })
}
#[derive(Debug, Eq, PartialEq)]
pub struct AddPlan {
pub resolution: AdapterResolution,
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
GitHub(GitHubAdapterError),
}

View file

@ -0,0 +1,77 @@
use crate::domain::app::{AppIdentity, IdentityConfidence};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityFallback {
DisallowRawUrl,
AllowRawUrl,
}
pub fn resolve_identity(
explicit_name: Option<&str>,
explicit_id: Option<&str>,
source_url: Option<&str>,
fallback: IdentityFallback,
) -> Result<AppIdentity, ResolveIdentityError> {
if let Some(explicit_id) = explicit_id.filter(|value| !value.trim().is_empty()) {
let stable_id = normalize_identifier(explicit_id);
let display_name = explicit_name
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| explicit_id.to_owned());
return Ok(AppIdentity {
stable_id,
display_name,
confidence: IdentityConfidence::Confident,
});
}
if let Some(explicit_name) = explicit_name.filter(|value| !value.trim().is_empty()) {
return Ok(AppIdentity {
stable_id: normalize_identifier(explicit_name),
display_name: explicit_name.to_owned(),
confidence: IdentityConfidence::NeedsConfirmation,
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& fallback == IdentityFallback::AllowRawUrl
{
return Ok(AppIdentity {
stable_id: normalize_url_identifier(source_url),
display_name: source_url.to_owned(),
confidence: IdentityConfidence::RawUrlFallback,
});
}
Err(ResolveIdentityError::Unresolved)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveIdentityError {
Unresolved,
}
fn normalize_identifier(value: &str) -> String {
value
.trim()
.chars()
.map(|ch| match ch {
'A'..='Z' => ch.to_ascii_lowercase(),
'a'..='z' | '0'..='9' | '.' | '-' => ch,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_owned()
}
fn normalize_url_identifier(url: &str) -> String {
let trimmed = url
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("file://");
format!("url-{}", normalize_identifier(trimmed))
}

View file

@ -0,0 +1,4 @@
#[derive(Debug, Eq, PartialEq)]
pub enum InteractionRequest {
SelectRegisteredApp { query: String, matches: Vec<String> },
}

View file

@ -0,0 +1,16 @@
use crate::domain::app::AppRecord;
#[derive(Debug, Eq, PartialEq)]
pub struct ListRow {
pub stable_id: String,
pub display_name: String,
}
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
apps.iter()
.map(|app| ListRow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
})
.collect()
}

View file

@ -0,0 +1,8 @@
pub mod add;
pub mod identity;
pub mod interaction;
pub mod list;
pub mod query;
pub mod remove;
pub mod scope;
pub mod update;

View file

@ -0,0 +1,55 @@
use crate::domain::source::SourceKind;
use crate::domain::source::SourceRef;
pub fn resolve_query(query: &str) -> Result<SourceRef, ResolveQueryError> {
if query.starts_with("file://") {
return Ok(SourceRef {
kind: SourceKind::File,
locator: query.to_owned(),
});
}
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
return Ok(SourceRef {
kind: SourceKind::GitLab,
locator: query.to_owned(),
});
}
if query.starts_with("https://") || query.starts_with("http://") {
return Ok(SourceRef {
kind: SourceKind::DirectUrl,
locator: query.to_owned(),
});
}
if is_github_shorthand(query) {
return Ok(SourceRef {
kind: SourceKind::GitHub,
locator: query.to_owned(),
});
}
Err(ResolveQueryError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveQueryError {
Unsupported,
}
fn is_github_shorthand(query: &str) -> bool {
let mut parts = query.split('/');
let Some(owner) = parts.next() else {
return false;
};
let Some(repo) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
!owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':')
}

View file

@ -0,0 +1,80 @@
use crate::app::interaction::InteractionRequest;
use crate::domain::app::AppRecord;
pub fn resolve_registered_app<'a>(
query: &str,
apps: &'a [AppRecord],
) -> Result<&'a AppRecord, ResolveRegisteredAppError> {
let normalized_query = normalize_lookup(query);
let matches = apps
.iter()
.filter(|app| {
normalize_lookup(&app.stable_id) == normalized_query
|| normalize_lookup(&app.display_name) == normalized_query
})
.collect::<Vec<_>>();
match matches.as_slice() {
[] => Err(ResolveRegisteredAppError::UnknownApp {
query: query.to_owned(),
}),
[app] => Ok(*app),
_ => Err(ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest::SelectRegisteredApp {
query: query.to_owned(),
matches: matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
},
}),
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalPlan {
pub stable_id: String,
pub display_name: String,
pub artifact_paths: Vec<String>,
}
pub fn build_removal_plan(app: &AppRecord) -> RemovalPlan {
RemovalPlan {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
artifact_paths: Vec::new(),
}
}
pub fn remove_registered_app(
query: &str,
apps: &[AppRecord],
) -> Result<RemovalResult, ResolveRegisteredAppError> {
let app = resolve_registered_app(query, apps)?;
let remaining_apps = apps
.iter()
.filter(|candidate| candidate.stable_id != app.stable_id)
.cloned()
.collect();
Ok(RemovalResult {
removed: build_removal_plan(app),
remaining_apps,
})
}
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalResult {
pub removed: RemovalPlan,
pub remaining_apps: Vec<AppRecord>,
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveRegisteredAppError {
UnknownApp { query: String },
Ambiguous { request: InteractionRequest },
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}

View file

@ -0,0 +1,28 @@
use crate::domain::app::InstallScope;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScopeOverride {
System,
User,
}
pub fn resolve_install_scope(
_is_effective_root: bool,
override_scope: ScopeOverride,
) -> InstallScope {
match override_scope {
ScopeOverride::System => InstallScope::System,
ScopeOverride::User => InstallScope::User,
}
}
pub fn resolve_install_scope_with_default(
is_effective_root: bool,
override_scope: Option<ScopeOverride>,
) -> InstallScope {
match override_scope {
Some(scope) => resolve_install_scope(is_effective_root, scope),
None if is_effective_root => InstallScope::System,
None => InstallScope::User,
}
}

View file

@ -0,0 +1,17 @@
use crate::domain::app::AppRecord;
use crate::domain::update::{PlannedUpdate, UpdatePlan};
pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePlanError> {
Ok(UpdatePlan {
items: apps
.iter()
.map(|app| PlannedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
})
.collect(),
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildUpdatePlanError {}

View file

@ -0,0 +1,25 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InstallScope {
User,
System,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityConfidence {
Confident,
NeedsConfirmation,
RawUrlFallback,
}
#[derive(Debug, Eq, PartialEq)]
pub struct AppIdentity {
pub stable_id: String,
pub display_name: String,
pub confidence: IdentityConfidence,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct AppRecord {
pub stable_id: String,
pub display_name: String,
}

View file

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

View file

@ -0,0 +1,29 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourceKind {
GitHub,
GitLab,
DirectUrl,
File,
}
impl SourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHub => "github",
Self::GitLab => "gitlab",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
}
#[derive(Debug, Eq, PartialEq)]
pub struct ResolvedRelease {
pub version: String,
}

View file

@ -0,0 +1,10 @@
#[derive(Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,
}
#[derive(Debug, Eq, PartialEq)]
pub struct PlannedUpdate {
pub stable_id: String,
pub display_name: String,
}

View file

@ -0,0 +1,14 @@
use std::path::{Path, PathBuf};
pub fn staged_appimage_path(staging_root: &Path, app_id: &str) -> PathBuf {
staging_root.join(format!("{app_id}.download"))
}
pub fn replacement_path(target: &Path) -> PathBuf {
let mut file_name = target
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default();
file_name.push(".new");
target.with_file_name(file_name)
}

View file

@ -0,0 +1,2 @@
pub mod install;
pub mod paths;

View file

@ -0,0 +1,40 @@
use std::path::{Path, PathBuf};
use crate::domain::app::InstallScope;
use crate::platform::{
system_applications_dir, system_icons_dir, system_managed_appimages_dir, user_applications_dir,
user_icons_dir, user_managed_appimages_dir,
};
pub fn managed_appimage_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_managed_dir(home_dir, scope).join(format!("{app_id}.AppImage"))
}
pub fn desktop_entry_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_applications_dir(home_dir, scope).join(format!("aim-{app_id}.desktop"))
}
pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_icons_dir(home_dir, scope).join(format!("{app_id}.png"))
}
fn scope_managed_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_managed_appimages_dir(home_dir),
InstallScope::System => system_managed_appimages_dir(),
}
}
fn scope_applications_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_applications_dir(home_dir),
InstallScope::System => system_applications_dir(),
}
}
fn scope_icons_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_icons_dir(home_dir),
InstallScope::System => system_icons_dir(),
}
}

View file

@ -0,0 +1,6 @@
pub mod adapters;
pub mod app;
pub mod domain;
pub mod integration;
pub mod platform;
pub mod registry;

View file

@ -0,0 +1,25 @@
use std::path::{Path, PathBuf};
pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/lib/aim/appimages")
}
pub fn user_applications_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/share/applications")
}
pub fn user_icons_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/share/icons/hicolor/256x256/apps")
}
pub fn system_managed_appimages_dir() -> PathBuf {
PathBuf::from("/opt/aim/appimages")
}
pub fn system_applications_dir() -> PathBuf {
PathBuf::from("/usr/share/applications")
}
pub fn system_icons_dir() -> PathBuf {
PathBuf::from("/usr/share/icons/hicolor/256x256/apps")
}

View file

@ -0,0 +1,2 @@
pub mod model;
pub mod store;

View file

@ -0,0 +1,14 @@
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Registry {
pub version: u32,
pub apps: Vec<crate::domain::app::AppRecord>,
}
impl Default for Registry {
fn default() -> Self {
Self {
version: 1,
apps: Vec::new(),
}
}
}

View file

@ -0,0 +1,59 @@
use std::fs;
use std::path::PathBuf;
use crate::registry::model::Registry;
pub struct RegistryStore {
path: PathBuf,
}
impl RegistryStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Registry, RegistryStoreError> {
if !self.path.exists() {
return Ok(Registry::default());
}
let contents = fs::read_to_string(&self.path)?;
let registry = toml::from_str(&contents)?;
Ok(registry)
}
pub fn save(&self, registry: &Registry) -> Result<(), RegistryStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string(registry)?;
fs::write(&self.path, contents)?;
Ok(())
}
}
#[derive(Debug)]
pub enum RegistryStoreError {
Io(std::io::Error),
SerializeToml(toml::ser::Error),
Toml(toml::de::Error),
}
impl From<std::io::Error> for RegistryStoreError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<toml::de::Error> for RegistryStoreError {
fn from(error: toml::de::Error) -> Self {
Self::Toml(error)
}
}
impl From<toml::ser::Error> for RegistryStoreError {
fn from(error: toml::ser::Error) -> Self {
Self::SerializeToml(error)
}
}

View file

@ -0,0 +1,7 @@
use aim_core::adapters::traits::AdapterCapabilities;
#[test]
fn adapter_capabilities_can_report_exact_resolution_only() {
let capabilities = AdapterCapabilities::exact_resolution_only();
assert!(!capabilities.supports_search);
}

View file

@ -0,0 +1,12 @@
use aim_core::adapters::all_adapter_kinds;
#[test]
fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds();
assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync"));
assert!(kinds.contains(&"sourceforge"));
assert!(kinds.contains(&"custom-json"));
}

View file

@ -0,0 +1,18 @@
use aim_core::app::add::build_add_plan;
use aim_core::app::query::resolve_query;
#[test]
fn github_adapter_can_normalize_owner_repo_source() {
let source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(source.kind.as_str(), "github");
}
#[test]
fn add_flow_builds_github_plan_from_owner_repo_query() {
let plan = build_add_plan("sharkdp/bat").unwrap();
assert_eq!(plan.resolution.source.kind.as_str(), "github");
assert_eq!(plan.resolution.source.locator, "sharkdp/bat");
assert_eq!(plan.resolution.release.version, "latest");
}

View file

@ -0,0 +1,31 @@
use aim_core::app::identity::{IdentityFallback, resolve_identity};
use aim_core::domain::app::IdentityConfidence;
#[test]
fn unresolved_identity_can_fall_back_to_url() {
let identity = resolve_identity(
None,
None,
Some("https://example.com/app.AppImage"),
IdentityFallback::AllowRawUrl,
)
.unwrap();
assert!(identity.stable_id.contains("example.com"));
assert_eq!(identity.confidence, IdentityConfidence::RawUrlFallback);
}
#[test]
fn explicit_id_is_treated_as_confident() {
let identity = resolve_identity(
Some("Bat"),
Some("sharkdp/bat"),
Some("https://github.com/sharkdp/bat/releases"),
IdentityFallback::AllowRawUrl,
)
.unwrap();
assert_eq!(identity.stable_id, "sharkdp-bat");
assert_eq!(identity.display_name, "Bat");
assert_eq!(identity.confidence, IdentityConfidence::Confident);
}

View file

@ -0,0 +1,21 @@
use std::path::Path;
use aim_core::domain::app::InstallScope;
use aim_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/aim/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_desktop_entry_uses_system_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/aim-bat.desktop"));
}

View file

@ -0,0 +1,8 @@
use aim_core::app::scope::{ScopeOverride, resolve_install_scope};
use aim_core::domain::app::InstallScope;
#[test]
fn explicit_scope_override_beats_effective_user() {
let scope = resolve_install_scope(false, ScopeOverride::System);
assert_eq!(scope, InstallScope::System);
}

View file

@ -0,0 +1,8 @@
use aim_core::app::query::resolve_query;
use aim_core::domain::source::SourceKind;
#[test]
fn owner_repo_defaults_to_github() {
let source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(source.kind, SourceKind::GitHub);
}

View file

@ -0,0 +1,10 @@
use aim_core::registry::store::RegistryStore;
use tempfile::tempdir;
#[test]
fn registry_round_trips_app_records() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let loaded = store.load().unwrap();
assert!(loaded.apps.is_empty());
}

View file

@ -0,0 +1,49 @@
use aim_core::app::interaction::InteractionRequest;
use aim_core::app::list::build_list_rows;
use aim_core::app::remove::resolve_registered_app;
use aim_core::domain::app::AppRecord;
#[test]
fn remove_flow_rejects_unknown_app_names() {
let result = resolve_registered_app("bat", &[]);
assert!(result.is_err());
}
#[test]
fn list_flow_returns_display_rows_for_registered_apps() {
let rows = build_list_rows(&[AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
}]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].stable_id, "bat");
assert_eq!(rows[0].display_name, "Bat");
}
#[test]
fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
let apps = [
AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
},
AppRecord {
stable_id: "bat-nightly".to_owned(),
display_name: "Bat".to_owned(),
},
];
let error = resolve_registered_app("Bat", &apps).unwrap_err();
assert_eq!(
error,
aim_core::app::remove::ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest::SelectRegisteredApp {
query: "Bat".to_owned(),
matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()],
},
}
);
}

View file

@ -0,0 +1,22 @@
use aim_core::app::update::build_update_plan;
use aim_core::domain::app::AppRecord;
#[test]
fn empty_registry_produces_empty_plan() {
let plan = build_update_plan(&[]).unwrap();
assert!(plan.items.is_empty());
}
#[test]
fn installed_apps_are_carried_into_review_plan() {
let apps = [AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].stable_id, "bat");
}