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