changepacks_core/
package.rs

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