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