Skip to main content

changepacks_core/
package.rs

1use std::{collections::HashSet, path::Path};
2
3use crate::{Config, Language, update_type::UpdateType};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6
7/// Interface for single versioned packages.
8///
9/// Implemented by language-specific package types for reading versions, updating files,
10/// detecting changes, and publishing. All I/O operations are async.
11#[async_trait]
12pub trait Package: std::fmt::Debug + Send + Sync {
13    fn name(&self) -> Option<&str>;
14    fn version(&self) -> Option<&str>;
15    fn path(&self) -> &Path;
16    fn relative_path(&self) -> &Path;
17    /// # Errors
18    /// Returns error if the version update operation fails.
19    async fn update_version(&mut self, update_type: UpdateType) -> Result<()>;
20    /// # Errors
21    /// Returns error if the parent path cannot be determined.
22    fn check_changed(&mut self, path: &Path) -> Result<()> {
23        if self.is_changed() {
24            return Ok(());
25        }
26        if !path.to_string_lossy().contains(".changepacks")
27            && path.starts_with(self.path().parent().context("Parent not found")?)
28        {
29            self.set_changed(true);
30        }
31        Ok(())
32    }
33    fn is_changed(&self) -> bool;
34    fn language(&self) -> Language;
35
36    fn dependencies(&self) -> &HashSet<String>;
37    fn add_dependency(&mut self, dependency: &str);
38
39    fn set_changed(&mut self, changed: bool);
40
41    /// Get the default publish command for this package type
42    fn default_publish_command(&self) -> String;
43
44    /// Whether this package inherits its version from the workspace root via `version.workspace = true`
45    fn inherits_workspace_version(&self) -> bool {
46        false
47    }
48
49    /// Path to the workspace root Cargo.toml, if this package inherits its version from workspace
50    fn workspace_root_path(&self) -> Option<&Path> {
51        None
52    }
53
54    /// Publish the package using the configured command or default
55    ///
56    /// # Errors
57    /// Returns error if the publish command fails to execute or returns non-zero exit code.
58    #[cfg(not(tarpaulin_include))]
59    async fn publish(&self, config: &Config) -> Result<()> {
60        let command = self.get_publish_command(config);
61        let dir = self
62            .path()
63            .parent()
64            .context("Package directory not found")?;
65        crate::publish::run_publish_command(&command, dir).await
66    }
67
68    /// Get the publish command for this package, checking config first
69    fn get_publish_command(&self, config: &Config) -> String {
70        crate::publish::resolve_publish_command(
71            self.relative_path(),
72            self.language(),
73            &self.default_publish_command(),
74            config,
75        )
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use std::collections::HashMap;
83    use std::path::PathBuf;
84
85    #[derive(Debug)]
86    struct MockPackage {
87        name: Option<String>,
88        path: PathBuf,
89        relative_path: PathBuf,
90        version: Option<String>,
91        language: Language,
92        dependencies: HashSet<String>,
93        changed: bool,
94    }
95
96    impl MockPackage {
97        fn new(name: Option<&str>, path: &str, relative_path: &str) -> Self {
98            Self {
99                name: name.map(String::from),
100                path: PathBuf::from(path),
101                relative_path: PathBuf::from(relative_path),
102                version: Some("1.0.0".to_string()),
103                language: Language::Node,
104                dependencies: HashSet::new(),
105                changed: false,
106            }
107        }
108
109        fn with_language(mut self, language: Language) -> Self {
110            self.language = language;
111            self
112        }
113    }
114
115    #[async_trait]
116    impl Package for MockPackage {
117        fn name(&self) -> Option<&str> {
118            self.name.as_deref()
119        }
120        fn version(&self) -> Option<&str> {
121            self.version.as_deref()
122        }
123        fn path(&self) -> &Path {
124            &self.path
125        }
126        fn relative_path(&self) -> &Path {
127            &self.relative_path
128        }
129        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
130            Ok(())
131        }
132        fn is_changed(&self) -> bool {
133            self.changed
134        }
135        fn language(&self) -> Language {
136            self.language
137        }
138        fn dependencies(&self) -> &HashSet<String> {
139            &self.dependencies
140        }
141        fn add_dependency(&mut self, dependency: &str) {
142            self.dependencies.insert(dependency.to_string());
143        }
144        fn set_changed(&mut self, changed: bool) {
145            self.changed = changed;
146        }
147        fn default_publish_command(&self) -> String {
148            "echo publish".to_string()
149        }
150    }
151
152    #[test]
153    fn test_check_changed_already_changed() {
154        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
155        package.changed = true;
156
157        package
158            .check_changed(Path::new("/project/src/index.js"))
159            .unwrap();
160        assert!(package.is_changed());
161    }
162
163    #[test]
164    fn test_check_changed_sets_changed() {
165        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
166
167        package
168            .check_changed(Path::new("/project/src/index.js"))
169            .unwrap();
170        assert!(package.is_changed());
171    }
172
173    #[test]
174    fn test_check_changed_ignores_changepacks() {
175        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
176
177        package
178            .check_changed(Path::new("/project/.changepacks/change.json"))
179            .unwrap();
180        assert!(!package.is_changed());
181    }
182
183    #[test]
184    fn test_check_changed_ignores_other_projects() {
185        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
186
187        package
188            .check_changed(Path::new("/other-project/src/index.js"))
189            .unwrap();
190        assert!(!package.is_changed());
191    }
192
193    #[test]
194    fn test_inherits_workspace_version_default() {
195        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
196        assert!(!package.inherits_workspace_version());
197    }
198
199    #[test]
200    fn test_workspace_root_path_default() {
201        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
202        assert!(package.workspace_root_path().is_none());
203    }
204
205    #[test]
206    fn test_get_publish_command_by_path() {
207        let package = MockPackage::new(
208            Some("test"),
209            "/project/package.json",
210            "packages/core/package.json",
211        );
212        let mut publish = HashMap::new();
213        publish.insert(
214            "packages/core/package.json".to_string(),
215            "custom publish".to_string(),
216        );
217        let config = Config {
218            publish,
219            ..Default::default()
220        };
221
222        assert_eq!(package.get_publish_command(&config), "custom publish");
223    }
224
225    #[test]
226    fn test_get_publish_command_by_language_node() {
227        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json")
228            .with_language(Language::Node);
229        let mut publish = HashMap::new();
230        publish.insert(
231            "node".to_string(),
232            "npm publish --access public".to_string(),
233        );
234        let config = Config {
235            publish,
236            ..Default::default()
237        };
238
239        assert_eq!(
240            package.get_publish_command(&config),
241            "npm publish --access public"
242        );
243    }
244
245    #[test]
246    fn test_get_publish_command_by_language_python() {
247        let package = MockPackage::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
248            .with_language(Language::Python);
249        let mut publish = HashMap::new();
250        publish.insert("python".to_string(), "poetry publish".to_string());
251        let config = Config {
252            publish,
253            ..Default::default()
254        };
255
256        assert_eq!(package.get_publish_command(&config), "poetry publish");
257    }
258
259    #[test]
260    fn test_get_publish_command_by_language_rust() {
261        let package = MockPackage::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
262            .with_language(Language::Rust);
263        let mut publish = HashMap::new();
264        publish.insert("rust".to_string(), "cargo publish".to_string());
265        let config = Config {
266            publish,
267            ..Default::default()
268        };
269
270        assert_eq!(package.get_publish_command(&config), "cargo publish");
271    }
272
273    #[test]
274    fn test_get_publish_command_by_language_dart() {
275        let package = MockPackage::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
276            .with_language(Language::Dart);
277        let mut publish = HashMap::new();
278        publish.insert("dart".to_string(), "dart pub publish".to_string());
279        let config = Config {
280            publish,
281            ..Default::default()
282        };
283
284        assert_eq!(package.get_publish_command(&config), "dart pub publish");
285    }
286
287    #[test]
288    fn test_get_publish_command_default() {
289        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
290        let config = Config::default();
291
292        assert_eq!(package.get_publish_command(&config), "echo publish");
293    }
294
295    #[tokio::test]
296    async fn test_publish_success() {
297        let temp_dir = std::env::temp_dir();
298        let path = temp_dir.join("package.json");
299        let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
300        let config = Config::default();
301
302        let result = package.publish(&config).await;
303        assert!(result.is_ok());
304    }
305
306    #[tokio::test]
307    async fn test_publish_failure() {
308        let temp_dir = std::env::temp_dir();
309        let path = temp_dir.join("package.json");
310        let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
311        let mut publish = HashMap::new();
312        let fail_cmd = if cfg!(target_os = "windows") {
313            "cmd /c exit 1"
314        } else {
315            "exit 1"
316        };
317        publish.insert("node".to_string(), fail_cmd.to_string());
318        let config = Config {
319            publish,
320            ..Default::default()
321        };
322
323        let result = package.publish(&config).await;
324        assert!(result.is_err());
325    }
326
327    #[tokio::test]
328    async fn test_publish_no_parent_directory() {
329        let package = MockPackage {
330            name: Some("test".to_string()),
331            path: PathBuf::from(""),
332            relative_path: PathBuf::from(""),
333            version: Some("1.0.0".to_string()),
334            language: Language::Node,
335            dependencies: HashSet::new(),
336            changed: false,
337        };
338        let config = Config::default();
339        let result = package.publish(&config).await;
340        assert!(result.is_err());
341        assert!(
342            result
343                .unwrap_err()
344                .to_string()
345                .contains("Package directory not found")
346        );
347    }
348}