use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepEntry {
pub name: String,
pub version: Option<String>,
pub section: String,
}
impl DepEntry {
#[must_use]
pub fn is_exact_pinned(&self) -> bool {
self.version
.as_ref()
.is_none_or(|v| is_exact_pin_specifier(v.trim()))
}
#[must_use]
pub fn is_wildcard(&self) -> bool {
matches!(self.version.as_deref().map(str::trim), Some("*" | "?"))
}
}
#[must_use]
pub fn is_exact_pin_specifier(s: &str) -> bool {
s.starts_with('=')
}
#[must_use]
pub fn read_manifest_deps(manifest_path: &Path) -> Vec<DepEntry> {
let Ok(content) = std::fs::read_to_string(manifest_path) else {
return Vec::new();
};
parse_manifest_deps(&content)
}
#[must_use]
pub fn parse_manifest_deps(content: &str) -> Vec<DepEntry> {
let mut entries = Vec::new();
let mut current_section: Option<String> = None;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(stripped) = line.strip_prefix('[') {
if let Some(header) = stripped.strip_suffix(']') {
current_section = section_kind(header).map(str::to_string);
continue;
}
}
let Some(section) = current_section.clone() else {
continue;
};
if let Some((name, value)) = split_kv(line) {
let version = extract_version(value);
entries.push(DepEntry {
name: name.to_string(),
version,
section: section.clone(),
});
}
}
entries
}
fn section_kind(header: &str) -> Option<&str> {
match header {
"dependencies" => Some("dependencies"),
"dev-dependencies" => Some("dev-dependencies"),
"build-dependencies" => Some("build-dependencies"),
_ => {
if header.starts_with("target.") && header.contains(".dependencies") {
Some("target-dependencies")
} else if header.starts_with("target.") && header.contains(".dev-dependencies") {
Some("target-dev-dependencies")
} else if header.starts_with("target.") && header.contains(".build-dependencies") {
Some("target-build-dependencies")
} else {
None
}
},
}
}
fn split_kv(line: &str) -> Option<(&str, &str)> {
let idx = line.find('=')?;
let key = line[..idx].trim();
let raw_value = line[idx + 1..].trim();
if key.contains('[') || key.contains(']') {
return None;
}
let value = strip_trailing_comment(raw_value);
Some((key, value))
}
fn strip_trailing_comment(s: &str) -> &str {
let mut in_quote = false;
let mut cut_at: Option<usize> = None;
for (i, ch) in s.char_indices() {
match ch {
'"' => in_quote = !in_quote,
'#' if !in_quote => {
cut_at = Some(i);
break;
},
_ => {},
}
}
cut_at.map_or(s, |i| s[..i].trim_end())
}
fn extract_version(value: &str) -> Option<String> {
if let Some(v) = strip_quotes(value) {
return Some(v.to_string());
}
let inner = value
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(value);
for part in inner.split(',') {
let part = part.trim();
if let Some(rest) = part
.strip_prefix("version")
.and_then(|s| s.trim_start().strip_prefix('='))
{
if let Some(v) = strip_quotes(rest.trim()) {
return Some(v.to_string());
}
}
}
None
}
fn strip_quotes(s: &str) -> Option<&str> {
let s = s.trim();
if let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
return Some(inner);
}
if let Some(inner) = s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
return Some(inner);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shorthand_caret_not_exact_pinned() {
let entries = parse_manifest_deps(
r#"
[dependencies]
serde = "1.0"
"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "serde");
assert_eq!(entries[0].version.as_deref(), Some("1.0"));
assert!(!entries[0].is_exact_pinned());
}
#[test]
fn shorthand_exact_pinned() {
let entries = parse_manifest_deps(
r#"
[dependencies]
serde = "=1.0.197"
"#,
);
assert_eq!(entries.len(), 1);
assert!(entries[0].is_exact_pinned());
}
#[test]
fn table_form_extracts_version() {
let entries = parse_manifest_deps(
r#"
[dependencies]
serde = { version = "=1.0.197", features = ["derive"] }
"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version.as_deref(), Some("=1.0.197"));
assert!(entries[0].is_exact_pinned());
}
#[test]
fn workspace_inheritance_treated_as_no_version() {
let entries = parse_manifest_deps(
r#"
[dependencies]
serde = { workspace = true, features = ["derive"] }
"#,
);
assert_eq!(entries.len(), 1);
assert!(entries[0].version.is_none());
assert!(entries[0].is_exact_pinned());
}
#[test]
fn wildcard_specifier_detected() {
let entries = parse_manifest_deps(
r#"
[dependencies]
loose-dep = "*"
"#,
);
assert!(entries[0].is_wildcard());
assert!(!entries[0].is_exact_pinned());
}
#[test]
fn dev_dependencies_section_picked_up() {
let entries = parse_manifest_deps(
r#"
[dependencies]
serde = "=1.0.197"
[dev-dependencies]
proptest = "1.0"
"#,
);
assert_eq!(entries.len(), 2);
assert_eq!(entries[1].section, "dev-dependencies");
assert_eq!(entries[1].name, "proptest");
}
#[test]
fn build_dependencies_section_picked_up() {
let entries = parse_manifest_deps(
r#"
[build-dependencies]
cc = "=1.0.79"
"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].section, "build-dependencies");
}
#[test]
fn target_cfg_dependencies_picked_up() {
let entries = parse_manifest_deps(
r#"
[target.'cfg(unix)'.dependencies]
libc = "=0.2.0"
"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].section, "target-dependencies");
}
#[test]
fn comments_and_blank_lines_skipped() {
let entries = parse_manifest_deps(
r#"
# This is the dep list
[dependencies]
serde = "=1.0.197" # workspace baseline
"#,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version.as_deref(), Some("=1.0.197"));
}
#[test]
fn non_dep_section_ignored() {
let entries = parse_manifest_deps(
r#"
[package]
name = "foo"
version = "0.1.0"
[lib]
name = "foo"
"#,
);
assert_eq!(entries.len(), 0);
}
}