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