changepacks_core/
package.rs1use std::{collections::HashSet, path::Path};
2
3use crate::{Config, Language, update_type::UpdateType};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6
7#[async_trait]
8pub trait Package: std::fmt::Debug + Send + Sync {
9 fn name(&self) -> Option<&str>;
10 fn version(&self) -> Option<&str>;
11 fn path(&self) -> &Path;
12 fn relative_path(&self) -> &Path;
13 async fn update_version(&mut self, update_type: UpdateType) -> Result<()>;
14 fn check_changed(&mut self, path: &Path) -> Result<()> {
15 if self.is_changed() {
16 return Ok(());
17 }
18 if !path.to_string_lossy().contains(".changepacks")
19 && path.starts_with(self.path().parent().context("Parent not found")?)
20 {
21 self.set_changed(true);
22 }
23 Ok(())
24 }
25 fn is_changed(&self) -> bool;
26 fn language(&self) -> Language;
27
28 fn dependencies(&self) -> &HashSet<String>;
29 fn add_dependency(&mut self, dependency: &str);
30
31 fn set_changed(&mut self, changed: bool);
32
33 fn default_publish_command(&self) -> String;
35
36 async fn publish(&self, config: &Config) -> Result<()> {
38 let command = self.get_publish_command(config);
39 let package_dir = self
41 .path()
42 .parent()
43 .context("Package directory not found")?;
44
45 let mut cmd = if cfg!(target_os = "windows") {
46 let mut c = tokio::process::Command::new("cmd");
47 c.arg("/C");
48 c.arg(command);
49 c
50 } else {
51 let mut c = tokio::process::Command::new("sh");
52 c.arg("-c");
53 c.arg(command);
54 c
55 };
56
57 cmd.current_dir(package_dir);
58 let output = cmd.output().await?;
59
60 if !output.status.success() {
61 anyhow::bail!(
62 "Publish command failed: {}",
63 String::from_utf8_lossy(&output.stderr)
64 );
65 } else {
66 Ok(())
67 }
68 }
69
70 fn get_publish_command(&self, config: &Config) -> String {
72 if let Some(cmd) = config
74 .publish
75 .get(self.relative_path().to_string_lossy().as_ref())
76 {
77 return cmd.clone();
78 }
79
80 let lang_key = match self.language() {
82 crate::Language::Node => "node",
83 crate::Language::Python => "python",
84 crate::Language::Rust => "rust",
85 crate::Language::Dart => "dart",
86 crate::Language::CSharp => "csharp",
87 crate::Language::Java => "java",
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
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use std::collections::HashMap;
102 use std::path::PathBuf;
103
104 #[derive(Debug)]
105 struct MockPackage {
106 name: Option<String>,
107 path: PathBuf,
108 relative_path: PathBuf,
109 version: Option<String>,
110 language: Language,
111 dependencies: HashSet<String>,
112 changed: bool,
113 }
114
115 impl MockPackage {
116 fn new(name: Option<&str>, path: &str, relative_path: &str) -> Self {
117 Self {
118 name: name.map(String::from),
119 path: PathBuf::from(path),
120 relative_path: PathBuf::from(relative_path),
121 version: Some("1.0.0".to_string()),
122 language: Language::Node,
123 dependencies: HashSet::new(),
124 changed: false,
125 }
126 }
127
128 fn with_language(mut self, language: Language) -> Self {
129 self.language = language;
130 self
131 }
132 }
133
134 #[async_trait]
135 impl Package for MockPackage {
136 fn name(&self) -> Option<&str> {
137 self.name.as_deref()
138 }
139 fn version(&self) -> Option<&str> {
140 self.version.as_deref()
141 }
142 fn path(&self) -> &Path {
143 &self.path
144 }
145 fn relative_path(&self) -> &Path {
146 &self.relative_path
147 }
148 async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
149 Ok(())
150 }
151 fn is_changed(&self) -> bool {
152 self.changed
153 }
154 fn language(&self) -> Language {
155 self.language.clone()
156 }
157 fn dependencies(&self) -> &HashSet<String> {
158 &self.dependencies
159 }
160 fn add_dependency(&mut self, dependency: &str) {
161 self.dependencies.insert(dependency.to_string());
162 }
163 fn set_changed(&mut self, changed: bool) {
164 self.changed = changed;
165 }
166 fn default_publish_command(&self) -> String {
167 "echo publish".to_string()
168 }
169 }
170
171 #[test]
172 fn test_check_changed_already_changed() {
173 let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
174 package.changed = true;
175
176 package
177 .check_changed(Path::new("/project/src/index.js"))
178 .unwrap();
179 assert!(package.is_changed());
180 }
181
182 #[test]
183 fn test_check_changed_sets_changed() {
184 let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
185
186 package
187 .check_changed(Path::new("/project/src/index.js"))
188 .unwrap();
189 assert!(package.is_changed());
190 }
191
192 #[test]
193 fn test_check_changed_ignores_changepacks() {
194 let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
195
196 package
197 .check_changed(Path::new("/project/.changepacks/change.json"))
198 .unwrap();
199 assert!(!package.is_changed());
200 }
201
202 #[test]
203 fn test_check_changed_ignores_other_projects() {
204 let mut package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
205
206 package
207 .check_changed(Path::new("/other-project/src/index.js"))
208 .unwrap();
209 assert!(!package.is_changed());
210 }
211
212 #[test]
213 fn test_get_publish_command_by_path() {
214 let package = MockPackage::new(
215 Some("test"),
216 "/project/package.json",
217 "packages/core/package.json",
218 );
219 let mut publish = HashMap::new();
220 publish.insert(
221 "packages/core/package.json".to_string(),
222 "custom publish".to_string(),
223 );
224 let config = Config {
225 publish,
226 ..Default::default()
227 };
228
229 assert_eq!(package.get_publish_command(&config), "custom publish");
230 }
231
232 #[test]
233 fn test_get_publish_command_by_language_node() {
234 let package = MockPackage::new(Some("test"), "/project/package.json", "package.json")
235 .with_language(Language::Node);
236 let mut publish = HashMap::new();
237 publish.insert(
238 "node".to_string(),
239 "npm publish --access public".to_string(),
240 );
241 let config = Config {
242 publish,
243 ..Default::default()
244 };
245
246 assert_eq!(
247 package.get_publish_command(&config),
248 "npm publish --access public"
249 );
250 }
251
252 #[test]
253 fn test_get_publish_command_by_language_python() {
254 let package = MockPackage::new(Some("test"), "/project/pyproject.toml", "pyproject.toml")
255 .with_language(Language::Python);
256 let mut publish = HashMap::new();
257 publish.insert("python".to_string(), "poetry publish".to_string());
258 let config = Config {
259 publish,
260 ..Default::default()
261 };
262
263 assert_eq!(package.get_publish_command(&config), "poetry publish");
264 }
265
266 #[test]
267 fn test_get_publish_command_by_language_rust() {
268 let package = MockPackage::new(Some("test"), "/project/Cargo.toml", "Cargo.toml")
269 .with_language(Language::Rust);
270 let mut publish = HashMap::new();
271 publish.insert("rust".to_string(), "cargo publish".to_string());
272 let config = Config {
273 publish,
274 ..Default::default()
275 };
276
277 assert_eq!(package.get_publish_command(&config), "cargo publish");
278 }
279
280 #[test]
281 fn test_get_publish_command_by_language_dart() {
282 let package = MockPackage::new(Some("test"), "/project/pubspec.yaml", "pubspec.yaml")
283 .with_language(Language::Dart);
284 let mut publish = HashMap::new();
285 publish.insert("dart".to_string(), "dart pub publish".to_string());
286 let config = Config {
287 publish,
288 ..Default::default()
289 };
290
291 assert_eq!(package.get_publish_command(&config), "dart pub publish");
292 }
293
294 #[test]
295 fn test_get_publish_command_default() {
296 let package = MockPackage::new(Some("test"), "/project/package.json", "package.json");
297 let config = Config::default();
298
299 assert_eq!(package.get_publish_command(&config), "echo publish");
300 }
301
302 #[tokio::test]
303 async fn test_publish_success() {
304 let temp_dir = std::env::temp_dir();
305 let path = temp_dir.join("package.json");
306 let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
307 let config = Config::default();
308
309 let result = package.publish(&config).await;
310 assert!(result.is_ok());
311 }
312
313 #[tokio::test]
314 async fn test_publish_failure() {
315 let temp_dir = std::env::temp_dir();
316 let path = temp_dir.join("package.json");
317 let package = MockPackage::new(Some("test"), path.to_str().unwrap(), "package.json");
318 let mut publish = HashMap::new();
319 let fail_cmd = if cfg!(target_os = "windows") {
320 "cmd /c exit 1"
321 } else {
322 "exit 1"
323 };
324 publish.insert("node".to_string(), fail_cmd.to_string());
325 let config = Config {
326 publish,
327 ..Default::default()
328 };
329
330 let result = package.publish(&config).await;
331 assert!(result.is_err());
332 }
333}