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    ///
23    /// Excluded from coverage: tarpaulin mis-attributes the multi-line
24    /// `&&`-condition's first line under normal rustfmt despite both
25    /// branches being exercised by `test_check_changed_*`. The function
26    /// is fully covered by its tests; the gap is a reporting artifact.
27    #[cfg(not(tarpaulin_include))]
28    fn check_changed(&mut self, path: &Path) -> Result<()> {
29        if self.is_changed() {
30            return Ok(());
31        }
32        if !path.to_string_lossy().contains(".changepacks")
33            && path.starts_with(self.path().parent().context("Parent not found")?)
34        {
35            self.set_changed(true);
36        }
37        Ok(())
38    }
39    fn is_changed(&self) -> bool;
40    fn language(&self) -> Language;
41
42    fn dependencies(&self) -> &HashSet<String>;
43    fn add_dependency(&mut self, dependency: &str);
44
45    fn set_changed(&mut self, changed: bool);
46
47    /// Set the package name (used for fallback when name is not found in manifest)
48    fn set_name(&mut self, _name: String) {}
49
50    /// Get the default publish command for this package type
51    fn default_publish_command(&self) -> String;
52
53    /// Get the default dry-run publish command for this package type.
54    ///
55    /// Returns `None` for ecosystems whose default publish tool does not
56    /// support a built-in dry-run mode (e.g. `dotnet nuget push`). Callers
57    /// should treat `None` as "dry-run not supported; skip with a warning"
58    /// rather than as a failure. Users may still provide an override via
59    /// `config.publish_dry_run`.
60    fn default_dry_run_publish_command(&self) -> Option<String>;
61
62    /// Whether this package inherits its version from the workspace root via `version.workspace = true`
63    fn inherits_workspace_version(&self) -> bool {
64        false
65    }
66
67    /// Path to the workspace root Cargo.toml, if this package inherits its version from workspace
68    fn workspace_root_path(&self) -> Option<&Path> {
69        None
70    }
71
72    /// Publish the package using the configured command or default
73    ///
74    /// # Errors
75    /// Returns error if the publish command fails to spawn or the package directory is missing.
76    /// A non-zero exit code is reported via `PublishOutput::success = false`.
77    #[cfg(not(tarpaulin_include))]
78    async fn publish(&self, config: &Config) -> Result<crate::publish::PublishOutput> {
79        let command = self.get_publish_command(config);
80        let dir = self
81            .path()
82            .parent()
83            .context("Package directory not found")?;
84        crate::publish::run_publish_command(&command, dir).await
85    }
86
87    /// Run the publish command in dry-run mode to verify the pre-release flow
88    /// works without actually publishing.
89    ///
90    /// Returns `Ok(Some(output))` with the captured command output, or
91    /// `Ok(None)` when the language does not support a dry-run mode and the
92    /// user has not provided an override in `config.publish_dry_run`.
93    ///
94    /// # Errors
95    /// Returns error if the dry-run command fails to spawn or the package
96    /// directory is missing. A non-zero exit code is reported via
97    /// `PublishOutput::success = false`.
98    #[cfg(not(tarpaulin_include))]
99    async fn dry_run_publish(
100        &self,
101        config: &Config,
102    ) -> Result<Option<crate::publish::PublishOutput>> {
103        let Some(command) = self.get_dry_run_publish_command(config) else {
104            return Ok(None);
105        };
106        let dir = self
107            .path()
108            .parent()
109            .context("Package directory not found")?;
110        Ok(Some(
111            crate::publish::run_publish_command(&command, dir).await?,
112        ))
113    }
114
115    /// Get the publish command for this package, checking config first
116    fn get_publish_command(&self, config: &Config) -> String {
117        crate::publish::resolve_publish_command(
118            self.relative_path(),
119            self.language(),
120            &self.default_publish_command(),
121            config,
122        )
123    }
124
125    /// Get the dry-run publish command for this package, checking config
126    /// first, then falling back to the package's `default_dry_run_publish_command`.
127    fn get_dry_run_publish_command(&self, config: &Config) -> Option<String> {
128        crate::publish::resolve_dry_run_publish_command(
129            self.relative_path(),
130            self.language(),
131            self.default_dry_run_publish_command().as_deref(),
132            config,
133        )
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::collections::HashMap;
141    use std::path::PathBuf;
142
143    #[derive(Debug)]
144    struct MockPackage {
145        name: Option<String>,
146        path: PathBuf,
147        relative_path: PathBuf,
148        version: Option<String>,
149        language: Language,
150        dependencies: HashSet<String>,
151        changed: bool,
152    }
153
154    impl MockPackage {
155        fn new(name: Option<&str>, path: &str, relative_path: &str) -> Self {
156            Self {
157                name: name.map(String::from),
158                path: PathBuf::from(path),
159                relative_path: PathBuf::from(relative_path),
160                version: Some("1.0.0".to_string()),
161                language: Language::Node,
162                dependencies: HashSet::new(),
163                changed: false,
164            }
165        }
166
167        fn with_language(mut self, language: Language) -> Self {
168            self.language = language;
169            self
170        }
171    }
172
173    #[async_trait]
174    impl Package for MockPackage {
175        fn name(&self) -> Option<&str> {
176            self.name.as_deref()
177        }
178        fn version(&self) -> Option<&str> {
179            self.version.as_deref()
180        }
181        fn path(&self) -> &Path {
182            &self.path
183        }
184        fn relative_path(&self) -> &Path {
185            &self.relative_path
186        }
187        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
188            Ok(())
189        }
190        fn is_changed(&self) -> bool {
191            self.changed
192        }
193        fn language(&self) -> Language {
194            self.language
195        }
196        fn dependencies(&self) -> &HashSet<String> {
197            &self.dependencies
198        }
199        fn add_dependency(&mut self, dependency: &str) {
200            self.dependencies.insert(dependency.to_string());
201        }
202        fn set_changed(&mut self, changed: bool) {
203            self.changed = changed;
204        }
205        fn default_publish_command(&self) -> String {
206            "echo publish".to_string()
207        }
208        fn default_dry_run_publish_command(&self) -> Option<String> {
209            Some("echo publish --dry-run".to_string())
210        }
211    }
212
213    #[test]
214    fn test_check_changed_already_changed() {
215        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
216        package.changed = true;
217
218        package
219            .check_changed(Path::new("/project/src/index.js"))
220            .unwrap();
221        assert!(package.is_changed());
222    }
223
224    #[test]
225    fn test_check_changed_sets_changed() {
226        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
227
228        package
229            .check_changed(Path::new("/project/src/index.js"))
230            .unwrap();
231        assert!(package.is_changed());
232    }
233
234    #[test]
235    fn test_check_changed_ignores_changepacks() {
236        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
237
238        package
239            .check_changed(Path::new("/project/.changepacks/change.json"))
240            .unwrap();
241        assert!(!package.is_changed());
242    }
243
244    #[test]
245    fn test_check_changed_ignores_other_projects() {
246        let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
247
248        package
249            .check_changed(Path::new("/other-project/src/index.js"))
250            .unwrap();
251        assert!(!package.is_changed());
252    }
253
254    #[test]
255    fn test_inherits_workspace_version_default() {
256        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
257        assert!(!package.inherits_workspace_version());
258    }
259
260    #[test]
261    fn test_workspace_root_path_default() {
262        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
263        assert!(package.workspace_root_path().is_none());
264    }
265
266    #[test]
267    fn test_get_publish_command_by_path() {
268        let package = MockPackage::new(
269            Some("test"),
270            "/project/package.json",
271            "packages/core/package.json",
272        );
273        let mut publish = HashMap::new();
274        publish.insert(
275            "packages/core/package.json".to_string(),
276            "custom publish".to_string(),
277        );
278        let config = Config {
279            publish,
280            ..Default::default()
281        };
282
283        assert_eq!(package.get_publish_command(&config), "custom publish");
284    }
285
286    #[test]
287    fn test_get_publish_command_by_language_node() {
288        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json")
289            .with_language(Language::Node);
290        let mut publish = HashMap::new();
291        publish.insert(
292            "node".to_string(),
293            "npm publish --access public".to_string(),
294        );
295        let config = Config {
296            publish,
297            ..Default::default()
298        };
299
300        assert_eq!(
301            package.get_publish_command(&config),
302            "npm publish --access public"
303        );
304    }
305
306    #[test]
307    fn test_get_publish_command_by_language_python() {
308        let package = MockPackage::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
309            .with_language(Language::Python);
310        let mut publish = HashMap::new();
311        publish.insert("python".to_string(), "poetry publish".to_string());
312        let config = Config {
313            publish,
314            ..Default::default()
315        };
316
317        assert_eq!(package.get_publish_command(&config), "poetry publish");
318    }
319
320    #[test]
321    fn test_get_publish_command_by_language_rust() {
322        let package = MockPackage::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
323            .with_language(Language::Rust);
324        let mut publish = HashMap::new();
325        publish.insert("rust".to_string(), "cargo publish".to_string());
326        let config = Config {
327            publish,
328            ..Default::default()
329        };
330
331        assert_eq!(package.get_publish_command(&config), "cargo publish");
332    }
333
334    #[test]
335    fn test_get_publish_command_by_language_dart() {
336        let package = MockPackage::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
337            .with_language(Language::Dart);
338        let mut publish = HashMap::new();
339        publish.insert("dart".to_string(), "dart pub publish".to_string());
340        let config = Config {
341            publish,
342            ..Default::default()
343        };
344
345        assert_eq!(package.get_publish_command(&config), "dart pub publish");
346    }
347
348    #[test]
349    fn test_get_publish_command_default() {
350        let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
351        let config = Config::default();
352
353        assert_eq!(package.get_publish_command(&config), "echo publish");
354    }
355
356    #[tokio::test]
357    async fn test_publish_success() {
358        let temp_dir = std::env::temp_dir();
359        let path = temp_dir.join("package.json");
360        let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
361        let config = Config::default();
362
363        let output = package.publish(&config).await.unwrap();
364        assert!(output.success);
365    }
366
367    #[tokio::test]
368    async fn test_publish_failure() {
369        let temp_dir = std::env::temp_dir();
370        let path = temp_dir.join("package.json");
371        let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
372        let mut publish = HashMap::new();
373        let fail_cmd = if cfg!(target_os = "windows") {
374            "cmd /c exit 1"
375        } else {
376            "exit 1"
377        };
378        publish.insert("node".to_string(), fail_cmd.to_string());
379        let config = Config {
380            publish,
381            ..Default::default()
382        };
383
384        let output = package.publish(&config).await.unwrap();
385        assert!(!output.success);
386    }
387
388    #[tokio::test]
389    async fn test_publish_no_parent_directory() {
390        let package = MockPackage {
391            name: Some("test".to_string()),
392            path: PathBuf::from(""),
393            relative_path: PathBuf::from(""),
394            version: Some("1.0.0".to_string()),
395            language: Language::Node,
396            dependencies: HashSet::new(),
397            changed: false,
398        };
399        let config = Config::default();
400        let result = package.publish(&config).await;
401        assert!(result.is_err());
402        assert!(
403            result
404                .unwrap_err()
405                .to_string()
406                .contains("Package directory not found")
407        );
408    }
409
410    #[test]
411    fn test_set_name_default_is_noop() {
412        let mut package =
413            MockPackage::new(Some("original"), "/project/package.json", "package.json");
414        package.set_name("new-name".to_string());
415        // Default implementation is a no-op, name should remain unchanged
416        assert_eq!(package.name(), Some("original"));
417    }
418}