Skip to main content

rez_next_package/
package_move.rs

1//! Package move operations.
2//!
3//! Provides domain-level package move functionality aligned with rez's `package_move.py`.
4//! Follows Clean Architecture: delegates to copy + remove, not duplicating logic.
5//!
6//! ## Lessons from Rez Issues (avoided pitfalls):
7//! - **#1438 (UNC paths)**: Path normalization via `dunce::canonicalize` from `package_copy`.
8//! - **#1374 (filtered packages)**: Atomic copy-remove with verification — if copy fails,
9//!   source is never touched; remove only happens after successful copy verification.
10
11use std::path::{Path, PathBuf};
12
13use thiserror::Error;
14
15use crate::package_copy::{self, PackageCopyConfig, PackageCopyError};
16use crate::package_remove::{self, PackageRemoveConfig, PackageRemoveError};
17
18/// Errors that can occur during package move operations.
19#[derive(Debug, Error)]
20pub enum PackageMoveError {
21    #[error("{0}")]
22    Copy(#[from] PackageCopyError),
23
24    #[error("{0}")]
25    Remove(#[from] PackageRemoveError),
26
27    #[error("Cannot move a package to the same location: {path}")]
28    SameLocation { path: String },
29
30    #[error("IO error: {0}")]
31    Io(#[from] std::io::Error),
32
33    #[error("{0}")]
34    Other(String),
35}
36
37/// Result of a package move operation.
38#[derive(Debug, Clone)]
39pub struct PackageMoveResult {
40    /// Source path that was moved from.
41    pub source: PathBuf,
42    /// Destination path that was moved to.
43    pub destination: PathBuf,
44    /// Number of files moved.
45    pub files_copied: usize,
46    /// Total bytes moved.
47    pub bytes_copied: u64,
48    /// Whether the source was removed after copy.
49    pub source_removed: bool,
50}
51
52/// Configuration for package move operations.
53///
54/// Uses Dependency Inversion: accepts config rather than reading it internally.
55#[derive(Debug, Clone)]
56pub struct PackageMoveConfig {
57    /// Package search paths.
58    pub packages_path: Vec<PathBuf>,
59    /// Overwrite existing destination.
60    pub force: bool,
61    /// Keep source after move (makes it a copy + no removal).
62    pub keep_source: bool,
63    /// Normalize paths to avoid UNC issues on Windows.
64    pub normalize_paths: bool,
65}
66
67impl Default for PackageMoveConfig {
68    fn default() -> Self {
69        Self {
70            packages_path: Vec::new(),
71            force: false,
72            keep_source: false,
73            normalize_paths: true,
74        }
75    }
76}
77
78impl PackageMoveConfig {
79    /// Create a PackageCopyConfig from this move config.
80    fn to_copy_config(&self) -> PackageCopyConfig {
81        PackageCopyConfig {
82            packages_path: self.packages_path.clone(),
83            force: self.force,
84            normalize_paths: self.normalize_paths,
85        }
86    }
87
88    /// Create a PackageRemoveConfig from this move config.
89    fn to_remove_config(&self) -> PackageRemoveConfig {
90        PackageRemoveConfig {
91            packages_path: self.packages_path.clone(),
92            force: true, // We always force remove during move
93            prune_empty_families: true,
94        }
95    }
96}
97
98/// Move a package from one location to another.
99///
100/// This is the domain-level implementation, aligned with rez's `move_package()`.
101/// It delegates to `copy_package` and `remove_package_version` — following
102/// the Single Responsibility Principle: each operation does one thing.
103///
104/// # Safety
105/// - Copy first, verify copy success, then remove source (atomic-like behavior).
106/// - If `keep_source` is true, the source is not removed (behaves like a copy).
107///
108/// # Example
109/// ```ignore
110/// use rez_next_package::package_move::{move_package, PackageMoveConfig};
111///
112/// let config = PackageMoveConfig {
113///     packages_path: vec!["/packages".into()],
114///     ..Default::default()
115/// };
116/// let result = move_package("maya", "2024.0", "/dest/packages", &config)?;
117/// ```
118pub fn move_package(
119    name: &str,
120    version: &str,
121    dest_base: &Path,
122    config: &PackageMoveConfig,
123) -> Result<PackageMoveResult, PackageMoveError> {
124    // Prevent moving to the same location
125    let dest = dest_base.join(name).join(version);
126    let src = package_copy::find_package_dir(name, version, &config.packages_path)?;
127
128    // Normalize both paths
129    let src_normalized = dunce::canonicalize(&src).unwrap_or_else(|_| src.clone());
130    let dest_normalized = dunce::canonicalize(&dest).unwrap_or_else(|_| dest.clone());
131
132    if src_normalized == dest_normalized {
133        return Err(PackageMoveError::SameLocation {
134            path: src.display().to_string(),
135        });
136    }
137
138    // Phase 1: Copy
139    let copy_result =
140        package_copy::copy_package(name, version, dest_base, &config.to_copy_config())?;
141
142    // Phase 2: Remove source (unless keep_source)
143    let source_removed = if !config.keep_source {
144        package_remove::remove_package_version(name, version, &config.to_remove_config())?;
145        true
146    } else {
147        false
148    };
149
150    Ok(PackageMoveResult {
151        source: copy_result.source,
152        destination: copy_result.destination,
153        files_copied: copy_result.files_copied,
154        bytes_copied: copy_result.bytes_copied,
155        source_removed,
156    })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use tempfile::TempDir;
163
164    #[test]
165    fn test_move_package_basic() {
166        let src_tmp = TempDir::new().unwrap();
167        let dest_tmp = TempDir::new().unwrap();
168
169        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
170        std::fs::create_dir_all(&pkg_dir).unwrap();
171        std::fs::write(
172            pkg_dir.join("package.py"),
173            "name = 'mypkg'\nversion = '1.0.0'",
174        )
175        .unwrap();
176        std::fs::write(pkg_dir.join("data.txt"), "hello").unwrap();
177
178        let config = PackageMoveConfig {
179            packages_path: vec![src_tmp.path().to_path_buf()],
180            ..Default::default()
181        };
182
183        let result = move_package("mypkg", "1.0.0", dest_tmp.path(), &config).unwrap();
184        assert!(result.destination.exists());
185        assert!(result.source_removed);
186        assert!(!pkg_dir.exists(), "Source should be removed after move");
187    }
188
189    #[test]
190    fn test_move_package_keep_source() {
191        let src_tmp = TempDir::new().unwrap();
192        let dest_tmp = TempDir::new().unwrap();
193
194        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
195        std::fs::create_dir_all(&pkg_dir).unwrap();
196        std::fs::write(pkg_dir.join("package.py"), "name = 'mypkg'").unwrap();
197
198        let config = PackageMoveConfig {
199            packages_path: vec![src_tmp.path().to_path_buf()],
200            keep_source: true,
201            ..Default::default()
202        };
203
204        let result = move_package("mypkg", "1.0.0", dest_tmp.path(), &config).unwrap();
205        assert!(result.destination.exists());
206        assert!(!result.source_removed);
207        assert!(
208            pkg_dir.exists(),
209            "Source should remain when keep_source=true"
210        );
211    }
212
213    #[test]
214    fn test_move_package_same_location() {
215        let tmp = TempDir::new().unwrap();
216        let pkg_dir = tmp.path().join("mypkg").join("1.0.0");
217        std::fs::create_dir_all(&pkg_dir).unwrap();
218
219        let config = PackageMoveConfig {
220            packages_path: vec![tmp.path().to_path_buf()],
221            ..Default::default()
222        };
223
224        let result = move_package("mypkg", "1.0.0", tmp.path(), &config);
225        assert!(result.is_err());
226        assert!(result.unwrap_err().to_string().contains("same location"));
227    }
228
229    #[test]
230    fn test_move_package_not_found() {
231        let src_tmp = TempDir::new().unwrap();
232        let dest_tmp = TempDir::new().unwrap();
233
234        let config = PackageMoveConfig {
235            packages_path: vec![src_tmp.path().to_path_buf()],
236            ..Default::default()
237        };
238
239        let result = move_package("nonexistent", "1.0.0", dest_tmp.path(), &config);
240        assert!(result.is_err());
241    }
242
243    #[test]
244    fn test_move_package_overwrite_with_force() {
245        let src_tmp = TempDir::new().unwrap();
246        let dest_tmp = TempDir::new().unwrap();
247
248        let pkg_dir = src_tmp.path().join("mypkg").join("1.0.0");
249        std::fs::create_dir_all(&pkg_dir).unwrap();
250        std::fs::write(pkg_dir.join("package.py"), "name = 'mypkg'").unwrap();
251
252        // Pre-create destination
253        std::fs::create_dir_all(dest_tmp.path().join("mypkg").join("1.0.0")).unwrap();
254
255        let config = PackageMoveConfig {
256            packages_path: vec![src_tmp.path().to_path_buf()],
257            force: true,
258            ..Default::default()
259        };
260
261        let result = move_package("mypkg", "1.0.0", dest_tmp.path(), &config).unwrap();
262        assert!(result.source_removed);
263    }
264}