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