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