Skip to content

Commit 59f910e

Browse files
committed
fix: preserve package descriptions through snapshot export/import
Add MarshalJSON to PackageSnapshot that outputs rich object format when descriptions exist, preserving them through export → re-import. Also show descriptions in runCustomInstall display and fix UnmarshalJSON to accept rich objects with casks/npm only. Fixes #14
1 parent 7de8d99 commit 59f910e

File tree

3 files changed

+176
-10
lines changed

3 files changed

+176
-10
lines changed

internal/installer/installer.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,9 @@ func runCustomInstall(cfg *config.Config) error {
115115
ui.Info(fmt.Sprintf("Estimated install time: ~%d min for %d packages", minutes, totalPackages))
116116
fmt.Println()
117117

118-
if formulaeCount > 0 {
119-
ui.Muted(" CLI tools: " + strings.Join(cfg.RemoteConfig.Packages.Names(), ", "))
120-
}
121-
if caskCount > 0 {
122-
ui.Muted(" Apps: " + strings.Join(cfg.RemoteConfig.Casks.Names(), ", "))
123-
}
124-
if npmCount > 0 {
125-
ui.Muted(" npm: " + strings.Join(cfg.RemoteConfig.Npm.Names(), ", "))
126-
}
118+
printPackageList("CLI tools", cfg.RemoteConfig.Packages)
119+
printPackageList("Apps", cfg.RemoteConfig.Casks)
120+
printPackageList("npm", cfg.RemoteConfig.Npm)
127121
fmt.Println()
128122

129123
if !cfg.Silent && !cfg.DryRun {
@@ -1114,6 +1108,31 @@ func runUpdate(cfg *config.Config) error {
11141108
return nil
11151109
}
11161110

1111+
func printPackageList(label string, pkgs config.PackageEntryList) {
1112+
if len(pkgs) == 0 {
1113+
return
1114+
}
1115+
hasDesc := false
1116+
for _, pkg := range pkgs {
1117+
if pkg.Desc != "" {
1118+
hasDesc = true
1119+
break
1120+
}
1121+
}
1122+
if !hasDesc {
1123+
ui.Muted(fmt.Sprintf(" %s: %s", label, strings.Join(pkgs.Names(), ", ")))
1124+
return
1125+
}
1126+
ui.Muted(fmt.Sprintf(" %s:", label))
1127+
for _, pkg := range pkgs {
1128+
if pkg.Desc != "" {
1129+
ui.Muted(fmt.Sprintf(" %s — %s", pkg.Name, pkg.Desc))
1130+
} else {
1131+
ui.Muted(fmt.Sprintf(" %s", pkg.Name))
1132+
}
1133+
}
1134+
}
1135+
11171136
func estimateInstallMinutes(formulaeCount, caskCount, npmCount int) int {
11181137
totalSeconds := formulaeCount*estimatedSecondsPerFormula +
11191138
caskCount*estimatedSecondsPerCask +

internal/snapshot/snapshot.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error {
6767
Desc string `json:"desc"`
6868
} `json:"npm"`
6969
}
70-
if err := json.Unmarshal(data, &richObj); err == nil && len(richObj.Formulae) > 0 {
70+
if err := json.Unmarshal(data, &richObj); err == nil &&
71+
(len(richObj.Formulae) > 0 || len(richObj.Casks) > 0 || len(richObj.Npm) > 0) {
7172
ps.Descriptions = make(map[string]string)
7273
for _, p := range richObj.Formulae {
7374
ps.Formulae = append(ps.Formulae, p.Name)
@@ -127,6 +128,47 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error {
127128
return fmt.Errorf("packages must be an object {formulae,casks,taps,npm} or an array")
128129
}
129130

131+
// MarshalJSON outputs packages as rich objects when descriptions exist,
132+
// falling back to plain string arrays for backward compatibility.
133+
func (ps PackageSnapshot) MarshalJSON() ([]byte, error) {
134+
if len(ps.Descriptions) == 0 {
135+
type alias PackageSnapshot
136+
return json.Marshal(alias(ps))
137+
}
138+
139+
type entry struct {
140+
Name string `json:"name"`
141+
Desc string `json:"desc,omitempty"`
142+
}
143+
144+
formulae := make([]entry, len(ps.Formulae))
145+
for i, name := range ps.Formulae {
146+
formulae[i] = entry{Name: name, Desc: ps.Descriptions[name]}
147+
}
148+
149+
casks := make([]entry, len(ps.Casks))
150+
for i, name := range ps.Casks {
151+
casks[i] = entry{Name: name, Desc: ps.Descriptions[name]}
152+
}
153+
154+
npm := make([]entry, len(ps.Npm))
155+
for i, name := range ps.Npm {
156+
npm[i] = entry{Name: name, Desc: ps.Descriptions[name]}
157+
}
158+
159+
return json.Marshal(struct {
160+
Formulae []entry `json:"formulae"`
161+
Casks []entry `json:"casks"`
162+
Taps []string `json:"taps"`
163+
Npm []entry `json:"npm"`
164+
}{
165+
Formulae: formulae,
166+
Casks: casks,
167+
Taps: ps.Taps,
168+
Npm: npm,
169+
})
170+
}
171+
130172
type MacOSPref struct {
131173
Domain string `json:"domain"`
132174
Key string `json:"key"`

internal/snapshot/snapshot_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,3 +461,108 @@ func TestSnapshot_CatalogMatchWithLowRate(t *testing.T) {
461461
assert.Equal(t, 4, len(snap.CatalogMatch.Unmatched))
462462
assert.InDelta(t, 0.2, snap.CatalogMatch.MatchRate, 0.01)
463463
}
464+
465+
func TestPackageSnapshot_MarshalJSON_NoDescriptions(t *testing.T) {
466+
ps := PackageSnapshot{
467+
Formulae: []string{"git", "curl"},
468+
Casks: []string{"docker"},
469+
Taps: []string{"homebrew/core"},
470+
Npm: []string{"typescript"},
471+
}
472+
473+
data, err := json.Marshal(ps)
474+
require.NoError(t, err)
475+
476+
// Should output plain string arrays (backward compat)
477+
var raw map[string]json.RawMessage
478+
require.NoError(t, json.Unmarshal(data, &raw))
479+
480+
var formulae []string
481+
require.NoError(t, json.Unmarshal(raw["formulae"], &formulae))
482+
assert.Equal(t, []string{"git", "curl"}, formulae)
483+
}
484+
485+
func TestPackageSnapshot_MarshalJSON_WithDescriptions(t *testing.T) {
486+
ps := PackageSnapshot{
487+
Formulae: []string{"git", "curl"},
488+
Casks: []string{"docker"},
489+
Taps: []string{"homebrew/core"},
490+
Npm: []string{"typescript"},
491+
Descriptions: map[string]string{
492+
"git": "Version control system",
493+
"curl": "Transfer data with URLs",
494+
"docker": "Container platform",
495+
"typescript": "Typed JavaScript",
496+
},
497+
}
498+
499+
data, err := json.Marshal(ps)
500+
require.NoError(t, err)
501+
502+
// Should output rich objects with desc
503+
type entry struct {
504+
Name string `json:"name"`
505+
Desc string `json:"desc"`
506+
}
507+
var raw struct {
508+
Formulae []entry `json:"formulae"`
509+
Casks []entry `json:"casks"`
510+
Taps []string `json:"taps"`
511+
Npm []entry `json:"npm"`
512+
}
513+
require.NoError(t, json.Unmarshal(data, &raw))
514+
515+
assert.Equal(t, "git", raw.Formulae[0].Name)
516+
assert.Equal(t, "Version control system", raw.Formulae[0].Desc)
517+
assert.Equal(t, "curl", raw.Formulae[1].Name)
518+
assert.Equal(t, "Transfer data with URLs", raw.Formulae[1].Desc)
519+
assert.Equal(t, "docker", raw.Casks[0].Name)
520+
assert.Equal(t, "Container platform", raw.Casks[0].Desc)
521+
assert.Equal(t, []string{"homebrew/core"}, raw.Taps)
522+
}
523+
524+
func TestPackageSnapshot_MarshalJSON_RoundTrip(t *testing.T) {
525+
original := PackageSnapshot{
526+
Formulae: []string{"git", "curl"},
527+
Casks: []string{"docker"},
528+
Taps: []string{"homebrew/core"},
529+
Npm: []string{"typescript"},
530+
Descriptions: map[string]string{
531+
"git": "Version control system",
532+
"curl": "Transfer data with URLs",
533+
"docker": "Container platform",
534+
"typescript": "Typed JavaScript",
535+
},
536+
}
537+
538+
data, err := json.Marshal(original)
539+
require.NoError(t, err)
540+
541+
var restored PackageSnapshot
542+
require.NoError(t, json.Unmarshal(data, &restored))
543+
544+
assert.Equal(t, original.Formulae, restored.Formulae)
545+
assert.Equal(t, original.Casks, restored.Casks)
546+
assert.Equal(t, original.Taps, restored.Taps)
547+
assert.Equal(t, original.Npm, restored.Npm)
548+
assert.Equal(t, original.Descriptions, restored.Descriptions)
549+
}
550+
551+
func TestPackageSnapshot_MarshalJSON_RoundTrip_CaskOnly(t *testing.T) {
552+
original := PackageSnapshot{
553+
Casks: []string{"docker", "slack"},
554+
Descriptions: map[string]string{
555+
"docker": "Container platform",
556+
"slack": "Team communication",
557+
},
558+
}
559+
560+
data, err := json.Marshal(original)
561+
require.NoError(t, err)
562+
563+
var restored PackageSnapshot
564+
require.NoError(t, json.Unmarshal(data, &restored))
565+
566+
assert.Equal(t, original.Casks, restored.Casks)
567+
assert.Equal(t, original.Descriptions, restored.Descriptions)
568+
}

0 commit comments

Comments
 (0)