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