Skip to main content

ferrous_forge/rust_version/
mod.rs

1//! Rust version management and checking
2//!
3//! This module provides functionality to detect installed Rust versions,
4//! check for updates from GitHub releases, and provide recommendations.
5//!
6//! @task T024
7//! @epic T014
8
9use crate::Result;
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::RwLock;
15
16/// Version information caching with TTL support.
17pub mod cache;
18/// Installed Rust version detection.
19pub mod detector;
20/// File-based cache for offline support.
21pub mod file_cache;
22/// GitHub API client for fetching Rust releases.
23pub mod github;
24/// Release notes parser for security/breaking changes.
25pub mod parser;
26/// Rustup integration for toolchain management.
27pub mod rustup;
28/// Security advisory checker.
29pub mod security;
30
31pub use detector::RustVersion;
32pub use github::{GitHubClient, GitHubRelease};
33pub use security::{SecurityCheckResult, SecurityChecker};
34
35/// Rust release channel
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub enum Channel {
38    /// Stable releases
39    Stable,
40    /// Beta releases
41    Beta,
42    /// Nightly builds
43    Nightly,
44    /// Custom or unknown channel
45    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/// Update information for available version
60#[derive(Debug, Clone)]
61pub struct UpdateInfo {
62    /// Current Rust version
63    pub current: Version,
64    /// Latest available version
65    pub latest: Version,
66    /// URL to the release page
67    pub release_url: String,
68    /// Security update details (if applicable)
69    pub security_details: Option<String>,
70}
71
72/// Version update recommendation
73#[derive(Debug, Clone)]
74pub enum UpdateRecommendation {
75    /// Already on latest version
76    UpToDate,
77    /// Minor update available
78    MinorUpdate(UpdateInfo),
79    /// Major update available
80    MajorUpdate(UpdateInfo),
81    /// Security update available
82    SecurityUpdate(UpdateInfo),
83}
84
85/// Release notes with parsed details
86#[derive(Debug, Clone)]
87pub struct ReleaseNotes {
88    /// Version string
89    pub version: String,
90    /// Full release notes
91    pub full_notes: String,
92    /// Parsed details
93    pub parsed: parser::ParsedRelease,
94}
95
96/// Version manager for checking and recommending updates
97pub 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    /// Create a new version manager
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if the HTTP client cannot be constructed.
109    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    /// Check current Rust installation
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if `rustc` is not found or its output cannot be parsed.
126    pub async fn check_current(&self) -> Result<RustVersion> {
127        detector::detect_rust_version()
128    }
129
130    /// Get latest stable release
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the GitHub API request fails or the response cannot be parsed.
135    pub async fn get_latest_stable(&self) -> Result<GitHubRelease> {
136        // Check file cache first for offline support
137        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        // Check in-memory cache
147        {
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        // Fetch from GitHub
157        let release = self.github_client.get_latest_release().await?;
158
159        // Cache in both in-memory and file cache
160        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    /// Get update recommendation
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the current version cannot be detected or the latest
174    /// release cannot be fetched from GitHub.
175    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        // Check if already up to date
180        if latest.version <= current.version {
181            return Ok(UpdateRecommendation::UpToDate);
182        }
183
184        // Determine update type based on release content and version difference
185        self.determine_update_type(&current, &latest)
186    }
187
188    /// Determine the type of update based on version comparison and release content
189    fn determine_update_type(
190        &self,
191        current: &RustVersion,
192        latest: &GitHubRelease,
193    ) -> Result<UpdateRecommendation> {
194        // Parse release notes for security advisories
195        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    /// Check if the release contains security-related updates
207    #[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    /// Check if this is a major version update
215    fn is_major_update(&self, current: &RustVersion, latest: &GitHubRelease) -> bool {
216        latest.version.major > current.version.major
217    }
218
219    /// Create a security update recommendation
220    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    /// Create a major update recommendation
255    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    /// Create a minor/patch update recommendation
270    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    /// Get multiple recent releases
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the GitHub API request fails or the response cannot be parsed.
289    pub async fn get_recent_releases(&self, count: usize) -> Result<Vec<GitHubRelease>> {
290        // Check file cache first
291        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        // Fetch from GitHub
301        let releases = self.github_client.get_releases(count).await?;
302
303        // Cache the result
304        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    /// Get release notes for a specific version
312    ///
313    /// # Errors
314    ///
315    /// Returns an error if the release cannot be found or fetched.
316    pub async fn get_release_notes(&self, version: &str) -> Result<ReleaseNotes> {
317        // Check file cache first
318        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        // Fetch from GitHub
332        let release = self.github_client.get_release_by_tag(version).await?;
333
334        // Cache the result
335        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    /// Check for updates (returns true if updates available)
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if the check fails.
352    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    /// Check if offline mode should be used
364    pub fn is_offline_mode(&self) -> bool {
365        self.file_cache.should_use_offline()
366    }
367
368    /// Get cache statistics
369    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        // Extract security-related information from release notes
375        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}