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 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}