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