Skip to main content

normalize_manifest/
pipfile.rs

1//! Parser for `Pipfile` files (Pipenv).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use toml::Value;
5
6/// Parser for `Pipfile` files (Pipenv).
7pub struct PipfileParser;
8
9impl ManifestParser for PipfileParser {
10    fn filename(&self) -> &'static str {
11        "Pipfile"
12    }
13
14    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
15        let toml: Value = content
16            .parse::<Value>()
17            .map_err(|e| ManifestError(e.to_string()))?;
18
19        let mut deps = Vec::new();
20
21        parse_section(&toml, "packages", DepKind::Normal, &mut deps);
22        parse_section(&toml, "dev-packages", DepKind::Dev, &mut deps);
23
24        Ok(ParsedManifest {
25            ecosystem: "pip",
26            name: None,
27            version: None,
28            dependencies: deps,
29        })
30    }
31}
32
33/// Parse a `[packages]` or `[dev-packages]` section into `deps`.
34fn parse_section(toml: &Value, section: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
35    let Some(table) = toml.get(section).and_then(|v| v.as_table()) else {
36        return;
37    };
38
39    for (name, val) in table {
40        let version_req = dep_version_req(val);
41        deps.push(DeclaredDep {
42            name: name.clone(),
43            version_req,
44            kind,
45        });
46    }
47}
48
49/// Extract the version requirement from a Pipfile dependency value.
50///
51/// - `"*"` → `None`
52/// - `">=2.0"` (any other string) → `Some(">=2.0")`
53/// - `{version = ">=2.0", ...}` → `Some(">=2.0")`, or `None` if `"*"`
54/// - `{git = "...", ...}` (VCS/path table without `version`) → `None`
55fn dep_version_req(val: &Value) -> Option<String> {
56    match val {
57        Value::String(s) => version_string(s),
58        Value::Table(t) => t
59            .get("version")
60            .and_then(|v| v.as_str())
61            .and_then(version_string),
62        _ => None,
63    }
64}
65
66/// Convert a version string to `Option<String>`, treating `"*"` as `None`.
67fn version_string(s: &str) -> Option<String> {
68    if s == "*" { None } else { Some(s.to_string()) }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::ManifestParser;
75
76    const SAMPLE: &str = r#"
77[[source]]
78url = "https://pypi.org/simple"
79verify_ssl = true
80name = "pypi"
81
82[packages]
83requests = "*"
84flask = ">=2.0"
85click = {version = ">=8.0", extras = ["dev"]}
86django = {version = "*", markers = "python_version >= '3.8'"}
87sqlalchemy = {git = "https://github.com/sqlalchemy/sqlalchemy.git", ref = "main"}
88
89[dev-packages]
90pytest = ">=7.0"
91black = "*"
92mypy = {version = ">=1.0"}
93
94[requires]
95python_version = "3.11"
96"#;
97
98    #[test]
99    fn test_ecosystem_and_no_name() {
100        let m = PipfileParser.parse(SAMPLE).unwrap();
101        assert_eq!(m.ecosystem, "pip");
102        assert!(m.name.is_none());
103        assert!(m.version.is_none());
104    }
105
106    #[test]
107    fn test_normal_deps() {
108        let m = PipfileParser.parse(SAMPLE).unwrap();
109        let normal: Vec<_> = m
110            .dependencies
111            .iter()
112            .filter(|d| d.kind == DepKind::Normal)
113            .collect();
114        assert_eq!(normal.len(), 5);
115
116        // "*" → no version
117        let requests = normal.iter().find(|d| d.name == "requests").unwrap();
118        assert!(requests.version_req.is_none());
119
120        // plain version string
121        let flask = normal.iter().find(|d| d.name == "flask").unwrap();
122        assert_eq!(flask.version_req.as_deref(), Some(">=2.0"));
123
124        // inline table with version
125        let click = normal.iter().find(|d| d.name == "click").unwrap();
126        assert_eq!(click.version_req.as_deref(), Some(">=8.0"));
127
128        // inline table with version = "*" → no version
129        let django = normal.iter().find(|d| d.name == "django").unwrap();
130        assert!(django.version_req.is_none());
131
132        // VCS table without version key → no version
133        let sqlalchemy = normal.iter().find(|d| d.name == "sqlalchemy").unwrap();
134        assert!(sqlalchemy.version_req.is_none());
135    }
136
137    #[test]
138    fn test_dev_deps() {
139        let m = PipfileParser.parse(SAMPLE).unwrap();
140        let dev: Vec<_> = m
141            .dependencies
142            .iter()
143            .filter(|d| d.kind == DepKind::Dev)
144            .collect();
145        assert_eq!(dev.len(), 3);
146
147        let pytest = dev.iter().find(|d| d.name == "pytest").unwrap();
148        assert_eq!(pytest.version_req.as_deref(), Some(">=7.0"));
149
150        let black = dev.iter().find(|d| d.name == "black").unwrap();
151        assert!(black.version_req.is_none());
152
153        let mypy = dev.iter().find(|d| d.name == "mypy").unwrap();
154        assert_eq!(mypy.version_req.as_deref(), Some(">=1.0"));
155    }
156
157    #[test]
158    fn test_empty_pipfile() {
159        let m = PipfileParser.parse("").unwrap();
160        assert!(m.dependencies.is_empty());
161    }
162
163    #[test]
164    fn test_no_dev_section() {
165        let content = r#"
166[packages]
167requests = ">=2.28"
168"#;
169        let m = PipfileParser.parse(content).unwrap();
170        assert_eq!(m.dependencies.len(), 1);
171        assert_eq!(m.dependencies[0].kind, DepKind::Normal);
172        assert_eq!(m.dependencies[0].version_req.as_deref(), Some(">=2.28"));
173    }
174}