1use crate::error::{Error, Result};
2use crate::models::GitDependency;
3use crate::utils::toml::{read_toml, update_section, write_toml};
4use log::{debug, info};
5use std::fs;
6use std::path::Path;
7use toml_edit::{Array, DocumentMut, Formatted, InlineTable, Item, Table, Value};
8
9pub fn update_pyproject_toml(project_dir: &Path, _extra_args: &[String]) -> Result<()> {
11 let pyproject_path = project_dir.join("pyproject.toml");
12 let old_pyproject_path = project_dir.join("old.pyproject.toml");
13
14 if !old_pyproject_path.exists() {
15 debug!("No old.pyproject.toml found, skipping update");
16 return Ok(());
17 }
18
19 let mut doc = read_toml(&pyproject_path)?;
20 let old_doc = read_toml(&old_pyproject_path)?;
21
22 if let Some(old_tool) = old_doc.get("tool") {
24 if let Some(old_poetry) = old_tool.get("poetry") {
25 if let Some(version) = old_poetry.get("version").and_then(|v| v.as_str()) {
27 update_section(
28 &mut doc,
29 &["project", "version"],
30 Item::Value(Value::String(Formatted::new(version.to_string()))),
31 );
32 }
33
34 if let Some(desc) = old_poetry.get("description").and_then(|d| d.as_str()) {
36 update_section(
37 &mut doc,
38 &["project", "description"],
39 Item::Value(Value::String(Formatted::new(desc.to_string()))),
40 );
41 }
42 }
43 }
44
45 if let Some(old_project) = old_doc.get("project") {
47 if let Some(version) = old_project.get("version").and_then(|v| v.as_str()) {
48 update_section(
49 &mut doc,
50 &["project", "version"],
51 Item::Value(Value::String(Formatted::new(version.to_string()))),
52 );
53 }
54
55 if let Some(desc) = old_project.get("description").and_then(|d| d.as_str()) {
56 update_section(
57 &mut doc,
58 &["project", "description"],
59 Item::Value(Value::String(Formatted::new(desc.to_string()))),
60 );
61 }
62 }
63
64 write_toml(&pyproject_path, &mut doc)?;
65 Ok(())
66}
67
68pub fn update_project_version(project_dir: &Path, version: &str) -> Result<()> {
70 let pyproject_path = project_dir.join("pyproject.toml");
71 let mut doc = read_toml(&pyproject_path)?;
72
73 update_section(
74 &mut doc,
75 &["project", "version"],
76 Item::Value(Value::String(Formatted::new(version.to_string()))),
77 );
78
79 write_toml(&pyproject_path, &mut doc)?;
80 info!("Updated project version to {}", version);
81 Ok(())
82}
83
84pub fn extract_poetry_sources(project_dir: &Path) -> Result<Vec<toml::Value>> {
86 let old_pyproject_path = project_dir.join("old.pyproject.toml");
87 if !old_pyproject_path.exists() {
88 return Ok(vec![]);
89 }
90
91 let content = fs::read_to_string(&old_pyproject_path).map_err(|e| Error::FileOperation {
92 path: old_pyproject_path.clone(),
93 message: format!("Failed to read old.pyproject.toml: {}", e),
94 })?;
95
96 let old_doc: toml::Value = toml::from_str(&content).map_err(Error::TomlSerde)?;
97
98 if let Some(sources) = old_doc
99 .get("tool")
100 .and_then(|t| t.get("poetry"))
101 .and_then(|p| p.get("source"))
102 .and_then(|s| s.as_array())
103 {
104 Ok(sources.clone())
105 } else {
106 Ok(vec![])
107 }
108}
109
110pub fn update_uv_indices(project_dir: &Path, sources: &[toml::Value]) -> Result<()> {
112 let pyproject_path = project_dir.join("pyproject.toml");
113 let mut doc = read_toml(&pyproject_path)?;
114
115 let mut indices = Array::new();
116 for source in sources {
117 if let Some(url) = source.get("url").and_then(|u| u.as_str()) {
118 let mut table = InlineTable::new();
119
120 if let Some(name) = source.get("name").and_then(|n| n.as_str()) {
121 table.insert("name", Value::String(Formatted::new(name.to_string())));
122 }
123
124 table.insert("url", Value::String(Formatted::new(url.to_string())));
125
126 indices.push(Value::InlineTable(table));
127 }
128 }
129
130 if !indices.is_empty() {
131 update_section(
132 &mut doc,
133 &["tool", "uv", "index"],
134 Item::Value(Value::Array(indices)),
135 );
136 write_toml(&pyproject_path, &mut doc)?;
137 info!("Migrated {} package sources to UV indices", sources.len());
138 }
139
140 Ok(())
141}
142
143pub fn update_uv_indices_from_urls(project_dir: &Path, urls: &[String]) -> Result<()> {
145 if urls.is_empty() {
146 return Ok(());
147 }
148
149 let pyproject_path = project_dir.join("pyproject.toml");
150 let mut doc = read_toml(&pyproject_path)?;
151
152 let mut indices = Array::new();
153 for (i, url_spec) in urls.iter().enumerate() {
154 let mut table = InlineTable::new();
155
156 let (name, url) = parse_index_spec(url_spec, i + 1);
158
159 table.insert("name", Value::String(Formatted::new(name)));
160 table.insert("url", Value::String(Formatted::new(url)));
161 indices.push(Value::InlineTable(table));
162 }
163
164 update_section(
165 &mut doc,
166 &["tool", "uv", "index"],
167 Item::Value(Value::Array(indices)),
168 );
169
170 write_toml(&pyproject_path, &mut doc)?;
171 info!("Added {} extra index URLs", urls.len());
172 Ok(())
173}
174
175#[doc(hidden)] pub fn parse_index_spec(spec: &str, index: usize) -> (String, String) {
179 if let Some(at_pos) = spec.find('@') {
180 if at_pos > 0 {
182 let name = spec[..at_pos].trim().to_string();
183 let url = spec[at_pos + 1..].trim().to_string();
184
185 if !name.is_empty() && !url.is_empty() {
187 return (name, url);
188 }
189 }
190 }
191
192 (format!("extra-{}", index), spec.to_string())
194}
195
196pub fn append_tool_sections(project_dir: &Path) -> Result<()> {
198 let old_pyproject_path = project_dir.join("old.pyproject.toml");
199 let pyproject_path = project_dir.join("pyproject.toml");
200
201 if !old_pyproject_path.exists() {
202 debug!("No old.pyproject.toml found, skipping tool section migration");
203 return Ok(());
204 }
205
206 let old_doc = read_toml(&old_pyproject_path)?;
207 let mut new_doc = read_toml(&pyproject_path)?;
208
209 if let Some(old_tool) = old_doc.get("tool").and_then(|t| t.as_table()) {
211 for (key, value) in old_tool.iter() {
212 if key != "poetry" && !is_empty_section(value) {
213 let section_exists = new_doc.get("tool").and_then(|t| t.get(key)).is_some();
215
216 if !section_exists {
217 let path = ["tool", key];
218 update_section(&mut new_doc, &path, value.clone());
219 debug!("Migrated tool.{} section", key);
220 } else {
221 debug!("Skipping tool.{} section - already exists in target", key);
222 }
223 }
224 }
225 }
226
227 write_toml(&pyproject_path, &mut new_doc)?;
228 Ok(())
229}
230
231fn is_empty_section(item: &Item) -> bool {
233 match item {
234 Item::Table(table) => table.is_empty() || table.iter().all(|(_, v)| is_empty_section(v)),
235 Item::Value(value) => {
236 if let Some(array) = value.as_array() {
237 array.is_empty()
238 } else {
239 false
240 }
241 }
242 Item::None => true,
243 Item::ArrayOfTables(array) => array.is_empty(),
244 }
245}
246
247pub fn update_scripts(project_dir: &Path) -> Result<bool> {
249 let old_pyproject_path = project_dir.join("old.pyproject.toml");
250 let pyproject_path = project_dir.join("pyproject.toml");
251
252 if !old_pyproject_path.exists() {
253 return Ok(false);
254 }
255
256 let old_doc = read_toml(&old_pyproject_path)?;
257 let mut new_doc = read_toml(&pyproject_path)?;
258
259 if let Some(scripts) = old_doc
261 .get("tool")
262 .and_then(|t| t.get("poetry"))
263 .and_then(|p| p.get("scripts"))
264 .and_then(|s| s.as_table())
265 {
266 if !scripts.is_empty() {
267 let mut scripts_table = InlineTable::new();
268
269 for (name, value) in scripts.iter() {
270 if let Item::Value(Value::String(s)) = value {
271 scripts_table.insert(name, Value::String(s.clone()));
272 }
273 }
274
275 if !scripts_table.is_empty() {
276 update_section(
277 &mut new_doc,
278 &["project", "scripts"],
279 Item::Value(Value::InlineTable(scripts_table)),
280 );
281 write_toml(&pyproject_path, &mut new_doc)?;
282 info!("Migrated {} scripts", scripts.len());
283 return Ok(true);
284 }
285 }
286 }
287
288 Ok(false)
289}
290
291pub fn extract_poetry_packages(doc: &DocumentMut) -> Vec<String> {
293 let mut packages = Vec::new();
294
295 if let Some(poetry_packages) = doc
296 .get("tool")
297 .and_then(|t| t.get("poetry"))
298 .and_then(|p| p.get("packages"))
299 .and_then(|p| p.as_array())
300 {
301 for pkg in poetry_packages.iter() {
302 if let Some(table) = pkg.as_inline_table() {
303 if let Some(include) = table.get("include").and_then(|i| i.as_str()) {
304 packages.push(include.to_string());
305 }
306 } else if let Some(pkg_str) = pkg.as_str() {
307 packages.push(pkg_str.to_string());
308 }
309 }
310 }
311
312 packages
313}
314
315pub fn update_git_dependencies(project_dir: &Path, git_deps: &[GitDependency]) -> Result<()> {
317 if git_deps.is_empty() {
318 return Ok(());
319 }
320
321 let pyproject_path = project_dir.join("pyproject.toml");
322 let mut doc = read_toml(&pyproject_path)?;
323
324 for dep in git_deps {
325 let mut source_table = Table::new();
326 source_table.insert(
327 "git",
328 Item::Value(Value::String(Formatted::new(dep.git_url.clone()))),
329 );
330
331 if let Some(branch) = &dep.branch {
332 source_table.insert(
333 "branch",
334 Item::Value(Value::String(Formatted::new(branch.clone()))),
335 );
336 }
337
338 if let Some(tag) = &dep.tag {
339 source_table.insert(
340 "tag",
341 Item::Value(Value::String(Formatted::new(tag.clone()))),
342 );
343 }
344
345 if let Some(rev) = &dep.rev {
346 source_table.insert(
347 "rev",
348 Item::Value(Value::String(Formatted::new(rev.clone()))),
349 );
350 }
351
352 let path = ["tool", "uv", "sources", &dep.name];
353 update_section(&mut doc, &path, Item::Table(source_table));
354 }
355
356 write_toml(&pyproject_path, &mut doc)?;
357 info!("Migrated {} git dependencies", git_deps.len());
358 Ok(())
359}
360
361pub fn extract_project_name(project_dir: &Path) -> Result<Option<String>> {
363 let pyproject_path = project_dir.join("pyproject.toml");
364 if !pyproject_path.exists() {
365 return Ok(None);
366 }
367
368 let doc = read_toml(&pyproject_path)?;
369
370 if let Some(name) = doc
371 .get("project")
372 .and_then(|p| p.get("name"))
373 .and_then(|n| n.as_str())
374 {
375 Ok(Some(name.to_string()))
376 } else {
377 Ok(None)
378 }
379}
380
381pub fn update_description(project_dir: &Path, description: &str) -> Result<()> {
383 let pyproject_path = project_dir.join("pyproject.toml");
384 let mut doc = read_toml(&pyproject_path)?;
385
386 update_section(
387 &mut doc,
388 &["project", "description"],
389 Item::Value(Value::String(Formatted::new(description.to_string()))),
390 );
391
392 write_toml(&pyproject_path, &mut doc)?;
393 info!("Updated project description");
394 Ok(())
395}
396
397pub fn update_url(project_dir: &Path, url: &str) -> Result<()> {
399 let pyproject_path = project_dir.join("pyproject.toml");
400 let mut doc = read_toml(&pyproject_path)?;
401
402 let mut urls_table = InlineTable::new();
403 urls_table.insert("repository", Value::String(Formatted::new(url.to_string())));
404
405 update_section(
406 &mut doc,
407 &["project", "urls"],
408 Item::Value(Value::InlineTable(urls_table)),
409 );
410
411 write_toml(&pyproject_path, &mut doc)?;
412 info!("Updated project URL");
413 Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use tempfile::TempDir;
420
421 #[test]
422 fn test_parse_index_spec() {
423 let (name, url) = parse_index_spec("myindex@https://example.com/simple/", 1);
425 assert_eq!(name, "myindex");
426 assert_eq!(url, "https://example.com/simple/");
427
428 let (name, url) = parse_index_spec("https://example.com/simple/", 5);
430 assert_eq!(name, "extra-5");
431 assert_eq!(url, "https://example.com/simple/");
432
433 let (name, url) = parse_index_spec(" my-index @ https://example.com/ ", 1);
435 assert_eq!(name, "my-index");
436 assert_eq!(url, "https://example.com/");
437
438 let (name, url) = parse_index_spec("@https://example.com/", 3);
440 assert_eq!(name, "extra-3");
441 assert_eq!(url, "@https://example.com/");
442
443 let (name, url) = parse_index_spec("name@", 4);
444 assert_eq!(name, "extra-4");
445 assert_eq!(url, "name@");
446
447 let (name, url) = parse_index_spec(" @https://example.com/", 2);
449 assert_eq!(name, "extra-2");
450 assert_eq!(url, " @https://example.com/");
451
452 let (name, url) = parse_index_spec("my@index@https://example.com/", 1);
454 assert_eq!(name, "my");
455 assert_eq!(url, "index@https://example.com/");
456 }
457
458 #[test]
459 fn test_update_uv_indices_with_custom_names() {
460 let temp_dir = TempDir::new().unwrap();
461 let project_dir = temp_dir.path().to_path_buf();
462
463 let content = r#"[project]
465name = "test-project"
466version = "0.1.0"
467"#;
468 fs::write(project_dir.join("pyproject.toml"), content).unwrap();
469
470 let urls = vec![
472 "mycompany@https://pypi.mycompany.com/simple/".to_string(),
473 "https://pypi.org/simple/".to_string(),
474 "torch@https://download.pytorch.org/whl/cu118".to_string(),
475 "@https://invalid.example.com/".to_string(), "name-with-dashes@https://example.com/pypi/".to_string(),
477 ];
478
479 update_uv_indices_from_urls(&project_dir, &urls).unwrap();
480
481 let result = fs::read_to_string(project_dir.join("pyproject.toml")).unwrap();
482
483 assert!(result.contains(r#"name = "mycompany""#));
485 assert!(result.contains(r#"url = "https://pypi.mycompany.com/simple/""#));
486
487 assert!(result.contains(r#"name = "torch""#));
488 assert!(result.contains(r#"url = "https://download.pytorch.org/whl/cu118""#));
489
490 assert!(result.contains(r#"name = "name-with-dashes""#));
491 assert!(result.contains(r#"url = "https://example.com/pypi/""#));
492
493 assert!(result.contains(r#"name = "extra-2""#)); assert!(result.contains(r#"url = "https://pypi.org/simple/""#));
496
497 assert!(result.contains(r#"name = "extra-4""#)); assert!(result.contains(r#"url = "@https://invalid.example.com/""#));
499 }
500}