Skip to main content

pro_core/pep/
pep621.rs

1//! PEP 621 - Storing project metadata in pyproject.toml
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Error, Result};
9
10/// Root pyproject.toml structure
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub struct PyProject {
14    /// PEP 621 project metadata
15    pub project: Option<ProjectMetadata>,
16
17    /// Build system configuration (PEP 517)
18    pub build_system: Option<BuildSystem>,
19
20    /// Tool-specific configuration
21    #[serde(default)]
22    pub tool: HashMap<String, toml::Value>,
23}
24
25/// PEP 621 project metadata
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub struct ProjectMetadata {
29    /// Project name
30    pub name: String,
31
32    /// Project version
33    pub version: Option<String>,
34
35    /// Short description
36    pub description: Option<String>,
37
38    /// Project readme file or text
39    pub readme: Option<Readme>,
40
41    /// Required Python version
42    pub requires_python: Option<String>,
43
44    /// Project license
45    pub license: Option<License>,
46
47    /// Project authors
48    #[serde(default)]
49    pub authors: Vec<Person>,
50
51    /// Project maintainers
52    #[serde(default)]
53    pub maintainers: Vec<Person>,
54
55    /// Project keywords
56    #[serde(default)]
57    pub keywords: Vec<String>,
58
59    /// Trove classifiers
60    #[serde(default)]
61    pub classifiers: Vec<String>,
62
63    /// Project URLs
64    #[serde(default)]
65    pub urls: HashMap<String, String>,
66
67    /// Project dependencies
68    #[serde(default)]
69    pub dependencies: Vec<String>,
70
71    /// Optional dependencies
72    #[serde(default)]
73    pub optional_dependencies: HashMap<String, Vec<String>>,
74
75    /// Entry points
76    #[serde(default)]
77    pub scripts: HashMap<String, String>,
78
79    /// GUI scripts
80    #[serde(default)]
81    pub gui_scripts: HashMap<String, String>,
82
83    /// Entry point groups
84    #[serde(default)]
85    pub entry_points: HashMap<String, HashMap<String, String>>,
86
87    /// Dynamic fields (computed at build time)
88    #[serde(default)]
89    pub dynamic: Vec<String>,
90}
91
92/// Readme specification
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(untagged)]
95pub enum Readme {
96    /// Path to readme file
97    Path(String),
98    /// Inline readme with content type
99    Inline {
100        file: Option<String>,
101        text: Option<String>,
102        content_type: Option<String>,
103    },
104}
105
106/// License specification
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum License {
110    /// SPDX identifier
111    Text { text: String },
112    /// Path to license file
113    File { file: String },
114}
115
116/// Person (author or maintainer)
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Person {
119    pub name: Option<String>,
120    pub email: Option<String>,
121}
122
123/// PEP 517 build system configuration
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub struct BuildSystem {
127    /// Build dependencies
128    pub requires: Vec<String>,
129
130    /// Build backend module
131    pub build_backend: Option<String>,
132
133    /// Backend path
134    pub backend_path: Option<Vec<String>>,
135}
136
137impl PyProject {
138    /// Create a new pyproject.toml with minimal configuration
139    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    /// Load pyproject.toml from a directory
170    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    /// Parse pyproject.toml content
177    pub fn parse(content: &str) -> Result<Self> {
178        toml::from_str(content).map_err(Error::TomlParse)
179    }
180
181    /// Save pyproject.toml to a directory
182    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    /// Get project name
189    pub fn name(&self) -> Option<&str> {
190        self.project.as_ref().map(|p| p.name.as_str())
191    }
192
193    /// Get project version
194    pub fn version(&self) -> Option<&str> {
195        self.project.as_ref().and_then(|p| p.version.as_deref())
196    }
197
198    /// Get dependencies
199    pub fn dependencies(&self) -> &[String] {
200        self.project
201            .as_ref()
202            .map(|p| p.dependencies.as_slice())
203            .unwrap_or(&[])
204    }
205
206    /// Add a dependency
207    pub fn add_dependency(&mut self, dep: String) {
208        if let Some(ref mut project) = self.project {
209            // Check if dependency already exists (by name)
210            let dep_name = dep
211                .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
212                .next()
213                .unwrap_or(&dep)
214                .to_lowercase();
215
216            // Remove existing dependency with same name
217            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    /// Add a development dependency
232    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    /// Get dev dependencies
260    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    /// Remove dependencies by name
269    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    /// Remove dev dependencies by name
290    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                // Remove empty dev group
309                if dev_deps.is_empty() {
310                    project.optional_dependencies.remove("dev");
311                }
312            }
313        }
314        Ok(())
315    }
316
317    /// Add a path dependency to [tool.rx.dependencies]
318    pub fn add_path_dependency(&mut self, name: String, path: String, editable: bool) {
319        // Ensure tool.rx.dependencies exists
320        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                // Create the path dependency entry
332                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    /// Remove a path dependency from [tool.rx.dependencies]
342    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    /// Get all dependencies (main + dev) as parsed Requirements
352    pub fn all_dependencies(&self) -> Vec<crate::pep::Requirement> {
353        let mut reqs = Vec::new();
354
355        // Main dependencies
356        for dep in self.dependencies() {
357            if let Ok(req) = crate::pep::Requirement::parse(dep) {
358                reqs.push(req);
359            }
360        }
361
362        // Dev dependencies
363        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}