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 #[cfg(not(tarpaulin_include))]
32 fn check_changed(&mut self, path: &Path) -> Result<()> {
33 if self.is_changed() {
34 return Ok(());
35 }
36 if !path.to_string_lossy().contains(".changepacks")
37 && path.starts_with(self.path().parent().context("Parent not found")?)
38 {
39 self.set_changed(true);
40 }
41 Ok(())
42 }
43
44 fn is_changed(&self) -> bool;
45 fn set_changed(&mut self, changed: bool);
46
47 fn set_name(&mut self, _name: String) {}
49
50 fn default_publish_command(&self) -> String;
52
53 fn default_dry_run_publish_command(&self) -> Option<String>;
59
60 #[cfg(not(tarpaulin_include))]
66 async fn publish(&self, config: &Config) -> Result<crate::publish::PublishOutput> {
67 let command = self.get_publish_command(config);
68 let dir = self
69 .path()
70 .parent()
71 .context("Workspace directory not found")?;
72 crate::publish::run_publish_command(&command, dir).await
73 }
74
75 #[cfg(not(tarpaulin_include))]
87 async fn dry_run_publish(
88 &self,
89 config: &Config,
90 ) -> Result<Option<crate::publish::PublishOutput>> {
91 let Some(command) = self.get_dry_run_publish_command(config) else {
92 return Ok(None);
93 };
94 let dir = self
95 .path()
96 .parent()
97 .context("Workspace directory not found")?;
98 Ok(Some(
99 crate::publish::run_publish_command(&command, dir).await?,
100 ))
101 }
102
103 fn get_publish_command(&self, config: &Config) -> String {
105 crate::publish::resolve_publish_command(
106 self.relative_path(),
107 self.language(),
108 &self.default_publish_command(),
109 config,
110 )
111 }
112
113 fn get_dry_run_publish_command(&self, config: &Config) -> Option<String> {
116 crate::publish::resolve_dry_run_publish_command(
117 self.relative_path(),
118 self.language(),
119 self.default_dry_run_publish_command().as_deref(),
120 config,
121 )
122 }
123
124 #[cfg(not(tarpaulin_include))]
125 async fn update_workspace_dependencies(&self, _packages: &[&dyn Package]) -> Result<()> {
126 Ok(())
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use std::collections::HashMap;
134 use std::path::PathBuf;
135
136 #[derive(Debug)]
137 struct MockWorkspace {
138 name: Option<String>,
139 path: PathBuf,
140 relative_path: PathBuf,
141 version: Option<String>,
142 language: Language,
143 dependencies: HashSet<String>,
144 changed: bool,
145 }
146
147 impl MockWorkspace {
148 fn new(name: Option<&str>, path: &str, relative_path: &str) -> Self {
149 Self {
150 name: name.map(String::from),
151 path: PathBuf::from(path),
152 relative_path: PathBuf::from(relative_path),
153 version: Some("1.0.0".to_string()),
154 language: Language::Node,
155 dependencies: HashSet::new(),
156 changed: false,
157 }
158 }
159
160 fn with_language(mut self, language: Language) -> Self {
161 self.language = language;
162 self
163 }
164 }
165
166 #[async_trait]
167 impl Workspace for MockWorkspace {
168 fn name(&self) -> Option<&str> {
169 self.name.as_deref()
170 }
171 fn path(&self) -> &Path {
172 &self.path
173 }
174 fn relative_path(&self) -> &Path {
175 &self.relative_path
176 }
177 fn version(&self) -> Option<&str> {
178 self.version.as_deref()
179 }
180 async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
181 Ok(())
182 }
183 fn language(&self) -> Language {
184 self.language
185 }
186 fn dependencies(&self) -> &HashSet<String> {
187 &self.dependencies
188 }
189 fn add_dependency(&mut self, dependency: &str) {
190 self.dependencies.insert(dependency.to_string());
191 }
192 fn is_changed(&self) -> bool {
193 self.changed
194 }
195 fn set_changed(&mut self, changed: bool) {
196 self.changed = changed;
197 }
198 fn default_publish_command(&self) -> String {
199 "echo publish".to_string()
200 }
201 fn default_dry_run_publish_command(&self) -> Option<String> {
202 Some("echo publish --dry-run".to_string())
203 }
204 }
205
206 #[test]
207 fn test_check_changed_already_changed() {
208 let mut workspace =
209 MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
210 workspace.changed = true;
211
212 workspace
214 .check_changed(Path::new("/project/src/index.js"))
215 .unwrap();
216 assert!(workspace.is_changed());
217 }
218
219 #[test]
220 fn test_check_changed_sets_changed() {
221 let mut workspace =
222 MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
223
224 workspace
226 .check_changed(Path::new("/project/src/index.js"))
227 .unwrap();
228 assert!(workspace.is_changed());
229 }
230
231 #[test]
232 fn test_check_changed_ignores_changepacks() {
233 let mut workspace =
234 MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
235
236 workspace
238 .check_changed(Path::new("/project/.changepacks/change.json"))
239 .unwrap();
240 assert!(!workspace.is_changed());
241 }
242
243 #[test]
244 fn test_check_changed_ignores_other_projects() {
245 let mut workspace =
246 MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
247
248 workspace
250 .check_changed(Path::new("/other-project/src/index.js"))
251 .unwrap();
252 assert!(!workspace.is_changed());
253 }
254
255 #[test]
256 fn test_get_publish_command_by_path() {
257 let workspace = MockWorkspace::new(
258 Some("test"),
259 "/project/package.json",
260 "packages/core/package.json",
261 );
262 let mut publish = HashMap::new();
263 publish.insert(
264 "packages/core/package.json".to_string(),
265 "custom publish".to_string(),
266 );
267 let config = Config {
268 publish,
269 ..Default::default()
270 };
271
272 assert_eq!(workspace.get_publish_command(&config), "custom publish");
273 }
274
275 #[test]
276 fn test_get_publish_command_by_language() {
277 let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
278 .with_language(Language::Node);
279 let mut publish = HashMap::new();
280 publish.insert(
281 "node".to_string(),
282 "npm publish --access public".to_string(),
283 );
284 let config = Config {
285 publish,
286 ..Default::default()
287 };
288
289 assert_eq!(
290 workspace.get_publish_command(&config),
291 "npm publish --access public"
292 );
293 }
294
295 #[test]
296 fn test_get_publish_command_python() {
297 let workspace =
298 MockWorkspace::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
299 .with_language(Language::Python);
300 let mut publish = HashMap::new();
301 publish.insert("python".to_string(), "poetry publish".to_string());
302 let config = Config {
303 publish,
304 ..Default::default()
305 };
306
307 assert_eq!(workspace.get_publish_command(&config), "poetry publish");
308 }
309
310 #[test]
311 fn test_get_publish_command_rust() {
312 let workspace = MockWorkspace::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
313 .with_language(Language::Rust);
314 let mut publish = HashMap::new();
315 publish.insert("rust".to_string(), "cargo publish".to_string());
316 let config = Config {
317 publish,
318 ..Default::default()
319 };
320
321 assert_eq!(workspace.get_publish_command(&config), "cargo publish");
322 }
323
324 #[test]
325 fn test_get_publish_command_dart() {
326 let workspace = MockWorkspace::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
327 .with_language(Language::Dart);
328 let mut publish = HashMap::new();
329 publish.insert("dart".to_string(), "dart pub publish".to_string());
330 let config = Config {
331 publish,
332 ..Default::default()
333 };
334
335 assert_eq!(workspace.get_publish_command(&config), "dart pub publish");
336 }
337
338 #[test]
339 fn test_get_publish_command_default() {
340 let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
341 let config = Config::default();
342
343 assert_eq!(workspace.get_publish_command(&config), "echo publish");
344 }
345
346 #[test]
347 fn test_get_dry_run_publish_command_falls_back_to_workspace_default() {
348 let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
349 .with_language(Language::Node);
350 let config = Config::default();
351
352 assert_eq!(
355 workspace.get_dry_run_publish_command(&config).as_deref(),
356 Some("echo publish --dry-run")
357 );
358 }
359
360 #[test]
361 fn test_get_dry_run_publish_command_override_by_path() {
362 let workspace = MockWorkspace::new(
363 Some("test"),
364 "/project/package.json",
365 "packages/core/package.json",
366 );
367 let mut publish_dry_run = HashMap::new();
368 publish_dry_run.insert(
369 "packages/core/package.json".to_string(),
370 "custom dry".to_string(),
371 );
372 let config = Config {
373 publish_dry_run,
374 ..Default::default()
375 };
376
377 assert_eq!(
379 workspace.get_dry_run_publish_command(&config).as_deref(),
380 Some("custom dry")
381 );
382 }
383
384 #[test]
385 fn test_get_dry_run_publish_command_override_by_language() {
386 let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json")
387 .with_language(Language::Node);
388 let mut publish_dry_run = HashMap::new();
389 publish_dry_run.insert(
390 "node".to_string(),
391 "npm publish --dry-run --tag next".to_string(),
392 );
393 let config = Config {
394 publish_dry_run,
395 ..Default::default()
396 };
397
398 assert_eq!(
400 workspace.get_dry_run_publish_command(&config).as_deref(),
401 Some("npm publish --dry-run --tag next")
402 );
403 }
404
405 #[tokio::test]
406 async fn test_publish_success() {
407 let temp_dir = std::env::temp_dir();
408 let path = temp_dir.join("package.json");
409 let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
410 let config = Config::default();
411
412 let output = workspace.publish(&config).await.unwrap();
414 assert!(output.success);
415 }
416
417 #[tokio::test]
418 async fn test_publish_failure() {
419 let temp_dir = std::env::temp_dir();
420 let path = temp_dir.join("package.json");
421 let workspace = MockWorkspace::new(Some("test"), path.to_str().unwrap(), "package.json");
422 let mut publish = HashMap::new();
423 let fail_cmd = if cfg!(target_os = "windows") {
424 "cmd /c exit 1"
425 } else {
426 "exit 1"
427 };
428 publish.insert("node".to_string(), fail_cmd.to_string());
429 let config = Config {
430 publish,
431 ..Default::default()
432 };
433
434 let output = workspace.publish(&config).await.unwrap();
435 assert!(!output.success);
436 }
437
438 #[tokio::test]
439 async fn test_update_workspace_dependencies_default() {
440 let workspace = MockWorkspace::new(Some("test"), "/project/package.json", "package.json");
441 let packages: Vec<&dyn Package> = vec![];
442
443 let result = workspace.update_workspace_dependencies(&packages).await;
444 assert!(result.is_ok());
445 }
446
447 #[tokio::test]
448 async fn test_publish_no_parent_directory() {
449 let workspace = MockWorkspace {
450 name: Some("test".to_string()),
451 path: PathBuf::from(""),
452 relative_path: PathBuf::from(""),
453 version: Some("1.0.0".to_string()),
454 language: Language::Node,
455 dependencies: HashSet::new(),
456 changed: false,
457 };
458 let config = Config::default();
459 let result = workspace.publish(&config).await;
460 assert!(result.is_err());
461 assert!(
462 result
463 .unwrap_err()
464 .to_string()
465 .contains("Workspace directory not found")
466 );
467 }
468
469 #[test]
470 fn test_set_name_default_is_noop() {
471 let mut workspace =
472 MockWorkspace::new(Some("original"), "/project/package.json", "package.json");
473 workspace.set_name("new-name".to_string());
474 assert_eq!(workspace.name(), Some("original"));
476 }
477}