code_moniker_core/lang/cs/
build.rs1#[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}