1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::pep::PyProject;
17use crate::{Error, Result};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PathDependency {
22 pub name: String,
24 pub path: PathBuf,
26 #[serde(default = "default_editable")]
28 pub editable: bool,
29 #[serde(default)]
31 pub extras: Vec<String>,
32}
33
34fn default_editable() -> bool {
35 true
36}
37
38impl PathDependency {
39 pub fn new(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
41 Self {
42 name: name.into(),
43 path: path.into(),
44 editable: true,
45 extras: Vec::new(),
46 }
47 }
48
49 pub fn with_editable(mut self, editable: bool) -> Self {
51 self.editable = editable;
52 self
53 }
54
55 pub fn with_extras(mut self, extras: Vec<String>) -> Self {
57 self.extras = extras;
58 self
59 }
60
61 pub fn resolve_path(&self, base_dir: &Path) -> PathBuf {
63 if self.path.is_absolute() {
64 self.path.clone()
65 } else {
66 base_dir.join(&self.path)
67 }
68 }
69
70 pub fn validate(&self, base_dir: &Path) -> Result<()> {
72 let resolved = self.resolve_path(base_dir);
73
74 if !resolved.exists() {
75 return Err(Error::Config(format!(
76 "Path dependency '{}' not found: {}",
77 self.name,
78 resolved.display()
79 )));
80 }
81
82 let pyproject_path = resolved.join("pyproject.toml");
83 if !pyproject_path.exists() {
84 return Err(Error::Config(format!(
85 "Path dependency '{}' has no pyproject.toml: {}",
86 self.name,
87 resolved.display()
88 )));
89 }
90
91 Ok(())
92 }
93
94 pub fn get_version(&self, base_dir: &Path) -> Result<Option<String>> {
96 let resolved = self.resolve_path(base_dir);
97 let pyproject = PyProject::load(&resolved)?;
98 Ok(pyproject.version().map(String::from))
99 }
100
101 pub fn get_dependencies(&self, base_dir: &Path) -> Result<Vec<String>> {
103 let resolved = self.resolve_path(base_dir);
104 let pyproject = PyProject::load(&resolved)?;
105 Ok(pyproject.dependencies().to_vec())
106 }
107}
108
109pub fn load_path_dependencies(project_dir: &Path) -> Result<HashMap<String, PathDependency>> {
111 let pyproject = PyProject::load(project_dir)?;
112
113 let mut path_deps = HashMap::new();
114
115 let rx_config = match pyproject.tool.get("rx") {
116 Some(c) => c,
117 None => return Ok(path_deps),
118 };
119
120 let deps_config = match rx_config.get("dependencies") {
121 Some(c) => c,
122 None => return Ok(path_deps),
123 };
124
125 let deps_table = match deps_config.as_table() {
126 Some(t) => t,
127 None => return Ok(path_deps),
128 };
129
130 for (name, value) in deps_table {
131 if let Some(table) = value.as_table() {
133 if let Some(path_value) = table.get("path") {
134 if let Some(path_str) = path_value.as_str() {
135 let editable = table
136 .get("editable")
137 .and_then(|v| v.as_bool())
138 .unwrap_or(true);
139
140 let extras: Vec<String> = table
141 .get("extras")
142 .and_then(|v| v.as_array())
143 .map(|arr| {
144 arr.iter()
145 .filter_map(|v| v.as_str().map(String::from))
146 .collect()
147 })
148 .unwrap_or_default();
149
150 let dep = PathDependency {
151 name: name.clone(),
152 path: PathBuf::from(path_str),
153 editable,
154 extras,
155 };
156
157 path_deps.insert(name.clone(), dep);
158 }
159 }
160 }
161 }
162
163 Ok(path_deps)
164}
165
166pub async fn install_path_dependency(
168 dep: &PathDependency,
169 base_dir: &Path,
170 site_packages: &Path,
171) -> Result<()> {
172 let resolved_path = dep.resolve_path(base_dir);
173
174 dep.validate(base_dir)?;
176
177 if dep.editable {
178 install_editable(&dep.name, &resolved_path, site_packages)?;
180 } else {
181 install_copy(&dep.name, &resolved_path, site_packages)?;
183 }
184
185 Ok(())
186}
187
188fn install_editable(name: &str, source_path: &Path, site_packages: &Path) -> Result<()> {
190 let package_dir = find_package_dir(name, source_path)?;
192
193 let pth_filename = format!("{}.pth", name.replace('-', "_"));
195 let pth_path = site_packages.join(pth_filename);
196
197 let pth_content = package_dir
199 .parent()
200 .unwrap_or(&package_dir)
201 .to_string_lossy()
202 .to_string();
203
204 std::fs::write(&pth_path, pth_content).map_err(Error::Io)?;
205
206 let egg_link_path = site_packages.join(format!("{}.egg-link", name.replace('-', "_")));
208 let egg_link_content = format!(
209 "{}\n.",
210 package_dir.parent().unwrap_or(&package_dir).display()
211 );
212 std::fs::write(&egg_link_path, egg_link_content).map_err(Error::Io)?;
213
214 tracing::info!(
215 "Installed {} (editable) from {}",
216 name,
217 source_path.display()
218 );
219
220 Ok(())
221}
222
223fn install_copy(name: &str, source_path: &Path, site_packages: &Path) -> Result<()> {
225 let package_dir = find_package_dir(name, source_path)?;
226 let package_name = package_dir
227 .file_name()
228 .ok_or_else(|| Error::Config("Invalid package directory".to_string()))?;
229
230 let dest_dir = site_packages.join(package_name);
231
232 if dest_dir.exists() {
234 std::fs::remove_dir_all(&dest_dir).map_err(Error::Io)?;
235 }
236
237 copy_dir_recursive(&package_dir, &dest_dir)?;
239
240 tracing::info!("Installed {} (copied) from {}", name, source_path.display());
241
242 Ok(())
243}
244
245fn find_package_dir(name: &str, project_path: &Path) -> Result<PathBuf> {
247 let normalized_name = name.replace('-', "_");
248
249 let src_layout = project_path.join("src").join(&normalized_name);
252 if src_layout.exists() && src_layout.join("__init__.py").exists() {
253 return Ok(src_layout);
254 }
255
256 let flat_layout = project_path.join(&normalized_name);
258 if flat_layout.exists() && flat_layout.join("__init__.py").exists() {
259 return Ok(flat_layout);
260 }
261
262 let root_init = project_path.join("__init__.py");
264 if root_init.exists() {
265 return Ok(project_path.to_path_buf());
266 }
267
268 if let Ok(entries) = std::fs::read_dir(project_path) {
270 for entry in entries.flatten() {
271 let path = entry.path();
272 if path.is_dir() && path.join("__init__.py").exists() {
273 return Ok(path);
274 }
275 }
276 }
277
278 let src_dir = project_path.join("src");
280 if src_dir.exists() {
281 if let Ok(entries) = std::fs::read_dir(&src_dir) {
282 for entry in entries.flatten() {
283 let path = entry.path();
284 if path.is_dir() && path.join("__init__.py").exists() {
285 return Ok(path);
286 }
287 }
288 }
289 }
290
291 Err(Error::Config(format!(
292 "Could not find Python package in {}. Expected src/{}/ or {}/",
293 project_path.display(),
294 normalized_name,
295 normalized_name
296 )))
297}
298
299fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
301 std::fs::create_dir_all(dst).map_err(Error::Io)?;
302
303 for entry in std::fs::read_dir(src).map_err(Error::Io)? {
304 let entry = entry.map_err(Error::Io)?;
305 let src_path = entry.path();
306 let dst_path = dst.join(entry.file_name());
307
308 if src_path.is_dir() {
309 let name = entry.file_name();
311 let name_str = name.to_string_lossy();
312 if name_str == "__pycache__"
313 || name_str == ".git"
314 || name_str == ".venv"
315 || name_str == "venv"
316 || name_str.ends_with(".egg-info")
317 {
318 continue;
319 }
320 copy_dir_recursive(&src_path, &dst_path)?;
321 } else {
322 if src_path.extension().is_some_and(|e| e == "pyc") {
324 continue;
325 }
326 std::fs::copy(&src_path, &dst_path).map_err(Error::Io)?;
327 }
328 }
329
330 Ok(())
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use tempfile::TempDir;
337
338 #[test]
339 fn test_path_dependency_resolve() {
340 let dep = PathDependency::new("my-lib", "../my-lib");
341 let base = PathBuf::from("/workspace/app");
342 let resolved = dep.resolve_path(&base);
343 assert_eq!(resolved, PathBuf::from("/workspace/app/../my-lib"));
344 }
345
346 #[test]
347 fn test_path_dependency_absolute() {
348 let dep = PathDependency::new("my-lib", "/absolute/path/my-lib");
349 let base = PathBuf::from("/workspace/app");
350 let resolved = dep.resolve_path(&base);
351 assert_eq!(resolved, PathBuf::from("/absolute/path/my-lib"));
352 }
353
354 #[test]
355 fn test_find_package_dir_src_layout() {
356 let temp = TempDir::new().unwrap();
357 let project = temp.path();
358
359 let pkg_dir = project.join("src").join("my_lib");
361 std::fs::create_dir_all(&pkg_dir).unwrap();
362 std::fs::write(pkg_dir.join("__init__.py"), "").unwrap();
363
364 let found = find_package_dir("my-lib", project).unwrap();
365 assert_eq!(found, pkg_dir);
366 }
367
368 #[test]
369 fn test_find_package_dir_flat_layout() {
370 let temp = TempDir::new().unwrap();
371 let project = temp.path();
372
373 let pkg_dir = project.join("my_lib");
375 std::fs::create_dir_all(&pkg_dir).unwrap();
376 std::fs::write(pkg_dir.join("__init__.py"), "").unwrap();
377
378 let found = find_package_dir("my-lib", project).unwrap();
379 assert_eq!(found, pkg_dir);
380 }
381}