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