1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub struct PyProject {
14 pub project: Option<ProjectMetadata>,
16
17 pub build_system: Option<BuildSystem>,
19
20 #[serde(default)]
22 pub tool: HashMap<String, toml::Value>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub struct ProjectMetadata {
29 pub name: String,
31
32 pub version: Option<String>,
34
35 pub description: Option<String>,
37
38 pub readme: Option<Readme>,
40
41 pub requires_python: Option<String>,
43
44 pub license: Option<License>,
46
47 #[serde(default)]
49 pub authors: Vec<Person>,
50
51 #[serde(default)]
53 pub maintainers: Vec<Person>,
54
55 #[serde(default)]
57 pub keywords: Vec<String>,
58
59 #[serde(default)]
61 pub classifiers: Vec<String>,
62
63 #[serde(default)]
65 pub urls: HashMap<String, String>,
66
67 #[serde(default)]
69 pub dependencies: Vec<String>,
70
71 #[serde(default)]
73 pub optional_dependencies: HashMap<String, Vec<String>>,
74
75 #[serde(default)]
77 pub scripts: HashMap<String, String>,
78
79 #[serde(default)]
81 pub gui_scripts: HashMap<String, String>,
82
83 #[serde(default)]
85 pub entry_points: HashMap<String, HashMap<String, String>>,
86
87 #[serde(default)]
89 pub dynamic: Vec<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(untagged)]
95pub enum Readme {
96 Path(String),
98 Inline {
100 file: Option<String>,
101 text: Option<String>,
102 content_type: Option<String>,
103 },
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum License {
110 Text { text: String },
112 File { file: String },
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Person {
119 pub name: Option<String>,
120 pub email: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub struct BuildSystem {
127 pub requires: Vec<String>,
129
130 pub build_backend: Option<String>,
132
133 pub backend_path: Option<Vec<String>>,
135}
136
137impl PyProject {
138 pub fn new(name: &str, version: &str, python_requires: &str) -> Self {
140 Self {
141 project: Some(ProjectMetadata {
142 name: name.to_string(),
143 version: Some(version.to_string()),
144 description: None,
145 readme: None,
146 requires_python: Some(python_requires.to_string()),
147 license: None,
148 authors: vec![],
149 maintainers: vec![],
150 keywords: vec![],
151 classifiers: vec![],
152 urls: HashMap::new(),
153 dependencies: vec![],
154 optional_dependencies: HashMap::new(),
155 scripts: HashMap::new(),
156 gui_scripts: HashMap::new(),
157 entry_points: HashMap::new(),
158 dynamic: vec![],
159 }),
160 build_system: Some(BuildSystem {
161 requires: vec!["pro-core".to_string()],
162 build_backend: Some("pro_core".to_string()),
163 backend_path: None,
164 }),
165 tool: HashMap::new(),
166 }
167 }
168
169 pub fn load(project_dir: &Path) -> Result<Self> {
171 let path = project_dir.join("pyproject.toml");
172 let content = std::fs::read_to_string(&path).map_err(|_| Error::PyProjectNotFound)?;
173 Self::parse(&content)
174 }
175
176 pub fn parse(content: &str) -> Result<Self> {
178 toml::from_str(content).map_err(Error::TomlParse)
179 }
180
181 pub fn save(&self, project_dir: &Path) -> Result<()> {
183 let path = project_dir.join("pyproject.toml");
184 let content = toml::to_string_pretty(self).map_err(Error::TomlSerialize)?;
185 std::fs::write(&path, content).map_err(Error::Io)
186 }
187
188 pub fn name(&self) -> Option<&str> {
190 self.project.as_ref().map(|p| p.name.as_str())
191 }
192
193 pub fn version(&self) -> Option<&str> {
195 self.project.as_ref().and_then(|p| p.version.as_deref())
196 }
197
198 pub fn dependencies(&self) -> &[String] {
200 self.project
201 .as_ref()
202 .map(|p| p.dependencies.as_slice())
203 .unwrap_or(&[])
204 }
205
206 pub fn add_dependency(&mut self, dep: String) {
208 if let Some(ref mut project) = self.project {
209 let dep_name = dep
211 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
212 .next()
213 .unwrap_or(&dep)
214 .to_lowercase();
215
216 project.dependencies.retain(|d| {
218 let existing_name = d
219 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
220 .next()
221 .unwrap_or(d)
222 .to_lowercase();
223 existing_name != dep_name
224 });
225
226 project.dependencies.push(dep);
227 project.dependencies.sort();
228 }
229 }
230
231 pub fn add_dev_dependency(&mut self, dep: String) {
233 if let Some(ref mut project) = self.project {
234 let dev_deps = project
235 .optional_dependencies
236 .entry("dev".to_string())
237 .or_insert_with(Vec::new);
238
239 let dep_name = dep
240 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
241 .next()
242 .unwrap_or(&dep)
243 .to_lowercase();
244
245 dev_deps.retain(|d| {
246 let existing_name = d
247 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
248 .next()
249 .unwrap_or(d)
250 .to_lowercase();
251 existing_name != dep_name
252 });
253
254 dev_deps.push(dep);
255 dev_deps.sort();
256 }
257 }
258
259 pub fn dev_dependencies(&self) -> &[String] {
261 self.project
262 .as_ref()
263 .and_then(|p| p.optional_dependencies.get("dev"))
264 .map(|v| v.as_slice())
265 .unwrap_or(&[])
266 }
267
268 pub fn remove_dependencies(&mut self, names: &[String]) -> Result<()> {
270 if let Some(ref mut project) = self.project {
271 let names_normalized: Vec<String> = names
272 .iter()
273 .map(|n| n.to_lowercase().replace('_', "-"))
274 .collect();
275
276 project.dependencies.retain(|d| {
277 let dep_name = d
278 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
279 .next()
280 .unwrap_or(d)
281 .to_lowercase()
282 .replace('_', "-");
283 !names_normalized.contains(&dep_name)
284 });
285 }
286 Ok(())
287 }
288
289 pub fn remove_dev_dependencies(&mut self, names: &[String]) -> Result<()> {
291 if let Some(ref mut project) = self.project {
292 if let Some(dev_deps) = project.optional_dependencies.get_mut("dev") {
293 let names_normalized: Vec<String> = names
294 .iter()
295 .map(|n| n.to_lowercase().replace('_', "-"))
296 .collect();
297
298 dev_deps.retain(|d| {
299 let dep_name = d
300 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
301 .next()
302 .unwrap_or(d)
303 .to_lowercase()
304 .replace('_', "-");
305 !names_normalized.contains(&dep_name)
306 });
307
308 if dev_deps.is_empty() {
310 project.optional_dependencies.remove("dev");
311 }
312 }
313 }
314 Ok(())
315 }
316
317 pub fn add_path_dependency(&mut self, name: String, path: String, editable: bool) {
319 let rx = self
321 .tool
322 .entry("rx".to_string())
323 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
324
325 if let toml::Value::Table(rx_table) = rx {
326 let deps = rx_table
327 .entry("dependencies".to_string())
328 .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
329
330 if let toml::Value::Table(deps_table) = deps {
331 let mut dep_entry = toml::map::Map::new();
333 dep_entry.insert("path".to_string(), toml::Value::String(path));
334 dep_entry.insert("editable".to_string(), toml::Value::Boolean(editable));
335
336 deps_table.insert(name, toml::Value::Table(dep_entry));
337 }
338 }
339 }
340
341 pub fn remove_path_dependency(&mut self, name: &str) -> bool {
343 if let Some(toml::Value::Table(rx)) = self.tool.get_mut("rx") {
344 if let Some(toml::Value::Table(deps)) = rx.get_mut("dependencies") {
345 return deps.remove(name).is_some();
346 }
347 }
348 false
349 }
350
351 pub fn all_dependencies(&self) -> Vec<crate::pep::Requirement> {
353 let mut reqs = Vec::new();
354
355 for dep in self.dependencies() {
357 if let Ok(req) = crate::pep::Requirement::parse(dep) {
358 reqs.push(req);
359 }
360 }
361
362 for dep in self.dev_dependencies() {
364 if let Ok(req) = crate::pep::Requirement::parse(dep) {
365 reqs.push(req);
366 }
367 }
368
369 reqs
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_parse_minimal() {
379 let content = r#"
380[project]
381name = "mypackage"
382version = "0.1.0"
383"#;
384
385 let pyproject = PyProject::parse(content).unwrap();
386 assert_eq!(pyproject.name(), Some("mypackage"));
387 assert_eq!(pyproject.version(), Some("0.1.0"));
388 }
389
390 #[test]
391 fn test_parse_with_dependencies() {
392 let content = r#"
393[project]
394name = "mypackage"
395version = "0.1.0"
396dependencies = [
397 "requests>=2.0",
398 "click",
399]
400"#;
401
402 let pyproject = PyProject::parse(content).unwrap();
403 assert_eq!(pyproject.dependencies().len(), 2);
404 }
405}