1use spex::{
2 parsing::XmlReader,
3 xml::{Element, XmlDocument},
4};
5use std::{
6 io::{self, Read},
7 path::{Path, PathBuf},
8};
9use thiserror::Error;
10
11use crate::{PackageReference, Project, ProjectLanguage, ProjectReference};
12
13pub fn parse<R, P>(reader: R, path: P) -> Result<Project, ParseError>
50where
51 R: Read,
52 P: AsRef<Path>,
53{
54 let path = path.as_ref();
55 if path.is_dir() {
56 return Err(ParseError::PathIsNotAFile);
57 }
58
59 let language = path.extension().and_then(ProjectLanguage::from_extension);
60 if language.is_none() {
61 return Err(ParseError::FileIsNotAProject);
62 }
63
64 let name = Project::get_project_name(path).ok_or(ParseError::FileDoesNotHaveAName)?;
65
66 let mut project = Project {
67 name,
68 language: language.unwrap(),
69 path: path.to_owned(),
70 target_framework: None,
71 project_references: vec![],
72 package_references: vec![],
73 };
74
75 fill_project_based_on_xml(&mut project, XmlReader::parse_auto(reader)?)?;
76
77 Ok(project)
78}
79
80fn fill_project_based_on_xml(
81 project: &mut Project,
82 document: XmlDocument,
83) -> Result<(), ParseError> {
84 for element in document.root().elements() {
85 match element.name().local_part() {
86 "PropertyGroup" => handle_property_group(project, element)?,
87 "ItemGroup" => handle_item_group(project, element)?,
88 _ => (),
89 }
90 }
91
92 Ok(())
93}
94
95fn handle_property_group(project: &mut Project, element: &Element) -> Result<(), ParseError> {
96 if project.target_framework.is_some() {
99 return Ok(());
100 }
101
102 project.target_framework = element
103 .opt("TargetFramework")
104 .text()?
105 .map(|target| target.to_string());
106
107 Ok(())
108}
109
110fn handle_item_group(project: &mut Project, element: &Element) -> Result<(), ParseError> {
111 for item in element.elements() {
112 match item.name().local_part() {
113 "ProjectReference" => {
114 let attr_content = item
115 .att_req("Include")
116 .map_err(|_| ParseError::DeserializationError)?
117 .replace("\\", "/");
118
119 let path = PathBuf::from(attr_content);
120
121 let name =
122 Project::get_project_name(&path).ok_or(ParseError::FileDoesNotHaveAName)?;
123
124 project
125 .project_references
126 .push(ProjectReference::new(name, path));
127 }
128 "PackageReference" => {
129 let name = item
130 .att_req("Include")
131 .map_err(|_| ParseError::DeserializationError)?
132 .to_string();
133
134 let version = item
135 .att_req("Version")
136 .map_err(|_| ParseError::DeserializationError)?
137 .to_string();
138
139 project
140 .package_references
141 .push(PackageReference::new(name, version));
142 }
143 _ => (),
144 }
145 }
146
147 Ok(())
148}
149
150#[derive(Debug, Error)]
152pub enum ParseError {
153 #[error("there was an error while deserializing the file")]
155 DeserializationError,
156 #[error("there was an error while reading the file")]
158 IoError(#[from] io::Error),
159 #[error("the path is a directory")]
161 PathIsNotAFile,
162 #[error("the file is not a project (.csproj, .fsproj, .vbproj)")]
164 FileIsNotAProject,
165 #[error("the file does not have a name")]
167 FileDoesNotHaveAName,
168}
169
170impl From<spex::common::XmlError> for ParseError {
171 fn from(_: spex::common::XmlError) -> Self {
172 Self::DeserializationError
173 }
174}
175
176#[cfg(test)]
177mod test {
178 use std::path::PathBuf;
179
180 use io::Cursor;
181
182 use crate::PackageReference;
183
184 use super::*;
185
186 #[test]
187 pub fn parse_valid_csproj() {
188 let content = r#"
190<Project Sdk="Microsoft.NET.Sdk">
191
192 <PropertyGroup>
193 <OutputType>Exe</OutputType>
194 <TargetFramework>net8.0</TargetFramework>
195 <ImplicitUsings>enable</ImplicitUsings>
196 </PropertyGroup>
197
198 <PropertyGroup>
199 <Nullable>enable</Nullable>
200 </PropertyGroup>
201
202 <ItemGroup>
203 <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
204 </ItemGroup>
205
206 <ItemGroup>
207 <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
208 </ItemGroup>
209
210 <ItemGroup>
211 <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
212 </ItemGroup>
213
214</Project>
215"#;
216
217 let project_path: &Path = "./TestProject.csproj".as_ref();
218
219 let parsed_project = parse(Cursor::new(content), project_path).unwrap();
221
222 let expected_project = Project {
224 name: "TestProject".to_string(),
225 path: PathBuf::from(project_path),
226 language: ProjectLanguage::CSharp,
227 target_framework: Some("net8.0".to_string()),
228 project_references: vec![ProjectReference {
229 name: "FsharpConsole".to_string(),
230 path: PathBuf::from("../FsharpConsole/FsharpConsole.fsproj"),
231 }],
232 package_references: vec![
233 PackageReference {
234 name: "Microsoft.Extensions.Configuration".to_string(),
235 version: "8.0.0".to_string(),
236 },
237 PackageReference {
238 name: "Microsoft.Extensions.Hosting".to_string(),
239 version: "8.0.0".to_string(),
240 },
241 ],
242 };
243
244 assert_eq!(parsed_project, expected_project);
245 }
246
247 #[test]
248 pub fn parse_valid_fsproj() {
249 let content = r#"
251<Project Sdk="Microsoft.NET.Sdk">
252
253 <PropertyGroup>
254 <OutputType>Exe</OutputType>
255 <TargetFramework>net8.0</TargetFramework>
256 </PropertyGroup>
257
258 <ItemGroup>
259 <Compile Include="Program.fs" />
260 </ItemGroup>
261
262 <ItemGroup>
263 <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
264 </ItemGroup>
265
266 <ItemGroup>
267 <ProjectReference Include="..\VbConsole\VbConsole.vbproj" />
268 </ItemGroup>
269
270</Project>
271"#;
272
273 let project_path: &Path = "./TestProject.fsproj".as_ref();
274
275 let parsed_project = parse(Cursor::new(content), project_path).unwrap();
277
278 let expected_project = Project {
280 name: "TestProject".to_string(),
281 path: PathBuf::from(project_path),
282 language: ProjectLanguage::FSharp,
283 target_framework: Some("net8.0".to_string()),
284 project_references: vec![ProjectReference {
285 name: "VbConsole".to_string(),
286 path: PathBuf::from("../VbConsole/VbConsole.vbproj"),
287 }],
288 package_references: vec![PackageReference {
289 name: "Microsoft.Extensions.Configuration".to_string(),
290 version: "8.0.0".to_string(),
291 }],
292 };
293
294 assert_eq!(parsed_project, expected_project);
295 }
296
297 #[test]
298 pub fn parse_valid_vbproj() {
299 let content = r#"
301<Project Sdk="Microsoft.NET.Sdk">
302
303 <PropertyGroup>
304 <OutputType>Exe</OutputType>
305 <RootNamespace>VbConsole</RootNamespace>
306 <TargetFramework>net8.0</TargetFramework>
307 </PropertyGroup>
308
309 <ItemGroup>
310 <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
311 </ItemGroup>
312
313 <ItemGroup>
314 <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
315 </ItemGroup>
316
317</Project>
318"#;
319
320 let project_path: &Path = "./TestProject.vbproj".as_ref();
321
322 let parsed_project = parse(Cursor::new(content), project_path).unwrap();
324
325 let expected_project = Project {
327 name: "TestProject".to_string(),
328 path: PathBuf::from(project_path),
329 language: ProjectLanguage::VB,
330 target_framework: Some("net8.0".to_string()),
331 project_references: vec![ProjectReference {
332 name: "FsharpConsole".to_string(),
333 path: PathBuf::from("../FsharpConsole/FsharpConsole.fsproj"),
334 }],
335 package_references: vec![PackageReference {
336 name: "Microsoft.Extensions.Configuration".to_string(),
337 version: "8.0.0".to_string(),
338 }],
339 };
340
341 assert_eq!(parsed_project, expected_project);
342 }
343
344 #[test]
345 pub fn invalid_xml() {
346 let content = r#"
348<Project Sdk="Microsoft.NET.Sdk">
349
350 <PropertyGroup>
351 <OutputType>Exe</OutputType>
352
353 <ItemGroup>
354 <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
355 </ItemGroup>
356
357 <ItemGroup>
358 <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
359 </ItemGroup>
360
361</Project>
362"#;
363
364 let project_path: &Path = "./TestProject.vbproj".as_ref();
365
366 let parsed_project = parse(Cursor::new(content), project_path);
368
369 if let Err(error) = parsed_project {
371 assert!(matches!(error, ParseError::IoError(_)));
372
373 return;
374 }
375
376 unreachable!()
377 }
378
379 #[test]
380 pub fn missing_field() {
381 let content = r#"
383<Project Sdk="Microsoft.NET.Sdk">
384
385 <ItemGroup>
386 <PackageReference Include="Microsoft.Extensions.Configuration" />
387 </ItemGroup>
388
389 <ItemGroup>
390 <ProjectReference Include="..\FsharpConsole\FsharpConsole.fsproj" />
391 </ItemGroup>
392
393</Project>
394"#;
395
396 let project_path: &Path = "./TestProject.vbproj".as_ref();
397
398 let parsed_project = parse(Cursor::new(content), project_path);
400
401 if let Err(error) = parsed_project {
403 assert!(matches!(error, ParseError::DeserializationError));
404
405 return;
406 }
407
408 unreachable!()
409 }
410
411 #[test]
412 pub fn path_is_not_a_file() {
413 let content = "";
415
416 let project_path: &Path = "./".as_ref();
417
418 let parsed_project = parse(Cursor::new(content), project_path);
420
421 if let Err(error) = parsed_project {
423 assert!(matches!(error, ParseError::PathIsNotAFile));
424
425 return;
426 }
427
428 unreachable!()
429 }
430
431 #[test]
432 pub fn path_is_not_a_project() {
433 let content = "";
435
436 let project_path: &Path = "./TestProject.txt".as_ref();
437
438 let parsed_project = parse(Cursor::new(content), project_path);
440
441 if let Err(error) = parsed_project {
443 assert!(matches!(error, ParseError::FileIsNotAProject));
444
445 return;
446 }
447
448 unreachable!()
449 }
450
451 #[test]
452 pub fn path_does_not_have_a_name() {
453 let content = "";
455
456 let project_path: &Path = "./.csproj".as_ref();
457
458 let parsed_project = parse(Cursor::new(content), project_path);
460
461 if let Err(error) = parsed_project {
463 assert!(matches!(error, ParseError::FileIsNotAProject));
464
465 return;
466 }
467
468 unreachable!()
469 }
470}