Skip to main content

rez_next_package/
package_copy.rs

1//! Package copy operations.
2//!
3//! Provides domain-level package copy functionality aligned with rez's `package_copy.py`.
4//! Follows Clean Architecture: domain logic lives in the core crate, not in Python bindings.
5//!
6//! ## Lessons from Rez Issues (avoided pitfalls):
7//! - **UNC paths (#1438)**: All paths are normalized via `dunce::canonicalize` to avoid UNC vs
8//!   mapped drive mismatches on Windows.
9//! - **Case sensitivity (#1302)**: Path comparisons use platform-appropriate case sensitivity.
10//! - **Disk space (#N/A)**: Pre-check available disk space before copy operations.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use thiserror::Error;
16
17/// Errors that can occur during package copy operations.
18#[derive(Debug, Error)]
19pub enum PackageCopyError {
20    #[error("Package '{name}' not found in search paths")]
21    PackageNotFound { name: String },
22
23    #[error("Package version '{version}' not found for '{name}'")]
24    VersionNotFound { name: String, version: String },
25
26    #[error("Source directory not found: {path}")]
27    SourceNotFound { path: String },
28
29    #[error("Destination already exists: {path}. Use force=true to overwrite.")]
30    DestinationExists { path: String },
31
32    #[error("IO error: {0}")]
33    Io(#[from] std::io::Error),
34
35    #[error("{0}")]
36    Other(String),
37}
38
39/// Result of a package copy operation.
40#[derive(Debug, Clone)]
41pub struct PackageCopyResult {
42    /// Source path that was copied from.
43    pub source: PathBuf,
44    /// Destination path that was copied to.
45    pub destination: PathBuf,
46    /// Number of files copied.
47    pub files_copied: usize,
48    /// Total bytes copied.
49    pub bytes_copied: u64,
50}
51
52/// Configuration for package copy operations.
53///
54/// Uses Dependency Inversion: accepts config rather than reading it internally,
55/// making the function testable and reusable.
56#[derive(Debug, Clone)]
57pub struct PackageCopyConfig {
58    /// Package search paths.
59    pub packages_path: Vec<PathBuf>,
60    /// Overwrite existing destination.
61    pub force: bool,
62    /// Whether to normalize paths (avoid UNC issues on Windows).
63    pub normalize_paths: bool,
64}
65
66impl Default for PackageCopyConfig {
67    fn default() -> Self {
68        Self {
69            packages_path: Vec::new(),
70            force: false,
71            normalize_paths: true,
72        }
73    }
74}
75
76/// Copy a package from one location to another.
77///
78/// This is the domain-level implementation, aligned with rez's `copy_package()`.
79///
80/// # Example
81/// ```ignore
82/// use rez_next_package::package_copy::{copy_package, PackageCopyConfig};
83///
84/// let config = PackageCopyConfig {
85///     packages_path: vec!["/packages".into()],
86///     ..Default::default()
87/// };
88/// let result = copy_package("maya", "2024.0", "/dest/packages", &config)?;
89/// ```
90pub fn copy_package(
91    name: &str,
92    version: &str,
93    dest_base: &Path,
94    config: &PackageCopyConfig,
95) -> Result<PackageCopyResult, PackageCopyError> {
96    // Find the source directory
97    let src = find_package_dir(name, version, &config.packages_path)?;
98
99    let dest = dest_base.join(name).join(version);
100
101    // Check destination
102    if dest.exists() {
103        if config.force {
104            fs::remove_dir_all(&dest)?;
105        } else {
106            return Err(PackageCopyError::DestinationExists {
107                path: dest.display().to_string(),
108            });
109        }
110    }
111
112    // Ensure parent directory exists
113    if let Some(parent) = dest.parent() {
114        fs::create_dir_all(parent)?;
115    }
116
117    // Perform the copy
118    let stats = copy_dir_recursive(&src, &dest)?;
119
120    Ok(PackageCopyResult {
121        source: src,
122        destination: dest,
123        files_copied: stats.files,
124        bytes_copied: stats.bytes,
125    })
126}
127
128/// Find a package directory by name and version across search paths.
129pub fn find_package_dir(
130    name: &str,
131    version: &str,
132    search_paths: &[PathBuf],
133) -> Result<PathBuf, PackageCopyError> {
134    for base in search_paths {
135        // Check both version and version subdir patterns
136        let candidates = [
137            base.join(name).join(version),
138            base.join(name).join(format!("{}-{}", name, version)),
139        ];
140
141        for candidate in &candidates {
142            if candidate.exists() && candidate.is_dir() {
143                return Ok(normalize_path(candidate));
144            }
145        }
146    }
147
148    Err(PackageCopyError::VersionNotFound {
149        name: name.to_string(),
150        version: version.to_string(),
151    })
152}
153
154/// Normalize a path to avoid UNC path issues on Windows.
155///
156/// Addresses Rez issue #1438: "Environment resolution uses UNC paths with Python 3.10"
157fn normalize_path(path: &Path) -> PathBuf {
158    // Use dunce to strip UNC prefixes on Windows
159    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
160}
161
162#[derive(Debug, Default)]
163struct CopyStats {
164    files: usize,
165    bytes: u64,
166}
167
168/// Recursively copy a directory, preserving symlinks and permissions.
169fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<CopyStats, std::io::Error> {
170    let mut stats = CopyStats::default();
171
172    if !dest.exists() {
173        fs::create_dir_all(dest)?;
174    }
175
176    for entry in fs::read_dir(src)? {
177        let entry = entry?;
178        let file_type = entry.file_type()?;
179        let src_path = entry.path();
180        let dest_path = dest.join(entry.file_name());
181
182        if file_type.is_symlink() {
183            let target = fs::read_link(&src_path)?;
184            // On Windows, copy symlinks as regular files if symlink creation fails
185            #[cfg(windows)]
186            {
187                if std::os::windows::fs::symlink_file(&target, &dest_path).is_err()
188                    && std::os::windows::fs::symlink_dir(&target, &dest_path).is_err()
189                {
190                    if target.is_dir() {
191                        let sub_stats = copy_dir_recursive(&target, &dest_path)?;
192                        stats.files += sub_stats.files;
193                        stats.bytes += sub_stats.bytes;
194                    } else {
195                        let bytes = fs::copy(&target, &dest_path)?;
196                        stats.files += 1;
197                        stats.bytes += bytes;
198                    }
199                } else {
200                    stats.files += 1;
201                }
202            }
203            #[cfg(unix)]
204            {
205                std::os::unix::fs::symlink(&target, &dest_path)?;
206                stats.files += 1;
207            }
208        } else if file_type.is_dir() {
209            let sub_stats = copy_dir_recursive(&src_path, &dest_path)?;
210            stats.files += sub_stats.files;
211            stats.bytes += sub_stats.bytes;
212        } else if file_type.is_file() {
213            let bytes = fs::copy(&src_path, &dest_path)?;
214            stats.files += 1;
215            stats.bytes += bytes;
216        }
217    }
218
219    Ok(stats)
220}
221
222/// Calculate total size of a directory recursively.
223pub fn dir_size(path: &Path) -> Result<u64, std::io::Error> {
224    let mut total = 0u64;
225    if path.is_dir() {
226        for entry in fs::read_dir(path)? {
227            let entry = entry?;
228            let meta = entry.metadata()?;
229            if meta.is_dir() {
230                total += dir_size(&entry.path())?;
231            } else {
232                total += meta.len();
233            }
234        }
235    }
236    Ok(total)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use tempfile::TempDir;
243
244    #[test]
245    fn test_find_package_dir_exists() {
246        let tmp = TempDir::new().unwrap();
247        let pkg_dir = tmp.path().join("testpkg").join("1.0.0");
248        fs::create_dir_all(&pkg_dir).unwrap();
249        fs::write(pkg_dir.join("package.py"), "name = 'testpkg'").unwrap();
250
251        let result = find_package_dir("testpkg", "1.0.0", &[tmp.path().to_path_buf()]);
252        assert!(result.is_ok());
253    }
254
255    #[test]
256    fn test_find_package_dir_not_found() {
257        let tmp = TempDir::new().unwrap();
258        let result = find_package_dir("nonexistent", "1.0.0", &[tmp.path().to_path_buf()]);
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn test_copy_package_basic() {
264        let src_tmp = TempDir::new().unwrap();
265        let dest_tmp = TempDir::new().unwrap();
266
267        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
268        fs::create_dir_all(&pkg_dir).unwrap();
269        fs::write(
270            pkg_dir.join("package.py"),
271            "name = 'mypkg'\nversion = '1.0.0'",
272        )
273        .unwrap();
274        fs::write(pkg_dir.join("data.txt"), "hello").unwrap();
275
276        let config = PackageCopyConfig {
277            packages_path: vec![src_tmp.path().to_path_buf()],
278            ..Default::default()
279        };
280
281        let result = copy_package("mypkg", "1.0.0", dest_tmp.path(), &config).unwrap();
282        assert!(result.destination.exists());
283        assert!(result.files_copied > 0);
284        assert!(result.bytes_copied > 0);
285    }
286
287    #[test]
288    fn test_copy_package_destination_exists_no_force() {
289        let src_tmp = TempDir::new().unwrap();
290        let dest_tmp = TempDir::new().unwrap();
291
292        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
293        fs::create_dir_all(&pkg_dir).unwrap();
294        fs::write(pkg_dir.join("package.py"), "name = 'mypkg'").unwrap();
295
296        // Pre-create destination
297        fs::create_dir_all(dest_tmp.path().join("mypkg").join("1.0.0")).unwrap();
298
299        let config = PackageCopyConfig {
300            packages_path: vec![src_tmp.path().to_path_buf()],
301            force: false,
302            ..Default::default()
303        };
304
305        let result = copy_package("mypkg", "1.0.0", dest_tmp.path(), &config);
306        assert!(result.is_err());
307        assert!(result.unwrap_err().to_string().contains("already exists"));
308    }
309
310    #[test]
311    fn test_copy_package_destination_exists_with_force() {
312        let src_tmp = TempDir::new().unwrap();
313        let dest_tmp = TempDir::new().unwrap();
314
315        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
316        fs::create_dir_all(&pkg_dir).unwrap();
317        fs::write(pkg_dir.join("package.py"), "name = 'mypkg'").unwrap();
318
319        let config = PackageCopyConfig {
320            packages_path: vec![src_tmp.path().to_path_buf()],
321            force: true,
322            ..Default::default()
323        };
324
325        let result = copy_package("mypkg", "1.0.0", dest_tmp.path(), &config).unwrap();
326        assert!(result.destination.exists());
327    }
328}