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 set_name(&mut self, name: String) {
99        self.name = Some(name);
100    }
101
102    fn default_publish_command(&self) -> String {
103        "uv publish".to_string()
104    }
105
106    fn dependencies(&self) -> &HashSet<String> {
107        &self.dependencies
108    }
109
110    fn add_dependency(&mut self, dependency: &str) {
111        self.dependencies.insert(dependency.to_string());
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use changepacks_core::UpdateType;
119    use std::fs;
120    use tempfile::TempDir;
121    use tokio::fs::read_to_string;
122
123    #[tokio::test]
124    async fn test_python_workspace_new() {
125        let workspace = PythonWorkspace::new(
126            Some("test-workspace".to_string()),
127            Some("1.0.0".to_string()),
128            PathBuf::from("/test/pyproject.toml"),
129            PathBuf::from("test/pyproject.toml"),
130        );
131
132        assert_eq!(workspace.name(), Some("test-workspace"));
133        assert_eq!(workspace.version(), Some("1.0.0"));
134        assert_eq!(workspace.path(), PathBuf::from("/test/pyproject.toml"));
135        assert_eq!(
136            workspace.relative_path(),
137            PathBuf::from("test/pyproject.toml")
138        );
139        assert_eq!(workspace.language(), Language::Python);
140        assert!(!workspace.is_changed());
141        assert_eq!(workspace.default_publish_command(), "uv publish");
142    }
143
144    #[tokio::test]
145    async fn test_python_workspace_new_without_name_and_version() {
146        let workspace = PythonWorkspace::new(
147            None,
148            None,
149            PathBuf::from("/test/pyproject.toml"),
150            PathBuf::from("test/pyproject.toml"),
151        );
152
153        assert_eq!(workspace.name(), None);
154        assert_eq!(workspace.version(), None);
155    }
156
157    #[tokio::test]
158    async fn test_python_workspace_set_changed() {
159        let mut workspace = PythonWorkspace::new(
160            Some("test-workspace".to_string()),
161            Some("1.0.0".to_string()),
162            PathBuf::from("/test/pyproject.toml"),
163            PathBuf::from("test/pyproject.toml"),
164        );
165
166        assert!(!workspace.is_changed());
167        workspace.set_changed(true);
168        assert!(workspace.is_changed());
169        workspace.set_changed(false);
170        assert!(!workspace.is_changed());
171    }
172
173    #[tokio::test]
174    async fn test_python_workspace_update_version_with_existing_project() {
175        let temp_dir = TempDir::new().unwrap();
176        let pyproject_toml = temp_dir.path().join("pyproject.toml");
177        fs::write(
178            &pyproject_toml,
179            r#"[tool.uv.workspace]
180members = ["packages/*"]
181
182[project]
183name = "test-workspace"
184version = "1.0.0"
185"#,
186        )
187        .unwrap();
188
189        let mut workspace = PythonWorkspace::new(
190            Some("test-workspace".to_string()),
191            Some("1.0.0".to_string()),
192            pyproject_toml.clone(),
193            PathBuf::from("pyproject.toml"),
194        );
195
196        workspace.update_version(UpdateType::Patch).await.unwrap();
197
198        let content = read_to_string(&pyproject_toml).await.unwrap();
199        assert!(content.contains("version = \"1.0.1\""));
200
201        temp_dir.close().unwrap();
202    }
203
204    #[tokio::test]
205    async fn test_python_workspace_update_version_without_project_section() {
206        let temp_dir = TempDir::new().unwrap();
207        let pyproject_toml = temp_dir.path().join("pyproject.toml");
208        fs::write(
209            &pyproject_toml,
210            r#"[tool.uv.workspace]
211members = ["packages/*"]
212"#,
213        )
214        .unwrap();
215
216        let mut workspace = PythonWorkspace::new(
217            Some("test-workspace".to_string()),
218            None,
219            pyproject_toml.clone(),
220            PathBuf::from("pyproject.toml"),
221        );
222
223        workspace.update_version(UpdateType::Patch).await.unwrap();
224
225        let content = read_to_string(&pyproject_toml).await.unwrap();
226        assert!(content.contains("[project]"));
227        assert!(content.contains("version = \"0.0.1\""));
228
229        temp_dir.close().unwrap();
230    }
231
232    #[tokio::test]
233    async fn test_python_workspace_update_version_minor() {
234        let temp_dir = TempDir::new().unwrap();
235        let pyproject_toml = temp_dir.path().join("pyproject.toml");
236        fs::write(
237            &pyproject_toml,
238            r#"[tool.uv.workspace]
239members = ["packages/*"]
240
241[project]
242name = "test-workspace"
243version = "1.0.0"
244"#,
245        )
246        .unwrap();
247
248        let mut workspace = PythonWorkspace::new(
249            Some("test-workspace".to_string()),
250            Some("1.0.0".to_string()),
251            pyproject_toml.clone(),
252            PathBuf::from("pyproject.toml"),
253        );
254
255        workspace.update_version(UpdateType::Minor).await.unwrap();
256
257        let content = read_to_string(&pyproject_toml).await.unwrap();
258        assert!(content.contains("version = \"1.1.0\""));
259
260        temp_dir.close().unwrap();
261    }
262
263    #[tokio::test]
264    async fn test_python_workspace_update_version_major() {
265        let temp_dir = TempDir::new().unwrap();
266        let pyproject_toml = temp_dir.path().join("pyproject.toml");
267        fs::write(
268            &pyproject_toml,
269            r#"[tool.uv.workspace]
270members = ["packages/*"]
271
272[project]
273name = "test-workspace"
274version = "1.0.0"
275"#,
276        )
277        .unwrap();
278
279        let mut workspace = PythonWorkspace::new(
280            Some("test-workspace".to_string()),
281            Some("1.0.0".to_string()),
282            pyproject_toml.clone(),
283            PathBuf::from("pyproject.toml"),
284        );
285
286        workspace.update_version(UpdateType::Major).await.unwrap();
287
288        let content = read_to_string(&pyproject_toml).await.unwrap();
289        assert!(content.contains("version = \"2.0.0\""));
290
291        temp_dir.close().unwrap();
292    }
293
294    #[test]
295    fn test_python_workspace_dependencies() {
296        let mut workspace = PythonWorkspace::new(
297            Some("test-workspace".to_string()),
298            Some("1.0.0".to_string()),
299            PathBuf::from("/test/pyproject.toml"),
300            PathBuf::from("test/pyproject.toml"),
301        );
302
303        // Initially empty
304        assert!(workspace.dependencies().is_empty());
305
306        // Add dependencies
307        workspace.add_dependency("requests");
308        workspace.add_dependency("core");
309
310        let deps = workspace.dependencies();
311        assert_eq!(deps.len(), 2);
312        assert!(deps.contains("requests"));
313        assert!(deps.contains("core"));
314
315        // Adding duplicate should not increase count
316        workspace.add_dependency("requests");
317        assert_eq!(workspace.dependencies().len(), 2);
318    }
319
320    #[test]
321    fn test_set_name() {
322        let mut workspace = PythonWorkspace::new(
323            None,
324            Some("1.0.0".to_string()),
325            PathBuf::from("/test/pyproject.toml"),
326            PathBuf::from("pyproject.toml"),
327        );
328        assert_eq!(workspace.name(), None);
329        workspace.set_name("my-project".to_string());
330        assert_eq!(workspace.name(), Some("my-project"));
331    }
332}