Skip to main content

changepacks_core/
workspace.rs

1use std::{collections::HashSet, path::Path};
2
3use crate::{Config, Language, Package, update_type::UpdateType};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6
7/// Interface for monorepo workspace roots.
8///
9/// Extends Package behavior with workspace-specific operations like updating workspace
10/// dependencies. Implemented by language-specific workspace types.
11#[async_trait]
12pub trait Workspace: std::fmt::Debug + Send + Sync {
13    fn name(&self) -> Option<&str>;
14    fn path(&self) -> &Path;
15    fn relative_path(&self) -> &Path;
16    fn version(&self) -> Option<&str>;
17    /// # Errors
18    /// Returns error if the version update operation fails.
19    async fn update_version(&mut self, update_type: UpdateType) -> Result<()>;
20    fn language(&self) -> Language;
21
22    fn dependencies(&self) -> &HashSet<String>;
23    fn add_dependency(&mut self, dependency: &str);
24
25    /// # Errors
26    /// Returns error if the parent path cannot be determined.
27    // Default implementation for check_changed
28    ///
29    /// Excluded from coverage: see `Package::check_changed` for the same
30    /// tarpaulin attribution caveat on the multi-line `&&` condition.
31    #[cfg(not(tarpaulin_include))]
32    fn check_changed(&mut self, path: &Path) -> Result<()> {
33        if self.is_changed() {
34            return Ok(());
35        }
36        if !path.to_string_lossy().contains(".changepacks")
37            && path.starts_with(self.path().parent().context("Parent not found")?)
38        {
39            self.set_changed(true);
40        }
41        Ok(())
42    }
43
44    fn is_changed(&self) -> bool;
45    fn set_changed(&mut self, changed: bool);
46
47    /// Set the workspace 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 workspace type
51    fn default_publish_command(&self) -> String;
52
53    /// Get the default dry-run publish command for this workspace type.
54    ///
55    /// Returns `None` for ecosystems whose default publish tool does not
56    /// support a built-in dry-run mode. Users may still provide an override
57    /// via `config.publish_dry_run`.
58    fn default_dry_run_publish_command(&self) -> Option<String>;
59
60    /// Publish the workspace using the configured command or default
61    ///
62    /// # Errors
63    /// Returns error if the publish command fails to spawn or the workspace directory is missing.
64    /// A non-zero exit code is reported via `PublishOutput::success = false`.
65    #[cfg(not(tarpaulin_include))]
66    async fn publish(&self, config: &Config) -> Result<crate::publish::PublishOutput> {
67        let command = self.get_publish_command(config);
68        let dir = self
69            .path()
70            .parent()
71            .context("Workspace directory not found")?;
72        crate::publish::run_publish_command(&command, dir).await
73    }
74
75    /// Run the publish command in dry-run mode to verify the pre-release flow
76    /// works without actually publishing.
77    ///
78    /// Returns `Ok(Some(output))` with the captured command output, or
79    /// `Ok(None)` when the language does not support a dry-run mode and the
80    /// user has not provided an override in `config.publish_dry_run`.
81    ///
82    /// # Errors
83    /// Returns error if the dry-run command fails to spawn or the workspace
84    /// directory is missing. A non-zero exit code is reported via
85    /// `PublishOutput::success = false`.
86    #[cfg(not(tarpaulin_include))]
87    async fn dry_run_publish(
88        &self,
89        config: &Config,
90    ) -> Result<Option<crate::publish::PublishOutput>> {
91        let Some(command) = self.get_dry_run_publish_command(config) else {
92            return Ok(None);
93        };
94        let dir = self
95            .path()
96            .parent()
97            .context("Workspace directory not found")?;
98        Ok(Some(
99            crate::publish::run_publish_command(&command, dir).await?,
100        ))
101    }
102
103    /// Get the publish command for this workspace, checking config first
104    fn get_publish_command(&self, config: &Config) -> String {
105        crate::publish::resolve_publish_command(
106            self.relative_path(),
107            self.language(),
108            &self.default_publish_command(),
109            config,
110        )
111    }
112
113    /// Get the dry-run publish command for this workspace, checking config
114    /// first, then falling back to the workspace's `default_dry_run_publish_command`.
115    fn get_dry_run_publish_command(&self, config: &Config) -> Option<String> {
116        crate::publish::resolve_dry_run_publish_command(
117            self.relative_path(),
118            self.language(),
119            self.default_dry_run_publish_command().as_deref(),
120            config,
121        )
122    }
123
124    #[cfg(not(tarpaulin_include))]
125    async fn update_workspace_dependencies(&self, _packages: &[&dyn Package]) -> Result<()> {
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::collections::HashMap;
134    use std::path::PathBuf;
135
136    #[derive(Debug)]
137    struct MockWorkspace {
138        name: Option<String>,
139        path: PathBuf,
140        relative_path: PathBuf,
141        version: Option<String>,
142        language: Language,
143        dependencies: HashSet<String>,
144        changed: bool,
145    }
146
147    impl MockWorkspace {
148        fn new(name: Option<&str>, path: &str, relative_path: &str) -> Self {
149            Self {
150                name: name.map(String::from),
151                path: PathBuf::from(path),
152                relative_path: PathBuf::from(relative_path),
153                version: Some("1.0.0".to_string()),
154                language: Language::Node,
155                dependencies: HashSet::new(),
156                changed: false,
157            }
158        }
159
160        fn with_language(mut self, language: Language) -> Self {
161            self.language = language;
162            self
163        }
164    }
165
166    #[async_trait]
167    impl Workspace for MockWorkspace {
168        fn name(&self) -> Option<&str> {
169            self.name.as_deref()
170        }
171        fn path(&self) -> &Path {
172            &self.path
173        }
174        fn relative_path(&self) -> &Path {
175            &self.relative_path
176        }
177        fn version(&self) -> Option<&str> {
178            self.version.as_deref()
179        }
180        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
181            Ok(())
182        }
183        fn language(&self) -> Language {
184            self.language
185        }
186        fn dependencies(&self) -> &HashSet<String> {
187            &self.dependencies
188        }
189        fn add_dependency(&mut self, dependency: &str) {
190            self.dependencies.insert(dependency.to_string());
191        }
192        fn is_changed(&self) -> bool {
193            self.changed
194        }
195        fn set_changed(&mut self, changed: bool) {
196            self.changed = changed;
197        }
198        fn default_publish_command(&self) -> String {
199            "echo publish".to_string()
200        }
201        fn default_dry_run_publish_command(&self) -> Option<String> {
202            Some("echo publish --dry-run".to_string())
203        }
204    }
205
206    #[test]
207    fn test_check_changed_already_changed() {
208        let mut workspace =
209            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
210        workspace.changed = true;
211
212        // Should return early if already changed
213        workspace
214            .check_changed(Path::new("/project/src/index.js"))
215            .unwrap();
216        assert!(workspace.is_changed());
217    }
218
219    #[test]
220    fn test_check_changed_sets_changed() {
221        let mut workspace =
222            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
223
224        // File in project directory should mark as changed
225        workspace
226            .check_changed(Path::new("/project/src/index.js"))
227            .unwrap();
228        assert!(workspace.is_changed());
229    }
230
231    #[test]
232    fn test_check_changed_ignores_changepacks() {
233        let mut workspace =
234            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
235
236        // Files in .changepacks should be ignored
237        workspace
238            .check_changed(Path::new("/project/.changepacks/change.json"))
239            .unwrap();
240        assert!(!workspace.is_changed());
241    }
242
243    #[test]
244    fn test_check_changed_ignores_other_projects() {
245        let mut workspace =
246            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
247
248        // Files in other directories should not mark as changed
249        workspace
250            .check_changed(Path::new("/other-project/src/index.js"))
251            .unwrap();
252        assert!(!workspace.is_changed());
253    }
254
255    #[test]
256    fn test_get_publish_command_by_path() {
257        let workspace = MockWorkspace::new(
258            Some("test"),
259            "/project/package.json",
260            "packages/core/package.json",
261        );
262        let mut publish = HashMap::new();
263        publish.insert(
264            "packages/core/package.json".to_string(),
265            "custom publish".to_string(),
266        );
267        let config = Config {
268            publish,
269            ..Default::default()
270        };
271
272        assert_eq!(workspace.get_publish_command(&config), "custom publish");
273    }
274
275    #[test]
276    fn test_get_publish_command_by_language() {
277        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
278            .with_language(Language::Node);
279        let mut publish = HashMap::new();
280        publish.insert(
281            "node".to_string(),
282            "npm publish --access public".to_string(),
283        );
284        let config = Config {
285            publish,
286            ..Default::default()
287        };
288
289        assert_eq!(
290            workspace.get_publish_command(&config),
291            "npm publish --access public"
292        );
293    }
294
295    #[test]
296    fn test_get_publish_command_python() {
297        let workspace =
298            MockWorkspace::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
299                .with_language(Language::Python);
300        let mut publish = HashMap::new();
301        publish.insert("python".to_string(), "poetry publish".to_string());
302        let config = Config {
303            publish,
304            ..Default::default()
305        };
306
307        assert_eq!(workspace.get_publish_command(&config), "poetry publish");
308    }
309
310    #[test]
311    fn test_get_publish_command_rust() {
312        let workspace = MockWorkspace::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
313            .with_language(Language::Rust);
314        let mut publish = HashMap::new();
315        publish.insert("rust".to_string(), "cargo publish".to_string());
316        let config = Config {
317            publish,
318            ..Default::default()
319        };
320
321        assert_eq!(workspace.get_publish_command(&config), "cargo publish");
322    }
323
324    #[test]
325    fn test_get_publish_command_dart() {
326        let workspace = MockWorkspace::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
327            .with_language(Language::Dart);
328        let mut publish = HashMap::new();
329        publish.insert("dart".to_string(), "dart pub publish".to_string());
330        let config = Config {
331            publish,
332            ..Default::default()
333        };
334
335        assert_eq!(workspace.get_publish_command(&config), "dart pub publish");
336    }
337
338    #[test]
339    fn test_get_publish_command_default() {
340        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
341        let config = Config::default();
342
343        assert_eq!(workspace.get_publish_command(&config), "echo publish");
344    }
345
346    #[test]
347    fn test_get_dry_run_publish_command_falls_back_to_workspace_default() {
348        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
349            .with_language(Language::Node);
350        let config = Config::default();
351
352        // With no override, the trait method returns the workspace's own
353        // `default_dry_run_publish_command()` (here, the MockWorkspace stub).
354        assert_eq!(
355            workspace.get_dry_run_publish_command(&config).as_deref(),
356            Some("echo publish --dry-run")
357        );
358    }
359
360    #[test]
361    fn test_get_dry_run_publish_command_override_by_path() {
362        let workspace = MockWorkspace::new(
363            Some("test"),
364            "/project/package.json",
365            "packages/core/package.json",
366        );
367        let mut publish_dry_run = HashMap::new();
368        publish_dry_run.insert(
369            "packages/core/package.json".to_string(),
370            "custom dry".to_string(),
371        );
372        let config = Config {
373            publish_dry_run,
374            ..Default::default()
375        };
376
377        // Per-project override wins over the workspace's own default.
378        assert_eq!(
379            workspace.get_dry_run_publish_command(&config).as_deref(),
380            Some("custom dry")
381        );
382    }
383
384    #[test]
385    fn test_get_dry_run_publish_command_override_by_language() {
386        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
387            .with_language(Language::Node);
388        let mut publish_dry_run = HashMap::new();
389        publish_dry_run.insert(
390            "node".to_string(),
391            "npm publish --dry-run --tag next".to_string(),
392        );
393        let config = Config {
394            publish_dry_run,
395            ..Default::default()
396        };
397
398        // Per-language override wins over the workspace's own default.
399        assert_eq!(
400            workspace.get_dry_run_publish_command(&config).as_deref(),
401            Some("npm publish --dry-run --tag next")
402        );
403    }
404
405    #[tokio::test]
406    async fn test_publish_success() {
407        let temp_dir = std::env::temp_dir();
408        let path = temp_dir.join("package.json");
409        let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
410        let config = Config::default();
411
412        // This will run "echo publish" which should succeed
413        let output = workspace.publish(&config).await.unwrap();
414        assert!(output.success);
415    }
416
417    #[tokio::test]
418    async fn test_publish_failure() {
419        let temp_dir = std::env::temp_dir();
420        let path = temp_dir.join("package.json");
421        let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
422        let mut publish = HashMap::new();
423        let fail_cmd = if cfg!(target_os = "windows") {
424            "cmd /c exit 1"
425        } else {
426            "exit 1"
427        };
428        publish.insert("node".to_string(), fail_cmd.to_string());
429        let config = Config {
430            publish,
431            ..Default::default()
432        };
433
434        let output = workspace.publish(&config).await.unwrap();
435        assert!(!output.success);
436    }
437
438    #[tokio::test]
439    async fn test_update_workspace_dependencies_default() {
440        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
441        let packages: Vec<&dyn Package> = vec![];
442
443        let result = workspace.update_workspace_dependencies(&packages).await;
444        assert!(result.is_ok());
445    }
446
447    #[tokio::test]
448    async fn test_publish_no_parent_directory() {
449        let workspace = MockWorkspace {
450            name: Some("test".to_string()),
451            path: PathBuf::from(""),
452            relative_path: PathBuf::from(""),
453            version: Some("1.0.0".to_string()),
454            language: Language::Node,
455            dependencies: HashSet::new(),
456            changed: false,
457        };
458        let config = Config::default();
459        let result = workspace.publish(&config).await;
460        assert!(result.is_err());
461        assert!(
462            result
463                .unwrap_err()
464                .to_string()
465                .contains("Workspace directory not found")
466        );
467    }
468
469    #[test]
470    fn test_set_name_default_is_noop() {
471        let mut workspace =
472            MockWorkspace::new(Some("original"), "/project/package.json", "package.json");
473        workspace.set_name("new-name".to_string());
474        // Default implementation is a no-op, name should remain unchanged
475        assert_eq!(workspace.name(), Some("original"));
476    }
477}