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 assert!(workspace.dependencies().is_empty());
305
306 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 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}