Add snapshot JSON parsing and formatting

Amolith created

Change summary

internal/restic/snapshots.go      |  63 +++++++
internal/restic/snapshots_test.go | 260 +++++++++++++++++++++++++++++++++
2 files changed, 323 insertions(+)

Detailed changes

internal/restic/snapshots.go 🔗

@@ -0,0 +1,63 @@
+package restic
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+)
+
+// Snapshot represents a single restic snapshot as returned by
+// `restic snapshots --json`.
+type Snapshot struct {
+	ID       string    `json:"id"`
+	ShortID  string    `json:"short_id"`
+	Time     time.Time `json:"time"`
+	Hostname string    `json:"hostname"`
+	Paths    []string  `json:"paths"`
+	Tags     []string  `json:"tags"`
+}
+
+// ParseSnapshots decodes the JSON output of `restic snapshots --json`
+// into a slice of Snapshot values.
+func ParseSnapshots(data []byte) ([]Snapshot, error) {
+	var snapshots []Snapshot
+	if err := json.Unmarshal(data, &snapshots); err != nil {
+		return nil, fmt.Errorf("parsing snapshot JSON: %w", err)
+	}
+	return snapshots, nil
+}
+
+// FormatSnapshotLine returns a single-line human-readable summary of a
+// snapshot, suitable for use as a menu option label. The format is:
+//
+//	<short_id>  <date> <time>  <hostname>  <paths>  [<tags>]
+//
+// Tags are omitted when empty. Hostname shows "(unknown)" when blank.
+func FormatSnapshotLine(s Snapshot) string {
+	var b strings.Builder
+
+	b.WriteString(s.ShortID)
+	b.WriteString("  ")
+	b.WriteString(s.Time.Format("2006-01-02 15:04"))
+
+	b.WriteString("  ")
+	if s.Hostname == "" {
+		b.WriteString("(unknown)")
+	} else {
+		b.WriteString(s.Hostname)
+	}
+
+	if len(s.Paths) > 0 {
+		b.WriteString("  ")
+		b.WriteString(strings.Join(s.Paths, ", "))
+	}
+
+	if len(s.Tags) > 0 {
+		b.WriteString("  [")
+		b.WriteString(strings.Join(s.Tags, ", "))
+		b.WriteString("]")
+	}
+
+	return b.String()
+}

internal/restic/snapshots_test.go 🔗

@@ -0,0 +1,260 @@
+package restic
+
+import (
+	"testing"
+	"time"
+)
+
+// resolveFixtureTOML-style: embed test JSON as string constants so tests
+// are self-contained and don't depend on external files.
+
+const snapshotFixtureNormal = `[
+  {
+    "time": "2026-03-15T10:30:00.123456789+01:00",
+    "parent": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
+    "tree": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
+    "paths": ["/home/user"],
+    "hostname": "myhost",
+    "username": "user",
+    "uid": 1000,
+    "gid": 1000,
+    "excludes": ["*.tmp"],
+    "tags": ["daily", "important"],
+    "program_version": "restic 0.18.1",
+    "id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
+    "short_id": "a1b2c3d4"
+  },
+  {
+    "time": "2026-03-14T08:00:00Z",
+    "tree": "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
+    "paths": ["/home/user", "/etc"],
+    "hostname": "server",
+    "username": "root",
+    "uid": 0,
+    "gid": 0,
+    "program_version": "restic 0.18.1",
+    "id": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
+    "short_id": "b2c3d4e5"
+  }
+]`
+
+const snapshotFixtureEmpty = `[]`
+
+const snapshotFixtureSingleNoTags = `[
+  {
+    "time": "2026-01-01T00:00:00Z",
+    "tree": "0000000000000000000000000000000000000000000000000000000000000000",
+    "paths": ["/srv/data"],
+    "hostname": "backup-host",
+    "username": "backup",
+    "uid": 1001,
+    "gid": 1001,
+    "program_version": "restic 0.17.0",
+    "id": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4",
+    "short_id": "c3d4e5f6"
+  }
+]`
+
+const snapshotFixtureManyPaths = `[
+  {
+    "time": "2026-06-15T14:22:33Z",
+    "tree": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "paths": ["/home/user/Documents", "/home/user/Music", "/home/user/Pictures", "/home/user/Videos"],
+    "hostname": "workstation",
+    "username": "user",
+    "uid": 1000,
+    "gid": 1000,
+    "tags": ["weekly"],
+    "program_version": "restic 0.18.1",
+    "id": "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5",
+    "short_id": "d4e5f6a7"
+  }
+]`
+
+func TestParseSnapshots(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name      string
+		json      string
+		wantCount int
+		wantErr   bool
+	}{
+		{
+			name:      "normal with two snapshots",
+			json:      snapshotFixtureNormal,
+			wantCount: 2,
+			wantErr:   false,
+		},
+		{
+			name:      "empty array",
+			json:      snapshotFixtureEmpty,
+			wantCount: 0,
+			wantErr:   false,
+		},
+		{
+			name:      "single snapshot without tags",
+			json:      snapshotFixtureSingleNoTags,
+			wantCount: 1,
+			wantErr:   false,
+		},
+		{
+			name:      "snapshot with many paths",
+			json:      snapshotFixtureManyPaths,
+			wantCount: 1,
+			wantErr:   false,
+		},
+		{
+			name:      "invalid JSON",
+			json:      `{not valid json`,
+			wantCount: 0,
+			wantErr:   true,
+		},
+		{
+			name:      "null",
+			json:      `null`,
+			wantCount: 0,
+			wantErr:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			snapshots, err := ParseSnapshots([]byte(tt.json))
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("ParseSnapshots(): err=%v, wantErr=%v", err, tt.wantErr)
+			}
+			if err != nil {
+				return
+			}
+			if len(snapshots) != tt.wantCount {
+				t.Fatalf("ParseSnapshots(): got %d snapshots, want %d", len(snapshots), tt.wantCount)
+			}
+		})
+	}
+}
+
+func TestParseSnapshotsFields(t *testing.T) {
+	t.Parallel()
+
+	snapshots, err := ParseSnapshots([]byte(snapshotFixtureNormal))
+	if err != nil {
+		t.Fatalf("ParseSnapshots(): unexpected error: %v", err)
+	}
+	if len(snapshots) != 2 {
+		t.Fatalf("expected 2 snapshots, got %d", len(snapshots))
+	}
+
+	// First snapshot: has tags, single path, has parent.
+	s := snapshots[0]
+	if s.ID != "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" {
+		t.Errorf("ID = %q", s.ID)
+	}
+	if s.ShortID != "a1b2c3d4" {
+		t.Errorf("ShortID = %q", s.ShortID)
+	}
+	if s.Hostname != "myhost" {
+		t.Errorf("Hostname = %q", s.Hostname)
+	}
+	if len(s.Paths) != 1 || s.Paths[0] != "/home/user" {
+		t.Errorf("Paths = %v", s.Paths)
+	}
+	if len(s.Tags) != 2 || s.Tags[0] != "daily" || s.Tags[1] != "important" {
+		t.Errorf("Tags = %v", s.Tags)
+	}
+	expectedTime := time.Date(2026, 3, 15, 10, 30, 0, 123456789, time.FixedZone("", 3600))
+	if !s.Time.Equal(expectedTime) {
+		t.Errorf("Time = %v, want %v", s.Time, expectedTime)
+	}
+
+	// Second snapshot: no tags, no parent, multiple paths.
+	s2 := snapshots[1]
+	if s2.ShortID != "b2c3d4e5" {
+		t.Errorf("ShortID = %q", s2.ShortID)
+	}
+	if s2.Hostname != "server" {
+		t.Errorf("Hostname = %q", s2.Hostname)
+	}
+	if len(s2.Paths) != 2 {
+		t.Errorf("Paths = %v", s2.Paths)
+	}
+	if len(s2.Tags) != 0 {
+		t.Errorf("Tags = %v, want empty", s2.Tags)
+	}
+}
+
+func TestFormatSnapshotLine(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		snapshot Snapshot
+		want     string
+	}{
+		{
+			name: "full snapshot with tags",
+			snapshot: Snapshot{
+				ShortID:  "a1b2c3d4",
+				Time:     time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC),
+				Hostname: "myhost",
+				Paths:    []string{"/home/user"},
+				Tags:     []string{"daily", "important"},
+			},
+			want: "a1b2c3d4  2026-03-15 10:30  myhost  /home/user  [daily, important]",
+		},
+		{
+			name: "no tags",
+			snapshot: Snapshot{
+				ShortID:  "b2c3d4e5",
+				Time:     time.Date(2026, 3, 14, 8, 0, 0, 0, time.UTC),
+				Hostname: "server",
+				Paths:    []string{"/etc"},
+			},
+			want: "b2c3d4e5  2026-03-14 08:00  server  /etc",
+		},
+		{
+			name: "multiple paths",
+			snapshot: Snapshot{
+				ShortID:  "c3d4e5f6",
+				Time:     time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+				Hostname: "backup-host",
+				Paths:    []string{"/home", "/etc", "/srv"},
+				Tags:     []string{"full"},
+			},
+			want: "c3d4e5f6  2026-01-01 00:00  backup-host  /home, /etc, /srv  [full]",
+		},
+		{
+			name: "empty paths",
+			snapshot: Snapshot{
+				ShortID:  "d4e5f6a7",
+				Time:     time.Date(2026, 6, 15, 14, 22, 0, 0, time.UTC),
+				Hostname: "box",
+			},
+			want: "d4e5f6a7  2026-06-15 14:22  box",
+		},
+		{
+			name: "empty hostname",
+			snapshot: Snapshot{
+				ShortID: "e5f6a7b8",
+				Time:    time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC),
+				Paths:   []string{"/data"},
+			},
+			want: "e5f6a7b8  2026-02-28 12:00  (unknown)  /data",
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := FormatSnapshotLine(tt.snapshot)
+			if got != tt.want {
+				t.Errorf("FormatSnapshotLine():\n  got  %q\n  want %q", got, tt.want)
+			}
+		})
+	}
+}