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