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