changepacks_python/
package.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, Package, UpdateType};
4use changepacks_utils::next_version;
5use std::path::{Path, PathBuf};
6use tokio::fs::{read_to_string, write};
7use toml_edit::DocumentMut;
8
9#[derive(Debug)]
10pub struct PythonPackage {
11    name: String,
12    version: String,
13    path: PathBuf,
14    relative_path: PathBuf,
15    is_changed: bool,
16}
17
18impl PythonPackage {
19    pub fn new(name: String, version: String, path: PathBuf, relative_path: PathBuf) -> Self {
20        Self {
21            name,
22            version,
23            path,
24            relative_path,
25            is_changed: false,
26        }
27    }
28}
29
30#[async_trait]
31impl Package for PythonPackage {
32    fn name(&self) -> &str {
33        &self.name
34    }
35
36    fn version(&self) -> &str {
37        &self.version
38    }
39
40    fn path(&self) -> &Path {
41        &self.path
42    }
43
44    fn relative_path(&self) -> &Path {
45        &self.relative_path
46    }
47
48    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
49        let next_version = next_version(&self.version, update_type)?;
50
51        let pyproject_toml_raw = read_to_string(&self.path).await?;
52        let mut pyproject_toml: DocumentMut = pyproject_toml_raw.parse::<DocumentMut>()?;
53        pyproject_toml["project"]["version"] = next_version.clone().into();
54        write(
55            &self.path,
56            format!(
57                "{}{}",
58                pyproject_toml.to_string().trim_end(),
59                if pyproject_toml_raw.ends_with("\n") {
60                    "\n"
61                } else {
62                    ""
63                }
64            ),
65        )
66        .await?;
67        self.version = next_version;
68        Ok(())
69    }
70
71    fn language(&self) -> Language {
72        Language::Python
73    }
74
75    fn set_changed(&mut self, changed: bool) {
76        self.is_changed = changed;
77    }
78
79    fn is_changed(&self) -> bool {
80        self.is_changed
81    }
82
83    fn default_publish_command(&self) -> &'static str {
84        "uv publish"
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use changepacks_core::UpdateType;
92    use std::fs;
93    use tempfile::TempDir;
94    use tokio::fs::read_to_string;
95
96    #[tokio::test]
97    async fn test_python_package_new() {
98        let package = PythonPackage::new(
99            "test-package".to_string(),
100            "1.0.0".to_string(),
101            PathBuf::from("/test/pyproject.toml"),
102            PathBuf::from("test/pyproject.toml"),
103        );
104
105        assert_eq!(package.name(), "test-package");
106        assert_eq!(package.version(), "1.0.0");
107        assert_eq!(package.path(), PathBuf::from("/test/pyproject.toml"));
108        assert_eq!(
109            package.relative_path(),
110            PathBuf::from("test/pyproject.toml")
111        );
112        assert_eq!(package.language(), Language::Python);
113        assert_eq!(package.is_changed(), false);
114        assert_eq!(package.default_publish_command(), "uv publish");
115    }
116
117    #[tokio::test]
118    async fn test_python_package_set_changed() {
119        let mut package = PythonPackage::new(
120            "test-package".to_string(),
121            "1.0.0".to_string(),
122            PathBuf::from("/test/pyproject.toml"),
123            PathBuf::from("test/pyproject.toml"),
124        );
125
126        assert_eq!(package.is_changed(), false);
127        package.set_changed(true);
128        assert_eq!(package.is_changed(), true);
129        package.set_changed(false);
130        assert_eq!(package.is_changed(), false);
131    }
132
133    #[tokio::test]
134    async fn test_python_package_update_version_patch() {
135        let temp_dir = TempDir::new().unwrap();
136        let pyproject_toml = temp_dir.path().join("pyproject.toml");
137        fs::write(
138            &pyproject_toml,
139            r#"[project]
140name = "test-package"
141version = "1.0.0"
142"#,
143        )
144        .unwrap();
145
146        let mut package = PythonPackage::new(
147            "test-package".to_string(),
148            "1.0.0".to_string(),
149            pyproject_toml.clone(),
150            PathBuf::from("pyproject.toml"),
151        );
152
153        package.update_version(UpdateType::Patch).await.unwrap();
154
155        let content = read_to_string(&pyproject_toml).await.unwrap();
156        assert!(content.contains("version = \"1.0.1\""));
157
158        temp_dir.close().unwrap();
159    }
160
161    #[tokio::test]
162    async fn test_python_package_update_version_minor() {
163        let temp_dir = TempDir::new().unwrap();
164        let pyproject_toml = temp_dir.path().join("pyproject.toml");
165        fs::write(
166            &pyproject_toml,
167            r#"[project]
168name = "test-package"
169version = "1.0.0"
170"#,
171        )
172        .unwrap();
173
174        let mut package = PythonPackage::new(
175            "test-package".to_string(),
176            "1.0.0".to_string(),
177            pyproject_toml.clone(),
178            PathBuf::from("pyproject.toml"),
179        );
180
181        package.update_version(UpdateType::Minor).await.unwrap();
182
183        let content = read_to_string(&pyproject_toml).await.unwrap();
184        assert!(content.contains("version = \"1.1.0\""));
185
186        temp_dir.close().unwrap();
187    }
188
189    #[tokio::test]
190    async fn test_python_package_update_version_major() {
191        let temp_dir = TempDir::new().unwrap();
192        let pyproject_toml = temp_dir.path().join("pyproject.toml");
193        fs::write(
194            &pyproject_toml,
195            r#"[project]
196name = "test-package"
197version = "1.0.0"
198"#,
199        )
200        .unwrap();
201
202        let mut package = PythonPackage::new(
203            "test-package".to_string(),
204            "1.0.0".to_string(),
205            pyproject_toml.clone(),
206            PathBuf::from("pyproject.toml"),
207        );
208
209        package.update_version(UpdateType::Major).await.unwrap();
210
211        let content = read_to_string(&pyproject_toml).await.unwrap();
212        assert!(content.contains("version = \"2.0.0\""));
213
214        temp_dir.close().unwrap();
215    }
216
217    #[tokio::test]
218    async fn test_python_package_update_version_preserves_formatting() {
219        let temp_dir = TempDir::new().unwrap();
220        let pyproject_toml = temp_dir.path().join("pyproject.toml");
221        fs::write(
222            &pyproject_toml,
223            r#"[project]
224name = "test-package"
225version = "1.2.3"
226description = "A test package"
227requires-python = ">=3.8"
228
229[dependencies]
230requests = "2.31.0"
231"#,
232        )
233        .unwrap();
234
235        let mut package = PythonPackage::new(
236            "test-package".to_string(),
237            "1.2.3".to_string(),
238            pyproject_toml.clone(),
239            PathBuf::from("pyproject.toml"),
240        );
241
242        package.update_version(UpdateType::Patch).await.unwrap();
243
244        let content = read_to_string(&pyproject_toml).await.unwrap();
245        assert!(content.contains("version = \"1.2.4\""));
246        assert!(content.contains("name = \"test-package\""));
247        assert!(content.contains("description = \"A test package\""));
248        assert!(content.contains("[dependencies]"));
249
250        temp_dir.close().unwrap();
251    }
252}