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