arch_toolkit/deps/
parse.rs1use std::collections::HashSet;
8
9use crate::types::DependencySpec;
10
11const DEPENDS_LABELS: &[&str] = &["Depends On"];
14
15const NONE_LABELS: &[&str] = &["None"];
17
18const CONFLICTS_LABELS: &[&str] = &["Conflicts With"];
20
21const COMMON_WORDS: &[&str] = &[
24 "for", "to", "with", "is", "that", "using", "usually", "bundled", "bindings", "tooling", "the",
25 "and", "or", "in", "on", "at", "by", "from", "as", "if", "when", "where", "which", "what",
26 "how", "why",
27];
28
29#[must_use]
56pub fn parse_dep_spec(spec: &str) -> DependencySpec {
57 for op in ["<=", ">=", "=", "<", ">"] {
59 if let Some(pos) = spec.find(op) {
60 return DependencySpec {
61 name: spec[..pos].trim().to_string(),
62 version_req: spec[pos..].trim().to_string(),
63 };
64 }
65 }
66 DependencySpec::new(spec.trim())
67}
68
69fn is_valid_package_token(token: &str) -> bool {
85 if token.is_empty() || token.len() < 2 {
86 return false;
87 }
88
89 let lower = token.to_lowercase();
92 #[allow(clippy::case_sensitive_file_extension_comparisons)]
93 {
94 if lower.ends_with(".so") || lower.contains(".so.") || lower.contains(".so=") {
95 return false;
96 }
97 }
98
99 if COMMON_WORDS.contains(&lower.as_str()) {
101 return false;
102 }
103
104 let Some(first_char) = token.chars().next() else {
106 return false;
107 };
108 if !first_char.is_alphanumeric() && first_char != '-' && first_char != '_' {
109 return false;
110 }
111
112 if token.ends_with(':') {
114 return false;
115 }
116
117 token.chars().any(char::is_alphanumeric)
119}
120
121fn collect_continuation_lines(lines: &[&str], start_index: usize, field_value: &str) -> String {
135 let mut result = field_value.to_string();
136
137 for continuation_line in lines.iter().skip(start_index + 1) {
138 if continuation_line.trim().is_empty() {
140 break;
141 }
142
143 let trimmed = continuation_line.trim_start();
145 if continuation_line.starts_with(char::is_whitespace) {
146 result.push(' ');
148 result.push_str(trimmed);
149 } else if trimmed.contains(':') && !trimmed.starts_with(char::is_whitespace) {
150 break;
152 } else {
153 break;
155 }
156 }
157
158 result
159}
160
161#[must_use]
192pub fn parse_pacman_si_deps(text: &str) -> Vec<String> {
193 let lines: Vec<&str> = text.lines().collect();
194
195 for (i, line) in lines.iter().enumerate() {
196 let is_depends_line = DEPENDS_LABELS.iter().any(|label| line.starts_with(label))
198 || (line.contains("Depends") && line.contains("On"));
199
200 if is_depends_line && let Some(colon_pos) = line.find(':') {
201 let initial_value = line[colon_pos + 1..].trim();
203
204 let deps_str = collect_continuation_lines(&lines, i, initial_value);
206
207 let deps_str_trimmed = deps_str.trim();
209 if deps_str_trimmed.is_empty()
210 || NONE_LABELS
211 .iter()
212 .any(|label| deps_str_trimmed.eq_ignore_ascii_case(label))
213 {
214 return Vec::new();
215 }
216
217 let mut seen = HashSet::new();
219 return deps_str_trimmed
220 .split_whitespace()
221 .map(str::trim)
222 .filter(|s| is_valid_package_token(s))
223 .filter_map(|s| {
224 if seen.insert(s) {
226 Some(s.to_string())
227 } else {
228 None
229 }
230 })
231 .collect();
232 }
233 }
234 Vec::new()
235}
236
237#[must_use]
268pub fn parse_pacman_si_conflicts(text: &str) -> Vec<String> {
269 let lines: Vec<&str> = text.lines().collect();
270
271 for (i, line) in lines.iter().enumerate() {
272 let is_conflicts_line = CONFLICTS_LABELS.iter().any(|label| line.starts_with(label))
274 || (line.contains("Conflicts") && line.contains("With"));
275
276 if is_conflicts_line && let Some(colon_pos) = line.find(':') {
277 let initial_value = line[colon_pos + 1..].trim();
279
280 let conflicts_str = collect_continuation_lines(&lines, i, initial_value);
282
283 let conflicts_str_trimmed = conflicts_str.trim();
285 if conflicts_str_trimmed.is_empty()
286 || NONE_LABELS
287 .iter()
288 .any(|label| conflicts_str_trimmed.eq_ignore_ascii_case(label))
289 {
290 return Vec::new();
291 }
292
293 let mut seen = HashSet::new();
295 return conflicts_str_trimmed
296 .split_whitespace()
297 .map(str::trim)
298 .filter(|s| is_valid_package_token(s))
299 .map(|s| parse_dep_spec(s).name)
300 .filter_map(|name| {
301 if seen.insert(name.clone()) {
303 Some(name)
304 } else {
305 None
306 }
307 })
308 .collect();
309 }
310 }
311 Vec::new()
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
321 fn parse_dep_spec_no_version() {
322 let spec = parse_dep_spec("glibc");
323 assert_eq!(spec.name, "glibc");
324 assert!(spec.version_req.is_empty());
325 assert!(!spec.has_version_req());
326 }
327
328 #[test]
329 fn parse_dep_spec_greater_equal() {
330 let spec = parse_dep_spec("python>=3.12");
331 assert_eq!(spec.name, "python");
332 assert_eq!(spec.version_req, ">=3.12");
333 }
334
335 #[test]
336 fn parse_dep_spec_less_equal() {
337 let spec = parse_dep_spec("openssl<=1.1.1");
338 assert_eq!(spec.name, "openssl");
339 assert_eq!(spec.version_req, "<=1.1.1");
340 }
341
342 #[test]
343 fn parse_dep_spec_equal() {
344 let spec = parse_dep_spec("firefox=121.0");
345 assert_eq!(spec.name, "firefox");
346 assert_eq!(spec.version_req, "=121.0");
347 }
348
349 #[test]
350 fn parse_dep_spec_greater() {
351 let spec = parse_dep_spec("rust>1.70");
352 assert_eq!(spec.name, "rust");
353 assert_eq!(spec.version_req, ">1.70");
354 }
355
356 #[test]
357 fn parse_dep_spec_less() {
358 let spec = parse_dep_spec("cmake<4.0");
359 assert_eq!(spec.name, "cmake");
360 assert_eq!(spec.version_req, "<4.0");
361 }
362
363 #[test]
364 fn parse_dep_spec_with_whitespace() {
365 let spec = parse_dep_spec(" python >= 3.12 ");
366 assert_eq!(spec.name, "python");
367 assert_eq!(spec.version_req, ">= 3.12");
368 }
369
370 #[test]
371 fn parse_dep_spec_complex_version() {
372 let spec = parse_dep_spec("qt5-base>=5.15.10-1");
373 assert_eq!(spec.name, "qt5-base");
374 assert_eq!(spec.version_req, ">=5.15.10-1");
375 }
376
377 #[test]
380 fn is_valid_package_token_valid() {
381 assert!(is_valid_package_token("glibc"));
382 assert!(is_valid_package_token("qt5-base"));
383 assert!(is_valid_package_token("python3"));
384 assert!(is_valid_package_token("lib32-glibc"));
385 }
386
387 #[test]
388 fn is_valid_package_token_so_files() {
389 assert!(!is_valid_package_token("libedit.so"));
390 assert!(!is_valid_package_token("libgit2.so.1"));
391 assert!(!is_valid_package_token("libfoo.so=0-64"));
392 }
393
394 #[test]
395 fn is_valid_package_token_common_words() {
396 assert!(!is_valid_package_token("for"));
397 assert!(!is_valid_package_token("with"));
398 assert!(!is_valid_package_token("the"));
399 }
400
401 #[test]
402 fn is_valid_package_token_short() {
403 assert!(!is_valid_package_token("a"));
404 assert!(!is_valid_package_token(""));
405 }
406
407 #[test]
408 fn is_valid_package_token_invalid_start() {
409 assert!(!is_valid_package_token("(test)"));
410 assert!(!is_valid_package_token("[optional]"));
411 }
412
413 #[test]
414 fn is_valid_package_token_colon_ending() {
415 assert!(!is_valid_package_token("error:"));
416 }
417
418 #[test]
421 fn parse_pacman_si_deps_basic() {
422 let text = "Name : firefox\nDepends On : glibc gtk3 nss\n";
423 let deps = parse_pacman_si_deps(text);
424 assert_eq!(deps.len(), 3);
425 assert!(deps.contains(&"glibc".to_string()));
426 assert!(deps.contains(&"gtk3".to_string()));
427 assert!(deps.contains(&"nss".to_string()));
428 }
429
430 #[test]
431 fn parse_pacman_si_deps_with_versions() {
432 let text = "Depends On : python>=3.10 rust>=1.70\n";
433 let deps = parse_pacman_si_deps(text);
434 assert_eq!(deps.len(), 2);
435 assert!(deps.contains(&"python>=3.10".to_string()));
436 assert!(deps.contains(&"rust>=1.70".to_string()));
437 }
438
439 #[test]
440 fn parse_pacman_si_deps_none() {
441 let text = "Depends On : None\n";
442 let deps = parse_pacman_si_deps(text);
443 assert!(deps.is_empty());
444 }
445
446 #[test]
447 fn parse_pacman_si_deps_empty() {
448 let text = "Depends On :\n";
449 let deps = parse_pacman_si_deps(text);
450 assert!(deps.is_empty());
451 }
452
453 #[test]
454 fn parse_pacman_si_deps_filters_so() {
455 let text = "Depends On : glibc libedit.so libgit2.so.1 nss\n";
456 let deps = parse_pacman_si_deps(text);
457 assert_eq!(deps.len(), 2);
458 assert!(deps.contains(&"glibc".to_string()));
459 assert!(deps.contains(&"nss".to_string()));
460 }
461
462 #[test]
463 fn parse_pacman_si_deps_no_depends_line() {
464 let text = "Name : firefox\nVersion : 121.0\n";
465 let deps = parse_pacman_si_deps(text);
466 assert!(deps.is_empty());
467 }
468
469 #[test]
470 fn parse_pacman_si_deps_deduplicates() {
471 let text = "Depends On : glibc gtk3 glibc nss gtk3\n";
472 let deps = parse_pacman_si_deps(text);
473 assert_eq!(deps.len(), 3, "Should deduplicate dependencies");
474 assert!(deps.contains(&"glibc".to_string()));
475 assert!(deps.contains(&"gtk3".to_string()));
476 assert!(deps.contains(&"nss".to_string()));
477 }
478
479 #[test]
480 fn parse_pacman_si_deps_multiline() {
481 let text = "Name : firefox\nDepends On : glibc gtk3 libpulse nss\n libxt libxss libxcomposite\n libx11 libxcb\n";
482 let deps = parse_pacman_si_deps(text);
483 assert_eq!(deps.len(), 9);
484 assert!(deps.contains(&"glibc".to_string()));
485 assert!(deps.contains(&"gtk3".to_string()));
486 assert!(deps.contains(&"libpulse".to_string()));
487 assert!(deps.contains(&"nss".to_string()));
488 assert!(deps.contains(&"libxt".to_string()));
489 assert!(deps.contains(&"libxss".to_string()));
490 assert!(deps.contains(&"libxcomposite".to_string()));
491 assert!(deps.contains(&"libx11".to_string()));
492 assert!(deps.contains(&"libxcb".to_string()));
493 }
494
495 #[test]
498 fn parse_pacman_si_conflicts_basic() {
499 let text = "Conflicts With : conflicting-pkg1 conflicting-pkg2\n";
500 let conflicts = parse_pacman_si_conflicts(text);
501 assert_eq!(conflicts.len(), 2);
502 assert!(conflicts.contains(&"conflicting-pkg1".to_string()));
503 assert!(conflicts.contains(&"conflicting-pkg2".to_string()));
504 }
505
506 #[test]
507 fn parse_pacman_si_conflicts_with_versions() {
508 let text = "Conflicts With : old-pkg<2.0 new-pkg>=3.0\n";
509 let conflicts = parse_pacman_si_conflicts(text);
510 assert_eq!(conflicts.len(), 2);
511 assert!(conflicts.contains(&"old-pkg".to_string()));
512 assert!(conflicts.contains(&"new-pkg".to_string()));
513 }
514
515 #[test]
516 fn parse_pacman_si_conflicts_none() {
517 let text = "Conflicts With : None\n";
518 let conflicts = parse_pacman_si_conflicts(text);
519 assert!(conflicts.is_empty());
520 }
521
522 #[test]
523 fn parse_pacman_si_conflicts_empty() {
524 let text = "Conflicts With :\n";
525 let conflicts = parse_pacman_si_conflicts(text);
526 assert!(conflicts.is_empty());
527 }
528
529 #[test]
530 fn parse_pacman_si_conflicts_no_conflicts_line() {
531 let text = "Name : firefox\nVersion : 121.0\n";
532 let conflicts = parse_pacman_si_conflicts(text);
533 assert!(conflicts.is_empty());
534 }
535
536 #[test]
537 fn parse_pacman_si_conflicts_deduplicates() {
538 let text = "Conflicts With : pkg1 pkg2 pkg1 pkg3\n";
539 let conflicts = parse_pacman_si_conflicts(text);
540 assert_eq!(conflicts.len(), 3, "Should deduplicate conflicts");
541 assert!(conflicts.contains(&"pkg1".to_string()));
542 assert!(conflicts.contains(&"pkg2".to_string()));
543 assert!(conflicts.contains(&"pkg3".to_string()));
544 }
545}