github source v1
This commit is contained in:
parent
71f89dde9c
commit
caf870d05e
50 changed files with 4139 additions and 131 deletions
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ impl AdapterCapabilities {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AdapterResolution {
|
||||
pub source: SourceRef,
|
||||
pub release: ResolvedRelease,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(':')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
crates/aim-core/src/metadata/document.rs
Normal file
24
crates/aim-core/src/metadata/document.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/aim-core/src/metadata/electron_builder.rs
Normal file
30
crates/aim-core/src/metadata/electron_builder.rs
Normal 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)
|
||||
}
|
||||
7
crates/aim-core/src/metadata/mod.rs
Normal file
7
crates/aim-core/src/metadata/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod document;
|
||||
mod electron_builder;
|
||||
mod parser;
|
||||
mod zsync;
|
||||
|
||||
pub use document::MetadataDocument;
|
||||
pub use parser::parse_document;
|
||||
22
crates/aim-core/src/metadata/parser.rs
Normal file
22
crates/aim-core/src/metadata/parser.rs
Normal 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 {}
|
||||
38
crates/aim-core/src/metadata/zsync.rs
Normal file
38
crates/aim-core/src/metadata/zsync.rs
Normal 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())
|
||||
}
|
||||
375
crates/aim-core/src/source/github.rs
Normal file
375
crates/aim-core/src/source/github.rs
Normal 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("-")
|
||||
}
|
||||
166
crates/aim-core/src/source/input.rs
Normal file
166
crates/aim-core/src/source/input.rs
Normal 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(':')
|
||||
}
|
||||
2
crates/aim-core/src/source/mod.rs
Normal file
2
crates/aim-core/src/source/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod github;
|
||||
pub mod input;
|
||||
93
crates/aim-core/src/update/channels.rs
Normal file
93
crates/aim-core/src/update/channels.rs
Normal 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
|
||||
}
|
||||
2
crates/aim-core/src/update/mod.rs
Normal file
2
crates/aim-core/src/update/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod channels;
|
||||
pub mod ranking;
|
||||
101
crates/aim-core/src/update/ranking.rs
Normal file
101
crates/aim-core/src/update/ranking.rs
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue