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