1414
1515use std:: path:: Path ;
1616use tokio:: fs;
17- use toml_edit:: { Array , DocumentMut , Item , Table , Value } ;
17+ use toml_edit:: { Array , DocumentMut , InlineTable , Item , Table , Value } ;
1818
1919use super :: detect:: { deps_contain_hook, spec_is_hook, HOOK_DEP } ;
2020
@@ -287,13 +287,33 @@ fn poetry_add(doc: &mut DocumentMut) -> Result<bool, String> {
287287 let poetry = ensure_table ( tool, "poetry" , true ) ?;
288288 let deps = ensure_table ( poetry, "dependencies" , false ) ?;
289289
290- // The hook is a standalone, version-agnostic dependency — add it as its own
291- // key rather than mutating the user's `socket-patch` entry. `"*"` because
292- // the hook needs no specific version (it runs whatever CLI is on PATH) .
290+ // Classic Poetry can't express `socket-patch[hook]` as a key, so declare
291+ // the equivalent: `socket-patch` carrying the `hook` extra. Already wired
292+ // if a bare `socket-patch-hook` key exists or the extra is already present .
293293 if deps. contains_key ( "socket-patch-hook" ) {
294294 return Ok ( false ) ;
295295 }
296- deps. insert ( "socket-patch-hook" , Item :: Value ( Value :: from ( "*" ) ) ) ;
296+ if let Some ( item) = deps. get_mut ( "socket-patch" ) {
297+ if item_has_hook_extra ( item) {
298+ return Ok ( false ) ;
299+ }
300+ // An existing `socket-patch` dep (bare string or a table): merge the
301+ // `hook` extra in place, preserving its version / source / markers.
302+ if let Some ( tbl) = item. as_table_like_mut ( ) {
303+ let mut extras = tbl
304+ . get ( "extras" )
305+ . and_then ( Item :: as_array)
306+ . cloned ( )
307+ . unwrap_or_default ( ) ;
308+ extras. push ( "hook" ) ;
309+ tbl. insert ( "extras" , Item :: Value ( Value :: Array ( extras) ) ) ;
310+ } else {
311+ let version = item. as_str ( ) . map ( str:: to_string) . unwrap_or_else ( || "*" . to_string ( ) ) ;
312+ deps. insert ( "socket-patch" , Item :: Value ( hook_inline_table ( & version) ) ) ;
313+ }
314+ return Ok ( true ) ;
315+ }
316+ deps. insert ( "socket-patch" , Item :: Value ( hook_inline_table ( "*" ) ) ) ;
297317 Ok ( true )
298318}
299319
@@ -309,7 +329,47 @@ fn poetry_remove(doc: &mut DocumentMut) -> bool {
309329 Some ( d) => d,
310330 None => return false ,
311331 } ;
312- deps. remove ( "socket-patch-hook" ) . is_some ( )
332+
333+ let mut changed = false ;
334+ // Drop a legacy bare `socket-patch-hook` key if present.
335+ if deps. remove ( "socket-patch-hook" ) . is_some ( ) {
336+ changed = true ;
337+ }
338+ // Strip the `hook` extra from a `socket-patch` dep table, leaving the rest
339+ // of the spec intact.
340+ if let Some ( tbl) = deps. get_mut ( "socket-patch" ) . and_then ( Item :: as_table_like_mut) {
341+ if let Some ( extras) = tbl. get_mut ( "extras" ) . and_then ( Item :: as_array_mut) {
342+ let before = extras. len ( ) ;
343+ extras. retain ( |v| v. as_str ( ) != Some ( "hook" ) ) ;
344+ if extras. len ( ) != before {
345+ changed = true ;
346+ }
347+ if extras. is_empty ( ) {
348+ tbl. remove ( "extras" ) ;
349+ }
350+ }
351+ }
352+ changed
353+ }
354+
355+ /// Build `{ version = "<v>", extras = ["hook"] }`.
356+ fn hook_inline_table ( version : & str ) -> Value {
357+ let mut it = InlineTable :: new ( ) ;
358+ it. insert ( "version" , Value :: from ( version) ) ;
359+ let mut extras = Array :: new ( ) ;
360+ extras. push ( "hook" ) ;
361+ it. insert ( "extras" , Value :: Array ( extras) ) ;
362+ Value :: InlineTable ( it)
363+ }
364+
365+ /// True if a dependency item (inline table or sub-table) already carries the
366+ /// `hook` extra.
367+ fn item_has_hook_extra ( item : & Item ) -> bool {
368+ item. as_table_like ( )
369+ . and_then ( |t| t. get ( "extras" ) )
370+ . and_then ( Item :: as_array)
371+ . map ( |a| a. iter ( ) . any ( |v| v. as_str ( ) == Some ( "hook" ) ) )
372+ . unwrap_or ( false )
313373}
314374
315375#[ cfg( test) ]
@@ -322,27 +382,27 @@ mod tests {
322382 fn test_requirements_add ( ) {
323383 let out = requirements_add ( "requests==2.31.0\n " ) . unwrap ( ) . unwrap ( ) ;
324384 assert ! ( out. contains( "requests==2.31.0" ) ) ;
325- assert ! ( out. contains( "socket-patch- hook" ) ) ;
385+ assert ! ( out. contains( "socket-patch[ hook] " ) ) ;
326386 assert ! ( out. ends_with( '\n' ) ) ;
327387 }
328388
329389 #[ test]
330390 fn test_requirements_add_no_trailing_newline ( ) {
331391 let out = requirements_add ( "requests" ) . unwrap ( ) . unwrap ( ) ;
332- assert_eq ! ( out, "requests\n socket-patch- hook\n " ) ;
392+ assert_eq ! ( out, "requests\n socket-patch[ hook] \n " ) ;
333393 }
334394
335395 #[ test]
336396 fn test_requirements_add_idempotent ( ) {
337- // Both the standalone wheel and the legacy `[hook]` extra are recognized.
397+ // The extra, the standalone wheel, and a pinned variant are all recognized.
398+ assert ! ( requirements_add( "socket-patch[hook]\n " ) . unwrap( ) . is_none( ) ) ;
338399 assert ! ( requirements_add( "socket-patch-hook\n " ) . unwrap( ) . is_none( ) ) ;
339400 assert ! ( requirements_add( "socket-patch-hook==3.3.0\n " ) . unwrap( ) . is_none( ) ) ;
340- assert ! ( requirements_add( "socket-patch[hook]\n " ) . unwrap( ) . is_none( ) ) ;
341401 }
342402
343403 #[ test]
344404 fn test_requirements_remove ( ) {
345- let out = requirements_remove ( "requests\n socket-patch- hook\n " )
405+ let out = requirements_remove ( "requests\n socket-patch[ hook] \n " )
346406 . unwrap ( )
347407 . unwrap ( ) ;
348408 assert_eq ! ( out, "requests\n " ) ;
@@ -359,7 +419,7 @@ mod tests {
359419 fn test_pep621_add_to_existing_array ( ) {
360420 let toml = "[project]\n name = \" x\" \n dependencies = [\" requests\" ]\n " ;
361421 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
362- assert ! ( out. contains( "socket-patch- hook" ) ) ;
422+ assert ! ( out. contains( "socket-patch[ hook] " ) ) ;
363423 assert ! ( out. contains( "requests" ) ) ;
364424 // Re-parse to confirm validity + idempotency.
365425 assert ! ( pyproject_add( & out) . unwrap( ) . is_none( ) ) ;
@@ -371,7 +431,7 @@ mod tests {
371431 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
372432 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
373433 let deps = doc[ "project" ] [ "dependencies" ] . as_array ( ) . unwrap ( ) ;
374- assert ! ( deps. iter( ) . any( |v| v. as_str( ) == Some ( "socket-patch- hook" ) ) ) ;
434+ assert ! ( deps. iter( ) . any( |v| v. as_str( ) == Some ( "socket-patch[ hook] " ) ) ) ;
375435 }
376436
377437 #[ test]
@@ -381,79 +441,74 @@ mod tests {
381441 assert ! ( out. contains( "[build-system]" ) ) ;
382442 assert ! ( out. contains( "version = \" 1.0\" " ) ) ;
383443 assert ! ( out. contains( "requests" ) ) ;
384- assert ! ( out. contains( "socket-patch- hook" ) ) ;
444+ assert ! ( out. contains( "socket-patch[ hook] " ) ) ;
385445 }
386446
387447 #[ test]
388448 fn test_pep621_remove ( ) {
389- let toml = "[project]\n dependencies = [\" requests\" , \" socket-patch- hook\" ]\n " ;
449+ let toml = "[project]\n dependencies = [\" requests\" , \" socket-patch[ hook] \" ]\n " ;
390450 let out = pyproject_remove ( toml) . unwrap ( ) . unwrap ( ) ;
391- assert ! ( !out. contains( "socket-patch- hook" ) ) ;
451+ assert ! ( !out. contains( "socket-patch[ hook] " ) ) ;
392452 assert ! ( out. contains( "requests" ) ) ;
393453 }
394454
395- // ── pyproject Poetry (standalone hook key, no extras-merging) ─────
455+ // ── pyproject Poetry (the `socket-patch[hook]` equivalent: the
456+ // `socket-patch` dep carrying the `hook` extra) ─────────────────
396457
397458 #[ test]
398- fn test_poetry_add_new_key ( ) {
459+ fn test_poetry_add_new_dep ( ) {
399460 let toml = "[tool.poetry]\n name = \" x\" \n \n [tool.poetry.dependencies]\n python = \" ^3.9\" \n " ;
400461 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
401462 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
402- assert_eq ! (
403- doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch-hook" ] . as_str ( ) ,
404- Some ( "*" )
463+ assert ! (
464+ item_has_hook_extra ( & doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch" ] ) ,
465+ "poetry dep must carry the hook extra; got: \n {out}"
405466 ) ;
406467 // Idempotent.
407468 assert ! ( pyproject_add( & out) . unwrap( ) . is_none( ) ) ;
408469 }
409470
410471 #[ test]
411- fn test_poetry_leaves_existing_socket_patch_untouched ( ) {
412- // An existing `socket-patch` dependency must NOT be mutated; we only add
413- // the standalone `socket-patch-hook` key.
472+ fn test_poetry_merges_extra_into_existing_dep ( ) {
473+ // An existing `socket-patch = "^3.3.0"` gains the hook extra, version kept.
414474 let toml = "[tool.poetry]\n name = \" x\" \n [tool.poetry.dependencies]\n socket-patch = \" ^3.3.0\" \n " ;
415475 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
416476 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
477+ let item = & doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch" ] ;
478+ assert ! ( item_has_hook_extra( item) , "hook extra must be added" ) ;
417479 assert_eq ! (
418- doc [ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch" ] . as_str ( ) ,
480+ item . as_table_like ( ) . and_then ( |t| t . get ( "version" ) ) . and_then ( Item :: as_str ) ,
419481 Some ( "^3.3.0" ) ,
420- "existing socket-patch dep must be left intact"
421- ) ;
422- assert_eq ! (
423- doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch-hook" ] . as_str( ) ,
424- Some ( "*" )
482+ "existing version must be preserved"
425483 ) ;
426484 }
427485
428486 #[ test]
429487 fn test_poetry_subtable_dependency_preserved ( ) {
430- // A `[tool.poetry.dependencies.socket-patch]` sub-table (version/source)
431- // must survive untouched; only the standalone hook key is added .
488+ // A `[tool.poetry.dependencies.socket-patch]` sub-table gains the hook
489+ // extra while keeping its version / source .
432490 let toml = "[tool.poetry.dependencies.socket-patch]\n version = \" ^3.3.0\" \n git = \" https://example.com/x.git\" \n " ;
433491 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
434492 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
435493 let sp = & doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch" ] ;
494+ assert ! ( item_has_hook_extra( sp) , "hook extra must be added" ) ;
436495 assert_eq ! (
437496 sp. as_table_like( ) . and_then( |t| t. get( "git" ) ) . and_then( Item :: as_str) ,
438497 Some ( "https://example.com/x.git" ) ,
439498 "sub-table keys must survive"
440499 ) ;
441- assert_eq ! (
442- doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch-hook" ] . as_str( ) ,
443- Some ( "*" )
444- ) ;
445500 // Idempotent.
446501 assert ! ( pyproject_add( & out) . unwrap( ) . is_none( ) ) ;
447502 }
448503
449504 #[ test]
450- fn test_poetry_remove ( ) {
451- let toml = "[tool.poetry.dependencies]\n socket-patch-hook = \" *\" \n python = \" ^3.9\" \n " ;
505+ fn test_poetry_remove_strips_extra ( ) {
506+ let toml = "[tool.poetry.dependencies]\n socket-patch = {version = \" *\" , extras = [ \" hook \" ]} \n python = \" ^3.9\" \n " ;
452507 let out = pyproject_remove ( toml) . unwrap ( ) . unwrap ( ) ;
453508 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
454- assert ! ( doc [ "tool" ] [ "poetry" ] [ "dependencies" ]
455- . get ( " socket-patch-hook" )
456- . is_none ( ) ) ;
509+ assert ! ( !item_has_hook_extra (
510+ & doc [ "tool" ] [ "poetry" ] [ "dependencies" ] [ " socket-patch" ]
511+ ) ) ;
457512 assert ! ( doc[ "tool" ] [ "poetry" ] [ "dependencies" ] . get( "python" ) . is_some( ) ) ;
458513 }
459514
@@ -467,7 +522,7 @@ mod tests {
467522 . as_array( )
468523 . unwrap( )
469524 . iter( )
470- . any( |v| v. as_str( ) == Some ( "socket-patch- hook" ) ) ) ;
525+ . any( |v| v. as_str( ) == Some ( "socket-patch[ hook] " ) ) ) ;
471526 }
472527
473528 #[ test]
@@ -483,9 +538,8 @@ mod tests {
483538 let toml = "[tool.poetry]\n name = \" x\" \n \n [tool.poetry.dependencies]\n python = \" ^3.9\" \n \n [project.urls]\n Home = \" https://example.com\" \n " ;
484539 let out = pyproject_add ( toml) . unwrap ( ) . unwrap ( ) ;
485540 let doc = out. parse :: < DocumentMut > ( ) . unwrap ( ) ;
486- assert_eq ! (
487- doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch-hook" ] . as_str( ) ,
488- Some ( "*" ) ,
541+ assert ! (
542+ item_has_hook_extra( & doc[ "tool" ] [ "poetry" ] [ "dependencies" ] [ "socket-patch" ] ) ,
489543 "must edit the poetry table, not create [project].dependencies; got:\n {out}"
490544 ) ;
491545 assert ! ( doc. get( "project" ) . and_then( |p| p. get( "dependencies" ) ) . is_none( ) ) ;
@@ -494,7 +548,7 @@ mod tests {
494548 #[ test]
495549 fn test_requirements_preserves_crlf ( ) {
496550 let out = requirements_add ( "requests\r \n " ) . unwrap ( ) . unwrap ( ) ;
497- assert_eq ! ( out, "requests\r \n socket-patch- hook\r \n " ) ;
551+ assert_eq ! ( out, "requests\r \n socket-patch[ hook] \r \n " ) ;
498552 let removed = requirements_remove ( & out) . unwrap ( ) . unwrap ( ) ;
499553 assert_eq ! ( removed, "requests\r \n " ) ;
500554 }
@@ -508,7 +562,7 @@ mod tests {
508562 let res = add_hook_dependency ( & req, ManifestKind :: Requirements , false ) . await ;
509563 assert_eq ! ( res. status, PthStatus :: Updated ) ;
510564 let body = tokio:: fs:: read_to_string ( & req) . await . unwrap ( ) ;
511- assert_eq ! ( body, "socket-patch- hook\n " ) ;
565+ assert_eq ! ( body, "socket-patch[ hook] \n " ) ;
512566 }
513567
514568 #[ tokio:: test]
0 commit comments