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