Skip to content

Commit 0a2d797

Browse files
feat(snapshot): add catalog matching and local storage
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 22b5353 commit 0a2d797

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

internal/snapshot/local.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package snapshot
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// LocalPath returns the path to the local snapshot file (~/.openboot/snapshot.json).
11+
func LocalPath() string {
12+
home, err := os.UserHomeDir()
13+
if err != nil {
14+
return ""
15+
}
16+
return filepath.Join(home, ".openboot", "snapshot.json")
17+
}
18+
19+
// SaveLocal persists the snapshot to ~/.openboot/snapshot.json.
20+
// Returns the path where the snapshot was saved.
21+
func SaveLocal(snap *Snapshot) (string, error) {
22+
path := LocalPath()
23+
24+
dir := filepath.Dir(path)
25+
if err := os.MkdirAll(dir, 0700); err != nil {
26+
return "", fmt.Errorf("failed to create snapshot directory: %w", err)
27+
}
28+
29+
data, err := json.MarshalIndent(snap, "", " ")
30+
if err != nil {
31+
return "", fmt.Errorf("failed to marshal snapshot: %w", err)
32+
}
33+
34+
if err := os.WriteFile(path, data, 0644); err != nil {
35+
return "", fmt.Errorf("failed to write snapshot file: %w", err)
36+
}
37+
38+
return path, nil
39+
}
40+
41+
// LoadLocal reads and unmarshals the snapshot from ~/.openboot/snapshot.json.
42+
func LoadLocal() (*Snapshot, error) {
43+
path := LocalPath()
44+
45+
data, err := os.ReadFile(path)
46+
if err != nil {
47+
if os.IsNotExist(err) {
48+
return nil, fmt.Errorf("snapshot file not found: %s", path)
49+
}
50+
return nil, fmt.Errorf("failed to read snapshot file: %w", err)
51+
}
52+
53+
var snap Snapshot
54+
if err := json.Unmarshal(data, &snap); err != nil {
55+
return nil, fmt.Errorf("failed to parse snapshot file: %w", err)
56+
}
57+
58+
return &snap, nil
59+
}

internal/snapshot/match.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package snapshot
2+
3+
import (
4+
"github.com/openbootdotdev/openboot/internal/config"
5+
)
6+
7+
// MatchPackages compares the snapshot's installed packages against the catalog.
8+
// Returns a CatalogMatch with matched/unmatched packages and match rate.
9+
func MatchPackages(snap *Snapshot) *CatalogMatch {
10+
catalogSet := make(map[string]bool)
11+
for _, cat := range config.Categories {
12+
for _, pkg := range cat.Packages {
13+
catalogSet[pkg.Name] = true
14+
}
15+
}
16+
17+
allPkgs := append([]string{}, snap.Packages.Formulae...)
18+
allPkgs = append(allPkgs, snap.Packages.Casks...)
19+
20+
matched := []string{}
21+
unmatched := []string{}
22+
23+
for _, pkg := range allPkgs {
24+
if catalogSet[pkg] {
25+
matched = append(matched, pkg)
26+
} else {
27+
unmatched = append(unmatched, pkg)
28+
}
29+
}
30+
31+
matchRate := 0.0
32+
if len(allPkgs) > 0 {
33+
matchRate = float64(len(matched)) / float64(len(allPkgs))
34+
}
35+
36+
return &CatalogMatch{
37+
Matched: matched,
38+
Unmatched: unmatched,
39+
MatchRate: matchRate,
40+
}
41+
}
42+
43+
// DetectBestPreset finds the preset with highest Jaccard similarity to the snapshot.
44+
// Returns preset name if similarity >= 0.3, otherwise returns empty string.
45+
func DetectBestPreset(snap *Snapshot) string {
46+
snapshotSet := make(map[string]bool)
47+
for _, pkg := range snap.Packages.Formulae {
48+
snapshotSet[pkg] = true
49+
}
50+
for _, pkg := range snap.Packages.Casks {
51+
snapshotSet[pkg] = true
52+
}
53+
54+
snapshotPkgs := make([]string, 0, len(snapshotSet))
55+
for pkg := range snapshotSet {
56+
snapshotPkgs = append(snapshotPkgs, pkg)
57+
}
58+
59+
bestPreset := ""
60+
bestSimilarity := 0.0
61+
62+
for presetName, preset := range config.Presets {
63+
presetPkgs := append([]string{}, preset.CLI...)
64+
presetPkgs = append(presetPkgs, preset.Cask...)
65+
66+
similarity := jaccardSimilarity(snapshotPkgs, presetPkgs)
67+
68+
if similarity > bestSimilarity {
69+
bestSimilarity = similarity
70+
bestPreset = presetName
71+
}
72+
}
73+
74+
if bestSimilarity >= 0.3 {
75+
return bestPreset
76+
}
77+
78+
return ""
79+
}
80+
81+
// jaccardSimilarity computes |A ∩ B| / |A ∪ B| for two string slices.
82+
func jaccardSimilarity(setA, setB []string) float64 {
83+
mapA := make(map[string]bool)
84+
for _, item := range setA {
85+
mapA[item] = true
86+
}
87+
88+
mapB := make(map[string]bool)
89+
for _, item := range setB {
90+
mapB[item] = true
91+
}
92+
93+
intersection := 0
94+
for item := range mapA {
95+
if mapB[item] {
96+
intersection++
97+
}
98+
}
99+
100+
union := len(mapA) + len(mapB) - intersection
101+
102+
if union == 0 {
103+
return 0.0
104+
}
105+
106+
return float64(intersection) / float64(union)
107+
}

0 commit comments

Comments
 (0)