origen/commands/env/
migration.rs

1use crate::commands::_prelude::*;
2use similar::TextDiff;
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use toml_edit::{value, Array, ArrayOfTables, Document, InlineTable, Item, Table, Value};
8
9const PYPROJECT: &str = "pyproject.toml";
10const POETRY_LOCK: &str = "poetry.lock";
11const UV_LOCK: &str = "uv.lock";
12const HATCHLING_REQUIREMENT: &str = "hatchling>=1.17.1,<1.18";
13const HATCHLING_BACKEND: &str = "hatchling.build";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum ManifestState {
17    PoetryOnly,
18    Pep621Only,
19    Conflicting,
20    Neither,
21}
22
23#[derive(Debug, Clone)]
24pub(crate) struct MigrationPlan {
25    pub(crate) manifest: String,
26    project_name: String,
27    direct_dependencies: BTreeSet<String>,
28    installable: bool,
29}
30
31#[derive(Debug)]
32struct ConvertedDependency {
33    requirement: String,
34    normalized_name: String,
35    source: Option<InlineTable>,
36    optional: bool,
37}
38
39#[derive(Debug)]
40struct IndexSource {
41    name: String,
42    url: String,
43}
44
45#[derive(Debug, Clone)]
46struct FileSnapshot {
47    path: PathBuf,
48    contents: Option<Vec<u8>>,
49}
50
51impl FileSnapshot {
52    fn capture(path: PathBuf) -> Result<Self> {
53        let contents = if path.exists() {
54            Some(fs::read(&path)?)
55        } else {
56            None
57        };
58        Ok(Self { path, contents })
59    }
60
61    fn restore(&self) -> Result<()> {
62        match &self.contents {
63            Some(contents) => atomic_write(&self.path, contents),
64            None => {
65                if self.path.exists() {
66                    fs::remove_file(&self.path)?;
67                }
68                Ok(())
69            }
70        }
71    }
72}
73
74pub(crate) fn manifest_state(contents: &str) -> Result<ManifestState> {
75    let doc = parse_document(contents)?;
76    let has_project = doc.get("project").and_then(Item::as_table).is_some();
77    let has_poetry = poetry_table(&doc).is_some();
78    Ok(match (has_poetry, has_project) {
79        (true, false) => ManifestState::PoetryOnly,
80        (false, true) => ManifestState::Pep621Only,
81        (true, true) => ManifestState::Conflicting,
82        (false, false) => ManifestState::Neither,
83    })
84}
85
86pub(crate) fn guard_uv_manifest(path: &Path) -> Result<()> {
87    if !path.is_file() {
88        return Ok(());
89    }
90    let contents = fs::read_to_string(path)?;
91    // Only intercept valid Poetry-only manifests. Malformed TOML must reach UV
92    // so callers retain UV's parse diagnostics on stderr.
93    if matches!(manifest_state(&contents), Ok(ManifestState::PoetryOnly)) {
94        return Err(origen::Error::new(
95            r#"This application uses Poetry-only metadata, which O2's UV environment
96workflow does not read.
97
98Preview the migration:
99    origen env migrate --dry-run
100
101Apply it:
102    origen env migrate
103    origen env setup"#,
104        ));
105    }
106    Ok(())
107}
108
109pub(crate) fn plan_poetry_migration(contents: &str) -> Result<MigrationPlan> {
110    let mut doc = parse_document(contents)?;
111    match manifest_state(contents)? {
112        ManifestState::PoetryOnly => {}
113        ManifestState::Pep621Only => {
114            return Err(origen::Error::new(
115                "project: this manifest already uses PEP 621 metadata",
116            ))
117        }
118        ManifestState::Conflicting => {
119            return Err(origen::Error::new(
120                "project and tool.poetry: both metadata tables exist; remove or reconcile one before migration",
121            ))
122        }
123        ManifestState::Neither => {
124            return Err(origen::Error::new(
125                "tool.poetry: no Poetry project metadata was found",
126            ))
127        }
128    }
129
130    let poetry = poetry_table(&doc).unwrap().clone();
131    let mut diagnostics = validate_poetry_keys(&poetry);
132    validate_metadata_types(&poetry, &mut diagnostics);
133    if doc
134        .get("tool")
135        .and_then(Item::as_table)
136        .and_then(|tool| tool.get("uv"))
137        .is_some()
138    {
139        diagnostics.push(
140            "tool.uv: existing UV configuration must be reconciled manually before migration"
141                .to_string(),
142        );
143    }
144    if doc.get("dependency-groups").is_some() {
145        diagnostics.push(
146            "dependency-groups: existing dependency groups conflict with Poetry group ownership"
147                .to_string(),
148        );
149    }
150    let project_name =
151        required_string(&poetry, "name", "tool.poetry.name", &mut diagnostics).unwrap_or_default();
152    required_string(&poetry, "version", "tool.poetry.version", &mut diagnostics);
153
154    let index_sources = convert_indexes(&poetry, &mut diagnostics);
155    let explicit_indexes: BTreeSet<String> = index_sources
156        .iter()
157        .map(|index| index.name.clone())
158        .collect();
159
160    let mut project = Table::new();
161    move_scalar(&poetry, &mut project, "name");
162    move_scalar(&poetry, &mut project, "version");
163    move_scalar(&poetry, &mut project, "description");
164    move_scalar(&poetry, &mut project, "license");
165    move_scalar(&poetry, &mut project, "readme");
166    move_scalar(&poetry, &mut project, "keywords");
167    move_scalar(&poetry, &mut project, "classifiers");
168    convert_people(&poetry, &mut project, "authors", &mut diagnostics);
169    convert_people(&poetry, &mut project, "maintainers", &mut diagnostics);
170    convert_urls(&poetry, &mut project, &mut diagnostics);
171    convert_scripts(&poetry, &mut project, &mut diagnostics);
172    convert_plugins(&poetry, &mut project, &mut diagnostics);
173
174    let dependencies = poetry
175        .get("dependencies")
176        .and_then(Item::as_table)
177        .cloned()
178        .unwrap_or_default();
179    let python_requirement = dependencies.get("python").and_then(Item::as_str);
180    match python_requirement {
181        Some(requirement) => match translate_constraint(requirement) {
182            Ok(requirement) if !requirement.is_empty() => {
183                project.insert("requires-python", value(requirement));
184            }
185            Ok(_) => diagnostics.push(
186                "tool.poetry.dependencies.python: an unconstrained Python version cannot become project.requires-python"
187                    .to_string(),
188            ),
189            Err(message) => diagnostics.push(format!(
190                "tool.poetry.dependencies.python: {}",
191                message
192            )),
193        },
194        None => diagnostics.push(
195            "tool.poetry.dependencies.python: a Python requirement is required for project.requires-python"
196                .to_string(),
197        ),
198    }
199
200    let mut runtime = Vec::new();
201    let mut optional = BTreeMap::<String, ConvertedDependency>::new();
202    let mut uv_sources = Table::new();
203    let mut direct_dependencies = BTreeSet::new();
204    for (name, item) in dependencies.iter().filter(|(name, _)| *name != "python") {
205        match convert_dependency(name, item, &explicit_indexes) {
206            Ok(converted) => {
207                direct_dependencies.insert(converted.normalized_name.clone());
208                if let Some(source) = converted.source.clone() {
209                    uv_sources.insert(
210                        &converted.normalized_name,
211                        Item::Value(Value::InlineTable(source)),
212                    );
213                }
214                if converted.optional {
215                    optional.insert(canonicalize_name(name), converted);
216                } else {
217                    runtime.push(converted.requirement);
218                }
219            }
220            Err(message) => {
221                diagnostics.push(format!("tool.poetry.dependencies.{}: {}", name, message))
222            }
223        }
224    }
225    project.insert("dependencies", string_array_item(runtime));
226    convert_extras(&poetry, &optional, &mut project, &mut diagnostics);
227
228    let mut dependency_groups = Table::new();
229    let mut default_groups = Vec::new();
230    convert_dependency_groups(
231        &poetry,
232        &explicit_indexes,
233        &mut dependency_groups,
234        &mut default_groups,
235        &mut uv_sources,
236        &mut direct_dependencies,
237        &mut diagnostics,
238    );
239
240    let package_mode = poetry
241        .get("package-mode")
242        .and_then(Item::as_bool)
243        .unwrap_or(true);
244    let installable = convert_build_system(&mut doc, package_mode, &mut diagnostics);
245
246    if !diagnostics.is_empty() {
247        diagnostics.sort();
248        diagnostics.dedup();
249        return Err(origen::Error::new(&format!(
250            "Cannot migrate pyproject.toml because the following constructs are unsupported or ambiguous:\n{}",
251            diagnostics
252                .iter()
253                .map(|message| format!("- {}", message))
254                .collect::<Vec<_>>()
255                .join("\n")
256        )));
257    }
258
259    doc.as_table_mut().insert("project", Item::Table(project));
260    let tool = doc
261        .get_mut("tool")
262        .and_then(Item::as_table_mut)
263        .expect("Poetry metadata has a tool table");
264    tool.remove("poetry");
265
266    if !dependency_groups.is_empty() {
267        doc.as_table_mut()
268            .insert("dependency-groups", Item::Table(dependency_groups));
269    }
270
271    if !uv_sources.is_empty()
272        || !index_sources.is_empty()
273        || !default_groups.is_empty()
274        || !installable
275    {
276        let tool = ensure_table(doc.as_table_mut(), "tool");
277        let uv = ensure_table(tool, "uv");
278        if !installable {
279            uv.insert("package", value(false));
280        }
281        if !default_groups.is_empty() {
282            uv.insert("default-groups", string_array_item(default_groups));
283        }
284        if !uv_sources.is_empty() {
285            uv.insert("sources", Item::Table(uv_sources));
286        }
287        if !index_sources.is_empty() {
288            let mut indexes = ArrayOfTables::new();
289            for index in index_sources {
290                let mut table = Table::new();
291                table.insert("name", value(index.name));
292                table.insert("url", value(index.url));
293                table.insert("explicit", value(true));
294                indexes.push(table);
295            }
296            uv.insert("index", Item::ArrayOfTables(indexes));
297        }
298    }
299
300    Ok(MigrationPlan {
301        manifest: doc.to_string(),
302        project_name: canonicalize_name(&project_name),
303        direct_dependencies,
304        installable,
305    })
306}
307
308pub(crate) fn run(invocation: &clap::ArgMatches) -> Result<()> {
309    let project_arg = invocation.get_one::<String>("project").map(String::as_str);
310    let root = find_project_root(project_arg)?;
311    let pyproject = root.join(PYPROJECT);
312    let original = fs::read_to_string(&pyproject)?;
313
314    match manifest_state(&original)? {
315        ManifestState::Pep621Only => {
316            displayln!("Project at {} is already migrated to PEP 621/UV; no changes made.", root.display());
317            return Ok(());
318        }
319        ManifestState::Conflicting => {
320            return Err(origen::Error::new(
321                "project and tool.poetry: both metadata tables exist; remove or reconcile one before migration",
322            ))
323        }
324        ManifestState::Neither => {
325            return Err(origen::Error::new(&format!(
326                "No Poetry project metadata was found in {}",
327                pyproject.display()
328            )))
329        }
330        ManifestState::PoetryOnly => {}
331    }
332
333    let plan = plan_poetry_migration(&original)?;
334    if *invocation.get_one::<bool>("dry-run").unwrap_or(&false) {
335        let diff = TextDiff::from_lines(&original, &plan.manifest)
336            .unified_diff()
337            .header("a/pyproject.toml", "b/pyproject.toml")
338            .to_string();
339        display!("{}", diff);
340        displayln!("Dry run only; no files were changed.");
341        return Ok(());
342    }
343
344    super::ensure_uv_available()?;
345    let removed_poetry_lock = root.join(POETRY_LOCK).is_file();
346    apply_migration(&root, &plan, |root| super::run_uv(root, &["lock"]))?;
347    displayln!("Migrated pyproject.toml from Poetry to PEP 621/UV.");
348    displayln!("Generated uv.lock.");
349    if removed_poetry_lock {
350        displayln!("Removed poetry.lock.");
351    }
352    displayln!("Run 'origen env setup' to provision the environment.");
353    displayln!("Review and commit pyproject.toml and uv.lock together.");
354    Ok(())
355}
356
357fn apply_migration<F>(root: &Path, plan: &MigrationPlan, lock: F) -> Result<()>
358where
359    F: FnOnce(&Path) -> Result<()>,
360{
361    let pyproject = FileSnapshot::capture(root.join(PYPROJECT))?;
362    let poetry_lock = FileSnapshot::capture(root.join(POETRY_LOCK))?;
363    let uv_lock = FileSnapshot::capture(root.join(UV_LOCK))?;
364
365    if let Some(contents) = &uv_lock.contents {
366        validate_existing_lock(contents, &plan.project_name)?;
367    }
368
369    let operation = (|| -> Result<()> {
370        atomic_write(&pyproject.path, plan.manifest.as_bytes())?;
371        lock(root)?;
372        validate_generated_lock(&root.join(UV_LOCK), plan)?;
373        if poetry_lock.path.exists() {
374            fs::remove_file(&poetry_lock.path)?;
375        }
376        Ok(())
377    })();
378
379    if let Err(error) = operation {
380        let mut rollback_errors = Vec::new();
381        for snapshot in [&pyproject, &poetry_lock, &uv_lock] {
382            if let Err(rollback_error) = snapshot.restore() {
383                rollback_errors.push(format!("{}: {}", snapshot.path.display(), rollback_error));
384            }
385        }
386        if rollback_errors.is_empty() {
387            return Err(error);
388        }
389        return Err(origen::Error::new(&format!(
390            "{}; rollback also failed: {}",
391            error,
392            rollback_errors.join("; ")
393        )));
394    }
395    Ok(())
396}
397
398fn validate_existing_lock(contents: &[u8], project_name: &str) -> Result<()> {
399    let text = std::str::from_utf8(contents).map_err(|error| {
400        origen::Error::new(&format!("Existing uv.lock is not UTF-8: {}", error))
401    })?;
402    let value: toml::Value = toml::from_str(text)
403        .map_err(|error| origen::Error::new(&format!("Existing uv.lock is invalid: {}", error)))?;
404    let packages = lock_package_names(&value);
405    if !packages.contains(project_name) {
406        return Err(origen::Error::new(&format!(
407            "uv.lock: an existing lockfile cannot be established as belonging to project '{}'; remove it or migrate it manually",
408            project_name
409        )));
410    }
411    Ok(())
412}
413
414fn validate_generated_lock(path: &Path, plan: &MigrationPlan) -> Result<()> {
415    if !path.is_file() {
416        return Err(origen::Error::new(
417            "uv lock completed without creating uv.lock",
418        ));
419    }
420    let text = fs::read_to_string(path)?;
421    let value: toml::Value = toml::from_str(&text)
422        .map_err(|error| origen::Error::new(&format!("Generated uv.lock is invalid: {}", error)))?;
423    let packages = lock_package_names(&value);
424    let mut missing: Vec<String> = plan
425        .direct_dependencies
426        .difference(&packages)
427        .cloned()
428        .collect();
429    if plan.installable && !packages.contains(&plan.project_name) {
430        missing.push(format!("{} (root project)", plan.project_name));
431    }
432    if !missing.is_empty() {
433        missing.sort();
434        return Err(origen::Error::new(&format!(
435            "Generated uv.lock is missing declared dependencies: {}",
436            missing.join(", ")
437        )));
438    }
439    Ok(())
440}
441
442fn lock_package_names(value: &toml::Value) -> BTreeSet<String> {
443    value
444        .get("package")
445        .and_then(toml::Value::as_array)
446        .into_iter()
447        .flatten()
448        .filter_map(|package| package.get("name").and_then(toml::Value::as_str))
449        .map(canonicalize_name)
450        .collect()
451}
452
453fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
454    let parent = path.parent().ok_or_else(|| {
455        origen::Error::new(&format!("{} has no parent directory", path.display()))
456    })?;
457    let permissions = fs::metadata(path)
458        .ok()
459        .map(|metadata| metadata.permissions());
460    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
461    temporary.write_all(contents)?;
462    temporary.as_file().sync_all()?;
463    if let Some(permissions) = permissions {
464        temporary.as_file().set_permissions(permissions)?;
465    }
466    temporary
467        .persist(path)
468        .map_err(|error| origen::Error::new(&error.to_string()))?;
469    Ok(())
470}
471
472fn find_project_root(project: Option<&str>) -> Result<PathBuf> {
473    let start = match project {
474        Some(project) => {
475            let path = PathBuf::from(project);
476            let path = if path.is_absolute() {
477                path
478            } else {
479                std::env::current_dir()?.join(path)
480            };
481            if !path.exists() {
482                return Err(origen::Error::new(&format!(
483                    "Project path '{}' does not exist",
484                    path.display()
485                )));
486            }
487            if path.is_file() {
488                if path.file_name().and_then(|name| name.to_str()) != Some(PYPROJECT) {
489                    return Err(origen::Error::new(&format!(
490                        "Project path '{}' is a file but is not pyproject.toml",
491                        path.display()
492                    )));
493                }
494                path.parent().unwrap().to_path_buf()
495            } else {
496                path
497            }
498        }
499        None => std::env::current_dir()?,
500    };
501
502    let start = start.canonicalize()?;
503    for directory in start.ancestors() {
504        if directory.join(PYPROJECT).is_file() {
505            return Ok(directory.to_path_buf());
506        }
507    }
508    Err(origen::Error::new(&format!(
509        "Could not find pyproject.toml from {} or any parent directory",
510        start.display()
511    )))
512}
513
514fn parse_document(contents: &str) -> Result<Document> {
515    contents
516        .parse::<Document>()
517        .map_err(|error| origen::Error::new(&format!("Could not parse pyproject.toml: {}", error)))
518}
519
520fn poetry_table(doc: &Document) -> Option<&Table> {
521    doc.get("tool")
522        .and_then(Item::as_table)
523        .and_then(|tool| tool.get("poetry"))
524        .and_then(Item::as_table)
525}
526
527fn ensure_table<'a>(parent: &'a mut Table, key: &str) -> &'a mut Table {
528    if parent.get(key).and_then(Item::as_table).is_none() {
529        parent.insert(key, Item::Table(Table::new()));
530    }
531    parent.get_mut(key).unwrap().as_table_mut().unwrap()
532}
533
534fn validate_poetry_keys(poetry: &Table) -> Vec<String> {
535    let supported: BTreeSet<&str> = [
536        "name",
537        "version",
538        "description",
539        "authors",
540        "maintainers",
541        "license",
542        "readme",
543        "homepage",
544        "repository",
545        "documentation",
546        "keywords",
547        "classifiers",
548        "scripts",
549        "plugins",
550        "dependencies",
551        "dev-dependencies",
552        "group",
553        "extras",
554        "source",
555        "package-mode",
556    ]
557    .into_iter()
558    .collect();
559    poetry
560        .iter()
561        .filter(|(key, _)| !supported.contains(*key))
562        .map(|(key, _)| {
563            let reason = match key {
564                "packages" | "include" | "exclude" => {
565                    "custom package/include/exclude rules require a manual Hatchling configuration"
566                }
567                _ => "no exact PEP 621/UV mapping is implemented",
568            };
569            format!("tool.poetry.{}: {}", key, reason)
570        })
571        .collect()
572}
573
574fn validate_metadata_types(poetry: &Table, diagnostics: &mut Vec<String>) {
575    for key in ["description", "license", "readme"] {
576        if poetry.get(key).is_some() && poetry.get(key).and_then(Item::as_str).is_none() {
577            diagnostics.push(format!("tool.poetry.{}: expected a string", key));
578        }
579    }
580    for key in ["keywords", "classifiers"] {
581        if let Some(item) = poetry.get(key) {
582            let valid = item
583                .as_array()
584                .map(|values| values.iter().all(|value| value.as_str().is_some()))
585                .unwrap_or(false);
586            if !valid {
587                diagnostics.push(format!("tool.poetry.{}: expected an array of strings", key));
588            }
589        }
590    }
591    if poetry.get("package-mode").is_some()
592        && poetry.get("package-mode").and_then(Item::as_bool).is_none()
593    {
594        diagnostics.push("tool.poetry.package-mode: expected a boolean".to_string());
595    }
596    if poetry.get("dependencies").is_some()
597        && poetry
598            .get("dependencies")
599            .and_then(Item::as_table)
600            .is_none()
601    {
602        diagnostics.push("tool.poetry.dependencies: expected a table".to_string());
603    }
604}
605
606fn required_string(
607    table: &Table,
608    key: &str,
609    path: &str,
610    diagnostics: &mut Vec<String>,
611) -> Option<String> {
612    match table.get(key).and_then(Item::as_str) {
613        Some(value) if !value.trim().is_empty() => Some(value.to_string()),
614        _ => {
615            diagnostics.push(format!("{}: a non-empty string is required", path));
616            None
617        }
618    }
619}
620
621fn move_scalar(from: &Table, to: &mut Table, key: &str) {
622    if let Some(item) = from.get(key) {
623        to.insert(key, item.clone());
624    }
625}
626
627fn convert_people(poetry: &Table, project: &mut Table, key: &str, diagnostics: &mut Vec<String>) {
628    let Some(item) = poetry.get(key) else {
629        return;
630    };
631    let Some(values) = item.as_array() else {
632        diagnostics.push(format!("tool.poetry.{}: expected an array of strings", key));
633        return;
634    };
635    let mut people = Array::new();
636    for (index, value) in values.iter().enumerate() {
637        let Some(person) = value.as_str() else {
638            diagnostics.push(format!("tool.poetry.{}[{}]: expected a string", key, index));
639            continue;
640        };
641        let (name, email) = parse_person(person);
642        if name.is_empty() && email.is_none() {
643            diagnostics.push(format!(
644                "tool.poetry.{}[{}]: author name or email is required",
645                key, index
646            ));
647            continue;
648        }
649        let mut output = InlineTable::new();
650        if !name.is_empty() {
651            output.insert("name", Value::from(name));
652        }
653        if let Some(email) = email {
654            output.insert("email", Value::from(email));
655        }
656        people.push(Value::InlineTable(output));
657    }
658    project.insert(key, Item::Value(Value::Array(people)));
659}
660
661fn parse_person(person: &str) -> (String, Option<String>) {
662    let person = person.trim();
663    if let Some(open) = person.rfind('<') {
664        if person.ends_with('>') {
665            let name = person[..open].trim().to_string();
666            let email = person[open + 1..person.len() - 1].trim();
667            if !email.is_empty() {
668                return (name, Some(email.to_string()));
669            }
670        }
671    }
672    (person.to_string(), None)
673}
674
675fn convert_urls(poetry: &Table, project: &mut Table, diagnostics: &mut Vec<String>) {
676    let mappings = [
677        ("homepage", "Homepage"),
678        ("repository", "Repository"),
679        ("documentation", "Documentation"),
680    ];
681    let mut urls = Table::new();
682    for (poetry_key, project_key) in mappings {
683        if let Some(item) = poetry.get(poetry_key) {
684            if item.as_str().is_some() {
685                urls.insert(project_key, item.clone());
686            } else {
687                diagnostics.push(format!("tool.poetry.{}: expected a URL string", poetry_key));
688            }
689        }
690    }
691    if !urls.is_empty() {
692        project.insert("urls", Item::Table(urls));
693    }
694}
695
696fn convert_scripts(poetry: &Table, project: &mut Table, diagnostics: &mut Vec<String>) {
697    let Some(scripts) = poetry.get("scripts").and_then(Item::as_table) else {
698        if poetry.get("scripts").is_some() {
699            diagnostics.push("tool.poetry.scripts: expected a table".to_string());
700        }
701        return;
702    };
703    let mut output = Table::new();
704    for (name, item) in scripts {
705        if item.as_str().is_some() {
706            output.insert(name, item.clone());
707        } else {
708            diagnostics.push(format!(
709                "tool.poetry.scripts.{}: only string console-script entries are supported",
710                name
711            ));
712        }
713    }
714    if !output.is_empty() {
715        project.insert("scripts", Item::Table(output));
716    }
717}
718
719fn convert_plugins(poetry: &Table, project: &mut Table, diagnostics: &mut Vec<String>) {
720    let Some(plugins) = poetry.get("plugins").and_then(Item::as_table) else {
721        if poetry.get("plugins").is_some() {
722            diagnostics.push("tool.poetry.plugins: expected a table".to_string());
723        }
724        return;
725    };
726    let mut groups = Table::new();
727    for (group_name, group_item) in plugins {
728        let Some(group) = group_item.as_table() else {
729            diagnostics.push(format!(
730                "tool.poetry.plugins.{}: expected a table",
731                group_name
732            ));
733            continue;
734        };
735        let mut output = Table::new();
736        for (name, item) in group {
737            if item.as_str().is_some() {
738                output.insert(name, item.clone());
739            } else {
740                diagnostics.push(format!(
741                    "tool.poetry.plugins.{}.{}: expected an import string",
742                    group_name, name
743                ));
744            }
745        }
746        groups.insert(group_name, Item::Table(output));
747    }
748    if !groups.is_empty() {
749        project.insert("entry-points", Item::Table(groups));
750    }
751}
752
753fn convert_indexes(poetry: &Table, diagnostics: &mut Vec<String>) -> Vec<IndexSource> {
754    let Some(sources) = poetry.get("source") else {
755        return Vec::new();
756    };
757    let Some(sources) = sources.as_array_of_tables() else {
758        diagnostics.push("tool.poetry.source: expected an array of tables".to_string());
759        return Vec::new();
760    };
761    let mut indexes = Vec::new();
762    for (index, source) in sources.iter().enumerate() {
763        let path = format!("tool.poetry.source[{}]", index);
764        let name = source.get("name").and_then(Item::as_str);
765        let url = source.get("url").and_then(Item::as_str);
766        let priority = source.get("priority").and_then(Item::as_str);
767        for (key, _) in source.iter() {
768            if !matches!(key, "name" | "url" | "priority") {
769                diagnostics.push(format!("{}.{}: unsupported source setting", path, key));
770            }
771        }
772        if priority != Some("explicit") {
773            diagnostics.push(format!(
774                "{}.priority: only Poetry priority = \"explicit\" has an exact UV mapping",
775                path
776            ));
777        }
778        match (name, url) {
779            (Some(name), Some(url)) => indexes.push(IndexSource {
780                name: name.to_string(),
781                url: url.to_string(),
782            }),
783            _ => diagnostics.push(format!("{}: name and url strings are required", path)),
784        }
785    }
786    indexes
787}
788
789fn convert_dependency(
790    name: &str,
791    item: &Item,
792    explicit_indexes: &BTreeSet<String>,
793) -> std::result::Result<ConvertedDependency, String> {
794    let normalized_name = canonicalize_name(name);
795    if let Some(constraint) = item.as_str() {
796        let constraint = translate_constraint(constraint)?;
797        return Ok(ConvertedDependency {
798            requirement: format_requirement(&normalized_name, &[], &constraint, &[]),
799            normalized_name,
800            source: None,
801            optional: false,
802        });
803    }
804    if item.as_bool() == Some(false) {
805        return Err("disabled dependencies cannot be represented in PEP 621".to_string());
806    }
807    let fields = dependency_fields(item)?;
808    let supported: BTreeSet<&str> = [
809        "version",
810        "extras",
811        "markers",
812        "python",
813        "platform",
814        "optional",
815        "path",
816        "develop",
817        "git",
818        "branch",
819        "tag",
820        "rev",
821        "subdirectory",
822        "url",
823        "source",
824    ]
825    .into_iter()
826    .collect();
827    let unknown: Vec<String> = fields
828        .keys()
829        .filter(|key| !supported.contains(key.as_str()))
830        .cloned()
831        .collect();
832    if !unknown.is_empty() {
833        return Err(format!("unsupported fields: {}", unknown.join(", ")));
834    }
835
836    let version = fields
837        .get("version")
838        .and_then(Value::as_str)
839        .map(translate_constraint)
840        .transpose()?
841        .unwrap_or_default();
842    let extras = string_array_field(&fields, "extras")?;
843    let optional = bool_field(&fields, "optional")?.unwrap_or(false);
844    let develop = bool_field(&fields, "develop")?.unwrap_or(false);
845    let mut markers = Vec::new();
846    if let Some(marker) = fields.get("markers").and_then(Value::as_str) {
847        markers.push(format!("({})", marker.trim()));
848    } else if fields.contains_key("markers") {
849        return Err("markers must be a string".to_string());
850    }
851    if let Some(python) = fields.get("python").and_then(Value::as_str) {
852        markers.extend(python_markers(python)?);
853    } else if fields.contains_key("python") {
854        return Err("python must be a version-constraint string".to_string());
855    }
856    if let Some(platform) = fields.get("platform").and_then(Value::as_str) {
857        markers.push(format!("sys_platform == '{}'", escape_marker(platform)));
858    } else if fields.contains_key("platform") {
859        return Err("platform must be a string".to_string());
860    }
861
862    let source_fields = ["path", "git", "url", "source"]
863        .iter()
864        .filter(|key| fields.contains_key(**key))
865        .count();
866    if source_fields > 1 {
867        return Err("path, git, url, and source are mutually exclusive".to_string());
868    }
869    let mut source = None;
870    if let Some(path) = fields.get("path").and_then(Value::as_str) {
871        let mut table = InlineTable::new();
872        table.insert("path", Value::from(path));
873        if develop {
874            table.insert("editable", Value::from(true));
875        }
876        source = Some(table);
877    } else if let Some(git) = fields.get("git").and_then(Value::as_str) {
878        let mut table = InlineTable::new();
879        table.insert("git", Value::from(git));
880        for key in ["branch", "tag", "rev", "subdirectory"] {
881            if let Some(value) = fields.get(key).and_then(Value::as_str) {
882                table.insert(key, Value::from(value));
883            } else if fields.contains_key(key) {
884                return Err(format!("{} must be a string", key));
885            }
886        }
887        source = Some(table);
888    } else if let Some(url) = fields.get("url").and_then(Value::as_str) {
889        let mut table = InlineTable::new();
890        table.insert("url", Value::from(url));
891        source = Some(table);
892    } else if let Some(index) = fields.get("source").and_then(Value::as_str) {
893        if !explicit_indexes.contains(index) {
894            return Err(format!(
895                "source '{}' is not declared with Poetry priority = \"explicit\"",
896                index
897            ));
898        }
899        let mut table = InlineTable::new();
900        table.insert("index", Value::from(index));
901        source = Some(table);
902    }
903    if develop && !fields.contains_key("path") {
904        return Err("develop is only valid for a local path dependency".to_string());
905    }
906    if !fields.contains_key("git") {
907        for key in ["branch", "tag", "rev", "subdirectory"] {
908            if fields.contains_key(key) {
909                return Err(format!("{} requires a git source", key));
910            }
911        }
912    }
913
914    Ok(ConvertedDependency {
915        requirement: format_requirement(&normalized_name, &extras, &version, &markers),
916        normalized_name,
917        source,
918        optional,
919    })
920}
921
922fn dependency_fields(item: &Item) -> std::result::Result<BTreeMap<String, Value>, String> {
923    if let Some(table) = item.as_inline_table() {
924        return Ok(table
925            .iter()
926            .map(|(key, value)| (key.to_string(), value.clone()))
927            .collect());
928    }
929    if let Some(table) = item.as_table() {
930        let mut fields = BTreeMap::new();
931        for (key, item) in table {
932            let Some(value) = item.as_value() else {
933                return Err(format!("{} must be a scalar or array value", key));
934            };
935            fields.insert(key.to_string(), value.clone());
936        }
937        return Ok(fields);
938    }
939    Err("expected a version string or dependency table".to_string())
940}
941
942fn string_array_field(
943    fields: &BTreeMap<String, Value>,
944    key: &str,
945) -> std::result::Result<Vec<String>, String> {
946    let Some(value) = fields.get(key) else {
947        return Ok(Vec::new());
948    };
949    let Some(array) = value.as_array() else {
950        return Err(format!("{} must be an array of strings", key));
951    };
952    array
953        .iter()
954        .map(|value| {
955            value
956                .as_str()
957                .map(str::to_string)
958                .ok_or_else(|| format!("{} must contain only strings", key))
959        })
960        .collect()
961}
962
963fn bool_field(
964    fields: &BTreeMap<String, Value>,
965    key: &str,
966) -> std::result::Result<Option<bool>, String> {
967    match fields.get(key) {
968        Some(value) => value
969            .as_bool()
970            .map(Some)
971            .ok_or_else(|| format!("{} must be a boolean", key)),
972        None => Ok(None),
973    }
974}
975
976fn format_requirement(name: &str, extras: &[String], version: &str, markers: &[String]) -> String {
977    let mut requirement = name.to_string();
978    if !extras.is_empty() {
979        requirement.push('[');
980        requirement.push_str(&extras.join(","));
981        requirement.push(']');
982    }
983    requirement.push_str(version);
984    if !markers.is_empty() {
985        requirement.push_str("; ");
986        requirement.push_str(&markers.join(" and "));
987    }
988    requirement
989}
990
991fn convert_extras(
992    poetry: &Table,
993    optional: &BTreeMap<String, ConvertedDependency>,
994    project: &mut Table,
995    diagnostics: &mut Vec<String>,
996) {
997    let Some(extras) = poetry.get("extras").and_then(Item::as_table) else {
998        if poetry.get("extras").is_some() {
999            diagnostics.push("tool.poetry.extras: expected a table".to_string());
1000        }
1001        for name in optional.keys() {
1002            diagnostics.push(format!(
1003                "tool.poetry.dependencies.{}: optional dependency is not assigned to a Poetry extra",
1004                name
1005            ));
1006        }
1007        return;
1008    };
1009    let mut output = Table::new();
1010    let mut referenced = BTreeSet::new();
1011    for (extra, item) in extras {
1012        let Some(names) = item.as_array() else {
1013            diagnostics.push(format!(
1014                "tool.poetry.extras.{}: expected an array of dependency names",
1015                extra
1016            ));
1017            continue;
1018        };
1019        let mut requirements = Vec::new();
1020        for value in names.iter() {
1021            let Some(name) = value.as_str() else {
1022                diagnostics.push(format!(
1023                    "tool.poetry.extras.{}: expected only dependency-name strings",
1024                    extra
1025                ));
1026                continue;
1027            };
1028            let normalized = canonicalize_name(name);
1029            match optional.get(&normalized) {
1030                Some(dependency) => {
1031                    referenced.insert(normalized);
1032                    requirements.push(dependency.requirement.clone());
1033                }
1034                None => diagnostics.push(format!(
1035                    "tool.poetry.extras.{}: '{}' is not an optional runtime dependency",
1036                    extra, name
1037                )),
1038            }
1039        }
1040        output.insert(extra, string_array_item(requirements));
1041    }
1042    for name in optional.keys().filter(|name| !referenced.contains(*name)) {
1043        diagnostics.push(format!(
1044            "tool.poetry.dependencies.{}: optional dependency is not assigned to a Poetry extra",
1045            name
1046        ));
1047    }
1048    if !output.is_empty() {
1049        project.insert("optional-dependencies", Item::Table(output));
1050    }
1051}
1052
1053#[allow(clippy::too_many_arguments)]
1054fn convert_dependency_groups(
1055    poetry: &Table,
1056    explicit_indexes: &BTreeSet<String>,
1057    output: &mut Table,
1058    default_groups: &mut Vec<String>,
1059    uv_sources: &mut Table,
1060    direct_dependencies: &mut BTreeSet<String>,
1061    diagnostics: &mut Vec<String>,
1062) {
1063    let legacy_dev = poetry.get("dev-dependencies").and_then(Item::as_table);
1064    let groups = poetry.get("group").and_then(Item::as_table);
1065    if poetry.get("dev-dependencies").is_some() && legacy_dev.is_none() {
1066        diagnostics.push("tool.poetry.dev-dependencies: expected a table".to_string());
1067    }
1068    if poetry.get("group").is_some() && groups.is_none() {
1069        diagnostics.push("tool.poetry.group: expected a table".to_string());
1070    }
1071    if legacy_dev.is_some() && groups.and_then(|groups| groups.get("dev")).is_some() {
1072        diagnostics.push(
1073            "tool.poetry.dev-dependencies and tool.poetry.group.dev.dependencies: both define the dev group"
1074                .to_string(),
1075        );
1076    }
1077    if let Some(dependencies) = legacy_dev {
1078        convert_one_group(
1079            "dev",
1080            dependencies,
1081            false,
1082            explicit_indexes,
1083            output,
1084            default_groups,
1085            uv_sources,
1086            direct_dependencies,
1087            diagnostics,
1088        );
1089    }
1090    if let Some(groups) = groups {
1091        for (name, group_item) in groups {
1092            let Some(group) = group_item.as_table() else {
1093                diagnostics.push(format!("tool.poetry.group.{}: expected a table", name));
1094                continue;
1095            };
1096            for (key, _) in group {
1097                if !matches!(key, "dependencies" | "optional") {
1098                    diagnostics.push(format!(
1099                        "tool.poetry.group.{}.{}: unsupported group setting",
1100                        name, key
1101                    ));
1102                }
1103            }
1104            let optional = group
1105                .get("optional")
1106                .and_then(Item::as_bool)
1107                .unwrap_or(false);
1108            if group.get("optional").is_some()
1109                && group.get("optional").and_then(Item::as_bool).is_none()
1110            {
1111                diagnostics.push(format!(
1112                    "tool.poetry.group.{}.optional: expected a boolean",
1113                    name
1114                ));
1115            }
1116            let Some(dependencies) = group.get("dependencies").and_then(Item::as_table) else {
1117                diagnostics.push(format!(
1118                    "tool.poetry.group.{}.dependencies: expected a table",
1119                    name
1120                ));
1121                continue;
1122            };
1123            convert_one_group(
1124                name,
1125                dependencies,
1126                optional,
1127                explicit_indexes,
1128                output,
1129                default_groups,
1130                uv_sources,
1131                direct_dependencies,
1132                diagnostics,
1133            );
1134        }
1135    }
1136}
1137
1138#[allow(clippy::too_many_arguments)]
1139fn convert_one_group(
1140    name: &str,
1141    dependencies: &Table,
1142    optional_group: bool,
1143    explicit_indexes: &BTreeSet<String>,
1144    output: &mut Table,
1145    default_groups: &mut Vec<String>,
1146    uv_sources: &mut Table,
1147    direct_dependencies: &mut BTreeSet<String>,
1148    diagnostics: &mut Vec<String>,
1149) {
1150    let mut requirements = Vec::new();
1151    for (dependency_name, item) in dependencies {
1152        match convert_dependency(dependency_name, item, explicit_indexes) {
1153            Ok(dependency) => {
1154                if dependency.optional {
1155                    diagnostics.push(format!(
1156                        "tool.poetry.group.{}.dependencies.{}: dependency-level optional is ambiguous inside a group",
1157                        name, dependency_name
1158                    ));
1159                    continue;
1160                }
1161                if let Some(source) = dependency.source {
1162                    let existing = uv_sources.get(&dependency.normalized_name);
1163                    let candidate = Item::Value(Value::InlineTable(source));
1164                    if let Some(existing) = existing {
1165                        if existing.to_string() != candidate.to_string() {
1166                            diagnostics.push(format!(
1167                                "tool.poetry.group.{}.dependencies.{}: source conflicts with another dependency declaration",
1168                                name, dependency_name
1169                            ));
1170                        }
1171                    } else {
1172                        uv_sources.insert(&dependency.normalized_name, candidate);
1173                    }
1174                }
1175                direct_dependencies.insert(dependency.normalized_name);
1176                requirements.push(dependency.requirement);
1177            }
1178            Err(message) => diagnostics.push(format!(
1179                "tool.poetry.group.{}.dependencies.{}: {}",
1180                name, dependency_name, message
1181            )),
1182        }
1183    }
1184    output.insert(name, string_array_item(requirements));
1185    if !optional_group {
1186        default_groups.push(name.to_string());
1187    }
1188}
1189
1190fn convert_build_system(
1191    doc: &mut Document,
1192    package_mode: bool,
1193    diagnostics: &mut Vec<String>,
1194) -> bool {
1195    if !package_mode {
1196        if doc.get("build-system").is_some() {
1197            diagnostics.push(
1198                "tool.poetry.package-mode and build-system: package-mode = false conflicts with an installable build system"
1199                    .to_string(),
1200            );
1201        }
1202        return false;
1203    }
1204    let Some(build) = doc.get_mut("build-system") else {
1205        return false;
1206    };
1207    let Some(build) = build.as_table_mut() else {
1208        diagnostics.push("build-system: expected a table".to_string());
1209        return false;
1210    };
1211    let backend_supported =
1212        build.get("build-backend").and_then(Item::as_str) == Some("poetry.core.masonry.api");
1213    if !backend_supported {
1214        diagnostics.push(
1215            "build-system.build-backend: only poetry.core.masonry.api can be migrated automatically"
1216                .to_string(),
1217        );
1218    }
1219    let poetry_core_required = build
1220        .get("requires")
1221        .and_then(Item::as_array)
1222        .map(|requirements| {
1223            requirements
1224                .iter()
1225                .all(|requirement| requirement.as_str().is_some())
1226                && requirements
1227                    .iter()
1228                    .filter_map(Value::as_str)
1229                    .any(|requirement| canonicalize_name(requirement).starts_with("poetry-core"))
1230        })
1231        .unwrap_or(false);
1232    if !poetry_core_required {
1233        diagnostics.push(
1234            "build-system.requires: expected a string array containing poetry-core".to_string(),
1235        );
1236    }
1237    for (key, _) in build.iter() {
1238        if !matches!(key, "requires" | "build-backend" | "backend-path") {
1239            diagnostics.push(format!("build-system.{}: unsupported build setting", key));
1240        }
1241    }
1242    if build.get("backend-path").is_some() {
1243        diagnostics.push(
1244            "build-system.backend-path: Poetry-to-Hatchling backend paths are ambiguous"
1245                .to_string(),
1246        );
1247    }
1248    if backend_supported && poetry_core_required {
1249        build.insert(
1250            "requires",
1251            string_array_item(vec![HATCHLING_REQUIREMENT.to_string()]),
1252        );
1253        build.insert("build-backend", value(HATCHLING_BACKEND));
1254        true
1255    } else {
1256        false
1257    }
1258}
1259
1260fn string_array_item(values: Vec<String>) -> Item {
1261    let mut array = Array::new();
1262    for value in values {
1263        array.push(value);
1264    }
1265    Item::Value(Value::Array(array))
1266}
1267
1268fn translate_constraint(constraint: &str) -> std::result::Result<String, String> {
1269    let constraint = constraint.trim();
1270    if constraint.is_empty() || constraint == "*" {
1271        return Ok(String::new());
1272    }
1273    if constraint.contains("||") {
1274        return Err(
1275            "union constraints using || do not have a single unambiguous PEP 508 mapping"
1276                .to_string(),
1277        );
1278    }
1279    let mut compact = constraint.to_string();
1280    for operator in ["===", ">=", "<=", "!=", "==", "~=", "^", "~", ">", "<"] {
1281        compact = compact.replace(&format!("{} ", operator), operator);
1282    }
1283    let parts: Vec<&str> = compact
1284        .split(',')
1285        .flat_map(|part| part.split_whitespace())
1286        .filter(|part| !part.is_empty())
1287        .collect();
1288    let mut translated = Vec::new();
1289    for part in parts {
1290        if let Some(version) = part.strip_prefix('^') {
1291            translated.push(format!(">={}", version));
1292            translated.push(format!("<{}", caret_upper_bound(version)?));
1293        } else if let Some(version) = part.strip_prefix('~') {
1294            if part.starts_with("~=") {
1295                translated.push(compact_operator_spacing(part));
1296            } else {
1297                translated.push(format!(">={}", version));
1298                translated.push(format!("<{}", tilde_upper_bound(version)?));
1299            }
1300        } else if starts_with_comparator(part) {
1301            translated.push(compact_operator_spacing(part));
1302        } else if part.contains('*') || part.contains('x') || part.contains('X') {
1303            translated.push(format!("=={}", part.replace(['x', 'X'], "*")));
1304        } else if is_version_literal(part) {
1305            translated.push(format!("=={}", part));
1306        } else {
1307            return Err(format!("constraint '{}' is not supported", part));
1308        }
1309    }
1310    Ok(translated.join(","))
1311}
1312
1313fn starts_with_comparator(value: &str) -> bool {
1314    ["===", ">=", "<=", "!=", "==", "~=", ">", "<"]
1315        .iter()
1316        .any(|operator| value.starts_with(operator))
1317}
1318
1319fn compact_operator_spacing(value: &str) -> String {
1320    let value = value.trim();
1321    for operator in ["===", ">=", "<=", "!=", "==", "~=", ">", "<"] {
1322        if let Some(rest) = value.strip_prefix(operator) {
1323            return format!("{}{}", operator, rest.trim());
1324        }
1325    }
1326    value.to_string()
1327}
1328
1329fn is_version_literal(value: &str) -> bool {
1330    value
1331        .chars()
1332        .next()
1333        .map(|character| character.is_ascii_digit())
1334        .unwrap_or(false)
1335        && !value.chars().any(char::is_whitespace)
1336}
1337
1338fn release_components(version: &str) -> std::result::Result<Vec<u64>, String> {
1339    let release = version
1340        .split(|character: char| !character.is_ascii_digit() && character != '.')
1341        .next()
1342        .unwrap_or_default();
1343    if release.is_empty() {
1344        return Err(format!(
1345            "version '{}' has no numeric release segment",
1346            version
1347        ));
1348    }
1349    release
1350        .split('.')
1351        .map(|part| {
1352            part.parse::<u64>()
1353                .map_err(|_| format!("version '{}' has an invalid release segment", version))
1354        })
1355        .collect()
1356}
1357
1358fn caret_upper_bound(version: &str) -> std::result::Result<String, String> {
1359    let mut parts = release_components(version)?;
1360    let index = parts
1361        .iter()
1362        .position(|part| *part != 0)
1363        .unwrap_or(parts.len() - 1);
1364    parts[index] += 1;
1365    for part in parts.iter_mut().skip(index + 1) {
1366        *part = 0;
1367    }
1368    while parts.len() < 3 {
1369        parts.push(0);
1370    }
1371    Ok(parts
1372        .iter()
1373        .map(u64::to_string)
1374        .collect::<Vec<_>>()
1375        .join("."))
1376}
1377
1378fn tilde_upper_bound(version: &str) -> std::result::Result<String, String> {
1379    let mut parts = release_components(version)?;
1380    let index = if parts.len() == 1 { 0 } else { 1 };
1381    while parts.len() <= index {
1382        parts.push(0);
1383    }
1384    parts[index] += 1;
1385    for part in parts.iter_mut().skip(index + 1) {
1386        *part = 0;
1387    }
1388    Ok(parts
1389        .iter()
1390        .map(u64::to_string)
1391        .collect::<Vec<_>>()
1392        .join("."))
1393}
1394
1395fn python_markers(constraint: &str) -> std::result::Result<Vec<String>, String> {
1396    let translated = translate_constraint(constraint)?;
1397    if translated.is_empty() {
1398        return Ok(Vec::new());
1399    }
1400    translated
1401        .split(',')
1402        .map(|specifier| {
1403            for operator in ["===", ">=", "<=", "!=", "==", "~=", ">", "<"] {
1404                if let Some(version) = specifier.strip_prefix(operator) {
1405                    return Ok(format!(
1406                        "python_full_version {} '{}'",
1407                        operator,
1408                        escape_marker(version)
1409                    ));
1410                }
1411            }
1412            Err(format!(
1413                "Python constraint '{}' cannot be expressed as an environment marker",
1414                specifier
1415            ))
1416        })
1417        .collect()
1418}
1419
1420fn escape_marker(value: &str) -> String {
1421    value.replace('\\', "\\\\").replace('\'', "\\'")
1422}
1423
1424fn canonicalize_name(name: &str) -> String {
1425    let mut output = String::new();
1426    let mut separator = false;
1427    for character in name.chars() {
1428        if matches!(character, '-' | '_' | '.') {
1429            separator = !output.is_empty();
1430        } else {
1431            if separator {
1432                output.push('-');
1433                separator = false;
1434            }
1435            output.extend(character.to_lowercase());
1436        }
1437    }
1438    output
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444
1445    const HISTORICAL_FIXTURES: &[(&str, &str, bool)] = &[
1446        (
1447            "python_app",
1448            include_str!("../../../tests/fixtures/poetry/python_app.toml"),
1449            true,
1450        ),
1451        (
1452            "python_no_app",
1453            include_str!("../../../tests/fixtures/poetry/python_no_app.toml"),
1454            false,
1455        ),
1456        (
1457            "python_plugin",
1458            include_str!("../../../tests/fixtures/poetry/python_plugin.toml"),
1459            true,
1460        ),
1461        (
1462            "python_plugin_the_second",
1463            include_str!("../../../tests/fixtures/poetry/python_plugin_the_second.toml"),
1464            true,
1465        ),
1466        (
1467            "python_plugin_no_cmds",
1468            include_str!("../../../tests/fixtures/poetry/python_plugin_no_cmds.toml"),
1469            true,
1470        ),
1471        (
1472            "pl_ext_cmds",
1473            include_str!("../../../tests/fixtures/poetry/pl_ext_cmds.toml"),
1474            true,
1475        ),
1476        (
1477            "test_apps_shared_test_helpers",
1478            include_str!("../../../tests/fixtures/poetry/test_apps_shared_test_helpers.toml"),
1479            true,
1480        ),
1481        (
1482            "user_install",
1483            include_str!("../../../tests/fixtures/poetry/user_install.toml"),
1484            false,
1485        ),
1486        (
1487            "rendered_user_template",
1488            include_str!("../../../tests/fixtures/poetry/rendered_user_template.toml"),
1489            false,
1490        ),
1491    ];
1492
1493    #[test]
1494    fn converts_every_historical_manifest_without_dropping_dependencies() {
1495        for (fixture_name, fixture, installable) in HISTORICAL_FIXTURES {
1496            let plan = plan_poetry_migration(fixture)
1497                .unwrap_or_else(|error| panic!("{} failed: {}", fixture_name, error));
1498            let converted: toml::Value = toml::from_str(&plan.manifest).unwrap();
1499            assert!(
1500                converted.get("project").is_some(),
1501                "{} has no project table",
1502                fixture_name
1503            );
1504            assert!(
1505                converted
1506                    .get("tool")
1507                    .and_then(|tool| tool.get("poetry"))
1508                    .is_none(),
1509                "{} retained Poetry metadata",
1510                fixture_name
1511            );
1512            assert!(
1513                converted
1514                    .get("project")
1515                    .and_then(|project| project.get("requires-python"))
1516                    .and_then(toml::Value::as_str)
1517                    .is_some(),
1518                "{} lost its Python requirement",
1519                fixture_name
1520            );
1521            assert_eq!(
1522                plan.installable, *installable,
1523                "{} installability changed",
1524                fixture_name
1525            );
1526            if *installable {
1527                assert_eq!(
1528                    converted["build-system"]["build-backend"].as_str(),
1529                    Some(HATCHLING_BACKEND),
1530                    "{} did not receive Hatchling",
1531                    fixture_name
1532                );
1533            } else {
1534                assert_eq!(
1535                    converted["tool"]["uv"]["package"].as_bool(),
1536                    Some(false),
1537                    "{} was not marked virtual",
1538                    fixture_name
1539                );
1540            }
1541            for dependency in &plan.direct_dependencies {
1542                assert!(
1543                    plan.manifest.contains(dependency),
1544                    "{} dropped {}",
1545                    fixture_name,
1546                    dependency
1547                );
1548            }
1549            if fixture.contains("[tool.pytest.ini_options]") {
1550                assert!(
1551                    converted["tool"].get("pytest").is_some(),
1552                    "{} lost unrelated pytest configuration",
1553                    fixture_name
1554                );
1555            }
1556            if fixture.contains("develop = true") {
1557                let sources = converted["tool"]["uv"]["sources"].as_table().unwrap();
1558                assert!(
1559                    sources.values().all(|source| {
1560                        source.get("editable").and_then(toml::Value::as_bool) == Some(true)
1561                    }),
1562                    "{} did not preserve Poetry develop intent",
1563                    fixture_name
1564                );
1565            }
1566        }
1567    }
1568
1569    #[test]
1570    fn translates_boundary_sensitive_poetry_constraints() {
1571        let cases = [
1572            ("^1.2.3", ">=1.2.3,<2.0.0"),
1573            ("^0.2.3", ">=0.2.3,<0.3.0"),
1574            ("^0.0.3", ">=0.0.3,<0.0.4"),
1575            ("^3", ">=3,<4.0.0"),
1576            ("~1.2", ">=1.2,<1.3"),
1577            ("~1", ">=1,<2"),
1578            ("1.2.3", "==1.2.3"),
1579            ("1.2.*", "==1.2.*"),
1580            (">= 1.0, != 1.5, < 2", ">=1.0,!=1.5,<2"),
1581            (">=1.0 <2.0", ">=1.0,<2.0"),
1582            ("1.2.x", "==1.2.*"),
1583            ("^1.2.3rc1", ">=1.2.3rc1,<2.0.0"),
1584        ];
1585        for (poetry, pep440) in cases {
1586            assert_eq!(translate_constraint(poetry).unwrap(), pep440, "{}", poetry);
1587        }
1588    }
1589
1590    #[test]
1591    fn converts_sources_extras_markers_groups_scripts_and_plugins() {
1592        let input = r#"
1593[tool.poetry]
1594name = "rich-project"
1595version = "1.0.0"
1596description = "Rich fixture"
1597authors = ["Example User <user@example.com>"]
1598homepage = "https://example.com"
1599
1600[tool.poetry.dependencies]
1601python = "^3.8"
1602local-lib = { path = "../local", develop = true, extras = ["speed"], python = "^3.9", platform = "linux" }
1603git-lib = { git = "https://example.com/lib.git", tag = "v1", subdirectory = "python" }
1604url-lib = { url = "https://example.com/url-lib.whl" }
1605index-lib = { version = "^2", source = "private" }
1606optional-lib = { version = "~1.4", optional = true }
1607
1608[tool.poetry.extras]
1609feature = ["optional-lib"]
1610
1611[tool.poetry.group.docs]
1612optional = true
1613
1614[tool.poetry.group.docs.dependencies]
1615sphinx = "^7"
1616
1617[tool.poetry.scripts]
1618rich = "rich_project.cli:main"
1619
1620[tool.poetry.plugins."origen.plugins"]
1621rich = "rich_project.plugin:Plugin"
1622
1623[[tool.poetry.source]]
1624name = "private"
1625url = "https://packages.example.com/simple"
1626priority = "explicit"
1627
1628[build-system]
1629requires = ["poetry-core>=1"]
1630build-backend = "poetry.core.masonry.api"
1631"#;
1632        let plan = plan_poetry_migration(input).unwrap();
1633        let converted: toml::Value = toml::from_str(&plan.manifest).unwrap();
1634        assert_eq!(
1635            converted["project"]["authors"][0]["email"].as_str(),
1636            Some("user@example.com")
1637        );
1638        assert_eq!(
1639            converted["project"]["scripts"]["rich"].as_str(),
1640            Some("rich_project.cli:main")
1641        );
1642        assert_eq!(
1643            converted["project"]["entry-points"]["origen.plugins"]["rich"].as_str(),
1644            Some("rich_project.plugin:Plugin")
1645        );
1646        assert_eq!(
1647            converted["tool"]["uv"]["sources"]["local-lib"]["editable"].as_bool(),
1648            Some(true)
1649        );
1650        assert_eq!(
1651            converted["tool"]["uv"]["sources"]["git-lib"]["tag"].as_str(),
1652            Some("v1")
1653        );
1654        assert_eq!(
1655            converted["tool"]["uv"]["sources"]["index-lib"]["index"].as_str(),
1656            Some("private")
1657        );
1658        assert_eq!(
1659            converted["tool"]["uv"]["index"][0]["explicit"].as_bool(),
1660            Some(true)
1661        );
1662        assert!(converted["project"]["dependencies"]
1663            .as_array()
1664            .unwrap()
1665            .iter()
1666            .filter_map(toml::Value::as_str)
1667            .any(|dependency| {
1668                dependency.contains("python_full_version >= '3.9'")
1669                    && dependency.contains("sys_platform == 'linux'")
1670            }));
1671        assert_eq!(
1672            converted["project"]["optional-dependencies"]["feature"][0].as_str(),
1673            Some("optional-lib>=1.4,<1.5")
1674        );
1675        assert_eq!(
1676            converted["dependency-groups"]["docs"][0].as_str(),
1677            Some("sphinx>=7,<8.0.0")
1678        );
1679        assert!(converted["tool"]["uv"].get("default-groups").is_none());
1680    }
1681
1682    #[test]
1683    fn reports_all_unsupported_constructs_before_conversion() {
1684        let input = r#"
1685[tool.poetry]
1686name = "unsupported"
1687version = "1.0.0"
1688description = ""
1689authors = ["Origen-SDK"]
1690packages = [{ include = "src" }]
1691include = ["data"]
1692
1693[tool.poetry.dependencies]
1694python = ">=3.8"
1695variant = [{ version = "^1" }, { version = "^2" }]
1696"#;
1697        let error = plan_poetry_migration(input).unwrap_err().to_string();
1698        assert!(error.contains("tool.poetry.packages"));
1699        assert!(error.contains("tool.poetry.include"));
1700        assert!(error.contains("tool.poetry.dependencies.variant"));
1701    }
1702
1703    #[test]
1704    fn successful_transaction_generates_lock_and_removes_poetry_lock() {
1705        let directory = tempfile::tempdir().unwrap();
1706        let root = directory.path();
1707        let original = HISTORICAL_FIXTURES[2].1;
1708        fs::write(root.join(PYPROJECT), original).unwrap();
1709        fs::write(root.join(POETRY_LOCK), b"original poetry lock").unwrap();
1710        let plan = plan_poetry_migration(original).unwrap();
1711
1712        apply_migration(root, &plan, |root| {
1713            fs::write(
1714                root.join(UV_LOCK),
1715                fake_lock(
1716                    std::iter::once(plan.project_name.as_str())
1717                        .chain(plan.direct_dependencies.iter().map(String::as_str)),
1718                ),
1719            )?;
1720            Ok(())
1721        })
1722        .unwrap();
1723
1724        assert_eq!(
1725            manifest_state(&fs::read_to_string(root.join(PYPROJECT)).unwrap()).unwrap(),
1726            ManifestState::Pep621Only
1727        );
1728        assert!(root.join(UV_LOCK).is_file());
1729        assert!(!root.join(POETRY_LOCK).exists());
1730    }
1731
1732    #[test]
1733    fn failed_transaction_restores_every_file_byte_for_byte() {
1734        let directory = tempfile::tempdir().unwrap();
1735        let root = directory.path();
1736        let original_manifest = HISTORICAL_FIXTURES[2].1.as_bytes();
1737        let original_poetry_lock = b"original poetry lock";
1738        let plan = plan_poetry_migration(HISTORICAL_FIXTURES[2].1).unwrap();
1739        let original_uv_lock = fake_lock([plan.project_name.as_str()]);
1740        fs::write(root.join(PYPROJECT), original_manifest).unwrap();
1741        fs::write(root.join(POETRY_LOCK), original_poetry_lock).unwrap();
1742        fs::write(root.join(UV_LOCK), &original_uv_lock).unwrap();
1743
1744        let error = apply_migration(root, &plan, |root| {
1745            fs::write(root.join(UV_LOCK), b"partial lock")?;
1746            Err(origen::Error::new("forced lock failure"))
1747        })
1748        .unwrap_err();
1749
1750        assert!(error.to_string().contains("forced lock failure"));
1751        assert_eq!(fs::read(root.join(PYPROJECT)).unwrap(), original_manifest);
1752        assert_eq!(
1753            fs::read(root.join(POETRY_LOCK)).unwrap(),
1754            original_poetry_lock
1755        );
1756        assert_eq!(fs::read(root.join(UV_LOCK)).unwrap(), original_uv_lock);
1757    }
1758
1759    #[test]
1760    fn poetry_guard_stops_uv_backed_commands_with_migration_instructions() {
1761        let directory = tempfile::tempdir().unwrap();
1762        let path = directory.path().join(PYPROJECT);
1763        fs::write(&path, HISTORICAL_FIXTURES[0].1).unwrap();
1764        let error = guard_uv_manifest(&path).unwrap_err().to_string();
1765        assert!(error.contains("origen env migrate --dry-run"));
1766        assert!(error.contains("origen env migrate"));
1767        assert!(error.contains("origen env setup"));
1768    }
1769
1770    #[test]
1771    fn poetry_guard_defers_malformed_toml_to_uv() {
1772        let directory = tempfile::tempdir().unwrap();
1773        let path = directory.path().join(PYPROJECT);
1774        fs::write(&path, "[tool.poetry\nname = \"broken\"").unwrap();
1775        guard_uv_manifest(&path).unwrap();
1776    }
1777
1778    fn fake_lock<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<u8> {
1779        let mut lock = String::from("version = 1\n");
1780        for name in names {
1781            lock.push_str(&format!(
1782                "\n[[package]]\nname = {:?}\nversion = \"1.0.0\"\n",
1783                name
1784            ));
1785        }
1786        lock.into_bytes()
1787    }
1788}