changepacks_python/
workspace.rs1use 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 assert!(workspace.dependencies().is_empty());
301
302 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 workspace.add_dependency("requests");
313 assert_eq!(workspace.dependencies().len(), 2);
314 }
315}