Skip to main content

changepacks_python/
workspace.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
4use changepacks_utils::next_version;
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7use tokio::fs::{read_to_string, write};
8use toml_edit::DocumentMut;
9
10#[derive(Debug)]
11pub struct PythonWorkspace {
12    path: PathBuf,
13    relative_path: PathBuf,
14    version: Option<String>,
15    name: Option<String>,
16    is_changed: bool,
17    dependencies: HashSet<String>,
18}
19
20impl PythonWorkspace {
21    #[must_use]
22    pub fn new(
23        name: Option<String>,
24        version: Option<String>,
25        path: PathBuf,
26        relative_path: PathBuf,
27    ) -> Self {
28        Self {
29            path,
30            relative_path,
31            name,
32            version,
33            is_changed: false,
34            dependencies: HashSet::new(),
35        }
36    }
37}
38
39#[async_trait]
40impl Workspace for PythonWorkspace {
41    fn name(&self) -> Option<&str> {
42        self.name.as_deref()
43    }
44
45    fn path(&self) -> &Path {
46        &self.path
47    }
48
49    fn version(&self) -> Option<&str> {
50        self.version.as_deref()
51    }
52
53    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
54        let next_version = next_version(
55            self.version.as_ref().unwrap_or(&String::from("0.0.0")),
56            update_type,
57        )?;
58
59        let pyproject_toml_raw = read_to_string(&self.path).await?;
60        let mut pyproject_toml: DocumentMut = pyproject_toml_raw.parse::<DocumentMut>()?;
61        if pyproject_toml.get("project").is_none() {
62            pyproject_toml["project"] = toml_edit::Item::Table(toml_edit::Table::new());
63        }
64        pyproject_toml["project"]["version"] = next_version.clone().into();
65        write(
66            &self.path,
67            format!(
68                "{}{}",
69                pyproject_toml.to_string().trim_end(),
70                if pyproject_toml_raw.ends_with('\n') {
71                    "\n"
72                } else {
73                    ""
74                }
75            ),
76        )
77        .await?;
78        self.version = Some(next_version);
79        Ok(())
80    }
81
82    fn language(&self) -> Language {
83        Language::Python
84    }
85
86    fn is_changed(&self) -> bool {
87        self.is_changed
88    }
89
90    fn set_changed(&mut self, changed: bool) {
91        self.is_changed = changed;
92    }
93
94    fn relative_path(&self) -> &Path {
95        &self.relative_path
96    }
97
98    fn default_publish_command(&self) -> String {
99        "uv publish".to_string()
100    }
101
102    fn dependencies(&self) -> &HashSet<String> {
103        &self.dependencies
104    }
105
106    fn add_dependency(&mut self, dependency: &str) {
107        self.dependencies.insert(dependency.to_string());
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use changepacks_core::UpdateType;
115    use std::fs;
116    use tempfile::TempDir;
117    use tokio::fs::read_to_string;
118
119    #[tokio::test]
120    async fn test_python_workspace_new() {
121        let workspace = PythonWorkspace::new(
122            Some("test-workspace".to_string()),
123            Some("1.0.0".to_string()),
124            PathBuf::from("/test/pyproject.toml"),
125            PathBuf::from("test/pyproject.toml"),
126        );
127
128        assert_eq!(workspace.name(), Some("test-workspace"));
129        assert_eq!(workspace.version(), Some("1.0.0"));
130        assert_eq!(workspace.path(), PathBuf::from("/test/pyproject.toml"));
131        assert_eq!(
132            workspace.relative_path(),
133            PathBuf::from("test/pyproject.toml")
134        );
135        assert_eq!(workspace.language(), Language::Python);
136        assert!(!workspace.is_changed());
137        assert_eq!(workspace.default_publish_command(), "uv publish");
138    }
139
140    #[tokio::test]
141    async fn test_python_workspace_new_without_name_and_version() {
142        let workspace = PythonWorkspace::new(
143            None,
144            None,
145            PathBuf::from("/test/pyproject.toml"),
146            PathBuf::from("test/pyproject.toml"),
147        );
148
149        assert_eq!(workspace.name(), None);
150        assert_eq!(workspace.version(), None);
151    }
152
153    #[tokio::test]
154    async fn test_python_workspace_set_changed() {
155        let mut workspace = PythonWorkspace::new(
156            Some("test-workspace".to_string()),
157            Some("1.0.0".to_string()),
158            PathBuf::from("/test/pyproject.toml"),
159            PathBuf::from("test/pyproject.toml"),
160        );
161
162        assert!(!workspace.is_changed());
163        workspace.set_changed(true);
164        assert!(workspace.is_changed());
165        workspace.set_changed(false);
166        assert!(!workspace.is_changed());
167    }
168
169    #[tokio::test]
170    async fn test_python_workspace_update_version_with_existing_project() {
171        let temp_dir = TempDir::new().unwrap();
172        let pyproject_toml = temp_dir.path().join("pyproject.toml");
173        fs::write(
174            &pyproject_toml,
175            r#"[tool.uv.workspace]
176members = ["packages/*"]
177
178[project]
179name = "test-workspace"
180version = "1.0.0"
181"#,
182        )
183        .unwrap();
184
185        let mut workspace = PythonWorkspace::new(
186            Some("test-workspace".to_string()),
187            Some("1.0.0".to_string()),
188            pyproject_toml.clone(),
189            PathBuf::from("pyproject.toml"),
190        );
191
192        workspace.update_version(UpdateType::Patch).await.unwrap();
193
194        let content = read_to_string(&pyproject_toml).await.unwrap();
195        assert!(content.contains("version = \"1.0.1\""));
196
197        temp_dir.close().unwrap();
198    }
199
200    #[tokio::test]
201    async fn test_python_workspace_update_version_without_project_section() {
202        let temp_dir = TempDir::new().unwrap();
203        let pyproject_toml = temp_dir.path().join("pyproject.toml");
204        fs::write(
205            &pyproject_toml,
206            r#"[tool.uv.workspace]
207members = ["packages/*"]
208"#,
209        )
210        .unwrap();
211
212        let mut workspace = PythonWorkspace::new(
213            Some("test-workspace".to_string()),
214            None,
215            pyproject_toml.clone(),
216            PathBuf::from("pyproject.toml"),
217        );
218
219        workspace.update_version(UpdateType::Patch).await.unwrap();
220
221        let content = read_to_string(&pyproject_toml).await.unwrap();
222        assert!(content.contains("[project]"));
223        assert!(content.contains("version = \"0.0.1\""));
224
225        temp_dir.close().unwrap();
226    }
227
228    #[tokio::test]
229    async fn test_python_workspace_update_version_minor() {
230        let temp_dir = TempDir::new().unwrap();
231        let pyproject_toml = temp_dir.path().join("pyproject.toml");
232        fs::write(
233            &pyproject_toml,
234            r#"[tool.uv.workspace]
235members = ["packages/*"]
236
237[project]
238name = "test-workspace"
239version = "1.0.0"
240"#,
241        )
242        .unwrap();
243
244        let mut workspace = PythonWorkspace::new(
245            Some("test-workspace".to_string()),
246            Some("1.0.0".to_string()),
247            pyproject_toml.clone(),
248            PathBuf::from("pyproject.toml"),
249        );
250
251        workspace.update_version(UpdateType::Minor).await.unwrap();
252
253        let content = read_to_string(&pyproject_toml).await.unwrap();
254        assert!(content.contains("version = \"1.1.0\""));
255
256        temp_dir.close().unwrap();
257    }
258
259    #[tokio::test]
260    async fn test_python_workspace_update_version_major() {
261        let temp_dir = TempDir::new().unwrap();
262        let pyproject_toml = temp_dir.path().join("pyproject.toml");
263        fs::write(
264            &pyproject_toml,
265            r#"[tool.uv.workspace]
266members = ["packages/*"]
267
268[project]
269name = "test-workspace"
270version = "1.0.0"
271"#,
272        )
273        .unwrap();
274
275        let mut workspace = PythonWorkspace::new(
276            Some("test-workspace".to_string()),
277            Some("1.0.0".to_string()),
278            pyproject_toml.clone(),
279            PathBuf::from("pyproject.toml"),
280        );
281
282        workspace.update_version(UpdateType::Major).await.unwrap();
283
284        let content = read_to_string(&pyproject_toml).await.unwrap();
285        assert!(content.contains("version = \"2.0.0\""));
286
287        temp_dir.close().unwrap();
288    }
289
290    #[test]
291    fn test_python_workspace_dependencies() {
292        let mut workspace = PythonWorkspace::new(
293            Some("test-workspace".to_string()),
294            Some("1.0.0".to_string()),
295            PathBuf::from("/test/pyproject.toml"),
296            PathBuf::from("test/pyproject.toml"),
297        );
298
299        // Initially empty
300        assert!(workspace.dependencies().is_empty());
301
302        // Add dependencies
303        workspace.add_dependency("requests");
304        workspace.add_dependency("core");
305
306        let deps = workspace.dependencies();
307        assert_eq!(deps.len(), 2);
308        assert!(deps.contains("requests"));
309        assert!(deps.contains("core"));
310
311        // Adding duplicate should not increase count
312        workspace.add_dependency("requests");
313        assert_eq!(workspace.dependencies().len(), 2);
314    }
315}