ferrous_forge/rust_version/
parser.rs1use regex::Regex;
10use std::sync::OnceLock;
11
12#[derive(Debug, Clone)]
14pub struct ParsedRelease {
15 pub version: String,
17 pub full_notes: String,
19 pub security_advisories: Vec<SecurityAdvisory>,
21 pub breaking_changes: Vec<BreakingChange>,
23 pub new_features: Vec<String>,
25 pub performance_improvements: Vec<String>,
27 pub bug_fixes: Vec<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct SecurityAdvisory {
34 pub id: Option<String>,
36 pub description: String,
38 pub severity: Severity,
40 pub affected_components: Vec<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct BreakingChange {
47 pub description: String,
49 pub migration: Option<String>,
51 pub affected_edition: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Severity {
60 Unknown,
62 Low,
64 Medium,
66 High,
68 Critical,
70}
71
72impl std::fmt::Display for Severity {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Critical => write!(f, "CRITICAL"),
76 Self::High => write!(f, "HIGH"),
77 Self::Medium => write!(f, "MEDIUM"),
78 Self::Low => write!(f, "LOW"),
79 Self::Unknown => write!(f, "UNKNOWN"),
80 }
81 }
82}
83
84#[allow(clippy::panic)] fn security_keywords() -> &'static [Regex] {
87 static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
88 KEYWORDS.get_or_init(|| {
89 vec![
90 Regex::new(r"(?i)security")
91 .unwrap_or_else(|_| panic!("Invalid regex pattern for security")),
92 Regex::new(r"(?i)vulnerability")
93 .unwrap_or_else(|_| panic!("Invalid regex pattern for vulnerability")),
94 Regex::new(r"(?i)CVE-\d{4}-\d+")
95 .unwrap_or_else(|_| panic!("Invalid regex pattern for CVE")),
96 Regex::new(r"(?i)exploit")
97 .unwrap_or_else(|_| panic!("Invalid regex pattern for exploit")),
98 Regex::new(r"(?i)buffer.?overflow")
99 .unwrap_or_else(|_| panic!("Invalid regex pattern for buffer overflow")),
100 Regex::new(r"(?i)memory.?safety")
101 .unwrap_or_else(|_| panic!("Invalid regex pattern for memory safety")),
102 Regex::new(r"(?i)unsound")
103 .unwrap_or_else(|_| panic!("Invalid regex pattern for unsound")),
104 Regex::new(r"(?i)undefined.?behavior")
105 .unwrap_or_else(|_| panic!("Invalid regex pattern for undefined behavior")),
106 ]
107 })
108}
109
110#[allow(clippy::panic)] fn breaking_keywords() -> &'static [Regex] {
113 static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
114 KEYWORDS.get_or_init(|| {
115 vec![
116 Regex::new(r"(?i)breaking.?change")
117 .unwrap_or_else(|_| panic!("Invalid regex for breaking change")),
118 Regex::new(r"(?i)\[breaking\]")
119 .unwrap_or_else(|_| panic!("Invalid regex for [breaking]")),
120 Regex::new(r"(?i)incompatible")
121 .unwrap_or_else(|_| panic!("Invalid regex for incompatible")),
122 Regex::new(r"(?i)deprecated")
123 .unwrap_or_else(|_| panic!("Invalid regex for deprecated")),
124 Regex::new(r"(?i)removed").unwrap_or_else(|_| panic!("Invalid regex for removed")),
125 ]
126 })
127}
128
129#[allow(dead_code)]
131#[allow(clippy::panic)] fn feature_keywords() -> &'static [Regex] {
133 static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
134 KEYWORDS.get_or_init(|| {
135 vec![
136 Regex::new(r"(?i)new.?feature")
137 .unwrap_or_else(|_| panic!("Invalid regex for new feature")),
138 Regex::new(r"(?i)stabilized")
139 .unwrap_or_else(|_| panic!("Invalid regex for stabilized")),
140 Regex::new(r"(?i)added.?support")
141 .unwrap_or_else(|_| panic!("Invalid regex for added support")),
142 ]
143 })
144}
145
146#[allow(dead_code)]
148#[allow(clippy::panic)] fn performance_keywords() -> &'static [Regex] {
150 static KEYWORDS: OnceLock<Vec<Regex>> = OnceLock::new();
151 KEYWORDS.get_or_init(|| {
152 vec![
153 Regex::new(r"(?i)performance")
154 .unwrap_or_else(|_| panic!("Invalid regex for performance")),
155 Regex::new(r"(?i)faster").unwrap_or_else(|_| panic!("Invalid regex for faster")),
156 Regex::new(r"(?i)optimized").unwrap_or_else(|_| panic!("Invalid regex for optimized")),
157 Regex::new(r"(?i)improved.?compile")
158 .unwrap_or_else(|_| panic!("Invalid regex for improved compile")),
159 ]
160 })
161}
162
163pub fn parse_release_notes(version: &str, notes: &str) -> ParsedRelease {
175 let mut parsed = ParsedRelease {
176 version: version.to_string(),
177 full_notes: notes.to_string(),
178 security_advisories: Vec::new(),
179 breaking_changes: Vec::new(),
180 new_features: Vec::new(),
181 performance_improvements: Vec::new(),
182 bug_fixes: Vec::new(),
183 };
184
185 let lines: Vec<&str> = notes.lines().collect();
186 let mut current_section: Option<&str> = None;
187
188 for line in lines {
189 let trimmed = line.trim();
190
191 if trimmed.starts_with("#") || trimmed.ends_with(':') {
193 current_section = Some(trimmed.trim_start_matches('#').trim());
194 continue;
195 }
196
197 if trimmed.is_empty() {
199 continue;
200 }
201
202 if is_security_related(trimmed)
204 && let Some(advisory) = parse_security_advisory(trimmed)
205 {
206 parsed.security_advisories.push(advisory);
207 }
208
209 if is_breaking_change(trimmed)
210 && let Some(change) = parse_breaking_change(trimmed)
211 {
212 parsed.breaking_changes.push(change);
213 }
214
215 if let Some(section) = current_section {
217 categorize_by_section(&mut parsed, section, trimmed);
218 }
219 }
220
221 parsed
222}
223
224fn is_security_related(line: &str) -> bool {
226 let lower = line.to_lowercase();
227 security_keywords().iter().any(|re| re.is_match(&lower))
228}
229
230fn is_breaking_change(line: &str) -> bool {
232 let lower = line.to_lowercase();
233 breaking_keywords().iter().any(|re| re.is_match(&lower))
234}
235
236fn parse_security_advisory(line: &str) -> Option<SecurityAdvisory> {
238 let line_lower = line.to_lowercase();
239
240 let id = extract_cve_id(line);
242
243 let severity = if line_lower.contains("critical") || line_lower.contains("severe") {
245 Severity::Critical
246 } else if line_lower.contains("high") {
247 Severity::High
248 } else if line_lower.contains("medium") || line_lower.contains("moderate") {
249 Severity::Medium
250 } else if line_lower.contains("low") {
251 Severity::Low
252 } else {
253 Severity::Unknown
254 };
255
256 let description = line.trim_start_matches(['-', '*', '•']).trim().to_string();
258
259 Some(SecurityAdvisory {
260 id,
261 description,
262 severity,
263 affected_components: Vec::new(),
264 })
265}
266
267fn extract_cve_id(text: &str) -> Option<String> {
269 let re = Regex::new(r"CVE-\d{4}-\d+").unwrap_or_else(|_| {
271 Regex::new(r"$^").unwrap_or_else(|_| unreachable!())
273 });
274 re.find(text).map(|m| m.as_str().to_string())
275}
276
277fn parse_breaking_change(line: &str) -> Option<BreakingChange> {
279 let description = line.trim_start_matches(['-', '*', '•']).trim().to_string();
280
281 let migration =
283 if line.to_lowercase().contains("use") || line.to_lowercase().contains("replace") {
284 Some(description.clone())
285 } else {
286 None
287 };
288
289 Some(BreakingChange {
290 description,
291 migration,
292 affected_edition: None,
293 })
294}
295
296fn categorize_by_section(parsed: &mut ParsedRelease, section: &str, content: &str) {
298 let section_lower = section.to_lowercase();
299
300 if section_lower.contains("feature") || section_lower.contains("language") {
301 parsed.new_features.push(content.to_string());
302 } else if section_lower.contains("performance") || section_lower.contains("compile") {
303 parsed.performance_improvements.push(content.to_string());
304 } else if section_lower.contains("bug") || section_lower.contains("fix") {
305 parsed.bug_fixes.push(content.to_string());
306 }
307}
308
309pub fn has_critical_security_issues(notes: &str) -> bool {
319 let parsed = parse_release_notes("", notes);
320 parsed
321 .security_advisories
322 .iter()
323 .any(|a| a.severity == Severity::Critical)
324}
325
326pub fn get_security_summary(notes: &str) -> String {
339 let parsed = parse_release_notes("", notes);
340
341 if parsed.security_advisories.is_empty() {
342 return "No security advisories".to_string();
343 }
344
345 let critical_count = parsed
346 .security_advisories
347 .iter()
348 .filter(|a| a.severity == Severity::Critical)
349 .count();
350 let high_count = parsed
351 .security_advisories
352 .iter()
353 .filter(|a| a.severity == Severity::High)
354 .count();
355
356 let mut summary = format!("{} security advisory", parsed.security_advisories.len());
357 if parsed.security_advisories.len() > 1 {
358 summary.push('s');
359 }
360
361 if critical_count > 0 {
362 summary.push_str(&format!(", {} CRITICAL", critical_count));
363 }
364 if high_count > 0 {
365 summary.push_str(&format!(", {} HIGH", high_count));
366 }
367
368 summary
369}
370
371pub fn is_version_affected(
384 current_version: &str,
385 releases: &[crate::rust_version::GitHubRelease],
386) -> bool {
387 let Ok(current) = semver::Version::parse(current_version.trim_start_matches('v')) else {
388 return false;
389 };
390
391 for release in releases {
392 if release.version > current {
394 let parsed = parse_release_notes(&release.tag_name, &release.body);
395 if !parsed.security_advisories.is_empty() {
396 return true;
397 }
398 }
399 }
400
401 false
402}
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_parse_security_advisory() {
411 let line = "- Fixed CVE-2023-1234: Critical buffer overflow vulnerability";
412 let advisory = parse_security_advisory(line).unwrap();
413
414 assert_eq!(advisory.id, Some("CVE-2023-1234".to_string()));
415 assert_eq!(advisory.severity, Severity::Critical);
416 assert!(advisory.description.contains("buffer overflow"));
417 }
418
419 #[test]
420 fn test_extract_cve_id() {
421 assert_eq!(
422 extract_cve_id("Fixed CVE-2023-1234 issue"),
423 Some("CVE-2023-1234".to_string())
424 );
425 assert_eq!(extract_cve_id("No CVE here"), None);
426 }
427
428 #[test]
429 fn test_has_critical_security_issues() {
430 assert!(has_critical_security_issues(
431 "Security: Fixed CRITICAL vulnerability"
432 ));
433 assert!(!has_critical_security_issues("Added new feature"));
434 }
435
436 #[test]
437 fn test_get_security_summary() {
438 let notes = "CVE-2023-1234: High severity\nCVE-2023-5678: Critical severity";
439 let summary = get_security_summary(notes);
440
441 assert!(summary.contains("2 security"));
442 assert!(summary.contains("1 CRITICAL"));
443 assert!(summary.contains("1 HIGH"));
444 }
445
446 #[test]
447 fn test_is_security_related() {
448 assert!(is_security_related("Fixed security vulnerability"));
449 assert!(is_security_related("CVE-2023-1234 buffer overflow"));
450 assert!(!is_security_related("Added new feature"));
451 }
452
453 #[test]
454 fn test_is_breaking_change() {
455 assert!(is_breaking_change("[Breaking] Removed old API"));
456 assert!(is_breaking_change("Deprecated function"));
457 assert!(!is_breaking_change("Bug fix"));
458 }
459
460 #[test]
461 fn test_parse_breaking_change() {
462 let line = "- Deprecated std::mem::uninitialized()";
463 let change = parse_breaking_change(line).unwrap();
464
465 assert!(change.description.contains("uninitialized"));
466 }
467
468 #[test]
469 fn test_parse_release_notes_comprehensive() {
470 let notes = r#"Rust 1.70.0
471
472## Security
473- Fixed CVE-2023-1234: Critical buffer overflow (CVE-2023-1234)
474- Addressed CVE-2023-5678: HIGH severity memory safety issue
475
476## Breaking Changes
477- Deprecated old API
478
479## Language
480- Stabilized new features
481
482## Performance
483- Improved compile times
484"#;
485
486 let parsed = parse_release_notes("1.70.0", notes);
487
488 assert_eq!(parsed.version, "1.70.0");
489 assert_eq!(parsed.security_advisories.len(), 2);
490 assert_eq!(parsed.breaking_changes.len(), 1);
491 assert!(!parsed.new_features.is_empty());
492 assert!(!parsed.performance_improvements.is_empty());
493 }
494
495 #[test]
496 fn test_severity_ordering() {
497 assert!(Severity::Critical > Severity::High);
498 assert!(Severity::High > Severity::Medium);
499 assert!(Severity::Medium > Severity::Low);
500 }
501}