Skip to main content

kdo_resolver/
python.rs

1//! Parser for `pyproject.toml` manifests (Python projects).
2
3use crate::ManifestParser;
4use kdo_core::{DepKind, Dependency, KdoError, Language, Project};
5use std::path::Path;
6use tracing::debug;
7
8/// Parses Python `pyproject.toml` manifests.
9pub struct PythonParser;
10
11impl ManifestParser for PythonParser {
12    fn manifest_name(&self) -> &str {
13        "pyproject.toml"
14    }
15
16    fn can_parse(&self, manifest_path: &Path) -> bool {
17        manifest_path
18            .file_name()
19            .map(|f| f == "pyproject.toml")
20            .unwrap_or(false)
21    }
22
23    fn parse(
24        &self,
25        manifest_path: &Path,
26        _workspace_root: &Path,
27    ) -> Result<(Project, Vec<Dependency>), KdoError> {
28        let content = std::fs::read_to_string(manifest_path)?;
29        let doc: toml::Value = toml::from_str(&content).map_err(|e| KdoError::ParseError {
30            path: manifest_path.to_path_buf(),
31            source: e.into(),
32        })?;
33
34        // Try [project] table first (PEP 621), then [tool.poetry]
35        let (name, description) = if let Some(project) = doc.get("project") {
36            let name = project
37                .get("name")
38                .and_then(|v| v.as_str())
39                .unwrap_or("unknown")
40                .to_string();
41            let desc = project
42                .get("description")
43                .and_then(|v| v.as_str())
44                .map(String::from);
45            (name, desc)
46        } else if let Some(poetry) = doc.get("tool").and_then(|t| t.get("poetry")) {
47            let name = poetry
48                .get("name")
49                .and_then(|v| v.as_str())
50                .unwrap_or("unknown")
51                .to_string();
52            let desc = poetry
53                .get("description")
54                .and_then(|v| v.as_str())
55                .map(String::from);
56            (name, desc)
57        } else {
58            return Err(KdoError::ParseError {
59                path: manifest_path.to_path_buf(),
60                source: anyhow::anyhow!("no [project] or [tool.poetry] table found"),
61            });
62        };
63
64        let project_dir = manifest_path
65            .parent()
66            .unwrap_or(Path::new("."))
67            .to_path_buf();
68
69        debug!(name = %name, "parsed pyproject.toml");
70
71        let mut deps = Vec::new();
72
73        // PEP 621 dependencies
74        if let Some(dep_list) = doc
75            .get("project")
76            .and_then(|p| p.get("dependencies"))
77            .and_then(|d| d.as_array())
78        {
79            for dep_val in dep_list {
80                if let Some(dep_str) = dep_val.as_str() {
81                    let (dep_name, version_req) = parse_pep508(dep_str);
82                    deps.push(Dependency {
83                        name: dep_name,
84                        version_req,
85                        kind: DepKind::Source,
86                        is_workspace: false,
87                        resolved_path: None,
88                    });
89                }
90            }
91        }
92
93        // Dev dependencies from optional-dependencies.dev
94        if let Some(dev_list) = doc
95            .get("project")
96            .and_then(|p| p.get("optional-dependencies"))
97            .and_then(|o| o.get("dev"))
98            .and_then(|d| d.as_array())
99        {
100            for dep_val in dev_list {
101                if let Some(dep_str) = dep_val.as_str() {
102                    let (dep_name, version_req) = parse_pep508(dep_str);
103                    deps.push(Dependency {
104                        name: dep_name,
105                        version_req,
106                        kind: DepKind::Dev,
107                        is_workspace: false,
108                        resolved_path: None,
109                    });
110                }
111            }
112        }
113
114        let project = Project {
115            name,
116            path: project_dir,
117            language: Language::Python,
118            manifest_path: manifest_path.to_path_buf(),
119            context_summary: description,
120            public_api_files: Vec::new(),
121            internal_files: Vec::new(),
122            content_hash: [0u8; 32],
123        };
124
125        Ok((project, deps))
126    }
127}
128
129/// Rough PEP 508 parser: splits `"requests>=2.28"` into `("requests", ">=2.28")`.
130fn parse_pep508(spec: &str) -> (String, String) {
131    let spec = spec.trim();
132    // Find first version specifier char
133    let split_pos = spec
134        .find(['>', '<', '=', '!', '~', '[', ';'])
135        .unwrap_or(spec.len());
136    let name = spec[..split_pos].trim().to_string();
137    let version = spec[split_pos..].trim().to_string();
138    (name, version)
139}