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