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