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