ferrous_forge/rust_version/
mod.rs1use crate::Result;
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::RwLock;
15
16pub mod cache;
18pub mod detector;
20pub mod file_cache;
22pub mod github;
24pub mod parser;
26pub mod rustup;
28pub mod security;
30
31pub use detector::RustVersion;
32pub use github::{GitHubClient, GitHubRelease};
33pub use security::{SecurityCheckResult, SecurityChecker};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum Channel {
38 Stable,
40 Beta,
42 Nightly,
44 Custom(String),
46}
47
48impl std::fmt::Display for Channel {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Stable => write!(f, "stable"),
52 Self::Beta => write!(f, "beta"),
53 Self::Nightly => write!(f, "nightly"),
54 Self::Custom(s) => write!(f, "{}", s),
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct UpdateInfo {
62 pub current: Version,
64 pub latest: Version,
66 pub release_url: String,
68 pub security_details: Option<String>,
70}
71
72#[derive(Debug, Clone)]
74pub enum UpdateRecommendation {
75 UpToDate,
77 MinorUpdate(UpdateInfo),
79 MajorUpdate(UpdateInfo),
81 SecurityUpdate(UpdateInfo),
83}
84
85#[derive(Debug, Clone)]
87pub struct ReleaseNotes {
88 pub version: String,
90 pub full_notes: String,
92 pub parsed: parser::ParsedRelease,
94}
95
96pub struct VersionManager {
98 github_client: GitHubClient,
99 cache: Arc<RwLock<cache::Cache<String, Vec<u8>>>>,
100 file_cache: file_cache::FileCache,
101}
102
103impl VersionManager {
104 pub fn new() -> Result<Self> {
110 let github_client = GitHubClient::new(None)?;
111 let cache = Arc::new(RwLock::new(cache::Cache::new(Duration::from_secs(3600))));
112 let file_cache = file_cache::FileCache::default()?;
113
114 Ok(Self {
115 github_client,
116 cache,
117 file_cache,
118 })
119 }
120
121 pub async fn check_current(&self) -> Result<RustVersion> {
127 detector::detect_rust_version()
128 }
129
130 pub async fn get_latest_stable(&self) -> Result<GitHubRelease> {
136 let cache_key = "latest_stable";
138
139 if let Some(entry) = self.file_cache.get(cache_key) {
140 if let Ok(release) = serde_json::from_slice::<GitHubRelease>(&entry.data) {
141 tracing::debug!("Using cached latest stable release");
142 return Ok(release);
143 }
144 }
145
146 {
148 let cache = self.cache.read().await;
149 if let Some(cached_bytes) = cache.get(&cache_key.to_string())
150 && let Ok(release) = serde_json::from_slice::<GitHubRelease>(&cached_bytes)
151 {
152 return Ok(release);
153 }
154 }
155
156 let release = self.github_client.get_latest_release().await?;
158
159 if let Ok(bytes) = serde_json::to_vec(&release) {
161 let mut cache = self.cache.write().await;
162 cache.insert(cache_key.to_string(), bytes.clone());
163 let _ = self.file_cache.set(cache_key, bytes, "application/json");
164 }
165
166 Ok(release)
167 }
168
169 pub async fn get_recommendation(&self) -> Result<UpdateRecommendation> {
176 let current = self.check_current().await?;
177 let latest = self.get_latest_stable().await?;
178
179 if latest.version <= current.version {
181 return Ok(UpdateRecommendation::UpToDate);
182 }
183
184 self.determine_update_type(¤t, &latest)
186 }
187
188 fn determine_update_type(
190 &self,
191 current: &RustVersion,
192 latest: &GitHubRelease,
193 ) -> Result<UpdateRecommendation> {
194 let parsed = parser::parse_release_notes(&latest.tag_name, &latest.body);
196
197 if !parsed.security_advisories.is_empty() {
198 Ok(self.create_security_update(current, latest, &parsed))
199 } else if self.is_major_update(current, latest) {
200 Ok(self.create_major_update(current, latest))
201 } else {
202 Ok(self.create_minor_update(current, latest))
203 }
204 }
205
206 #[allow(dead_code)]
208 fn is_security_update(&self, release: &GitHubRelease) -> bool {
209 let body_lower = release.body.to_lowercase();
210 let name_lower = release.name.to_lowercase();
211 body_lower.contains("security") || name_lower.contains("security")
212 }
213
214 fn is_major_update(&self, current: &RustVersion, latest: &GitHubRelease) -> bool {
216 latest.version.major > current.version.major
217 }
218
219 fn create_security_update(
221 &self,
222 current: &RustVersion,
223 latest: &GitHubRelease,
224 parsed: &parser::ParsedRelease,
225 ) -> UpdateRecommendation {
226 let security_summary = if !parsed.security_advisories.is_empty() {
227 Some(
228 parsed
229 .security_advisories
230 .iter()
231 .map(|a| {
232 if let Some(ref id) = a.id {
233 format!("{}: {}", id, a.description)
234 } else {
235 a.description.clone()
236 }
237 })
238 .collect::<Vec<_>>()
239 .join("; "),
240 )
241 } else {
242 Some(self.extract_security_details(&latest.body))
243 };
244
245 let info = UpdateInfo {
246 current: current.version.clone(),
247 latest: latest.version.clone(),
248 release_url: latest.html_url.clone(),
249 security_details: security_summary,
250 };
251 UpdateRecommendation::SecurityUpdate(info)
252 }
253
254 fn create_major_update(
256 &self,
257 current: &RustVersion,
258 latest: &GitHubRelease,
259 ) -> UpdateRecommendation {
260 let info = UpdateInfo {
261 current: current.version.clone(),
262 latest: latest.version.clone(),
263 release_url: latest.html_url.clone(),
264 security_details: None,
265 };
266 UpdateRecommendation::MajorUpdate(info)
267 }
268
269 fn create_minor_update(
271 &self,
272 current: &RustVersion,
273 latest: &GitHubRelease,
274 ) -> UpdateRecommendation {
275 let info = UpdateInfo {
276 current: current.version.clone(),
277 latest: latest.version.clone(),
278 release_url: latest.html_url.clone(),
279 security_details: None,
280 };
281 UpdateRecommendation::MinorUpdate(info)
282 }
283
284 pub async fn get_recent_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
290 let cache_key = format!("recent_releases_{}", count);
292
293 if let Some(entry) = self.file_cache.get(&cache_key) {
294 if let Ok(releases) = serde_json::from_slice::<Vec<GitHubRelease>>(&entry.data) {
295 tracing::debug!("Using cached releases");
296 return Ok(releases);
297 }
298 }
299
300 let releases = self.github_client.get_releases(count).await?;
302
303 if let Ok(data) = serde_json::to_vec(&releases) {
305 let _ = self.file_cache.set(&cache_key, data, "application/json");
306 }
307
308 Ok(releases)
309 }
310
311 pub async fn get_release_notes(&self, version: &str) -> Result<ReleaseNotes> {
317 let cache_key = format!("release_notes_{}", version);
319
320 if let Some(entry) = self.file_cache.get(&cache_key) {
321 if let Ok(release) = serde_json::from_slice::<GitHubRelease>(&entry.data) {
322 let parsed = parser::parse_release_notes(&release.tag_name, &release.body);
323 return Ok(ReleaseNotes {
324 version: release.tag_name,
325 full_notes: release.body,
326 parsed,
327 });
328 }
329 }
330
331 let release = self.github_client.get_release_by_tag(version).await?;
333
334 if let Ok(data) = serde_json::to_vec(&release) {
336 let _ = self.file_cache.set(&cache_key, data, "application/json");
337 }
338
339 let parsed = parser::parse_release_notes(&release.tag_name, &release.body);
340 Ok(ReleaseNotes {
341 version: release.tag_name,
342 full_notes: release.body,
343 parsed,
344 })
345 }
346
347 pub async fn check_updates(&self) -> Result<(bool, Option<Version>)> {
353 let current = self.check_current().await?;
354 let latest = self.get_latest_stable().await?;
355
356 if latest.version > current.version {
357 Ok((true, Some(latest.version)))
358 } else {
359 Ok((false, None))
360 }
361 }
362
363 pub fn is_offline_mode(&self) -> bool {
365 self.file_cache.should_use_offline()
366 }
367
368 pub fn cache_stats(&self) -> file_cache::CacheStats {
370 self.file_cache.stats()
371 }
372
373 fn extract_security_details(&self, body: &str) -> String {
374 body.lines()
376 .filter(|line| {
377 let lower = line.to_lowercase();
378 lower.contains("security")
379 || lower.contains("vulnerability")
380 || lower.contains("cve-")
381 })
382 .take(3)
383 .collect::<Vec<_>>()
384 .join("\n")
385 }
386}
387
388#[cfg(test)]
389#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_channel_display() {
395 assert_eq!(Channel::Stable.to_string(), "stable");
396 assert_eq!(Channel::Beta.to_string(), "beta");
397 assert_eq!(Channel::Nightly.to_string(), "nightly");
398 assert_eq!(Channel::Custom("custom".to_string()).to_string(), "custom");
399 }
400}