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