github source v1

This commit is contained in:
stoorps 2026-03-19 20:14:39 +00:00
parent 71f89dde9c
commit caf870d05e
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
50 changed files with 4139 additions and 131 deletions

View file

@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
clap.workspace = true
dialoguer.workspace = true
aim-core = { path = "../aim-core" }
[dev-dependencies]

View file

@ -4,12 +4,13 @@ pub mod ui;
use std::env;
use std::path::PathBuf;
use aim_core::app::add::build_add_plan;
use aim_core::app::add::{AddPlan, build_add_plan, materialize_app_record};
use aim_core::app::list::{ListRow, build_list_rows};
use aim_core::app::remove::remove_registered_app;
use aim_core::app::update::build_update_plan;
use aim_core::domain::app::AppRecord;
use aim_core::domain::source::SourceRef;
use aim_core::domain::update::UpdatePlan;
use aim_core::domain::update::{ArtifactCandidate, UpdatePlan};
use aim_core::registry::model::Registry;
use aim_core::registry::store::RegistryStore;
@ -45,9 +46,29 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
}
if let Some(query) = cli.query {
return Ok(DispatchResult::AddPlan(
build_add_plan(&query)?.resolution.source,
));
let mut plan = build_add_plan(&query)?;
if !plan.interactions.is_empty() {
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
Some(resolved) => {
plan = resolved;
}
None => return Ok(DispatchResult::PendingAdd(plan)),
}
}
let record = materialize_app_record(&query, &plan)?;
let mut updated_apps = registry.apps.clone();
upsert_app_record(&mut updated_apps, record.clone());
store.save(&Registry {
version: registry.version,
apps: updated_apps,
})?;
return Ok(DispatchResult::Added(AddedApp {
record,
selected_artifact: plan.selected_artifact,
source: plan.resolution.source,
}));
}
Ok(DispatchResult::Noop)
@ -68,16 +89,26 @@ fn registry_path() -> PathBuf {
#[derive(Debug, Eq, PartialEq)]
pub enum DispatchResult {
AddPlan(SourceRef),
Added(AddedApp),
List(Vec<ListRow>),
PendingAdd(AddPlan),
Removed(String),
UpdatePlan(UpdatePlan),
Noop,
}
#[derive(Debug, Eq, PartialEq)]
pub struct AddedApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub source: SourceRef,
}
#[derive(Debug)]
pub enum DispatchError {
AddPlan(aim_core::app::add::BuildAddPlanError),
AddRecord(aim_core::app::add::MaterializeAddRecordError),
Prompt(ui::prompt::PromptError),
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
Registry(aim_core::registry::store::RegistryStoreError),
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
@ -89,6 +120,18 @@ impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
}
}
impl From<aim_core::app::add::MaterializeAddRecordError> for DispatchError {
fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self {
Self::AddRecord(value)
}
}
impl From<ui::prompt::PromptError> for DispatchError {
fn from(value: ui::prompt::PromptError) -> Self {
Self::Prompt(value)
}
}
impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self {
Self::UpdatePlan(value)
@ -106,3 +149,15 @@ impl From<aim_core::registry::store::RegistryStoreError> for DispatchError {
Self::Registry(value)
}
}
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
if let Some(existing) = apps
.iter_mut()
.find(|item| item.stable_id == record.stable_id)
{
*existing = record;
return;
}
apps.push(record);
}

View file

@ -1,3 +1,113 @@
use aim_core::app::interaction::InteractionRequest;
use std::env;
use std::io::IsTerminal;
pub fn handle_interaction(_request: &InteractionRequest) {}
use aim_core::app::add::{AddPlan, prefer_latest_tracking};
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use dialoguer::{Select, theme::ColorfulTheme};
const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE";
pub fn render_interaction(request: &InteractionRequest) -> String {
match &request.kind {
InteractionKind::SelectRegisteredApp { query, matches } => format!(
"multiple installed apps match '{query}': {}",
matches.join(", ")
),
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => format!(
"tracking preference required: requested {requested_version}, latest available {latest_version}",
),
InteractionKind::SelectArtifact { candidates } => {
format!("artifact selection required: {}", candidates.join(", "))
}
}
}
pub fn render_interactions(requests: &[InteractionRequest]) -> String {
requests
.iter()
.map(render_interaction)
.collect::<Vec<_>>()
.join("\n")
}
pub fn resolve_add_plan_interactions(plan: AddPlan) -> Result<Option<AddPlan>, PromptError> {
let mut resolved = plan;
for request in resolved.interactions.clone() {
match &request.kind {
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => match resolve_tracking_preference(requested_version, latest_version)? {
Some(TrackingPreference::Requested) => {
resolved
.interactions
.retain(|item| item.key != "tracking-preference");
}
Some(TrackingPreference::Latest) => {
resolved = prefer_latest_tracking(resolved);
}
None => return Ok(None),
},
InteractionKind::SelectRegisteredApp { .. }
| InteractionKind::SelectArtifact { .. } => {
return Ok(None);
}
}
}
Ok(Some(resolved))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TrackingPreference {
Requested,
Latest,
}
#[derive(Debug)]
pub enum PromptError {
InvalidTrackingPreference(String),
Dialoguer(dialoguer::Error),
}
impl From<dialoguer::Error> for PromptError {
fn from(value: dialoguer::Error) -> Self {
Self::Dialoguer(value)
}
}
fn resolve_tracking_preference(
requested_version: &str,
latest_version: &str,
) -> Result<Option<TrackingPreference>, PromptError> {
if let Ok(value) = env::var(TRACKING_PREFERENCE_ENV) {
return match value.trim().to_ascii_lowercase().as_str() {
"requested" | "current" => Ok(Some(TrackingPreference::Requested)),
"latest" => Ok(Some(TrackingPreference::Latest)),
other => Err(PromptError::InvalidTrackingPreference(other.to_owned())),
};
}
if !std::io::stdin().is_terminal() {
return Ok(None);
}
let options = [
format!("Keep tracking the requested release lineage ({requested_version})"),
format!("Track the latest release after install ({latest_version})"),
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose update tracking behavior")
.items(options)
.default(1)
.interact()?;
Ok(Some(match selection {
0 => TrackingPreference::Requested,
_ => TrackingPreference::Latest,
}))
}

View file

@ -1,4 +1,4 @@
use aim_core::domain::source::SourceRef;
use aim_core::app::add::AddPlan;
use crate::DispatchResult;
@ -8,8 +8,9 @@ pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> St
pub fn render_dispatch_result(result: &DispatchResult) -> String {
match result {
DispatchResult::AddPlan(source) => render_add_plan(source),
DispatchResult::Added(added) => render_added_app(added),
DispatchResult::List(rows) => render_list(rows),
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
DispatchResult::Removed(display_name) => format!("removed: {display_name}"),
DispatchResult::UpdatePlan(plan) => {
render_update_summary(plan.items.len(), plan.items.len(), 0)
@ -18,11 +19,26 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
}
}
fn render_add_plan(source: &SourceRef) -> String {
fn render_added_app(added: &crate::AddedApp) -> String {
format!(
"resolved source: {} {}",
source.kind.as_str(),
source.locator
"tracked app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]",
added.record.display_name,
added.record.stable_id,
added.source.kind.as_str(),
added.source.locator,
added.selected_artifact.url,
added.selected_artifact.selection_reason,
)
}
fn render_pending_add(plan: &AddPlan) -> String {
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
format!(
"resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}",
plan.resolution.source.kind.as_str(),
plan.resolution.source.locator,
plan.selected_artifact.url,
plan.selected_artifact.selection_reason,
)
}

View file

@ -2,6 +2,8 @@ use assert_cmd::Command;
use predicates::str::contains;
use tempfile::tempdir;
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
#[test]
fn list_command_runs_without_registry_entries() {
let mut cmd = Command::cargo_bin("aim").unwrap();
@ -52,3 +54,58 @@ fn remove_command_removes_registered_app_from_registry_file() {
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(!contents.contains("stable_id = \"bat\""));
}
#[test]
fn query_command_registers_unambiguous_app_in_registry_file() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("sharkdp/bat")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("tracked app: bat (sharkdp-bat)"));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
assert!(contents.contains("source_input = \"sharkdp/bat\""));
}
#[test]
fn old_release_query_renders_tracking_prompt_without_writing_registry() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("tracking preference required"))
.stdout(contains("v0.0.11"))
.stdout(contains("v0.0.12"));
assert!(!registry_path.exists());
}
#[test]
fn old_release_query_can_track_latest_and_register_app() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.env("AIM_TRACKING_PREFERENCE", "latest")
.assert()
.success()
.stdout(contains("tracked app: t3code (pingdotgg-t3code)"));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
assert!(contents.contains("locator = \"pingdotgg/t3code\""));
}

View file

@ -1,7 +1,24 @@
use aim_cli::ui::prompt::render_interaction;
use aim_cli::ui::render::render_update_summary;
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
#[test]
fn update_summary_mentions_selected_count() {
let output = render_update_summary(3, 2, 1);
assert!(output.contains("selected: 2"));
}
#[test]
fn tracking_prompt_mentions_requested_and_latest_versions() {
let output = render_interaction(&InteractionRequest {
key: "tracking-preference".to_owned(),
kind: InteractionKind::ChooseTrackingPreference {
requested_version: "v0.0.11".to_owned(),
latest_version: "v0.0.12".to_owned(),
},
});
assert!(output.contains("tracking preference required"));
assert!(output.contains("v0.0.11"));
assert!(output.contains("v0.0.12"));
}

View file

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

View file

@ -10,12 +10,10 @@ impl DirectUrlAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::DirectUrl,
locator: source.locator.clone(),
},
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
})
}

View file

@ -1,4 +1,5 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct GitHubAdapter;
@ -20,15 +21,17 @@ impl GitHubAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::GitHub,
locator: source.locator.clone(),
},
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 {

View file

@ -10,12 +10,10 @@ impl GitLabAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::GitLab,
locator: source.locator.clone(),
},
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}

View file

@ -16,7 +16,7 @@ impl AdapterCapabilities {
}
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,

View file

@ -1,36 +1,184 @@
use crate::adapters::github::{GitHubAdapter, GitHubAdapterError};
use crate::adapters::traits::AdapterResolution;
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::domain::source::{SourceKind, SourceRef};
use crate::domain::app::AppRecord;
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
use crate::metadata::parse_document;
use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
build_add_plan_with(query, transport.as_ref())
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> 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 {
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => {
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
parsed_metadata
.push(parse_document(document).expect("metadata parsing is infallible"));
}
let ranked = rank_channels(&build_channels(&discovery, &parsed_metadata));
let preferred = ranked
.first()
.cloned()
.ok_or(BuildAddPlanError::NoCandidates)?;
let strategy = UpdateStrategy {
preferred: to_preference(&preferred),
alternates: ranked.iter().skip(1).map(to_preference).collect(),
};
let metadata_hints = parsed_metadata
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
interactions.push(InteractionRequest {
key: "tracking-preference".to_owned(),
kind: InteractionKind::ChooseTrackingPreference {
requested_version: source.requested_tag.clone().unwrap_or_default(),
latest_version: discovery
.releases
.first()
.map(|release| release.tag.clone())
.unwrap_or_default(),
},
});
}
(
AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: artifact.version.clone(),
prerelease: false,
},
},
artifact,
strategy,
)
}
_ => {
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
};
let artifact = ArtifactCandidate {
url: source.locator.clone(),
version: "unresolved".to_owned(),
},
},
arch: None,
selection_reason: "heuristic-match".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: source.locator.clone(),
reason: "heuristic-match".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy)
}
};
Ok(AddPlan { resolution })
Ok(AddPlan {
resolution,
selected_artifact,
interactions,
update_strategy,
metadata: parsed_metadata,
})
}
#[derive(Debug, Eq, PartialEq)]
pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan {
if let Some(index) = plan
.update_strategy
.alternates
.iter()
.position(|item| item.kind != UpdateChannelKind::DirectAsset)
{
let alternate = plan.update_strategy.alternates.remove(index);
let previous = std::mem::replace(&mut plan.update_strategy.preferred, alternate);
plan.update_strategy.alternates.insert(0, previous);
}
if let Some(canonical_locator) = plan.resolution.source.canonical_locator.clone() {
plan.resolution.source.locator = canonical_locator;
plan.resolution.source.normalized_kind = NormalizedSourceKind::GitHubRepository;
plan.resolution.source.tracks_latest = true;
}
plan.interactions
.retain(|interaction| interaction.key != "tracking-preference");
plan
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddPlan {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub interactions: Vec<InteractionRequest>,
pub update_strategy: UpdateStrategy,
pub metadata: Vec<ParsedMetadata>,
}
pub fn materialize_app_record(
source_input: &str,
plan: &AddPlan,
) -> Result<AppRecord, MaterializeAddRecordError> {
let identity_source = plan
.resolution
.source
.canonical_locator
.as_deref()
.unwrap_or(source_input);
let identity = resolve_identity(
None,
None,
Some(identity_source),
IdentityFallback::AllowRawUrl,
)
.map_err(MaterializeAddRecordError::Identity)?;
Ok(AppRecord {
stable_id: identity.stable_id,
display_name: identity.display_name,
source_input: Some(source_input.to_owned()),
source: Some(plan.resolution.source.clone()),
installed_version: Some(plan.selected_artifact.version.clone()),
update_strategy: Some(plan.update_strategy.clone()),
metadata: plan.metadata.clone(),
})
}
#[derive(Debug)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
GitHubDiscovery(GitHubDiscoveryError),
NoCandidates,
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
GitHub(GitHubAdapterError),
pub enum MaterializeAddRecordError {
Identity(ResolveIdentityError),
}

View file

@ -1,4 +1,5 @@
use crate::domain::app::{AppIdentity, IdentityConfidence};
use crate::source::input::classify_input;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityFallback {
@ -34,6 +35,18 @@ pub fn resolve_identity(
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& let Ok(classified) = classify_input(source_url)
&& let Some(repo) = classified.canonical_locator
{
let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned();
return Ok(AppIdentity {
stable_id: normalize_identifier(&repo),
display_name,
confidence: IdentityConfidence::Confident,
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& fallback == IdentityFallback::AllowRawUrl
{

View file

@ -1,4 +1,20 @@
#[derive(Debug, Eq, PartialEq)]
pub enum InteractionRequest {
SelectRegisteredApp { query: String, matches: Vec<String> },
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionRequest {
pub key: String,
pub kind: InteractionKind,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractionKind {
SelectRegisteredApp {
query: String,
matches: Vec<String>,
},
ChooseTrackingPreference {
requested_version: String,
latest_version: String,
},
SelectArtifact {
candidates: Vec<String>,
},
}

View file

@ -1,55 +1,13 @@
use crate::domain::source::SourceKind;
use crate::domain::source::SourceRef;
use crate::source::input::classify_input;
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)
classify_input(query)
.map(|input| input.into_source_ref())
.map_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

@ -1,4 +1,4 @@
use crate::app::interaction::InteractionRequest;
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::domain::app::AppRecord;
pub fn resolve_registered_app<'a>(
@ -20,12 +20,15 @@ pub fn resolve_registered_app<'a>(
}),
[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(),
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
query: query.to_owned(),
matches: matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
},
},
}),
}

View file

@ -1,17 +1,49 @@
use crate::domain::app::AppRecord;
use crate::domain::update::{PlannedUpdate, UpdatePlan};
use crate::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, 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(),
items: apps.iter().map(plan_update).collect(),
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildUpdatePlanError {}
fn plan_update(app: &AppRecord) -> PlannedUpdate {
let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy {
if strategy.preferred.locator.contains("fail") {
let fallback = strategy
.alternates
.first()
.cloned()
.unwrap_or_else(|| strategy.preferred.clone());
(fallback, "preferred-channel-failed".to_owned())
} else {
(
strategy.preferred.clone(),
strategy.preferred.reason.clone(),
)
}
} else {
(
ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: app
.source
.as_ref()
.map(|source| source.locator.clone())
.unwrap_or_else(|| app.stable_id.clone()),
reason: "install-origin-match".to_owned(),
},
"install-origin-match".to_owned(),
)
};
PlannedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
selected_channel,
selection_reason,
}
}

View file

@ -1,3 +1,6 @@
use crate::domain::source::SourceRef;
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InstallScope {
User,
@ -22,4 +25,14 @@ pub struct AppIdentity {
pub struct AppRecord {
pub stable_id: String,
pub display_name: String,
#[serde(default)]
pub source_input: Option<String>,
#[serde(default)]
pub source: Option<SourceRef>,
#[serde(default)]
pub installed_version: Option<String>,
#[serde(default)]
pub update_strategy: Option<UpdateStrategy>,
#[serde(default)]
pub metadata: Vec<ParsedMetadata>,
}

View file

@ -1,4 +1,4 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceKind {
GitHub,
GitLab,
@ -17,13 +17,83 @@ impl SourceKind {
}
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceInputKind {
RepoShorthand,
GitHubRepositoryUrl,
GitHubReleaseUrl,
GitHubReleaseAssetUrl,
GitLabUrl,
DirectUrl,
File,
}
impl SourceInputKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::RepoShorthand => "repo-shorthand",
Self::GitHubRepositoryUrl => "github-repository-url",
Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum NormalizedSourceKind {
GitHubRepository,
GitHubRelease,
GitHubReleaseAsset,
GitLab,
DirectUrl,
File,
}
impl NormalizedSourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubRepository => "github-repository",
Self::GitHubRelease => "github-release",
Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
#[serde(default = "default_source_input_kind")]
pub input_kind: SourceInputKind,
#[serde(default = "default_normalized_source_kind")]
pub normalized_kind: NormalizedSourceKind,
#[serde(default)]
pub canonical_locator: Option<String>,
#[serde(default)]
pub requested_tag: Option<String>,
#[serde(default)]
pub requested_asset_name: Option<String>,
#[serde(default)]
pub tracks_latest: bool,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ResolvedRelease {
pub version: String,
#[serde(default)]
pub prerelease: bool,
}
const fn default_source_input_kind() -> SourceInputKind {
SourceInputKind::DirectUrl
}
const fn default_normalized_source_kind() -> NormalizedSourceKind {
NormalizedSourceKind::DirectUrl
}

View file

@ -1,10 +1,100 @@
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
Unknown,
ElectronBuilder,
Zsync,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct MetadataHints {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub primary_download: Option<String>,
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub architecture: Option<String>,
#[serde(default)]
pub channel_label: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ParsedMetadata {
pub kind: ParsedMetadataKind,
pub hints: MetadataHints,
#[serde(default)]
pub warnings: Vec<String>,
#[serde(default)]
pub confidence: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateChannel {
pub kind: UpdateChannelKind,
pub locator: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub artifact_name: Option<String>,
#[serde(default)]
pub confidence: u8,
#[serde(default)]
pub matches_install_origin: bool,
#[serde(default)]
pub prerelease: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedUpdate {
pub stable_id: String,
pub display_name: String,
pub selected_channel: ChannelPreference,
pub selection_reason: String,
}

View file

@ -2,5 +2,8 @@ pub mod adapters;
pub mod app;
pub mod domain;
pub mod integration;
pub mod metadata;
pub mod platform;
pub mod registry;
pub mod source;
pub mod update;

View file

@ -0,0 +1,24 @@
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataDocument {
pub url: String,
pub content_type: Option<String>,
pub contents: Vec<u8>,
}
impl MetadataDocument {
pub fn plain_text(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("text/plain".to_owned()),
contents: contents.to_vec(),
}
}
pub fn yaml(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("application/yaml".to_owned()),
contents: contents.to_vec(),
}
}
}

View file

@ -0,0 +1,30 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let version = extract_value(&contents, "version:");
let path = extract_value(&contents, "path:").or_else(|| extract_value(&contents, "url:"));
let checksum = extract_value(&contents, "sha512:");
ParsedMetadata {
kind: ParsedMetadataKind::ElectronBuilder,
hints: MetadataHints {
version,
primary_download: path,
checksum,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
},
warnings: Vec::new(),
confidence: 90,
}
}
fn extract_value(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}

View file

@ -0,0 +1,7 @@
mod document;
mod electron_builder;
mod parser;
mod zsync;
pub use document::MetadataDocument;
pub use parser::parse_document;

View file

@ -0,0 +1,22 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse_document(document: &MetadataDocument) -> Result<ParsedMetadata, MetadataParseError> {
if document.url.ends_with("latest-linux.yml") || document.url.ends_with("latest-linux.yaml") {
return Ok(super::electron_builder::parse(document));
}
if document.url.ends_with(".zsync") {
return Ok(super::zsync::parse(document));
}
Ok(ParsedMetadata {
kind: ParsedMetadataKind::Unknown,
hints: MetadataHints::default(),
warnings: vec!["unsupported metadata document".to_owned()],
confidence: 0,
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum MetadataParseError {}

View file

@ -0,0 +1,38 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let url = extract_field(&contents, "URL:");
let filename = extract_field(&contents, "Filename:");
ParsedMetadata {
kind: ParsedMetadataKind::Zsync,
hints: MetadataHints {
version: filename
.as_ref()
.and_then(|value| version_from_filename(value)),
primary_download: url.or(filename),
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("zsync".to_owned()),
},
warnings: Vec::new(),
confidence: 75,
}
}
fn extract_field(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn version_from_filename(filename: &str) -> Option<String> {
filename
.split('-')
.find(|segment| segment.chars().any(|ch| ch.is_ascii_digit()) && segment.contains('.'))
.map(|value| value.trim_end_matches(".AppImage").to_owned())
}

View file

@ -0,0 +1,375 @@
use std::env;
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";
pub trait GitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError>;
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportAsset {
pub name: String,
pub url: String,
pub content_type: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRelease {
pub tag: String,
pub prerelease: bool,
pub assets: Vec<TransportAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubAsset {
pub name: String,
pub url: String,
pub version: String,
pub prerelease: bool,
pub arch: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubRelease {
pub tag: String,
pub release: ResolvedRelease,
pub assets: Vec<GitHubAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubDiscovery {
pub source: SourceRef,
pub releases: Vec<GitHubRelease>,
pub assets: Vec<GitHubAsset>,
pub metadata_documents: Vec<MetadataDocument>,
pub requested_is_older_release: bool,
}
pub fn discover_github_candidates(
source: &SourceRef,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let transport = default_transport();
discover_github_candidates_with(source, transport.as_ref())
}
pub fn discover_github_candidates_with<T: GitHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let repo = source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone());
let transport_releases = transport.fetch_releases(&repo)?;
if transport_releases.is_empty() {
return Err(GitHubDiscoveryError::NoReleases { repo });
}
let releases = transport_releases
.iter()
.map(|release| GitHubRelease {
tag: release.tag.clone(),
release: ResolvedRelease {
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
},
assets: release
.assets
.iter()
.filter(|asset| is_appimage_asset(&asset.name))
.map(|asset| GitHubAsset {
name: asset.name.clone(),
url: asset.url.clone(),
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
arch: Some(infer_architecture(&asset.name)),
})
.collect(),
})
.collect::<Vec<_>>();
let metadata_documents = transport_releases
.iter()
.flat_map(|release| release.assets.iter())
.filter(|asset| is_metadata_document(&asset.name))
.filter_map(|asset| {
transport
.fetch_document(&asset.url, asset.content_type.as_deref())
.ok()
})
.collect::<Vec<_>>();
let assets = releases
.iter()
.flat_map(|release| release.assets.iter().cloned())
.collect::<Vec<_>>();
let requested_is_older_release = source
.requested_tag
.as_ref()
.map(|requested| requested != &releases[0].tag)
.unwrap_or(false);
Ok(GitHubDiscovery {
source: source.clone(),
releases,
assets,
metadata_documents,
requested_is_older_release,
})
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureGitHubTransport)
} else {
Box::new(ReqwestGitHubTransport::new())
}
}
pub struct ReqwestGitHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestGitHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestGitHubTransport {
pub fn new() -> Self {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("aim/0.1"),
);
default_headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(token) = env::var("AIM_GITHUB_TOKEN")
.ok()
.or_else(|| env::var("GITHUB_TOKEN").ok())
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
{
default_headers.insert(reqwest::header::AUTHORIZATION, value);
}
Self {
client: reqwest::blocking::Client::builder()
.default_headers(default_headers)
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()),
}
}
}
impl GitHubTransport for ReqwestGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
let url = format!("{}/repos/{repo}/releases?per_page=10", self.api_base);
let releases = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?
.json::<Vec<ApiRelease>>()
.map_err(GitHubDiscoveryError::Transport)?;
Ok(releases
.into_iter()
.map(|release| TransportRelease {
tag: release.tag_name,
prerelease: release.prerelease,
assets: release
.assets
.into_iter()
.map(|asset| TransportAsset {
name: asset.name,
url: asset.browser_download_url,
content_type: asset.content_type,
})
.collect(),
})
.collect())
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let response = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?;
let header_content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.or_else(|| content_type.map(ToOwned::to_owned));
let contents = response.bytes().map_err(GitHubDiscoveryError::Transport)?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: header_content_type,
contents: contents.to_vec(),
})
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureGitHubTransport;
impl GitHubTransport for FixtureGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
Ok(fixture_releases(repo))
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let contents = fixture_document(url)
.ok_or_else(|| GitHubDiscoveryError::FixtureDocumentMissing(url.to_owned()))?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: content_type.map(ToOwned::to_owned),
contents,
})
}
}
#[derive(Debug)]
pub enum GitHubDiscoveryError {
Unsupported,
FixtureDocumentMissing(String),
NoReleases { repo: String },
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
prerelease: bool,
assets: Vec<ApiAsset>,
}
#[derive(serde::Deserialize)]
struct ApiAsset {
name: String,
browser_download_url: String,
content_type: Option<String>,
}
fn is_appimage_asset(name: &str) -> bool {
name.ends_with(".AppImage")
}
fn is_metadata_document(name: &str) -> bool {
name.ends_with("latest-linux.yml")
|| name.ends_with("latest-linux.yaml")
|| name.ends_with(".zsync")
}
fn infer_architecture(name: &str) -> String {
if name.contains("aarch64") || name.contains("arm64") {
"aarch64".to_owned()
} else {
"x86_64".to_owned()
}
}
fn fixture_releases(repo: &str) -> Vec<TransportRelease> {
match repo {
"pingdotgg/t3code" => vec![
fixture_release(repo, "v0.0.12", "T3-Code-0.0.12-x86_64.AppImage"),
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")],
_ => {
let repo_name = repo.split('/').next_back().unwrap_or("app");
let title = title_case(repo_name);
vec![fixture_release(
repo,
"v1.0.0",
&format!("{title}-1.0.0-x86_64.AppImage"),
)]
}
}
}
fn fixture_release(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/octet-stream".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()?;
match name {
"latest-linux.yml" => {
let appimage = match tag {
"v0.0.11" => "T3-Code-0.0.11-x86_64.AppImage",
"v0.0.12" => "T3-Code-0.0.12-x86_64.AppImage",
"v1.0.0" => "Bat-1.0.0-x86_64.AppImage",
_ => return None,
};
let version = tag.trim_start_matches('v');
Some(
format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(),
)
}
_ => None,
}
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_ascii_uppercase(), chars.as_str())
})
.collect::<Vec<_>>()
.join("-")
}

View file

@ -0,0 +1,166 @@
use crate::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassifiedInput {
pub kind: SourceInputKind,
pub source_kind: SourceKind,
pub normalized_kind: NormalizedSourceKind,
pub locator: String,
pub canonical_locator: Option<String>,
pub requested_tag: Option<String>,
pub requested_asset_name: Option<String>,
pub tracks_latest: bool,
}
impl ClassifiedInput {
pub fn into_source_ref(self) -> SourceRef {
SourceRef {
kind: self.source_kind,
locator: self.locator,
input_kind: self.kind,
normalized_kind: self.normalized_kind,
canonical_locator: self.canonical_locator,
requested_tag: self.requested_tag,
requested_asset_name: self.requested_asset_name,
tracks_latest: self.tracks_latest,
}
}
}
pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError> {
if query.starts_with("file://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::File,
source_kind: SourceKind::File,
normalized_kind: NormalizedSourceKind::File,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if let Some(classified) = classify_github_http(query) {
return Ok(classified);
}
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
return Ok(ClassifiedInput {
kind: SourceInputKind::GitLabUrl,
source_kind: SourceKind::GitLab,
normalized_kind: NormalizedSourceKind::GitLab,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if query.starts_with("https://") || query.starts_with("http://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::DirectUrl,
source_kind: SourceKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if is_github_shorthand(query) {
return Ok(ClassifiedInput {
kind: SourceInputKind::RepoShorthand,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(query.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
});
}
Err(ClassifyInputError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ClassifyInputError {
Unsupported,
}
fn classify_github_http(query: &str) -> Option<ClassifiedInput> {
let trimmed = query
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/");
if trimmed == query {
return None;
}
let parts = trimmed
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if parts.len() < 2 {
return None;
}
let repo = format!("{}/{}", parts[0], parts[1]);
if parts.len() >= 5 && parts[2] == "releases" && parts[3] == "tag" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRelease,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: None,
tracks_latest: false,
});
}
if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseAssetUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubReleaseAsset,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: Some(parts[5].to_owned()),
tracks_latest: false,
});
}
Some(ClassifiedInput {
kind: SourceInputKind::GitHubRepositoryUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
})
}
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,2 @@
pub mod github;
pub mod input;

View file

@ -0,0 +1,93 @@
use crate::domain::source::{SourceInputKind, SourceRef};
use crate::domain::update::{ParsedMetadata, UpdateChannel, UpdateChannelKind};
use crate::source::github::GitHubDiscovery;
pub fn build_channels(
discovery: &GitHubDiscovery,
metadata: &[ParsedMetadata],
) -> Vec<UpdateChannel> {
let mut channels = Vec::new();
if let Some(asset) = discovery.assets.first() {
channels.push(UpdateChannel {
kind: UpdateChannelKind::GitHubReleases,
locator: discovery
.source
.canonical_locator
.clone()
.unwrap_or_else(|| discovery.source.locator.clone()),
version: Some(asset.version.clone()),
artifact_name: Some(asset.name.clone()),
confidence: 60,
matches_install_origin: matches!(
discovery.source.input_kind,
SourceInputKind::RepoShorthand
| SourceInputKind::GitHubRepositoryUrl
| SourceInputKind::GitHubReleaseUrl
),
prerelease: asset.prerelease,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::ElectronBuilder)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::ElectronBuilder,
locator: discovery
.metadata_documents
.iter()
.find(|doc| doc.url.ends_with("latest-linux.yml"))
.map(|doc| doc.url.clone())
.unwrap_or_else(|| discovery.source.locator.clone()),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: discovery.source.tracks_latest,
prerelease: false,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::Zsync)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::Zsync,
locator: parsed.hints.primary_download.clone().unwrap_or_default(),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: false,
prerelease: false,
});
}
if matches!(
discovery.source.input_kind,
SourceInputKind::GitHubReleaseAssetUrl
) {
channels.push(UpdateChannel {
kind: UpdateChannelKind::DirectAsset,
locator: discovery.source.locator.clone(),
version: discovery
.source
.requested_tag
.clone()
.map(|value| value.trim_start_matches('v').to_owned()),
artifact_name: discovery.source.requested_asset_name.clone(),
confidence: 85,
matches_install_origin: true,
prerelease: false,
});
}
channels
}
pub fn source_ref_from_channel(source: &SourceRef, channel: &UpdateChannel) -> SourceRef {
let mut value = source.clone();
value.locator = channel.locator.clone();
value
}

View file

@ -0,0 +1,2 @@
pub mod channels;
pub mod ranking;

View file

@ -0,0 +1,101 @@
use crate::domain::update::{
ArtifactCandidate, ChannelPreference, MetadataHints, UpdateChannel, UpdateChannelKind,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RankedChannel {
pub channel: UpdateChannel,
pub reason: String,
pub score: i32,
}
pub fn rank_channels(channels: &[UpdateChannel]) -> Vec<RankedChannel> {
let mut ranked = channels
.iter()
.cloned()
.map(|channel| {
let install_origin_bonus = if channel.matches_install_origin {
100
} else {
0
};
let prerelease_penalty = if channel.prerelease { 20 } else { 0 };
let metadata_bonus = match channel.kind {
UpdateChannelKind::ElectronBuilder | UpdateChannelKind::Zsync => 25,
_ => 0,
};
let score = channel.confidence as i32 + install_origin_bonus + metadata_bonus
- prerelease_penalty;
let reason = if channel.matches_install_origin {
"install-origin-match"
} else if metadata_bonus > 0 {
"metadata-guided"
} else {
"heuristic-match"
};
RankedChannel {
channel,
reason: reason.to_owned(),
score,
}
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| right.score.cmp(&left.score));
ranked
}
pub fn select_artifact(
channel: &RankedChannel,
hints: Option<&MetadataHints>,
) -> ArtifactCandidate {
let resolved_url = resolve_artifact_url(
&channel.channel.locator,
hints.and_then(|value| value.primary_download.as_deref()),
);
let selection_reason = if hints
.and_then(|value| value.primary_download.clone())
.is_some()
{
"metadata-guided"
} else {
channel.reason.as_str()
};
ArtifactCandidate {
url: resolved_url,
version: channel
.channel
.version
.clone()
.unwrap_or_else(|| "latest".to_owned()),
arch: Some("x86_64".to_owned()),
selection_reason: selection_reason.to_owned(),
}
}
pub fn to_preference(channel: &RankedChannel) -> ChannelPreference {
ChannelPreference {
kind: channel.channel.kind,
locator: channel.channel.locator.clone(),
reason: channel.reason.clone(),
}
}
fn resolve_artifact_url(channel_locator: &str, primary_download: Option<&str>) -> String {
let Some(primary_download) = primary_download else {
return channel_locator.to_owned();
};
if primary_download.contains("://") || primary_download.starts_with("file://") {
return primary_download.to_owned();
}
if (channel_locator.ends_with(".yml") || channel_locator.ends_with(".yaml"))
&& let Some((base, _)) = channel_locator.rsplit_once('/')
{
return format!("{base}/{primary_download}");
}
primary_download.to_owned()
}

View file

@ -1,3 +1,4 @@
use aim_core::adapters::github::GitHubAdapter;
use aim_core::adapters::traits::AdapterCapabilities;
#[test]
@ -5,3 +6,13 @@ fn adapter_capabilities_can_report_exact_resolution_only() {
let capabilities = AdapterCapabilities::exact_resolution_only();
assert!(!capabilities.supports_search);
}
#[test]
fn legacy_github_adapter_delegates_to_source_pipeline() {
let adapter = 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"));
}

View file

@ -4,6 +4,7 @@ use aim_core::adapters::all_adapter_kinds;
fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds();
assert!(kinds.contains(&"github"));
assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync"));

View file

@ -0,0 +1,3 @@
zsync: 0.6.2
Filename: T3-Code-0.0.11-x86_64.AppImage
URL: https://example.test/T3-Code-0.0.11-x86_64.AppImage

View file

@ -0,0 +1,3 @@
version: 0.0.11
path: T3-Code-0.0.11-x86_64.AppImage
sha512: example-sha

View file

@ -1,5 +1,6 @@
use aim_core::app::add::build_add_plan;
use aim_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
use aim_core::app::query::resolve_query;
use aim_core::source::github::FixtureGitHubTransport;
#[test]
fn github_adapter_can_normalize_owner_repo_source() {
@ -10,9 +11,78 @@ fn github_adapter_can_normalize_owner_repo_source() {
#[test]
fn add_flow_builds_github_plan_from_owner_repo_query() {
let plan = build_add_plan("sharkdp/bat").unwrap();
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).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");
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
}
#[test]
fn add_plan_prefers_metadata_guided_appimage_when_available() {
let plan = build_add_plan_with("pingdotgg/t3code", &FixtureGitHubTransport).unwrap();
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
assert_eq!(
plan.update_strategy.preferred.kind.as_str(),
"electron-builder"
);
}
#[test]
fn direct_old_release_url_requests_tracking_choice_prompt() {
let plan = build_add_plan_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&FixtureGitHubTransport,
)
.unwrap();
assert!(
plan.interactions
.iter()
.any(|item| item.key == "tracking-preference")
);
}
#[test]
fn materialized_record_preserves_source_and_strategy() {
let query = "sharkdp/bat";
let plan = build_add_plan_with(query, &FixtureGitHubTransport).unwrap();
let record = materialize_app_record(query, &plan).unwrap();
assert_eq!(record.stable_id, "sharkdp-bat");
assert_eq!(record.display_name, "bat");
assert_eq!(record.source_input.as_deref(), Some(query));
assert_eq!(record.installed_version.as_deref(), Some("1.0.0"));
assert_eq!(
record
.update_strategy
.as_ref()
.unwrap()
.preferred
.kind
.as_str(),
"electron-builder"
);
assert_eq!(record.source.as_ref().unwrap().locator, query);
}
#[test]
fn latest_tracking_choice_promotes_non_direct_update_channel() {
let plan = build_add_plan_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&FixtureGitHubTransport,
)
.unwrap();
let resolved = prefer_latest_tracking(plan);
assert!(resolved.interactions.is_empty());
assert_eq!(resolved.resolution.source.locator, "pingdotgg/t3code");
assert!(resolved.resolution.source.tracks_latest);
assert_ne!(
resolved.update_strategy.preferred.kind.as_str(),
"direct-asset-lineage"
);
}

View file

@ -0,0 +1,33 @@
use aim_core::app::query::resolve_query;
use aim_core::source::github::{FixtureGitHubTransport, discover_github_candidates_with};
#[test]
fn discovery_reports_appimage_assets_and_latest_linux_yml() {
let source = resolve_query("pingdotgg/t3code").unwrap();
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
assert!(
discovery
.assets
.iter()
.any(|asset| asset.name.ends_with(".AppImage"))
);
assert!(
discovery
.metadata_documents
.iter()
.any(|doc| doc.url.ends_with("latest-linux.yml"))
);
}
#[test]
fn discovery_marks_explicit_older_release_against_latest_fixture_release() {
let source = resolve_query(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
)
.unwrap();
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
assert_eq!(discovery.releases[0].tag, "v0.0.12");
assert!(discovery.requested_is_older_release);
}

View file

@ -0,0 +1,11 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
#[test]
fn unknown_document_returns_typed_warning_not_panic() {
let doc = MetadataDocument::plain_text("https://example.test/notes.txt", b"not metadata");
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::Unknown);
assert!(!result.warnings.is_empty());
}

View file

@ -0,0 +1,15 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_latest_linux_yml_into_download_hints() {
let raw = include_bytes!("fixtures/latest-linux.yml");
let doc = MetadataDocument::yaml("https://example.test/latest-linux.yml", raw);
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::ElectronBuilder);
assert_eq!(
result.hints.primary_download.as_deref(),
Some("T3-Code-0.0.11-x86_64.AppImage")
);
}

View file

@ -0,0 +1,12 @@
use aim_core::domain::update::ParsedMetadataKind;
use aim_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_zsync_document_into_channel_hints() {
let raw = include_bytes!("fixtures/example.zsync");
let doc = MetadataDocument::plain_text("https://example.test/app.AppImage.zsync", raw);
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::Zsync);
assert!(result.hints.primary_download.is_some());
}

View file

@ -1,8 +1,27 @@
use aim_core::app::query::resolve_query;
use aim_core::domain::source::SourceKind;
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
#[test]
fn owner_repo_defaults_to_github() {
let source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(source.kind, SourceKind::GitHub);
assert_eq!(source.input_kind, SourceInputKind::RepoShorthand);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::GitHubRepository
);
}
#[test]
fn classifies_github_release_asset_url() {
let source = resolve_query(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
)
.unwrap();
assert_eq!(source.input_kind, SourceInputKind::GitHubReleaseAssetUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::GitHubReleaseAsset
);
}

View file

@ -8,3 +8,46 @@ fn registry_round_trips_app_records() {
let loaded = store.load().unwrap();
assert!(loaded.apps.is_empty());
}
#[test]
fn registry_round_trips_update_strategy_and_alternates() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = aim_core::registry::model::Registry {
version: 1,
apps: vec![aim_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(aim_core::domain::update::UpdateStrategy {
preferred: aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::DirectAsset,
locator: "https://example.test/app.AppImage".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: vec![
aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::GitHubReleases,
locator: "pingdotgg/t3code".to_owned(),
reason: "heuristic-match".to_owned(),
},
aim_core::domain::update::ChannelPreference {
kind: aim_core::domain::update::UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(),
},
],
}),
metadata: Vec::new(),
}],
};
store.save(&registry).unwrap();
let loaded = store.load().unwrap();
let strategy = loaded.apps[0].update_strategy.as_ref().unwrap();
assert_eq!(strategy.preferred.reason, "install-origin-match");
assert_eq!(strategy.alternates.len(), 2);
}

View file

@ -1,4 +1,4 @@
use aim_core::app::interaction::InteractionRequest;
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::app::list::build_list_rows;
use aim_core::app::remove::resolve_registered_app;
use aim_core::domain::app::AppRecord;
@ -15,6 +15,11 @@ 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(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
}]);
assert_eq!(rows.len(), 1);
@ -28,10 +33,20 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
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(),
},
AppRecord {
stable_id: "bat-nightly".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
},
];
@ -40,9 +55,12 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
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()],
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
query: "Bat".to_owned(),
matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()],
},
},
}
);

View file

@ -1,5 +1,6 @@
use aim_core::app::update::build_update_plan;
use aim_core::domain::app::AppRecord;
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
#[test]
fn empty_registry_produces_empty_plan() {
@ -13,10 +14,48 @@ fn installed_apps_are_carried_into_review_plan() {
let apps = [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(),
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].stable_id, "bat");
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
}
#[test]
fn update_plan_uses_alternate_channel_after_preferred_failure() {
let apps = [AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: "fail://github".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: vec![ChannelPreference {
kind: UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(),
}],
}),
metadata: Vec::new(),
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(
plan.items[0].selected_channel.kind.as_str(),
"electron-builder"
);
assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed");
}