Skip to main content

normalize_manifest/
nuget.rs

1//! Parsers for NuGet package manifests (.NET).
2//!
3//! - `packages.config` (legacy NuGet format)
4//! - `*.csproj` (SDK-style, `<PackageReference>` elements)
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `packages.config` files (legacy NuGet).
9///
10/// Format: `<package id="Name" version="1.0" />`
11pub struct PackagesConfigParser;
12
13impl ManifestParser for PackagesConfigParser {
14    fn filename(&self) -> &'static str {
15        "packages.config"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let doc = roxmltree::Document::parse(content).map_err(|e| ManifestError(e.to_string()))?;
20
21        let mut deps = Vec::new();
22
23        for node in doc.descendants().filter(|n| n.has_tag_name("package")) {
24            let id = node.attribute("id");
25            let version = node.attribute("version");
26            let dev_dep = node.attribute("developmentDependency");
27
28            if let Some(name) = id {
29                let kind = if dev_dep == Some("true") {
30                    DepKind::Dev
31                } else {
32                    DepKind::Normal
33                };
34                deps.push(DeclaredDep {
35                    name: name.to_string(),
36                    version_req: version.map(|v| v.to_string()),
37                    kind,
38                });
39            }
40        }
41
42        Ok(ParsedManifest {
43            ecosystem: "nuget",
44            name: None,
45            version: None,
46            dependencies: deps,
47        })
48    }
49}
50
51/// Parser for SDK-style `*.csproj` files (NuGet PackageReference).
52///
53/// Handles:
54/// - `<PackageReference Include="Name" Version="1.0" />`
55/// - `<PackageReference Include="Name"><Version>1.0</Version></PackageReference>`
56pub struct CsprojParser;
57
58impl CsprojParser {
59    /// Parse a `.csproj` file content directly (for extension-based dispatch).
60    pub fn parse_content(content: &str) -> Result<ParsedManifest, ManifestError> {
61        CsprojParser.parse(content)
62    }
63}
64
65impl ManifestParser for CsprojParser {
66    fn filename(&self) -> &'static str {
67        "*.csproj"
68    }
69
70    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
71        let doc = roxmltree::Document::parse(content).map_err(|e| ManifestError(e.to_string()))?;
72
73        // Project name/version from PropertyGroup
74        let name = doc
75            .descendants()
76            .find(|n| n.has_tag_name("AssemblyName"))
77            .and_then(|n| n.text())
78            .map(|s| s.trim().to_string());
79
80        let version = doc
81            .descendants()
82            .find(|n| n.has_tag_name("Version"))
83            .and_then(|n| n.text())
84            .map(|s| s.trim().to_string());
85
86        let mut deps = Vec::new();
87
88        for node in doc
89            .descendants()
90            .filter(|n| n.has_tag_name("PackageReference"))
91        {
92            let Some(pkg_name) = node
93                .attribute("Include")
94                .or_else(|| node.attribute("include"))
95            else {
96                continue;
97            };
98            let pkg_name = pkg_name.to_string();
99
100            // Version can be attribute or child element
101            let version_req = node
102                .attribute("Version")
103                .or_else(|| node.attribute("version"))
104                .map(|v| v.to_string())
105                .or_else(|| {
106                    node.children()
107                        .find(|n| n.has_tag_name("Version"))
108                        .and_then(|n| n.text())
109                        .map(|v| v.trim().to_string())
110                });
111
112            // PrivateAssets="all" typically marks dev/build tools
113            let private_assets = node
114                .attribute("PrivateAssets")
115                .or_else(|| node.attribute("privateAssets"));
116            let kind = if private_assets == Some("all") {
117                DepKind::Dev
118            } else {
119                DepKind::Normal
120            };
121
122            deps.push(DeclaredDep {
123                name: pkg_name,
124                version_req,
125                kind,
126            });
127        }
128
129        Ok(ParsedManifest {
130            ecosystem: "nuget",
131            name,
132            version,
133            dependencies: deps,
134        })
135    }
136}
137
138/// Parser for `Directory.Packages.props` files (.NET Central Package Management).
139///
140/// Extracts `<PackageVersion Include="Name" Version="..." />` elements.
141/// All entries are `Normal` — this file declares available versions, not scopes.
142pub struct DirectoryPackagesPropsParser;
143
144impl ManifestParser for DirectoryPackagesPropsParser {
145    fn filename(&self) -> &'static str {
146        "Directory.Packages.props"
147    }
148
149    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
150        let doc = roxmltree::Document::parse(content).map_err(|e| ManifestError(e.to_string()))?;
151
152        let mut deps = Vec::new();
153
154        for node in doc
155            .descendants()
156            .filter(|n| n.has_tag_name("PackageVersion"))
157        {
158            let pkg_name = node
159                .attribute("Include")
160                .or_else(|| node.attribute("include"));
161            let Some(pkg_name) = pkg_name else {
162                continue;
163            };
164
165            let version_req = node
166                .attribute("Version")
167                .or_else(|| node.attribute("version"))
168                .map(|v| v.to_string());
169
170            deps.push(DeclaredDep {
171                name: pkg_name.to_string(),
172                version_req,
173                kind: DepKind::Normal,
174            });
175        }
176
177        Ok(ParsedManifest {
178            ecosystem: "nuget",
179            name: None,
180            version: None,
181            dependencies: deps,
182        })
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::ManifestParser;
190
191    #[test]
192    fn test_packages_config() {
193        let content = r#"<?xml version="1.0" encoding="utf-8"?>
194<packages>
195  <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
196  <package id="NUnit" version="3.13.3" targetFramework="net48" />
197  <package id="StyleCop.Analyzers" version="1.1.118" developmentDependency="true" />
198</packages>"#;
199
200        let m = PackagesConfigParser.parse(content).unwrap();
201        assert_eq!(m.ecosystem, "nuget");
202        assert_eq!(m.dependencies.len(), 3);
203
204        let json = m
205            .dependencies
206            .iter()
207            .find(|d| d.name == "Newtonsoft.Json")
208            .unwrap();
209        assert_eq!(json.version_req.as_deref(), Some("13.0.3"));
210        assert_eq!(json.kind, DepKind::Normal);
211
212        let style = m
213            .dependencies
214            .iter()
215            .find(|d| d.name == "StyleCop.Analyzers")
216            .unwrap();
217        assert_eq!(style.kind, DepKind::Dev);
218    }
219
220    #[test]
221    fn test_csproj_package_reference() {
222        let content = r#"<Project Sdk="Microsoft.NET.Sdk">
223  <PropertyGroup>
224    <OutputType>Exe</OutputType>
225    <TargetFramework>net8.0</TargetFramework>
226    <AssemblyName>MyApp</AssemblyName>
227    <Version>2.0.0</Version>
228  </PropertyGroup>
229
230  <ItemGroup>
231    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
232    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
233    <PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
234  </ItemGroup>
235</Project>"#;
236
237        let m = CsprojParser.parse(content).unwrap();
238        assert_eq!(m.ecosystem, "nuget");
239        assert_eq!(m.name.as_deref(), Some("MyApp"));
240        assert_eq!(m.version.as_deref(), Some("2.0.0"));
241        assert_eq!(m.dependencies.len(), 3);
242
243        let efcore = m
244            .dependencies
245            .iter()
246            .find(|d| d.name == "Microsoft.EntityFrameworkCore")
247            .unwrap();
248        assert_eq!(efcore.version_req.as_deref(), Some("8.0.0"));
249        assert_eq!(efcore.kind, DepKind::Normal);
250
251        let coverlet = m
252            .dependencies
253            .iter()
254            .find(|d| d.name == "coverlet.collector")
255            .unwrap();
256        assert_eq!(coverlet.kind, DepKind::Dev);
257    }
258
259    #[test]
260    fn test_directory_packages_props() {
261        let content = r#"<Project>
262  <PropertyGroup>
263    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
264  </PropertyGroup>
265  <ItemGroup>
266    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
267    <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
268    <PackageVersion Include="coverlet.collector" Version="6.0.0" />
269  </ItemGroup>
270</Project>"#;
271
272        let m = DirectoryPackagesPropsParser.parse(content).unwrap();
273        assert_eq!(m.ecosystem, "nuget");
274        assert_eq!(m.dependencies.len(), 3);
275
276        let json_dep = m
277            .dependencies
278            .iter()
279            .find(|d| d.name == "Newtonsoft.Json")
280            .unwrap();
281        assert_eq!(json_dep.version_req.as_deref(), Some("13.0.3"));
282        assert_eq!(json_dep.kind, DepKind::Normal);
283
284        let efcore = m
285            .dependencies
286            .iter()
287            .find(|d| d.name == "Microsoft.EntityFrameworkCore")
288            .unwrap();
289        assert_eq!(efcore.version_req.as_deref(), Some("8.0.0"));
290        assert_eq!(efcore.kind, DepKind::Normal);
291
292        let coverlet = m
293            .dependencies
294            .iter()
295            .find(|d| d.name == "coverlet.collector")
296            .unwrap();
297        assert_eq!(coverlet.version_req.as_deref(), Some("6.0.0"));
298        assert_eq!(coverlet.kind, DepKind::Normal);
299    }
300}