Skip to main content

socket_patch_core/utils/
purl.rs

1/// Strip query string qualifiers from a PURL.
2///
3/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `"pkg:pypi/requests@2.28.0"`
4pub fn strip_purl_qualifiers(purl: &str) -> &str {
5    match purl.find('?') {
6        Some(idx) => &purl[..idx],
7        None => purl,
8    }
9}
10
11/// Check if a PURL is a PyPI package.
12pub fn is_pypi_purl(purl: &str) -> bool {
13    purl.starts_with("pkg:pypi/")
14}
15
16/// Check if a PURL is an npm package.
17pub fn is_npm_purl(purl: &str) -> bool {
18    purl.starts_with("pkg:npm/")
19}
20
21/// Parse a PyPI PURL to extract name and version.
22///
23/// e.g., `"pkg:pypi/requests@2.28.0?artifact_id=abc"` -> `Some(("requests", "2.28.0"))`
24pub fn parse_pypi_purl(purl: &str) -> Option<(&str, &str)> {
25    let base = strip_purl_qualifiers(purl);
26    let rest = base.strip_prefix("pkg:pypi/")?;
27    let at_idx = rest.rfind('@')?;
28    let name = &rest[..at_idx];
29    let version = &rest[at_idx + 1..];
30    if name.is_empty() || version.is_empty() {
31        return None;
32    }
33    Some((name, version))
34}
35
36/// Parse an npm PURL to extract namespace, name, and version.
37///
38/// e.g., `"pkg:npm/@types/node@20.0.0"` -> `Some((Some("@types"), "node", "20.0.0"))`
39/// e.g., `"pkg:npm/lodash@4.17.21"` -> `Some((None, "lodash", "4.17.21"))`
40pub fn parse_npm_purl(purl: &str) -> Option<(Option<&str>, &str, &str)> {
41    let base = strip_purl_qualifiers(purl);
42    let rest = base.strip_prefix("pkg:npm/")?;
43
44    // Find the last @ that separates name from version
45    let at_idx = rest.rfind('@')?;
46    let name_part = &rest[..at_idx];
47    let version = &rest[at_idx + 1..];
48
49    if name_part.is_empty() || version.is_empty() {
50        return None;
51    }
52
53    // Check for scoped package (@scope/name)
54    if name_part.starts_with('@') {
55        let slash_idx = name_part.find('/')?;
56        let namespace = &name_part[..slash_idx];
57        let name = &name_part[slash_idx + 1..];
58        if name.is_empty() {
59            return None;
60        }
61        Some((Some(namespace), name, version))
62    } else {
63        Some((None, name_part, version))
64    }
65}
66
67/// Check if a PURL is a Ruby gem.
68#[cfg(feature = "gem")]
69pub fn is_gem_purl(purl: &str) -> bool {
70    purl.starts_with("pkg:gem/")
71}
72
73/// Parse a gem PURL to extract name and version.
74///
75/// e.g., `"pkg:gem/rails@7.1.0"` -> `Some(("rails", "7.1.0"))`
76#[cfg(feature = "gem")]
77pub fn parse_gem_purl(purl: &str) -> Option<(&str, &str)> {
78    let base = strip_purl_qualifiers(purl);
79    let rest = base.strip_prefix("pkg:gem/")?;
80    let at_idx = rest.rfind('@')?;
81    let name = &rest[..at_idx];
82    let version = &rest[at_idx + 1..];
83    if name.is_empty() || version.is_empty() {
84        return None;
85    }
86    Some((name, version))
87}
88
89/// Build a gem PURL from components.
90#[cfg(feature = "gem")]
91pub fn build_gem_purl(name: &str, version: &str) -> String {
92    format!("pkg:gem/{name}@{version}")
93}
94
95/// Check if a PURL is a Maven package.
96#[cfg(feature = "maven")]
97pub fn is_maven_purl(purl: &str) -> bool {
98    purl.starts_with("pkg:maven/")
99}
100
101/// Parse a Maven PURL to extract groupId, artifactId, and version.
102///
103/// e.g., `"pkg:maven/org.apache.commons/commons-lang3@3.12.0"` -> `Some(("org.apache.commons", "commons-lang3", "3.12.0"))`
104#[cfg(feature = "maven")]
105pub fn parse_maven_purl(purl: &str) -> Option<(&str, &str, &str)> {
106    let base = strip_purl_qualifiers(purl);
107    let rest = base.strip_prefix("pkg:maven/")?;
108    let at_idx = rest.rfind('@')?;
109    let name_part = &rest[..at_idx];
110    let version = &rest[at_idx + 1..];
111
112    if name_part.is_empty() || version.is_empty() {
113        return None;
114    }
115
116    // Split groupId/artifactId
117    let slash_idx = name_part.find('/')?;
118    let group_id = &name_part[..slash_idx];
119    let artifact_id = &name_part[slash_idx + 1..];
120
121    if group_id.is_empty() || artifact_id.is_empty() {
122        return None;
123    }
124
125    Some((group_id, artifact_id, version))
126}
127
128/// Build a Maven PURL from components.
129#[cfg(feature = "maven")]
130pub fn build_maven_purl(group_id: &str, artifact_id: &str, version: &str) -> String {
131    format!("pkg:maven/{group_id}/{artifact_id}@{version}")
132}
133
134/// Check if a PURL is a Go module.
135#[cfg(feature = "golang")]
136pub fn is_golang_purl(purl: &str) -> bool {
137    purl.starts_with("pkg:golang/")
138}
139
140/// Parse a Go module PURL to extract module path and version.
141///
142/// e.g., `"pkg:golang/github.com/gin-gonic/gin@v1.9.1"` -> `Some(("github.com/gin-gonic/gin", "v1.9.1"))`
143#[cfg(feature = "golang")]
144pub fn parse_golang_purl(purl: &str) -> Option<(&str, &str)> {
145    let base = strip_purl_qualifiers(purl);
146    let rest = base.strip_prefix("pkg:golang/")?;
147    let at_idx = rest.rfind('@')?;
148    let module_path = &rest[..at_idx];
149    let version = &rest[at_idx + 1..];
150    if module_path.is_empty() || version.is_empty() {
151        return None;
152    }
153    Some((module_path, version))
154}
155
156/// Build a Go module PURL from components.
157#[cfg(feature = "golang")]
158pub fn build_golang_purl(module_path: &str, version: &str) -> String {
159    format!("pkg:golang/{module_path}@{version}")
160}
161
162/// Check if a PURL is a Composer/PHP package.
163#[cfg(feature = "composer")]
164pub fn is_composer_purl(purl: &str) -> bool {
165    purl.starts_with("pkg:composer/")
166}
167
168/// Parse a Composer PURL to extract namespace, name, and version.
169///
170/// Composer packages always have a namespace (vendor).
171/// e.g., `"pkg:composer/monolog/monolog@3.5.0"` -> `Some((("monolog", "monolog"), "3.5.0"))`
172#[cfg(feature = "composer")]
173pub fn parse_composer_purl(purl: &str) -> Option<((&str, &str), &str)> {
174    let base = strip_purl_qualifiers(purl);
175    let rest = base.strip_prefix("pkg:composer/")?;
176    let at_idx = rest.rfind('@')?;
177    let name_part = &rest[..at_idx];
178    let version = &rest[at_idx + 1..];
179
180    if name_part.is_empty() || version.is_empty() {
181        return None;
182    }
183
184    // Split namespace/name
185    let slash_idx = name_part.find('/')?;
186    let namespace = &name_part[..slash_idx];
187    let name = &name_part[slash_idx + 1..];
188
189    if namespace.is_empty() || name.is_empty() {
190        return None;
191    }
192
193    Some(((namespace, name), version))
194}
195
196/// Build a Composer PURL from components.
197#[cfg(feature = "composer")]
198pub fn build_composer_purl(namespace: &str, name: &str, version: &str) -> String {
199    format!("pkg:composer/{namespace}/{name}@{version}")
200}
201
202/// Check if a PURL is a NuGet/.NET package.
203#[cfg(feature = "nuget")]
204pub fn is_nuget_purl(purl: &str) -> bool {
205    purl.starts_with("pkg:nuget/")
206}
207
208/// Parse a NuGet PURL to extract name and version.
209///
210/// e.g., `"pkg:nuget/Newtonsoft.Json@13.0.3"` -> `Some(("Newtonsoft.Json", "13.0.3"))`
211#[cfg(feature = "nuget")]
212pub fn parse_nuget_purl(purl: &str) -> Option<(&str, &str)> {
213    let base = strip_purl_qualifiers(purl);
214    let rest = base.strip_prefix("pkg:nuget/")?;
215    let at_idx = rest.rfind('@')?;
216    let name = &rest[..at_idx];
217    let version = &rest[at_idx + 1..];
218    if name.is_empty() || version.is_empty() {
219        return None;
220    }
221    Some((name, version))
222}
223
224/// Build a NuGet PURL from components.
225#[cfg(feature = "nuget")]
226pub fn build_nuget_purl(name: &str, version: &str) -> String {
227    format!("pkg:nuget/{name}@{version}")
228}
229
230/// Check if a PURL is a Cargo/Rust crate.
231#[cfg(feature = "cargo")]
232pub fn is_cargo_purl(purl: &str) -> bool {
233    purl.starts_with("pkg:cargo/")
234}
235
236/// Parse a Cargo PURL to extract name and version.
237///
238/// e.g., `"pkg:cargo/serde@1.0.200"` -> `Some(("serde", "1.0.200"))`
239#[cfg(feature = "cargo")]
240pub fn parse_cargo_purl(purl: &str) -> Option<(&str, &str)> {
241    let base = strip_purl_qualifiers(purl);
242    let rest = base.strip_prefix("pkg:cargo/")?;
243    let at_idx = rest.rfind('@')?;
244    let name = &rest[..at_idx];
245    let version = &rest[at_idx + 1..];
246    if name.is_empty() || version.is_empty() {
247        return None;
248    }
249    Some((name, version))
250}
251
252/// Build a Cargo PURL from components.
253#[cfg(feature = "cargo")]
254pub fn build_cargo_purl(name: &str, version: &str) -> String {
255    format!("pkg:cargo/{name}@{version}")
256}
257
258/// Parse a PURL into ecosystem, package directory path, and version.
259/// Supports npm, pypi, and (with `cargo` feature) cargo PURLs.
260pub fn parse_purl(purl: &str) -> Option<(&str, String, &str)> {
261    let base = strip_purl_qualifiers(purl);
262    if let Some(rest) = base.strip_prefix("pkg:npm/") {
263        let at_idx = rest.rfind('@')?;
264        let pkg_dir = &rest[..at_idx];
265        let version = &rest[at_idx + 1..];
266        if pkg_dir.is_empty() || version.is_empty() {
267            return None;
268        }
269        Some(("npm", pkg_dir.to_string(), version))
270    } else if let Some(rest) = base.strip_prefix("pkg:pypi/") {
271        let at_idx = rest.rfind('@')?;
272        let name = &rest[..at_idx];
273        let version = &rest[at_idx + 1..];
274        if name.is_empty() || version.is_empty() {
275            return None;
276        }
277        Some(("pypi", name.to_string(), version))
278    } else {
279        #[cfg(feature = "cargo")]
280        if let Some(rest) = base.strip_prefix("pkg:cargo/") {
281            let at_idx = rest.rfind('@')?;
282            let name = &rest[..at_idx];
283            let version = &rest[at_idx + 1..];
284            if name.is_empty() || version.is_empty() {
285                return None;
286            }
287            return Some(("cargo", name.to_string(), version));
288        }
289        #[cfg(feature = "golang")]
290        if let Some(rest) = base.strip_prefix("pkg:golang/") {
291            let at_idx = rest.rfind('@')?;
292            let module_path = &rest[..at_idx];
293            let version = &rest[at_idx + 1..];
294            if module_path.is_empty() || version.is_empty() {
295                return None;
296            }
297            return Some(("golang", module_path.to_string(), version));
298        }
299        #[cfg(feature = "gem")]
300        if let Some(rest) = base.strip_prefix("pkg:gem/") {
301            let at_idx = rest.rfind('@')?;
302            let name = &rest[..at_idx];
303            let version = &rest[at_idx + 1..];
304            if name.is_empty() || version.is_empty() {
305                return None;
306            }
307            return Some(("gem", name.to_string(), version));
308        }
309        #[cfg(feature = "maven")]
310        if let Some(rest) = base.strip_prefix("pkg:maven/") {
311            let at_idx = rest.rfind('@')?;
312            let name_part = &rest[..at_idx];
313            let version = &rest[at_idx + 1..];
314            if name_part.is_empty() || version.is_empty() {
315                return None;
316            }
317            return Some(("maven", name_part.to_string(), version));
318        }
319        #[cfg(feature = "composer")]
320        if let Some(rest) = base.strip_prefix("pkg:composer/") {
321            let at_idx = rest.rfind('@')?;
322            let name_part = &rest[..at_idx];
323            let version = &rest[at_idx + 1..];
324            if name_part.is_empty() || version.is_empty() {
325                return None;
326            }
327            return Some(("composer", name_part.to_string(), version));
328        }
329        #[cfg(feature = "nuget")]
330        if let Some(rest) = base.strip_prefix("pkg:nuget/") {
331            let at_idx = rest.rfind('@')?;
332            let name = &rest[..at_idx];
333            let version = &rest[at_idx + 1..];
334            if name.is_empty() || version.is_empty() {
335                return None;
336            }
337            return Some(("nuget", name.to_string(), version));
338        }
339        None
340    }
341}
342
343/// Check if a string looks like a PURL.
344pub fn is_purl(s: &str) -> bool {
345    s.starts_with("pkg:")
346}
347
348/// Build an npm PURL from components.
349pub fn build_npm_purl(namespace: Option<&str>, name: &str, version: &str) -> String {
350    match namespace {
351        Some(ns) => format!("pkg:npm/{}/{name}@{version}", ns),
352        None => format!("pkg:npm/{name}@{version}"),
353    }
354}
355
356/// Build a PyPI PURL from components.
357pub fn build_pypi_purl(name: &str, version: &str) -> String {
358    format!("pkg:pypi/{name}@{version}")
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_strip_qualifiers() {
367        assert_eq!(
368            strip_purl_qualifiers("pkg:pypi/requests@2.28.0?artifact_id=abc"),
369            "pkg:pypi/requests@2.28.0"
370        );
371        assert_eq!(
372            strip_purl_qualifiers("pkg:npm/lodash@4.17.21"),
373            "pkg:npm/lodash@4.17.21"
374        );
375    }
376
377    #[test]
378    fn test_is_pypi_purl() {
379        assert!(is_pypi_purl("pkg:pypi/requests@2.28.0"));
380        assert!(!is_pypi_purl("pkg:npm/lodash@4.17.21"));
381    }
382
383    #[test]
384    fn test_is_npm_purl() {
385        assert!(is_npm_purl("pkg:npm/lodash@4.17.21"));
386        assert!(!is_npm_purl("pkg:pypi/requests@2.28.0"));
387    }
388
389    #[test]
390    fn test_parse_pypi_purl() {
391        assert_eq!(
392            parse_pypi_purl("pkg:pypi/requests@2.28.0"),
393            Some(("requests", "2.28.0"))
394        );
395        assert_eq!(
396            parse_pypi_purl("pkg:pypi/requests@2.28.0?artifact_id=abc"),
397            Some(("requests", "2.28.0"))
398        );
399        assert_eq!(parse_pypi_purl("pkg:npm/lodash@4.17.21"), None);
400        assert_eq!(parse_pypi_purl("pkg:pypi/@2.28.0"), None);
401        assert_eq!(parse_pypi_purl("pkg:pypi/requests@"), None);
402    }
403
404    #[test]
405    fn test_parse_npm_purl() {
406        assert_eq!(
407            parse_npm_purl("pkg:npm/lodash@4.17.21"),
408            Some((None, "lodash", "4.17.21"))
409        );
410        assert_eq!(
411            parse_npm_purl("pkg:npm/@types/node@20.0.0"),
412            Some((Some("@types"), "node", "20.0.0"))
413        );
414        assert_eq!(parse_npm_purl("pkg:pypi/requests@2.28.0"), None);
415    }
416
417    #[test]
418    fn test_parse_purl() {
419        let (eco, dir, ver) = parse_purl("pkg:npm/lodash@4.17.21").unwrap();
420        assert_eq!(eco, "npm");
421        assert_eq!(dir, "lodash");
422        assert_eq!(ver, "4.17.21");
423
424        let (eco, dir, ver) = parse_purl("pkg:npm/@types/node@20.0.0").unwrap();
425        assert_eq!(eco, "npm");
426        assert_eq!(dir, "@types/node");
427        assert_eq!(ver, "20.0.0");
428
429        let (eco, dir, ver) = parse_purl("pkg:pypi/requests@2.28.0").unwrap();
430        assert_eq!(eco, "pypi");
431        assert_eq!(dir, "requests");
432        assert_eq!(ver, "2.28.0");
433    }
434
435    #[test]
436    fn test_is_purl() {
437        assert!(is_purl("pkg:npm/lodash@4.17.21"));
438        assert!(is_purl("pkg:pypi/requests@2.28.0"));
439        assert!(!is_purl("lodash"));
440        assert!(!is_purl("CVE-2024-1234"));
441    }
442
443    #[test]
444    fn test_build_npm_purl() {
445        assert_eq!(
446            build_npm_purl(None, "lodash", "4.17.21"),
447            "pkg:npm/lodash@4.17.21"
448        );
449        assert_eq!(
450            build_npm_purl(Some("@types"), "node", "20.0.0"),
451            "pkg:npm/@types/node@20.0.0"
452        );
453    }
454
455    #[test]
456    fn test_build_pypi_purl() {
457        assert_eq!(
458            build_pypi_purl("requests", "2.28.0"),
459            "pkg:pypi/requests@2.28.0"
460        );
461    }
462
463    #[cfg(feature = "cargo")]
464    #[test]
465    fn test_is_cargo_purl() {
466        assert!(is_cargo_purl("pkg:cargo/serde@1.0.200"));
467        assert!(!is_cargo_purl("pkg:npm/lodash@4.17.21"));
468        assert!(!is_cargo_purl("pkg:pypi/requests@2.28.0"));
469    }
470
471    #[cfg(feature = "cargo")]
472    #[test]
473    fn test_parse_cargo_purl() {
474        assert_eq!(
475            parse_cargo_purl("pkg:cargo/serde@1.0.200"),
476            Some(("serde", "1.0.200"))
477        );
478        assert_eq!(
479            parse_cargo_purl("pkg:cargo/serde_json@1.0.120"),
480            Some(("serde_json", "1.0.120"))
481        );
482        assert_eq!(parse_cargo_purl("pkg:npm/lodash@4.17.21"), None);
483        assert_eq!(parse_cargo_purl("pkg:cargo/@1.0.0"), None);
484        assert_eq!(parse_cargo_purl("pkg:cargo/serde@"), None);
485    }
486
487    #[cfg(feature = "cargo")]
488    #[test]
489    fn test_build_cargo_purl() {
490        assert_eq!(
491            build_cargo_purl("serde", "1.0.200"),
492            "pkg:cargo/serde@1.0.200"
493        );
494    }
495
496    #[cfg(feature = "cargo")]
497    #[test]
498    fn test_cargo_purl_round_trip() {
499        let purl = build_cargo_purl("tokio", "1.38.0");
500        let (name, version) = parse_cargo_purl(&purl).unwrap();
501        assert_eq!(name, "tokio");
502        assert_eq!(version, "1.38.0");
503    }
504
505    #[cfg(feature = "cargo")]
506    #[test]
507    fn test_parse_purl_cargo() {
508        let (eco, dir, ver) = parse_purl("pkg:cargo/serde@1.0.200").unwrap();
509        assert_eq!(eco, "cargo");
510        assert_eq!(dir, "serde");
511        assert_eq!(ver, "1.0.200");
512    }
513
514    #[cfg(feature = "gem")]
515    #[test]
516    fn test_is_gem_purl() {
517        assert!(is_gem_purl("pkg:gem/rails@7.1.0"));
518        assert!(!is_gem_purl("pkg:npm/lodash@4.17.21"));
519        assert!(!is_gem_purl("pkg:pypi/requests@2.28.0"));
520    }
521
522    #[cfg(feature = "gem")]
523    #[test]
524    fn test_parse_gem_purl() {
525        assert_eq!(
526            parse_gem_purl("pkg:gem/rails@7.1.0"),
527            Some(("rails", "7.1.0"))
528        );
529        assert_eq!(
530            parse_gem_purl("pkg:gem/nokogiri@1.16.5"),
531            Some(("nokogiri", "1.16.5"))
532        );
533        assert_eq!(parse_gem_purl("pkg:npm/lodash@4.17.21"), None);
534        assert_eq!(parse_gem_purl("pkg:gem/@1.0.0"), None);
535        assert_eq!(parse_gem_purl("pkg:gem/rails@"), None);
536    }
537
538    #[cfg(feature = "gem")]
539    #[test]
540    fn test_build_gem_purl() {
541        assert_eq!(
542            build_gem_purl("rails", "7.1.0"),
543            "pkg:gem/rails@7.1.0"
544        );
545    }
546
547    #[cfg(feature = "gem")]
548    #[test]
549    fn test_gem_purl_round_trip() {
550        let purl = build_gem_purl("nokogiri", "1.16.5");
551        let (name, version) = parse_gem_purl(&purl).unwrap();
552        assert_eq!(name, "nokogiri");
553        assert_eq!(version, "1.16.5");
554    }
555
556    #[cfg(feature = "gem")]
557    #[test]
558    fn test_parse_purl_gem() {
559        let (eco, dir, ver) = parse_purl("pkg:gem/rails@7.1.0").unwrap();
560        assert_eq!(eco, "gem");
561        assert_eq!(dir, "rails");
562        assert_eq!(ver, "7.1.0");
563    }
564
565    #[cfg(feature = "maven")]
566    #[test]
567    fn test_is_maven_purl() {
568        assert!(is_maven_purl("pkg:maven/org.apache.commons/commons-lang3@3.12.0"));
569        assert!(!is_maven_purl("pkg:npm/lodash@4.17.21"));
570        assert!(!is_maven_purl("pkg:pypi/requests@2.28.0"));
571    }
572
573    #[cfg(feature = "maven")]
574    #[test]
575    fn test_parse_maven_purl() {
576        assert_eq!(
577            parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@3.12.0"),
578            Some(("org.apache.commons", "commons-lang3", "3.12.0"))
579        );
580        assert_eq!(
581            parse_maven_purl("pkg:maven/com.google.guava/guava@32.1.3-jre"),
582            Some(("com.google.guava", "guava", "32.1.3-jre"))
583        );
584        assert_eq!(parse_maven_purl("pkg:npm/lodash@4.17.21"), None);
585        assert_eq!(parse_maven_purl("pkg:maven/@3.12.0"), None);
586        assert_eq!(parse_maven_purl("pkg:maven/org.apache.commons/@3.12.0"), None);
587        assert_eq!(parse_maven_purl("pkg:maven/org.apache.commons/commons-lang3@"), None);
588    }
589
590    #[cfg(feature = "maven")]
591    #[test]
592    fn test_build_maven_purl() {
593        assert_eq!(
594            build_maven_purl("org.apache.commons", "commons-lang3", "3.12.0"),
595            "pkg:maven/org.apache.commons/commons-lang3@3.12.0"
596        );
597    }
598
599    #[cfg(feature = "maven")]
600    #[test]
601    fn test_maven_purl_round_trip() {
602        let purl = build_maven_purl("com.google.guava", "guava", "32.1.3-jre");
603        let (group_id, artifact_id, version) = parse_maven_purl(&purl).unwrap();
604        assert_eq!(group_id, "com.google.guava");
605        assert_eq!(artifact_id, "guava");
606        assert_eq!(version, "32.1.3-jre");
607    }
608
609    #[cfg(feature = "maven")]
610    #[test]
611    fn test_parse_purl_maven() {
612        let (eco, dir, ver) = parse_purl("pkg:maven/org.apache.commons/commons-lang3@3.12.0").unwrap();
613        assert_eq!(eco, "maven");
614        assert_eq!(dir, "org.apache.commons/commons-lang3");
615        assert_eq!(ver, "3.12.0");
616    }
617
618    #[cfg(feature = "golang")]
619    #[test]
620    fn test_is_golang_purl() {
621        assert!(is_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1"));
622        assert!(!is_golang_purl("pkg:npm/lodash@4.17.21"));
623        assert!(!is_golang_purl("pkg:pypi/requests@2.28.0"));
624    }
625
626    #[cfg(feature = "golang")]
627    #[test]
628    fn test_parse_golang_purl() {
629        assert_eq!(
630            parse_golang_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1"),
631            Some(("github.com/gin-gonic/gin", "v1.9.1"))
632        );
633        assert_eq!(
634            parse_golang_purl("pkg:golang/golang.org/x/text@v0.14.0"),
635            Some(("golang.org/x/text", "v0.14.0"))
636        );
637        assert_eq!(parse_golang_purl("pkg:npm/lodash@4.17.21"), None);
638        assert_eq!(parse_golang_purl("pkg:golang/@v1.0.0"), None);
639        assert_eq!(parse_golang_purl("pkg:golang/github.com/foo/bar@"), None);
640    }
641
642    #[cfg(feature = "golang")]
643    #[test]
644    fn test_build_golang_purl() {
645        assert_eq!(
646            build_golang_purl("github.com/gin-gonic/gin", "v1.9.1"),
647            "pkg:golang/github.com/gin-gonic/gin@v1.9.1"
648        );
649    }
650
651    #[cfg(feature = "golang")]
652    #[test]
653    fn test_golang_purl_round_trip() {
654        let purl = build_golang_purl("golang.org/x/text", "v0.14.0");
655        let (module_path, version) = parse_golang_purl(&purl).unwrap();
656        assert_eq!(module_path, "golang.org/x/text");
657        assert_eq!(version, "v0.14.0");
658    }
659
660    #[cfg(feature = "golang")]
661    #[test]
662    fn test_parse_purl_golang() {
663        let (eco, dir, ver) = parse_purl("pkg:golang/github.com/gin-gonic/gin@v1.9.1").unwrap();
664        assert_eq!(eco, "golang");
665        assert_eq!(dir, "github.com/gin-gonic/gin");
666        assert_eq!(ver, "v1.9.1");
667    }
668
669    #[cfg(feature = "composer")]
670    #[test]
671    fn test_is_composer_purl() {
672        assert!(is_composer_purl("pkg:composer/monolog/monolog@3.5.0"));
673        assert!(!is_composer_purl("pkg:npm/lodash@4.17.21"));
674        assert!(!is_composer_purl("pkg:pypi/requests@2.28.0"));
675    }
676
677    #[cfg(feature = "composer")]
678    #[test]
679    fn test_parse_composer_purl() {
680        assert_eq!(
681            parse_composer_purl("pkg:composer/monolog/monolog@3.5.0"),
682            Some((("monolog", "monolog"), "3.5.0"))
683        );
684        assert_eq!(
685            parse_composer_purl("pkg:composer/symfony/console@6.4.1"),
686            Some((("symfony", "console"), "6.4.1"))
687        );
688        assert_eq!(parse_composer_purl("pkg:npm/lodash@4.17.21"), None);
689        assert_eq!(parse_composer_purl("pkg:composer/@3.5.0"), None);
690        assert_eq!(parse_composer_purl("pkg:composer/monolog/@3.5.0"), None);
691        assert_eq!(parse_composer_purl("pkg:composer/monolog/monolog@"), None);
692    }
693
694    #[cfg(feature = "composer")]
695    #[test]
696    fn test_build_composer_purl() {
697        assert_eq!(
698            build_composer_purl("monolog", "monolog", "3.5.0"),
699            "pkg:composer/monolog/monolog@3.5.0"
700        );
701    }
702
703    #[cfg(feature = "composer")]
704    #[test]
705    fn test_composer_purl_round_trip() {
706        let purl = build_composer_purl("symfony", "console", "6.4.1");
707        let ((namespace, name), version) = parse_composer_purl(&purl).unwrap();
708        assert_eq!(namespace, "symfony");
709        assert_eq!(name, "console");
710        assert_eq!(version, "6.4.1");
711    }
712
713    #[cfg(feature = "composer")]
714    #[test]
715    fn test_parse_purl_composer() {
716        let (eco, dir, ver) = parse_purl("pkg:composer/monolog/monolog@3.5.0").unwrap();
717        assert_eq!(eco, "composer");
718        assert_eq!(dir, "monolog/monolog");
719        assert_eq!(ver, "3.5.0");
720    }
721
722    #[cfg(feature = "nuget")]
723    #[test]
724    fn test_is_nuget_purl() {
725        assert!(is_nuget_purl("pkg:nuget/Newtonsoft.Json@13.0.3"));
726        assert!(!is_nuget_purl("pkg:npm/lodash@4.17.21"));
727        assert!(!is_nuget_purl("pkg:pypi/requests@2.28.0"));
728    }
729
730    #[cfg(feature = "nuget")]
731    #[test]
732    fn test_parse_nuget_purl() {
733        assert_eq!(
734            parse_nuget_purl("pkg:nuget/Newtonsoft.Json@13.0.3"),
735            Some(("Newtonsoft.Json", "13.0.3"))
736        );
737        assert_eq!(
738            parse_nuget_purl("pkg:nuget/System.Text.Json@8.0.0"),
739            Some(("System.Text.Json", "8.0.0"))
740        );
741        assert_eq!(parse_nuget_purl("pkg:npm/lodash@4.17.21"), None);
742        assert_eq!(parse_nuget_purl("pkg:nuget/@1.0.0"), None);
743        assert_eq!(parse_nuget_purl("pkg:nuget/Newtonsoft.Json@"), None);
744    }
745
746    #[cfg(feature = "nuget")]
747    #[test]
748    fn test_build_nuget_purl() {
749        assert_eq!(
750            build_nuget_purl("Newtonsoft.Json", "13.0.3"),
751            "pkg:nuget/Newtonsoft.Json@13.0.3"
752        );
753    }
754
755    #[cfg(feature = "nuget")]
756    #[test]
757    fn test_nuget_purl_round_trip() {
758        let purl = build_nuget_purl("System.Text.Json", "8.0.0");
759        let (name, version) = parse_nuget_purl(&purl).unwrap();
760        assert_eq!(name, "System.Text.Json");
761        assert_eq!(version, "8.0.0");
762    }
763
764    #[cfg(feature = "nuget")]
765    #[test]
766    fn test_parse_purl_nuget() {
767        let (eco, dir, ver) = parse_purl("pkg:nuget/Newtonsoft.Json@13.0.3").unwrap();
768        assert_eq!(eco, "nuget");
769        assert_eq!(dir, "Newtonsoft.Json");
770        assert_eq!(ver, "13.0.3");
771    }
772}