Skip to main content

code_moniker_core/lang/cs/
build.rs

1#[derive(Clone, Debug, Eq, PartialEq)]
2pub struct Dep {
3	pub name: String,
4	pub version: Option<String>,
5	pub dep_kind: String,
6	pub import_root: String,
7}
8
9#[derive(Debug)]
10pub enum CsprojError {
11	Parse(roxmltree::Error),
12}
13
14impl std::fmt::Display for CsprojError {
15	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16		match self {
17			Self::Parse(e) => write!(f, ".csproj parse error: {e}"),
18		}
19	}
20}
21
22impl std::error::Error for CsprojError {}
23
24pub fn parse(content: &str) -> Result<Vec<Dep>, CsprojError> {
25	let doc = roxmltree::Document::parse(content).map_err(CsprojError::Parse)?;
26	let root = doc.root_element();
27	let mut out = Vec::new();
28
29	if let Some(name) = project_self_name(root) {
30		let version = property_value(root, "Version");
31		out.push(Dep {
32			name: name.clone(),
33			version,
34			dep_kind: "package".into(),
35			import_root: name,
36		});
37	}
38
39	for node in root.descendants() {
40		match node.tag_name().name() {
41			"PackageReference" => {
42				let Some(name) = node.attribute("Include") else {
43					continue;
44				};
45				let version = node
46					.attribute("Version")
47					.map(str::to_string)
48					.or_else(|| element_text(node, "Version"));
49				out.push(Dep {
50					name: name.into(),
51					version,
52					dep_kind: "normal".into(),
53					import_root: name.into(),
54				});
55			}
56			"ProjectReference" => {
57				let Some(path) = node.attribute("Include") else {
58					continue;
59				};
60				let stem = project_path_stem(path);
61				out.push(Dep {
62					name: stem.clone(),
63					version: None,
64					dep_kind: "project".into(),
65					import_root: stem,
66				});
67			}
68			_ => {}
69		}
70	}
71
72	Ok(out)
73}
74
75fn project_self_name(root: roxmltree::Node<'_, '_>) -> Option<String> {
76	let mut fallback: Option<String> = None;
77	for n in root.descendants() {
78		if !n.is_element() {
79			continue;
80		}
81		match n.tag_name().name() {
82			"AssemblyName" => {
83				if let Some(s) = node_trimmed_text(n) {
84					return Some(s);
85				}
86			}
87			"RootNamespace" if fallback.is_none() => {
88				fallback = node_trimmed_text(n);
89			}
90			_ => {}
91		}
92	}
93	fallback
94}
95
96fn property_value(root: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
97	root.descendants()
98		.find(|n| n.is_element() && n.tag_name().name() == tag)
99		.and_then(node_trimmed_text)
100}
101
102fn node_trimmed_text(n: roxmltree::Node<'_, '_>) -> Option<String> {
103	n.text()
104		.map(|s| s.trim().to_string())
105		.filter(|s| !s.is_empty())
106}
107
108fn element_text(node: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
109	node.children()
110		.find(|n| n.is_element() && n.tag_name().name() == tag)
111		.and_then(|n| n.text())
112		.map(|s| s.trim().to_string())
113		.filter(|s| !s.is_empty())
114}
115
116fn project_path_stem(path: &str) -> String {
117	let leaf = path.rsplit(['/', '\\']).next().unwrap_or(path);
118	leaf.strip_suffix(".csproj").unwrap_or(leaf).to_string()
119}
120
121#[cfg(test)]
122mod tests {
123	use super::*;
124
125	#[test]
126	fn parse_empty_project_returns_empty_vec() {
127		let xml = r#"<Project Sdk="Microsoft.NET.Sdk"></Project>"#;
128		assert!(parse(xml).unwrap().is_empty());
129	}
130
131	#[test]
132	fn parse_self_name_from_assembly_name() {
133		let xml = r#"<Project Sdk="Microsoft.NET.Sdk">
134			<PropertyGroup>
135				<AssemblyName>MyApp</AssemblyName>
136				<Version>1.2.3</Version>
137			</PropertyGroup>
138		</Project>"#;
139		let deps = parse(xml).unwrap();
140		let pkg = deps.iter().find(|d| d.dep_kind == "package").unwrap();
141		assert_eq!(pkg.name, "MyApp");
142		assert_eq!(pkg.version.as_deref(), Some("1.2.3"));
143		assert_eq!(pkg.import_root, "MyApp");
144	}
145
146	#[test]
147	fn parse_falls_back_to_root_namespace() {
148		let xml = r#"<Project>
149			<PropertyGroup>
150				<RootNamespace>Acme</RootNamespace>
151			</PropertyGroup>
152		</Project>"#;
153		let deps = parse(xml).unwrap();
154		assert!(
155			deps.iter()
156				.any(|d| d.dep_kind == "package" && d.name == "Acme")
157		);
158	}
159
160	#[test]
161	fn parse_package_reference_attribute_version() {
162		let xml = r#"<Project>
163			<ItemGroup>
164				<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
165			</ItemGroup>
166		</Project>"#;
167		let deps = parse(xml).unwrap();
168		let pkg = deps.iter().find(|d| d.name == "Newtonsoft.Json").unwrap();
169		assert_eq!(pkg.version.as_deref(), Some("13.0.1"));
170		assert_eq!(pkg.dep_kind, "normal");
171		assert_eq!(pkg.import_root, "Newtonsoft.Json");
172	}
173
174	#[test]
175	fn parse_package_reference_element_version() {
176		let xml = r#"<Project>
177			<ItemGroup>
178				<PackageReference Include="Serilog">
179					<Version>3.0.0</Version>
180				</PackageReference>
181			</ItemGroup>
182		</Project>"#;
183		let deps = parse(xml).unwrap();
184		let pkg = deps.iter().find(|d| d.name == "Serilog").unwrap();
185		assert_eq!(pkg.version.as_deref(), Some("3.0.0"));
186	}
187
188	#[test]
189	fn parse_project_reference_strips_path_and_extension() {
190		let xml = r#"<Project>
191			<ItemGroup>
192				<ProjectReference Include="..\Other\Other.csproj" />
193			</ItemGroup>
194		</Project>"#;
195		let deps = parse(xml).unwrap();
196		let pr = deps.iter().find(|d| d.dep_kind == "project").unwrap();
197		assert_eq!(pr.name, "Other");
198		assert!(pr.version.is_none());
199	}
200
201	#[test]
202	fn parse_project_reference_handles_unix_paths() {
203		let xml = r#"<Project>
204			<ItemGroup>
205				<ProjectReference Include="../Other/Other.csproj" />
206			</ItemGroup>
207		</Project>"#;
208		let deps = parse(xml).unwrap();
209		assert!(
210			deps.iter()
211				.any(|d| d.name == "Other" && d.dep_kind == "project")
212		);
213	}
214
215	#[test]
216	fn parse_invalid_xml_returns_parse_error() {
217		assert!(matches!(parse("<not closed"), Err(CsprojError::Parse(_))));
218	}
219}