use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub(crate) struct DirectOverrideRule {
pub name: String,
pub version_req: Option<String>,
pub replacement: String,
}
pub(crate) fn compile(raw: &BTreeMap<String, String>) -> Vec<DirectOverrideRule> {
let mut rules: Vec<DirectOverrideRule> = raw
.iter()
.filter_map(|(k, v)| {
parse_key(k).map(|(n, r)| DirectOverrideRule {
name: n,
version_req: r,
replacement: v.clone(),
})
})
.collect();
rules.sort_by_key(|r| r.version_req.is_none());
rules
}
pub(crate) fn apply<'a>(
rules: &'a [DirectOverrideRule],
name: &str,
spec: &str,
) -> Option<&'a str> {
rules.iter().find_map(|rule| {
if rule.name != name {
return None;
}
match rule.version_req.as_deref() {
None => Some(rule.replacement.as_str()),
Some(req) if range_could_satisfy(spec, req) => Some(rule.replacement.as_str()),
_ => None,
}
})
}
fn parse_key(key: &str) -> Option<(String, Option<String>)> {
if key.is_empty() {
return None;
}
let segments = split_segments(key)?;
if segments.len() != 1 {
return None;
}
parse_segment(segments[0])
}
fn split_segments(key: &str) -> Option<Vec<&str>> {
if key.contains('>') {
let bytes = key.as_bytes();
let mut parts: Vec<&str> = Vec::new();
let mut start = 0;
let mut i = 0;
let mut in_req = false;
while i < bytes.len() {
let c = bytes[i];
if c == b'@' && !in_req && i != start {
in_req = true;
} else if c == b'>' {
if in_req {
let comparator_cont = bytes
.get(i + 1)
.is_some_and(|&n| matches!(n, b'=' | b' ' | b'v') || n.is_ascii_digit());
if comparator_cont {
i += 1;
continue;
}
}
if start == i {
return None;
}
parts.push(&key[start..i]);
start = i + 1;
in_req = false;
}
i += 1;
}
if start >= bytes.len() {
return None;
}
parts.push(&key[start..]);
return Some(parts);
}
let bytes = key.as_bytes();
let mut out: Vec<&str> = Vec::new();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'/' {
let current = &key[start..i];
let scope = current.starts_with('@') && !current[1..].contains('/');
if !scope {
if current.is_empty() {
return None;
}
out.push(current);
start = i + 1;
}
}
i += 1;
}
let tail = &key[start..];
if tail.is_empty() {
return None;
}
out.push(tail);
Some(out)
}
fn parse_segment(seg: &str) -> Option<(String, Option<String>)> {
if seg == "**" {
return None;
}
if let Some(after_at) = seg.strip_prefix('@') {
let slash = after_at.find('/')?;
let rest = &after_at[slash + 1..];
if rest.is_empty() {
return None;
}
if let Some(at) = rest.find('@') {
let pkg_tail = &rest[..at];
let req = &rest[at + 1..];
if pkg_tail.is_empty() || req.is_empty() {
return None;
}
Some((
format!("@{}/{}", &after_at[..slash], pkg_tail),
Some(req.to_string()),
))
} else {
Some((format!("@{after_at}"), None))
}
} else if let Some(at) = seg.find('@') {
if at == 0 {
return None;
}
let name = &seg[..at];
let req = &seg[at + 1..];
if name.is_empty() || req.is_empty() {
return None;
}
Some((name.to_string(), Some(req.to_string())))
} else {
Some((seg.to_string(), None))
}
}
fn range_could_satisfy(task_range: &str, req: &str) -> bool {
let Ok(r) = node_semver::Range::parse(req) else {
return true;
};
if let Ok(v) = node_semver::Version::parse(task_range)
&& v.satisfies(&r)
{
return true;
}
let trimmed = task_range.trim();
let exclusive = trimmed.starts_with('>') && !trimmed.starts_with(">=");
if let Some(candidate) = lower_bound_version(trimmed)
&& let Ok(mut v) = node_semver::Version::parse(&candidate)
{
if exclusive {
v.patch += 1;
}
return v.satisfies(&r);
}
true
}
fn lower_bound_version(range: &str) -> Option<String> {
let s = range
.trim()
.trim_start_matches(['^', '~', '=', '>', 'v', ' ']);
let end = s.find([' ', ',', '<', '|', '>']).unwrap_or(s.len());
let v = &s[..end];
if v.is_empty() || !v.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return None;
}
Some(v.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn bare_name_matches_any_spec() {
let rules = compile(&map(&[("lodash", "4.17.21")]));
assert_eq!(apply(&rules, "lodash", "^4.17.0"), Some("4.17.21"));
assert_eq!(apply(&rules, "lodash", "*"), Some("4.17.21"));
assert_eq!(apply(&rules, "other", "^1"), None);
}
#[test]
fn scoped_bare_name() {
let rules = compile(&map(&[("@babel/core", "7.20.0")]));
assert_eq!(apply(&rules, "@babel/core", "^7"), Some("7.20.0"));
}
#[test]
fn version_qualified_filters_by_range() {
let rules = compile(&map(&[("plist@<3.0.5", ">=3.0.5")]));
assert_eq!(apply(&rules, "plist", "^3.0.4"), Some(">=3.0.5"));
assert_eq!(apply(&rules, "plist", "^4.0.0"), None);
}
#[test]
fn scoped_with_range() {
let rules = compile(&map(&[("@scope/pkg@^1", "1.5.0")]));
assert_eq!(apply(&rules, "@scope/pkg", "^1.0.0"), Some("1.5.0"));
assert_eq!(apply(&rules, "@scope/pkg", "^2.0.0"), None);
}
#[test]
fn parent_chain_keys_dropped() {
let rules = compile(&map(&[
("foo>bar", "1.0.0"),
("**/foo", "1.0.0"),
("parent/foo", "1.0.0"),
]));
assert!(rules.is_empty());
}
#[test]
fn empty_or_malformed_keys_dropped() {
let rules = compile(&map(&[
("", "1"),
("@scope", "1"),
("foo@", "1"),
("@", "1"),
]));
assert!(rules.is_empty());
}
#[test]
fn version_keyed_rule_wins_over_bare_when_both_match() {
let rules = compile(&map(&[("plist", "9.9.9"), ("plist@<3", "2.0.0")]));
assert_eq!(apply(&rules, "plist", "^2.0.0"), Some("2.0.0"));
assert_eq!(apply(&rules, "plist", "^4.0.0"), Some("9.9.9"));
}
#[test]
fn key_with_gte_comparator_parses() {
let rules = compile(&map(&[("lodash@>=4.17.21", "4.18.0")]));
assert_eq!(apply(&rules, "lodash", "4.17.21"), Some("4.18.0"));
assert_eq!(apply(&rules, "lodash", "4.0.0"), None);
}
#[test]
fn key_with_gt_comparator_parses() {
let rules = compile(&map(&[("lodash@>1.0.0", "1.5.0")]));
assert_eq!(apply(&rules, "lodash", "1.2.0"), Some("1.5.0"));
}
#[test]
fn exclusive_gt_spec_against_lt_req_does_not_overlap() {
assert!(!range_could_satisfy(">3.0.5", "<3.0.5"));
assert!(range_could_satisfy(">3.0.5", ">=3.0.5"));
}
}