uv_migrator/utils/
pyproject.rs

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
9/// Updates the pyproject.toml with basic project metadata
10pub 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    // Transfer basic metadata if available
23    if let Some(old_tool) = old_doc.get("tool") {
24        if let Some(old_poetry) = old_tool.get("poetry") {
25            // Transfer version if available
26            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            // Transfer description if available
35            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    // Also check Poetry 2.0 style
46    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
68/// Updates the project version in pyproject.toml
69pub 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
84/// Extracts Poetry package sources from old pyproject.toml
85pub 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
110/// Updates UV indices in pyproject.toml
111pub 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
143/// Updates UV indices from URLs
144pub 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        // Parse [name@]url format
157        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/// Parses an index specification in the format [name@]url
176/// Returns (name, url) where name is either the specified name or "extra-{index}"
177#[doc(hidden)] // Hide from public docs but make available for tests
178pub fn parse_index_spec(spec: &str, index: usize) -> (String, String) {
179    if let Some(at_pos) = spec.find('@') {
180        // Check if @ is not at the beginning
181        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            // Validate that both parts are non-empty
186            if !name.is_empty() && !url.is_empty() {
187                return (name, url);
188            }
189        }
190    }
191
192    // If no valid name@url format found, treat the whole string as URL
193    (format!("extra-{}", index), spec.to_string())
194}
195
196/// Appends tool sections from old pyproject.toml to new one
197pub 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    // Copy tool sections except poetry
210    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                // Check if the section already exists in the new document
214                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
231/// Checks if a TOML item is empty
232fn 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
247/// Updates scripts section from Poetry to standard format
248pub 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    // Check for Poetry scripts
260    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
291/// Extracts Poetry packages configuration
292pub 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
315/// Updates git dependencies in pyproject.toml
316pub 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
361/// Extracts project name from pyproject.toml
362pub 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
381/// Updates project description
382pub 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
397/// Updates project URL
398pub 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        // Test valid name@url format
424        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        // Test URL without name
429        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        // Test with spaces
434        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        // Test invalid formats
439        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        // Test empty name
448        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        // Test multiple @ symbols
453        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        // Create initial pyproject.toml
464        let content = r#"[project]
465name = "test-project"
466version = "0.1.0"
467"#;
468        fs::write(project_dir.join("pyproject.toml"), content).unwrap();
469
470        // Test with mixed named and unnamed indexes
471        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(), // Invalid format, should be treated as URL
476            "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        // Verify named indexes
484        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        // Verify auto-generated names
494        assert!(result.contains(r#"name = "extra-2""#)); // For the unnamed URL
495        assert!(result.contains(r#"url = "https://pypi.org/simple/""#));
496
497        assert!(result.contains(r#"name = "extra-4""#)); // For the invalid format
498        assert!(result.contains(r#"url = "@https://invalid.example.com/""#));
499    }
500}