@@ -35,7 +35,6 @@ pub struct PluginInstallConfig {
3535}
3636
3737impl PluginInstallConfig {
38- /// Resolve `~` in `target_path` to the given home directory.
3938 pub fn resolve_path ( & self , home : & Path ) -> PathBuf {
4039 let s = self . target_path . to_string_lossy ( ) ;
4140 if s. starts_with ( "~/" ) {
@@ -45,31 +44,19 @@ impl PluginInstallConfig {
4544 }
4645 }
4746
48- /// Write the plugin config into the target settings file.
49- ///
50- /// For JSON files (`.json`): deep-merges `config_content` into the
51- /// existing file, preserving other keys. Creates the file and parent
52- /// directories if they don't exist.
53- ///
54- /// Returns the resolved path that was written to.
5547 pub fn apply_install ( & self , home : & Path ) -> Result < PathBuf , ApplyInstallError > {
5648 let resolved = self . resolve_path ( home) ;
57-
5849 if let Some ( parent) = resolved. parent ( ) {
5950 std:: fs:: create_dir_all ( parent)
6051 . map_err ( |e| ApplyInstallError :: Io ( parent. to_path_buf ( ) , e) ) ?;
6152 }
62-
6353 let is_json = resolved
6454 . extension ( )
6555 . map ( |ext| ext == "json" )
6656 . unwrap_or ( false ) ;
67-
6857 if is_json {
6958 self . apply_json_merge ( & resolved) ?;
7059 } else {
71- // For non-JSON files (CLAUDE.md, instructions.md, config.toml),
72- // append if not already present.
7360 let existing = std:: fs:: read_to_string ( & resolved) . unwrap_or_default ( ) ;
7461 if !existing. contains ( & self . config_content ) {
7562 let mut content = existing;
@@ -81,31 +68,24 @@ impl PluginInstallConfig {
8168 . map_err ( |e| ApplyInstallError :: Io ( resolved. clone ( ) , e) ) ?;
8269 }
8370 }
84-
8571 Ok ( resolved)
8672 }
8773
8874 fn apply_json_merge ( & self , path : & Path ) -> Result < ( ) , ApplyInstallError > {
8975 let existing_str = std:: fs:: read_to_string ( path) . unwrap_or_else ( |_| "{}" . to_string ( ) ) ;
9076 let mut existing: serde_json:: Value = serde_json:: from_str ( & existing_str)
9177 . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
92-
9378 let incoming: serde_json:: Value = serde_json:: from_str ( & self . config_content )
9479 . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
95-
9680 json_deep_merge ( & mut existing, & incoming) ;
97-
9881 let output = serde_json:: to_string_pretty ( & existing)
9982 . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
10083 std:: fs:: write ( path, output. as_bytes ( ) )
10184 . map_err ( |e| ApplyInstallError :: Io ( path. to_path_buf ( ) , e) ) ?;
102-
10385 Ok ( ( ) )
10486 }
10587}
10688
107- /// Recursively merge `source` into `target`. Objects are merged key-by-key;
108- /// all other types overwrite.
10989fn json_deep_merge ( target : & mut serde_json:: Value , source : & serde_json:: Value ) {
11090 match ( target, source) {
11191 ( serde_json:: Value :: Object ( t) , serde_json:: Value :: Object ( s) ) => {
@@ -438,10 +418,10 @@ mod tests {
438418 assert ! ( !result. installed || result. scope != InstallScope :: Project ) ;
439419 }
440420
441- // ── resolve_path tests ──────────────────────────────────────────
421+ // ── resolve_path TDD ─ ──────────────────────────────────────────
442422
443423 #[ test]
444- fn test_resolve_path_tilde ( ) {
424+ fn resolve_path_expands_tilde ( ) {
445425 let config = PluginInstallConfig {
446426 agent : "claude-code" . into ( ) ,
447427 scope : InstallScope :: User ,
@@ -453,7 +433,7 @@ mod tests {
453433 }
454434
455435 #[ test]
456- fn test_resolve_path_relative ( ) {
436+ fn resolve_path_leaves_relative_unchanged ( ) {
457437 let config = PluginInstallConfig {
458438 agent : "claude-code" . into ( ) ,
459439 scope : InstallScope :: Project ,
@@ -464,10 +444,58 @@ mod tests {
464444 assert_eq ! ( resolved, PathBuf :: from( ".claude/settings.json" ) ) ;
465445 }
466446
467- // ── apply_install JSON merge tests ──────────────────────────────
447+ #[ test]
448+ fn resolve_path_leaves_absolute_unchanged ( ) {
449+ let config = PluginInstallConfig {
450+ agent : "claude-code" . into ( ) ,
451+ scope : InstallScope :: User ,
452+ target_path : PathBuf :: from ( "/etc/config.json" ) ,
453+ config_content : "{}" . into ( ) ,
454+ } ;
455+ let resolved = config. resolve_path ( Path :: new ( "/home/user" ) ) ;
456+ assert_eq ! ( resolved, PathBuf :: from( "/etc/config.json" ) ) ;
457+ }
458+
459+ // ── json_deep_merge TDD ────────────────────────────────────────
468460
469461 #[ test]
470- fn test_apply_install_creates_new_file ( ) {
462+ fn json_merge_adds_new_keys ( ) {
463+ let mut target = serde_json:: json!( { "a" : 1 } ) ;
464+ let source = serde_json:: json!( { "b" : 2 } ) ;
465+ json_deep_merge ( & mut target, & source) ;
466+ assert_eq ! ( target[ "a" ] , 1 ) ;
467+ assert_eq ! ( target[ "b" ] , 2 ) ;
468+ }
469+
470+ #[ test]
471+ fn json_merge_recurses_into_nested_objects ( ) {
472+ let mut target = serde_json:: json!( { "nested" : { "x" : 10 } } ) ;
473+ let source = serde_json:: json!( { "nested" : { "y" : 20 } } ) ;
474+ json_deep_merge ( & mut target, & source) ;
475+ assert_eq ! ( target[ "nested" ] [ "x" ] , 10 ) ;
476+ assert_eq ! ( target[ "nested" ] [ "y" ] , 20 ) ;
477+ }
478+
479+ #[ test]
480+ fn json_merge_overwrites_scalar ( ) {
481+ let mut target = serde_json:: json!( { "a" : 1 } ) ;
482+ let source = serde_json:: json!( { "a" : 99 } ) ;
483+ json_deep_merge ( & mut target, & source) ;
484+ assert_eq ! ( target[ "a" ] , 99 ) ;
485+ }
486+
487+ #[ test]
488+ fn json_merge_overwrites_array ( ) {
489+ let mut target = serde_json:: json!( { "arr" : [ 1 , 2 ] } ) ;
490+ let source = serde_json:: json!( { "arr" : [ 3 ] } ) ;
491+ json_deep_merge ( & mut target, & source) ;
492+ assert_eq ! ( target[ "arr" ] , serde_json:: json!( [ 3 ] ) ) ;
493+ }
494+
495+ // ── apply_install TDD ──────────────────────────────────────────
496+
497+ #[ test]
498+ fn apply_install_creates_new_json_file ( ) {
471499 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
472500 let config = PluginInstallConfig {
473501 agent : "claude-code" . into ( ) ,
@@ -478,11 +506,11 @@ mod tests {
478506 let path = config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
479507 let content: serde_json:: Value =
480508 serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
481- assert ! ( content[ "mcpServers" ] [ "test" ] [ "command" ] . as_str ( ) == Some ( "npx" ) ) ;
509+ assert_eq ! ( content[ "mcpServers" ] [ "test" ] [ "command" ] , "npx" ) ;
482510 }
483511
484512 #[ test]
485- fn test_apply_install_merges_into_existing ( ) {
513+ fn apply_install_merges_preserving_existing_keys ( ) {
486514 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
487515 let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
488516 std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
@@ -491,7 +519,6 @@ mod tests {
491519 r#"{"mcpServers":{"existing":{"command":"node"}},"other":"keep"}"# ,
492520 )
493521 . unwrap ( ) ;
494-
495522 let config = PluginInstallConfig {
496523 agent : "claude-code" . into ( ) ,
497524 scope : InstallScope :: User ,
@@ -501,16 +528,13 @@ mod tests {
501528 let path = config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
502529 let content: serde_json:: Value =
503530 serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
504- // Existing entries preserved
505531 assert_eq ! ( content[ "mcpServers" ] [ "existing" ] [ "command" ] , "node" ) ;
506- // New entry added
507532 assert_eq ! ( content[ "mcpServers" ] [ "new-plugin" ] [ "command" ] , "npx" ) ;
508- // Other keys preserved
509533 assert_eq ! ( content[ "other" ] , "keep" ) ;
510534 }
511535
512536 #[ test]
513- fn test_apply_install_idempotent ( ) {
537+ fn apply_install_idempotent_json ( ) {
514538 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
515539 let config = PluginInstallConfig {
516540 agent : "claude-code" . into ( ) ,
@@ -523,17 +547,15 @@ mod tests {
523547 let path = tmp. path ( ) . join ( ".claude/settings.json" ) ;
524548 let content: serde_json:: Value =
525549 serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
526- // Only one entry, not duplicated
527550 assert_eq ! ( content[ "mcpServers" ] . as_object( ) . unwrap( ) . len( ) , 1 ) ;
528551 }
529552
530553 #[ test]
531- fn test_apply_install_invalid_existing_json ( ) {
554+ fn apply_install_rejects_invalid_json ( ) {
532555 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
533556 let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
534557 std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
535558 std:: fs:: write ( claude_dir. join ( "settings.json" ) , "not json!!!" ) . unwrap ( ) ;
536-
537559 let config = PluginInstallConfig {
538560 agent : "claude-code" . into ( ) ,
539561 scope : InstallScope :: User ,
@@ -542,76 +564,60 @@ mod tests {
542564 } ;
543565 let result = config. apply_install ( tmp. path ( ) ) ;
544566 assert ! ( result. is_err( ) ) ;
545- assert ! ( result. unwrap_err( ) . to_string( ) . contains( "JSON error" ) ) ;
546567 }
547568
548- // ── apply_install non-JSON (append) tests ───────────────────────
549-
550569 #[ test]
551- fn test_apply_install_appends_to_md ( ) {
570+ fn apply_install_appends_to_non_json ( ) {
552571 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
553572 let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
554573 std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
555574 std:: fs:: write ( claude_dir. join ( "CLAUDE.md" ) , "# Existing\n " ) . unwrap ( ) ;
556-
557575 let config = PluginInstallConfig {
558576 agent : "claude-code" . into ( ) ,
559577 scope : InstallScope :: User ,
560578 target_path : PathBuf :: from ( "~/.claude/CLAUDE.md" ) ,
561- config_content : "# Superpowers \n " . into ( ) ,
579+ config_content : "# Plugin \n " . into ( ) ,
562580 } ;
563581 config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
564582 let content = std:: fs:: read_to_string ( claude_dir. join ( "CLAUDE.md" ) ) . unwrap ( ) ;
565583 assert ! ( content. contains( "# Existing" ) ) ;
566- assert ! ( content. contains( "# Superpowers " ) ) ;
584+ assert ! ( content. contains( "# Plugin " ) ) ;
567585 }
568586
569587 #[ test]
570- fn test_apply_install_md_idempotent ( ) {
588+ fn apply_install_non_json_idempotent ( ) {
571589 let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
572590 let config = PluginInstallConfig {
573591 agent : "claude-code" . into ( ) ,
574592 scope : InstallScope :: User ,
575593 target_path : PathBuf :: from ( "~/.claude/CLAUDE.md" ) ,
576- config_content : "# Superpowers \n " . into ( ) ,
594+ config_content : "# Plugin \n " . into ( ) ,
577595 } ;
578596 config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
579597 config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
580598 let content = std:: fs:: read_to_string ( tmp. path ( ) . join ( ".claude/CLAUDE.md" ) ) . unwrap ( ) ;
581- assert_eq ! ( content. matches( "# Superpowers" ) . count( ) , 1 ) ;
582- }
583-
584- // ── json_deep_merge tests ───────────────────────────────────────
585-
586- #[ test]
587- fn test_json_deep_merge_objects ( ) {
588- let mut target: serde_json:: Value = serde_json:: json!( { "a" : 1 , "nested" : { "x" : 10 } } ) ;
589- let source = serde_json:: json!( { "b" : 2 , "nested" : { "y" : 20 } } ) ;
590- json_deep_merge ( & mut target, & source) ;
591- assert_eq ! ( target[ "a" ] , 1 ) ;
592- assert_eq ! ( target[ "b" ] , 2 ) ;
593- assert_eq ! ( target[ "nested" ] [ "x" ] , 10 ) ;
594- assert_eq ! ( target[ "nested" ] [ "y" ] , 20 ) ;
595- }
596-
597- #[ test]
598- fn test_json_deep_merge_overwrite_scalar ( ) {
599- let mut target: serde_json:: Value = serde_json:: json!( { "a" : 1 } ) ;
600- let source = serde_json:: json!( { "a" : 99 } ) ;
601- json_deep_merge ( & mut target, & source) ;
602- assert_eq ! ( target[ "a" ] , 99 ) ;
599+ assert_eq ! ( content. matches( "# Plugin" ) . count( ) , 1 ) ;
603600 }
604601
605- // ── ApplyInstallError display ───────────────────────────────────
602+ // ── ApplyInstallError TDD ─── ───────────────────────────────────
606603
607604 #[ test]
608- fn test_apply_install_error_display ( ) {
605+ fn apply_install_error_display_io ( ) {
609606 let err = ApplyInstallError :: Io (
610607 PathBuf :: from ( "/test" ) ,
611608 std:: io:: Error :: new ( std:: io:: ErrorKind :: PermissionDenied , "denied" ) ,
612609 ) ;
613610 let msg = err. to_string ( ) ;
614- assert ! ( msg. contains( "IO error" ) ) ;
615611 assert ! ( msg. contains( "/test" ) ) ;
612+ assert ! ( msg. contains( "denied" ) ) ;
613+ }
614+
615+ #[ test]
616+ fn apply_install_error_is_std_error ( ) {
617+ let err = ApplyInstallError :: Io (
618+ PathBuf :: from ( "/test" ) ,
619+ std:: io:: Error :: new ( std:: io:: ErrorKind :: Other , "test" ) ,
620+ ) ;
621+ let _: & dyn std:: error:: Error = & err;
616622 }
617623}
0 commit comments