use crate::Result;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
pub mod cache;
pub mod detector;
pub mod file_cache;
pub mod github;
pub mod parser;
pub mod rustup;
pub mod security;
pub use detector::RustVersion;
pub use github::{GitHubClient, GitHubRelease};
pub use security::{SecurityCheckResult, SecurityChecker};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Channel {
Stable,
Beta,
Nightly,
Custom(String),
}
impl std::fmt::Display for Channel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stable => write!(f, "stable"),
Self::Beta => write!(f, "beta"),
Self::Nightly => write!(f, "nightly"),
Self::Custom(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub current: Version,
pub latest: Version,
pub release_url: String,
pub security_details: Option<String>,
}
#[derive(Debug, Clone)]
pub enum UpdateRecommendation {
UpToDate,
MinorUpdate(UpdateInfo),
MajorUpdate(UpdateInfo),
SecurityUpdate(UpdateInfo),
}
#[derive(Debug, Clone)]
pub struct ReleaseNotes {
pub version: String,
pub full_notes: String,
pub parsed: parser::ParsedRelease,
}
pub struct VersionManager {
github_client: GitHubClient,
cache: Arc<RwLock<cache::Cache<String, Vec<u8>>>>,
file_cache: file_cache::FileCache,
}
impl VersionManager {
pub fn new() -> Result<Self> {
let github_client = GitHubClient::new(None)?;
let cache = Arc::new(RwLock::new(cache::Cache::new(Duration::from_secs(3600))));
let file_cache = file_cache::FileCache::create_default()?;
Ok(Self {
github_client,
cache,
file_cache,
})
}
pub async fn check_current(&self) -> Result<RustVersion> {
detector::detect_rust_version().await
}
pub async fn get_latest_stable(&self) -> Result<GitHubRelease> {
let cache_key = "latest_stable";
if let Some(entry) = self.file_cache.get(cache_key)
&& let Ok(release) = serde_json::from_slice::<GitHubRelease>(&entry.data)
{
tracing::debug!("Using cached latest stable release");
return Ok(release);
}
{
let cache = self.cache.read().await;
if let Some(cached_bytes) = cache.get(&cache_key.to_string())
&& let Ok(release) = serde_json::from_slice::<GitHubRelease>(&cached_bytes)
{
return Ok(release);
}
}
let release = self.github_client.get_latest_release().await?;
if let Ok(bytes) = serde_json::to_vec(&release) {
let mut cache = self.cache.write().await;
cache.insert(cache_key.to_string(), bytes.clone());
let _ = self.file_cache.set(cache_key, bytes, "application/json");
}
Ok(release)
}
pub async fn get_recommendation(&self) -> Result<UpdateRecommendation> {
let current = self.check_current().await?;
let latest = self.get_latest_stable().await?;
if latest.version <= current.version {
return Ok(UpdateRecommendation::UpToDate);
}
self.determine_update_type(¤t, &latest)
}
fn determine_update_type(
&self,
current: &RustVersion,
latest: &GitHubRelease,
) -> Result<UpdateRecommendation> {
let parsed = parser::parse_release_notes(&latest.tag_name, &latest.body);
if !parsed.security_advisories.is_empty() {
Ok(self.create_security_update(current, latest, &parsed))
} else if self.is_major_update(current, latest) {
Ok(self.create_major_update(current, latest))
} else {
Ok(self.create_minor_update(current, latest))
}
}
#[allow(dead_code)]
fn is_security_update(&self, release: &GitHubRelease) -> bool {
let body_lower = release.body.to_lowercase();
let name_lower = release.name.to_lowercase();
body_lower.contains("security") || name_lower.contains("security")
}
fn is_major_update(&self, current: &RustVersion, latest: &GitHubRelease) -> bool {
latest.version.major > current.version.major
}
fn create_security_update(
&self,
current: &RustVersion,
latest: &GitHubRelease,
parsed: &parser::ParsedRelease,
) -> UpdateRecommendation {
let security_summary = if !parsed.security_advisories.is_empty() {
Some(
parsed
.security_advisories
.iter()
.map(|a| {
if let Some(ref id) = a.id {
format!("{}: {}", id, a.description)
} else {
a.description.clone()
}
})
.collect::<Vec<_>>()
.join("; "),
)
} else {
Some(self.extract_security_details(&latest.body))
};
let info = UpdateInfo {
current: current.version.clone(),
latest: latest.version.clone(),
release_url: latest.html_url.clone(),
security_details: security_summary,
};
UpdateRecommendation::SecurityUpdate(info)
}
fn create_major_update(
&self,
current: &RustVersion,
latest: &GitHubRelease,
) -> UpdateRecommendation {
let info = UpdateInfo {
current: current.version.clone(),
latest: latest.version.clone(),
release_url: latest.html_url.clone(),
security_details: None,
};
UpdateRecommendation::MajorUpdate(info)
}
fn create_minor_update(
&self,
current: &RustVersion,
latest: &GitHubRelease,
) -> UpdateRecommendation {
let info = UpdateInfo {
current: current.version.clone(),
latest: latest.version.clone(),
release_url: latest.html_url.clone(),
security_details: None,
};
UpdateRecommendation::MinorUpdate(info)
}
pub async fn get_recent_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
let cache_key = format!("recent_releases_{}", count);
if let Some(entry) = self.file_cache.get(&cache_key)
&& let Ok(releases) = serde_json::from_slice::<Vec<GitHubRelease>>(&entry.data)
{
tracing::debug!("Using cached releases");
return Ok(releases);
}
let releases = self.github_client.get_releases(count).await?;
if let Ok(data) = serde_json::to_vec(&releases) {
let _ = self.file_cache.set(&cache_key, data, "application/json");
}
Ok(releases)
}
pub async fn get_release_notes(&self, version: &str) -> Result<ReleaseNotes> {
let cache_key = format!("release_notes_{}", version);
if let Some(entry) = self.file_cache.get(&cache_key)
&& let Ok(release) = serde_json::from_slice::<GitHubRelease>(&entry.data)
{
let parsed = parser::parse_release_notes(&release.tag_name, &release.body);
return Ok(ReleaseNotes {
version: release.tag_name,
full_notes: release.body,
parsed,
});
}
let release = self.github_client.get_release_by_tag(version).await?;
if let Ok(data) = serde_json::to_vec(&release) {
let _ = self.file_cache.set(&cache_key, data, "application/json");
}
let parsed = parser::parse_release_notes(&release.tag_name, &release.body);
Ok(ReleaseNotes {
version: release.tag_name,
full_notes: release.body,
parsed,
})
}
pub async fn check_updates(&self) -> Result<(bool, Option<Version>)> {
let current = self.check_current().await?;
let latest = self.get_latest_stable().await?;
if latest.version > current.version {
Ok((true, Some(latest.version)))
} else {
Ok((false, None))
}
}
pub fn is_offline_mode(&self) -> bool {
self.file_cache.should_use_offline()
}
pub fn cache_stats(&self) -> file_cache::CacheStats {
self.file_cache.stats()
}
fn extract_security_details(&self, body: &str) -> String {
body.lines()
.filter(|line| {
let lower = line.to_lowercase();
lower.contains("security")
|| lower.contains("vulnerability")
|| lower.contains("cve-")
})
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn test_channel_display() {
assert_eq!(Channel::Stable.to_string(), "stable");
assert_eq!(Channel::Beta.to_string(), "beta");
assert_eq!(Channel::Nightly.to_string(), "nightly");
assert_eq!(Channel::Custom("custom".to_string()).to_string(), "custom");
}
}