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    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
40    fn is_changed(&self) -> bool;
41    fn set_changed(&mut self, changed: bool);
42
43    /// Set the workspace name (used for fallback when name is not found in manifest)
44    fn set_name(&mut self, _name: String) {}
45
46    /// Get the default publish command for this workspace type
47    fn default_publish_command(&self) -> String;
48
49    /// Publish the workspace using the configured command or default
50    ///
51    /// # Errors
52    /// Returns error if the publish command fails to execute or returns non-zero exit code.
53    #[cfg(not(tarpaulin_include))]
54    async fn publish(&self, config: &Config) -> Result<()> {
55        let command = self.get_publish_command(config);
56        let dir = self
57            .path()
58            .parent()
59            .context("Workspace directory not found")?;
60        crate::publish::run_publish_command(&command, dir).await
61    }
62
63    /// Get the publish command for this workspace, checking config first
64    fn get_publish_command(&self, config: &Config) -> String {
65        crate::publish::resolve_publish_command(
66            self.relative_path(),
67            self.language(),
68            &self.default_publish_command(),
69            config,
70        )
71    }
72
73    #[cfg(not(tarpaulin_include))]
74    async fn update_workspace_dependencies(&self, _packages: &[&dyn Package]) -> Result<()> {
75        Ok(())
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 MockWorkspace {
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 MockWorkspace {
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 Workspace for MockWorkspace {
117        fn name(&self) -> Option<&str> {
118            self.name.as_deref()
119        }
120        fn path(&self) -> &Path {
121            &self.path
122        }
123        fn relative_path(&self) -> &Path {
124            &self.relative_path
125        }
126        fn version(&self) -> Option<&str> {
127            self.version.as_deref()
128        }
129        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
130            Ok(())
131        }
132        fn language(&self) -> Language {
133            self.language
134        }
135        fn dependencies(&self) -> &HashSet<String> {
136            &self.dependencies
137        }
138        fn add_dependency(&mut self, dependency: &str) {
139            self.dependencies.insert(dependency.to_string());
140        }
141        fn is_changed(&self) -> bool {
142            self.changed
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 workspace =
155            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
156        workspace.changed = true;
157
158        // Should return early if already changed
159        workspace
160            .check_changed(Path::new("/project/src/index.js"))
161            .unwrap();
162        assert!(workspace.is_changed());
163    }
164
165    #[test]
166    fn test_check_changed_sets_changed() {
167        let mut workspace =
168            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
169
170        // File in project directory should mark as changed
171        workspace
172            .check_changed(Path::new("/project/src/index.js"))
173            .unwrap();
174        assert!(workspace.is_changed());
175    }
176
177    #[test]
178    fn test_check_changed_ignores_changepacks() {
179        let mut workspace =
180            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
181
182        // Files in .changepacks should be ignored
183        workspace
184            .check_changed(Path::new("/project/.changepacks/change.json"))
185            .unwrap();
186        assert!(!workspace.is_changed());
187    }
188
189    #[test]
190    fn test_check_changed_ignores_other_projects() {
191        let mut workspace =
192            MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
193
194        // Files in other directories should not mark as changed
195        workspace
196            .check_changed(Path::new("/other-project/src/index.js"))
197            .unwrap();
198        assert!(!workspace.is_changed());
199    }
200
201    #[test]
202    fn test_get_publish_command_by_path() {
203        let workspace = MockWorkspace::new(
204            Some("test"),
205            "/project/package.json",
206            "packages/core/package.json",
207        );
208        let mut publish = HashMap::new();
209        publish.insert(
210            "packages/core/package.json".to_string(),
211            "custom publish".to_string(),
212        );
213        let config = Config {
214            publish,
215            ..Default::default()
216        };
217
218        assert_eq!(workspace.get_publish_command(&config), "custom publish");
219    }
220
221    #[test]
222    fn test_get_publish_command_by_language() {
223        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
224            .with_language(Language::Node);
225        let mut publish = HashMap::new();
226        publish.insert(
227            "node".to_string(),
228            "npm publish --access public".to_string(),
229        );
230        let config = Config {
231            publish,
232            ..Default::default()
233        };
234
235        assert_eq!(
236            workspace.get_publish_command(&config),
237            "npm publish --access public"
238        );
239    }
240
241    #[test]
242    fn test_get_publish_command_python() {
243        let workspace =
244            MockWorkspace::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
245                .with_language(Language::Python);
246        let mut publish = HashMap::new();
247        publish.insert("python".to_string(), "poetry publish".to_string());
248        let config = Config {
249            publish,
250            ..Default::default()
251        };
252
253        assert_eq!(workspace.get_publish_command(&config), "poetry publish");
254    }
255
256    #[test]
257    fn test_get_publish_command_rust() {
258        let workspace = MockWorkspace::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
259            .with_language(Language::Rust);
260        let mut publish = HashMap::new();
261        publish.insert("rust".to_string(), "cargo publish".to_string());
262        let config = Config {
263            publish,
264            ..Default::default()
265        };
266
267        assert_eq!(workspace.get_publish_command(&config), "cargo publish");
268    }
269
270    #[test]
271    fn test_get_publish_command_dart() {
272        let workspace = MockWorkspace::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
273            .with_language(Language::Dart);
274        let mut publish = HashMap::new();
275        publish.insert("dart".to_string(), "dart pub publish".to_string());
276        let config = Config {
277            publish,
278            ..Default::default()
279        };
280
281        assert_eq!(workspace.get_publish_command(&config), "dart pub publish");
282    }
283
284    #[test]
285    fn test_get_publish_command_default() {
286        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
287        let config = Config::default();
288
289        assert_eq!(workspace.get_publish_command(&config), "echo publish");
290    }
291
292    #[tokio::test]
293    async fn test_publish_success() {
294        let temp_dir = std::env::temp_dir();
295        let path = temp_dir.join("package.json");
296        let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
297        let config = Config::default();
298
299        // This will run "echo publish" which should succeed
300        let result = workspace.publish(&config).await;
301        assert!(result.is_ok());
302    }
303
304    #[tokio::test]
305    async fn test_publish_failure() {
306        let temp_dir = std::env::temp_dir();
307        let path = temp_dir.join("package.json");
308        let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
309        let mut publish = HashMap::new();
310        let fail_cmd = if cfg!(target_os = "windows") {
311            "cmd /c exit 1"
312        } else {
313            "exit 1"
314        };
315        publish.insert("node".to_string(), fail_cmd.to_string());
316        let config = Config {
317            publish,
318            ..Default::default()
319        };
320
321        let result = workspace.publish(&config).await;
322        assert!(result.is_err());
323    }
324
325    #[tokio::test]
326    async fn test_update_workspace_dependencies_default() {
327        let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
328        let packages: Vec<&dyn Package> = vec![];
329
330        let result = workspace.update_workspace_dependencies(&packages).await;
331        assert!(result.is_ok());
332    }
333
334    #[tokio::test]
335    async fn test_publish_no_parent_directory() {
336        let workspace = MockWorkspace {
337            name: Some("test".to_string()),
338            path: PathBuf::from(""),
339            relative_path: PathBuf::from(""),
340            version: Some("1.0.0".to_string()),
341            language: Language::Node,
342            dependencies: HashSet::new(),
343            changed: false,
344        };
345        let config = Config::default();
346        let result = workspace.publish(&config).await;
347        assert!(result.is_err());
348        assert!(
349            result
350                .unwrap_err()
351                .to_string()
352                .contains("Workspace directory not found")
353        );
354    }
355
356    #[test]
357    fn test_set_name_default_is_noop() {
358        let mut workspace =
359            MockWorkspace::new(Some("original"), "/project/package.json", "package.json");
360        workspace.set_name("new-name".to_string());
361        // Default implementation is a no-op, name should remain unchanged
362        assert_eq!(workspace.name(), Some("original"));
363    }
364}