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