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