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 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 fn default_publish_command(&self) -> String;
37
38 async fn publish(&self, config: &Config) -> Result<()> {
40 let command = self.get_publish_command(config);
41 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 fn get_publish_command(&self, config: &Config) -> String {
74 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 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 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 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 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 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 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 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}