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