use std::path::Path;
use crate::error::CoreError;
use crate::model::{Ecosystem, ParsedLockfile};
mod cargo;
mod composer;
mod gosum;
mod maven;
mod npm;
mod pnpm;
mod poetry;
mod requirements;
mod yarn;
use cargo::CargoLockParser;
use composer::ComposerParser;
use gosum::GoSumParser;
use maven::MavenParser;
use npm::NpmPackageLockParser;
use pnpm::PnpmParser;
use poetry::PoetryParser;
use requirements::RequirementsParser;
use yarn::YarnParser;
pub trait LockfileParser: Send + Sync {
fn ecosystem(&self) -> Ecosystem;
fn matches(&self, path: &Path) -> bool;
fn parse(&self, path: &Path, content: &str) -> Result<ParsedLockfile, CoreError>;
}
const NPM_PACKAGE_LOCK: NpmPackageLockParser = NpmPackageLockParser;
const PNPM_LOCK: PnpmParser = PnpmParser;
const YARN_LOCK: YarnParser = YarnParser;
const POETRY_LOCK: PoetryParser = PoetryParser;
const REQUIREMENTS: RequirementsParser = RequirementsParser;
const COMPOSER_LOCK: ComposerParser = ComposerParser;
const GO_SUM: GoSumParser = GoSumParser;
const POM_XML: MavenParser = MavenParser;
const CARGO_LOCK: CargoLockParser = CargoLockParser;
static PARSERS: &[&dyn LockfileParser] = &[
&NPM_PACKAGE_LOCK,
&PNPM_LOCK,
&YARN_LOCK,
&POETRY_LOCK,
&REQUIREMENTS,
&COMPOSER_LOCK,
&GO_SUM,
&POM_XML,
&CARGO_LOCK,
];
pub fn parser_for(path: &Path) -> Option<&'static dyn LockfileParser> {
PARSERS.iter().copied().find(|parser| parser.matches(path))
}
pub fn supported_file_names() -> Vec<&'static str> {
vec![
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"poetry.lock",
"requirements.txt",
"composer.lock",
"go.sum",
"pom.xml",
"Cargo.lock",
]
}
pub fn normalize_pypi_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
let mut last_was_dash = false;
for ch in name.chars() {
if ch == '-' || ch == '_' || ch == '.' {
if !last_was_dash {
normalized.push('-');
last_was_dash = true;
}
} else {
normalized.push(ch.to_ascii_lowercase());
last_was_dash = false;
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn nine_lockfile_names_map_to_osv_ecosystems() {
let cases = [
("package-lock.json", Ecosystem::Npm, "npm"),
("pnpm-lock.yaml", Ecosystem::Npm, "npm"),
("yarn.lock", Ecosystem::Npm, "npm"),
("poetry.lock", Ecosystem::Pypi, "PyPI"),
("requirements.txt", Ecosystem::Pypi, "PyPI"),
("composer.lock", Ecosystem::Packagist, "Packagist"),
("go.sum", Ecosystem::Go, "Go"),
("pom.xml", Ecosystem::Maven, "Maven"),
("Cargo.lock", Ecosystem::CratesIo, "crates.io"),
];
assert_eq!(cases.len(), 9);
for (file_name, ecosystem, osv) in cases {
let path = PathBuf::from("services/api").join(file_name);
let parser =
parser_for(&path).unwrap_or_else(|| panic!("missing parser for {file_name}"));
assert_eq!(parser.ecosystem(), ecosystem, "{file_name}");
assert_eq!(parser.ecosystem().as_osv_str(), osv, "{file_name}");
assert!(parser.matches(Path::new(file_name)));
}
}
#[test]
fn file_name_match_ignores_ascii_case_and_rejects_unknown() {
let parser =
parser_for(Path::new("pkg/PACKAGE-LOCK.JSON")).expect("case-insensitive match");
assert_eq!(parser.ecosystem(), Ecosystem::Npm);
assert!(parser_for(Path::new("Cargo.toml")).is_none());
assert_eq!(
parser_for(Path::new("requirements-dev.txt")).map(|parser| parser.ecosystem()),
Some(Ecosystem::Pypi)
);
assert!(parser_for(Path::new("myrequirements.txt")).is_none());
assert!(parser_for(Path::new("requirements.txt.bak")).is_none());
assert!(parser_for(Path::new("package-lock.json.bak")).is_none());
}
#[test]
fn cargo_lock_parser_is_registered() {
let path = Path::new("does-not-exist/Cargo.lock");
let parsed = parser_for(path)
.expect("parser")
.parse(
path,
"version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n",
)
.expect("parse");
assert_eq!(parsed.packages.len(), 1);
assert_eq!(parsed.packages[0].name, "serde");
assert_eq!(parsed.packages[0].direct, None);
}
#[test]
fn pypi_names_follow_pep_503() {
let cases = [
("Requests", "requests"),
("Friendly_Bard", "friendly-bard"),
("Some..Pkg", "some-pkg"),
("A__B--C", "a-b-c"),
("foo.bar_baz", "foo-bar-baz"),
("already-normal", "already-normal"),
];
for (raw, expected) in cases {
assert_eq!(normalize_pypi_name(raw), expected, "{raw}");
}
}
}