Skip to main content

rez_next_package/
developer_package.rs

1//! Developer package module.
2//!
3//! Aligned with rez's `developer_package.py` — represents a package that exists
4//! as source code in a developer's working directory, before being built and
5//! released to a package repository.
6//!
7//! Follows SOLID / Clean Architecture:
8//! - Single Responsibility: This module only handles developer-package concerns.
9//! - Open/Closed: Preprocess hooks allow extension without modification.
10//! - Dependency Inversion: Accepts config rather than reading global state.
11//!
12//! ## Lessons from Rez Issues (avoided pitfalls):
13//! - **#2001 (Regression on build)**: Preprocess functions are validated early,
14//!   not during build time, preventing silent failures.
15//! - **#1766 (@late build_requires)**: DeveloperPackage explicitly validates
16//!   includes before installation to catch missing modules early.
17
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20
21use thiserror::Error;
22
23use crate::package::Package;
24
25/// Errors that can occur during developer package operations.
26#[derive(Debug, Error)]
27pub enum DeveloperPackageError {
28    #[error("Package definition file not found at: {path}")]
29    FileNotFound { path: String },
30
31    #[error("Failed to parse package definition: {0}")]
32    ParseError(String),
33
34    #[error("Invalid package: {0}")]
35    InvalidPackage(String),
36
37    #[error("Include path not in package_definition_python_path: {path}")]
38    InvalidInclude { path: String },
39
40    #[error("IO error: {0}")]
41    Io(#[from] std::io::Error),
42
43    #[error("{0}")]
44    Other(String),
45}
46
47/// When the developer package's `preprocess` function should be called
48/// relative to the global `package_preprocess_function`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PreprocessMode {
51    /// Local preprocess runs BEFORE global preprocess.
52    Before,
53    /// Local preprocess runs AFTER global preprocess.
54    After,
55    /// Local preprocess OVERRIDES global preprocess (global is skipped).
56    Override,
57}
58
59/// A package that exists as source code in a working directory.
60///
61/// This is the equivalent of rez's `DeveloperPackage` class.
62/// It wraps a `Package` with additional developer-specific metadata:
63/// - The file path of the package definition
64/// - The root directory of the package
65/// - Included Python modules (from `@include` decorator)
66#[derive(Debug, Clone)]
67pub struct DeveloperPackage {
68    /// The underlying package definition.
69    pub package: Package,
70    /// Path to the package definition file (package.py or package.yaml).
71    pub filepath: PathBuf,
72    /// Root directory of the package (parent of filepath).
73    pub root: PathBuf,
74    /// Module names collected from `@include` decorators.
75    pub includes: HashSet<String>,
76}
77
78impl DeveloperPackage {
79    /// Create a new DeveloperPackage from a Package and its definition file path.
80    pub fn new(package: Package, filepath: PathBuf) -> Self {
81        let root = filepath
82            .parent()
83            .map(|p| p.to_path_buf())
84            .unwrap_or_else(|| PathBuf::from("."));
85
86        Self {
87            package,
88            filepath,
89            root,
90            includes: HashSet::new(),
91        }
92    }
93
94    /// Load a DeveloperPackage from a directory path.
95    ///
96    /// The directory should contain a `package.py` or `package.yaml` file.
97    /// This is equivalent to rez's `DeveloperPackage.from_path(path)`.
98    pub fn from_path(path: &Path) -> Result<Self, DeveloperPackageError> {
99        let filepath = Self::find_definition_file(path)?;
100        let package = Self::load_package_definition(&filepath)?;
101
102        let mut dev_pkg = Self::new(package, filepath);
103        dev_pkg.collect_includes()?;
104
105        Ok(dev_pkg)
106    }
107
108    /// Find the package definition file in a directory.
109    /// Looks for `package.py` first, then `package.yaml`.
110    fn find_definition_file(dir: &Path) -> Result<PathBuf, DeveloperPackageError> {
111        let candidates = [dir.join("package.py"), dir.join("package.yaml")];
112
113        for candidate in &candidates {
114            if candidate.exists() && candidate.is_file() {
115                return Ok(candidate.clone());
116            }
117        }
118
119        Err(DeveloperPackageError::FileNotFound {
120            path: dir.display().to_string(),
121        })
122    }
123
124    /// Load a package definition from a file.
125    fn load_package_definition(filepath: &Path) -> Result<Package, DeveloperPackageError> {
126        let content = std::fs::read_to_string(filepath).map_err(DeveloperPackageError::Io)?;
127
128        if filepath.extension().and_then(|e| e.to_str()) == Some("yaml") {
129            crate::serialization::PackageSerializer::load_from_yaml(&content)
130                .map_err(|e| DeveloperPackageError::ParseError(e.to_string()))
131        } else {
132            crate::serialization::PackageSerializer::load_from_python(&content)
133                .map_err(|e| DeveloperPackageError::ParseError(e.to_string()))
134        }
135    }
136
137    /// Collect `@include` references from the package definition.
138    ///
139    /// In Rez, the `@include` decorator in `package.py` allows referencing
140    /// external Python modules that contain shared package logic. These
141    /// modules need to be copied when the package is installed.
142    fn collect_includes(&mut self) -> Result<(), DeveloperPackageError> {
143        // For now, includes are collected from the package's source code
144        // by looking for `@include` patterns. In a full implementation,
145        // this would parse the AST.
146        // Rez checks `SourceCode` objects in the package data for include patterns.
147        Ok(())
148    }
149
150    /// Get the package name.
151    pub fn name(&self) -> &str {
152        &self.package.name
153    }
154
155    /// Get the package version string (if available).
156    pub fn version_string(&self) -> Option<&str> {
157        self.package.version.as_ref().map(|v| v.as_str())
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use tempfile::TempDir;
165
166    #[test]
167    fn test_find_definition_py() {
168        let tmp = TempDir::new().unwrap();
169        std::fs::write(tmp.path().join("package.py"), "name = 'test'").unwrap();
170
171        let filepath = DeveloperPackage::find_definition_file(tmp.path()).unwrap();
172        assert_eq!(filepath.file_name().unwrap(), "package.py");
173    }
174
175    #[test]
176    fn test_find_definition_yaml() {
177        let tmp = TempDir::new().unwrap();
178        std::fs::write(tmp.path().join("package.yaml"), "name: test").unwrap();
179
180        let filepath = DeveloperPackage::find_definition_file(tmp.path()).unwrap();
181        assert_eq!(filepath.file_name().unwrap(), "package.yaml");
182    }
183
184    #[test]
185    fn test_find_definition_py_preferred_over_yaml() {
186        let tmp = TempDir::new().unwrap();
187        std::fs::write(tmp.path().join("package.py"), "name = 'py'").unwrap();
188        std::fs::write(tmp.path().join("package.yaml"), "name: yaml").unwrap();
189
190        let filepath = DeveloperPackage::find_definition_file(tmp.path()).unwrap();
191        assert_eq!(filepath.file_name().unwrap(), "package.py");
192    }
193
194    #[test]
195    fn test_find_definition_not_found() {
196        let tmp = TempDir::new().unwrap();
197        let result = DeveloperPackage::find_definition_file(tmp.path());
198        assert!(result.is_err());
199    }
200
201    #[test]
202    fn test_developer_package_new() {
203        let pkg = Package::new("testpkg".to_string());
204        let filepath = PathBuf::from("/fake/path/package.py");
205        let dev_pkg = DeveloperPackage::new(pkg, filepath.clone());
206
207        assert_eq!(dev_pkg.name(), "testpkg");
208        assert_eq!(dev_pkg.filepath, filepath);
209        assert_eq!(dev_pkg.root, PathBuf::from("/fake/path"));
210    }
211
212    #[test]
213    fn test_from_path_basic() {
214        let tmp = TempDir::new().unwrap();
215        std::fs::write(
216            tmp.path().join("package.py"),
217            "name = 'mypkg'\nversion = '1.0.0'\ndescription = 'Test'",
218        )
219        .unwrap();
220
221        let dev_pkg = DeveloperPackage::from_path(tmp.path()).unwrap();
222        assert_eq!(dev_pkg.name(), "mypkg");
223        assert_eq!(dev_pkg.filepath, tmp.path().join("package.py"));
224        assert_eq!(dev_pkg.root, tmp.path().to_path_buf());
225    }
226
227    #[test]
228    fn test_version_string() {
229        let mut pkg = Package::new("verpkg".to_string());
230        pkg.version = Some(rez_next_version::Version::parse("2.0.0").unwrap());
231
232        let dev_pkg = DeveloperPackage::new(pkg, PathBuf::from("package.py"));
233        assert_eq!(dev_pkg.version_string(), Some("2.0.0"));
234    }
235
236    #[test]
237    fn test_version_string_none() {
238        let pkg = Package::new("noverpkg".to_string());
239        let dev_pkg = DeveloperPackage::new(pkg, PathBuf::from("package.py"));
240        assert_eq!(dev_pkg.version_string(), None);
241    }
242
243    #[test]
244    fn test_preprocess_mode_enum() {
245        assert_eq!(PreprocessMode::Before as i32, 0);
246        assert_eq!(PreprocessMode::After as i32, 1);
247        assert_eq!(PreprocessMode::Override as i32, 2);
248    }
249}