From 4b3365435c3625f80e16e31cc0159ad46f50e5fb Mon Sep 17 00:00:00 2001 From: Patrick Dewey <57921252+ptdewey@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:09:49 -0500 Subject: [PATCH] feat: implementation --- __snapshots__/test_snap_custom_type.new | 5 + __snapshots__/test_snap_multiple.new | 7 ++ __snapshots__/test_snap_string.new | 5 + cmd/freeze/main.go | 57 +++++++++ diff.go | 102 +++++++++++++++ format.go | 159 +++++++++++++++++++++++ freeze.go | 12 +- freeze_test.go | 110 +++++++++++++++- review.go | 161 ++++++++++++++++++++++++ 9 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 __snapshots__/test_snap_custom_type.new create mode 100644 __snapshots__/test_snap_multiple.new create mode 100644 __snapshots__/test_snap_string.new create mode 100644 cmd/freeze/main.go create mode 100644 diff.go create mode 100644 format.go create mode 100644 review.go diff --git a/__snapshots__/test_snap_custom_type.new b/__snapshots__/test_snap_custom_type.new new file mode 100644 index 0000000..5eb19cd --- /dev/null +++ b/__snapshots__/test_snap_custom_type.new @@ -0,0 +1,5 @@ +--- +version: 0.1.0 +test_name: TestSnapCustomType +--- +CustomStruct{Name: Alice, Age: } \ No newline at end of file diff --git a/__snapshots__/test_snap_multiple.new b/__snapshots__/test_snap_multiple.new new file mode 100644 index 0000000..459de63 --- /dev/null +++ b/__snapshots__/test_snap_multiple.new @@ -0,0 +1,7 @@ +--- +version: 0.1.0 +test_name: TestSnapMultiple +--- +value1 +value2 +42 \ No newline at end of file diff --git a/__snapshots__/test_snap_string.new b/__snapshots__/test_snap_string.new new file mode 100644 index 0000000..36b244e --- /dev/null +++ b/__snapshots__/test_snap_string.new @@ -0,0 +1,5 @@ +--- +version: 0.1.0 +test_name: TestSnapString +--- +hello world \ No newline at end of file diff --git a/cmd/freeze/main.go b/cmd/freeze/main.go new file mode 100644 index 0000000..6cf3924 --- /dev/null +++ b/cmd/freeze/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/ptdewey/freeze" +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, `Usage: freeze [COMMAND] + +Commands: + review Review and accept/reject new snapshots (default) + accept-all Accept all new snapshots + reject-all Reject all new snapshots + help Show this help message + +Examples: + freeze # Start interactive review + freeze review # Same as above + freeze accept-all # Accept all new snapshots + freeze reject-all # Reject all new snapshots +`) + } + + flag.Parse() + + var cmd string + if flag.NArg() > 0 { + cmd = flag.Arg(0) + } + + var err error + switch cmd { + case "", "review": + err = freeze.Review() + case "accept-all": + err = freeze.AcceptAll() + case "reject-all": + err = freeze.RejectAll() + case "help", "-h", "--help": + flag.Usage() + return + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) + flag.Usage() + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..b7fc76a --- /dev/null +++ b/diff.go @@ -0,0 +1,102 @@ +package freeze + +import ( + "strings" +) + +type DiffKind int + +const ( + DiffShared DiffKind = iota + DiffOld + DiffNew +) + +type DiffLine struct { + Number int + Line string + Kind DiffKind +} + +func Histogram(old, new string) []DiffLine { + oldLines := strings.Split(old, "\n") + newLines := strings.Split(new, "\n") + + if len(oldLines) == 1 && oldLines[0] == "" { + oldLines = []string{} + } + if len(newLines) == 1 && newLines[0] == "" { + newLines = []string{} + } + + matrix := computeEditDistance(oldLines, newLines) + return traceback(oldLines, newLines, matrix) +} + +func computeEditDistance(old, new []string) [][]int { + m, n := len(old), len(new) + matrix := make([][]int, m+1) + for i := range matrix { + matrix[i] = make([]int, n+1) + } + + for i := 0; i <= m; i++ { + matrix[i][0] = i + } + for j := 0; j <= n; j++ { + matrix[0][j] = j + } + + for i := 1; i <= m; i++ { + for j := 1; j <= n; j++ { + if old[i-1] == new[j-1] { + matrix[i][j] = matrix[i-1][j-1] + } else { + matrix[i][j] = 1 + minThree(matrix[i-1][j], matrix[i][j-1], matrix[i-1][j-1]) + } + } + } + + return matrix +} + +func traceback(old, new []string, matrix [][]int) []DiffLine { + var result []DiffLine + i, j := len(old), len(new) + + for i > 0 || j > 0 { + if i > 0 && j > 0 && old[i-1] == new[j-1] { + result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared}}, result...) + i-- + j-- + } else if j > 0 && (i == 0 || matrix[i][j-1] < matrix[i-1][j]) { + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) + j-- + } else if i > 0 { + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld}}, result...) + i-- + } else { + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) + j-- + } + } + + for idx := range result { + result[idx].Number = idx + 1 + } + + return result +} + +func minThree(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..b10636a --- /dev/null +++ b/format.go @@ -0,0 +1,159 @@ +package freeze + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +const ( + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorGray = "\033[90m" + colorReset = "\033[0m" + colorBold = "\033[1m" +) + +func TerminalWidth() int { + width := os.Getenv("COLUMNS") + if w, err := strconv.Atoi(width); err == nil && w > 0 { + return w + } + return 80 +} + +func ClearScreen() { + fmt.Print("\033[2J") + fmt.Print("\033[H") +} + +func ClearLine() { + fmt.Print("\033[K") +} + +func Red(s string) string { + if !hasColor() { + return s + } + return colorRed + s + colorReset +} + +func Green(s string) string { + if !hasColor() { + return s + } + return colorGreen + s + colorReset +} + +func Yellow(s string) string { + if !hasColor() { + return s + } + return colorYellow + s + colorReset +} + +func Blue(s string) string { + if !hasColor() { + return s + } + return colorBlue + s + colorReset +} + +func Gray(s string) string { + if !hasColor() { + return s + } + return colorGray + s + colorReset +} + +func Bold(s string) string { + if !hasColor() { + return s + } + return colorBold + s + colorReset +} + +func hasColor() bool { + return os.Getenv("NO_COLOR") == "" +} + +func NewSnapshotBox(snap *Snapshot) string { + width := TerminalWidth() + separator := strings.Repeat("─", width) + + var sb strings.Builder + sb.WriteString("╭" + strings.Repeat("─", width) + "╮\n") + // FIX: this line is missing the '│' symbol at the end + sb.WriteString(fmt.Sprintf("│ %s\n", Blue("New Snapshot"))) + sb.WriteString("├" + separator + "┤\n") + + lines := strings.Split(snap.Content, "\n") + for _, line := range lines { + if len(line) > width-4 { + line = line[:width-7] + "..." + } + // TODO: added code lines in snapshots should be in green with " +" next to them + // - line numbers should be left aligned with space padding + // FIX: each of these lines is missing the '│' symbol at the end + sb.WriteString(fmt.Sprintf("│ %s\n", line)) + } + + sb.WriteString("╰" + strings.Repeat("─", width) + "╯\n") + return sb.String() +} + +func DiffSnapshotBox(old, new *Snapshot) string { + width := TerminalWidth() + + diffLines := Histogram(old.Content, new.Content) + + var sb strings.Builder + sb.WriteString("╭" + strings.Repeat("─", width-2) + "╮\n") + sb.WriteString(fmt.Sprintf("│ %s\n", Blue("Snapshot Diff"))) + sb.WriteString("├" + strings.Repeat("─", width-2) + "┤\n") + + for _, dl := range diffLines { + var prefix string + var formatted string + + switch dl.Kind { + case DiffOld: + prefix = Red("−") + formatted = Red(dl.Line) + case DiffNew: + prefix = Green("+") + formatted = Green(dl.Line) + case DiffShared: + prefix = " " + formatted = dl.Line + } + + display := fmt.Sprintf("%s %s", prefix, formatted) + if len(display) > width-4 { + display = display[:width-7] + "..." + } + sb.WriteString(fmt.Sprintf("│ %s\n", display)) + } + + sb.WriteString("╰" + strings.Repeat("─", width-2) + "╯\n") + return sb.String() +} + +func FormatHeader(text string) string { + return Bold(Blue(text)) +} + +func FormatSuccess(text string) string { + return Green(text) +} + +func FormatError(text string) string { + return Red(text) +} + +func FormatWarning(text string) string { + return Yellow(text) +} diff --git a/freeze.go b/freeze.go index 6ce3b47..3682634 100644 --- a/freeze.go +++ b/freeze.go @@ -44,6 +44,15 @@ func snapWithTitle(t testingT, title string, content string) { if accepted.Content == content { return } + + if err := SaveSnapshot(snapshot, "new"); err != nil { + t.Error("failed to save snapshot:", err) + return + } + + fmt.Println(DiffSnapshotBox(accepted, snapshot)) + t.Error("snapshot mismatch - run 'freeze review' to update") + return } if err := SaveSnapshot(snapshot, "new"); err != nil { @@ -51,7 +60,8 @@ func snapWithTitle(t testingT, title string, content string) { return } - t.Error("snapshot mismatch - run 'freeze review' to update") + fmt.Println(NewSnapshotBox(snapshot)) + t.Error("new snapshot created - run 'freeze review' to accept") } func formatValues(values ...any) string { diff --git a/freeze_test.go b/freeze_test.go index fbf2685..9f65109 100644 --- a/freeze_test.go +++ b/freeze_test.go @@ -3,6 +3,7 @@ package freeze_test import ( "os" "path/filepath" + "strings" "testing" "github.com/ptdewey/freeze" @@ -26,7 +27,10 @@ func (c CustomStruct) Format() string { } func TestSnapCustomType(t *testing.T) { - cs := CustomStruct{Name: "Alice", Age: 30} + cs := CustomStruct{ + Name: "Alice", + Age: 30, + } freeze.Snap(t, cs) } @@ -101,6 +105,108 @@ func TestSnapshotFileName(t *testing.T) { } } +func TestHistogramDiff(t *testing.T) { + old := "line1\nline2\nline3" + new := "line1\nmodified\nline3" + + diff := freeze.Histogram(old, new) + + if len(diff) < 3 { + t.Errorf("expected at least 3 diff lines, got %d", len(diff)) + } + + if diff[0].Kind != freeze.DiffShared || diff[0].Line != "line1" { + t.Errorf("line 0: expected shared 'line1', got %v %s", diff[0].Kind, diff[0].Line) + } + + hasModified := false + for _, d := range diff { + if d.Line == "modified" { + hasModified = true + if d.Kind != freeze.DiffNew { + t.Errorf("'modified' should be marked as new") + } + } + } + if !hasModified { + t.Error("diff missing 'modified' line") + } + + hasLine3 := false + for _, d := range diff { + if d.Line == "line3" && d.Kind == freeze.DiffShared { + hasLine3 = true + } + } + if !hasLine3 { + t.Error("diff should have 'line3' as shared") + } +} + +func TestDiffSnapshotBox(t *testing.T) { + old := &freeze.Snapshot{ + Version: "0.1.0", + TestName: "TestDiff", + Content: "old content", + } + + new := &freeze.Snapshot{ + Version: "0.1.0", + TestName: "TestDiff", + Content: "new content", + } + + box := freeze.DiffSnapshotBox(old, new) + if box == "" { + t.Error("DiffSnapshotBox returned empty string") + } + + if !contains(box, "Snapshot Diff") { + t.Error("DiffSnapshotBox missing header") + } +} + +func TestNewSnapshotBox(t *testing.T) { + snap := &freeze.Snapshot{ + Version: "0.1.0", + TestName: "TestNew", + Content: "test content", + } + + box := freeze.NewSnapshotBox(snap) + if box == "" { + t.Error("NewSnapshotBox returned empty string") + } + + if !contains(box, "New Snapshot") { + t.Error("NewSnapshotBox missing header") + } +} + +func TestFormatFunctions(t *testing.T) { + tests := []struct { + name string + fn func(string) string + text string + }{ + {"Red", freeze.Red, "error"}, + {"Green", freeze.Green, "success"}, + {"Yellow", freeze.Yellow, "warning"}, + {"Blue", freeze.Blue, "info"}, + } + + for _, tt := range tests { + result := tt.fn(tt.text) + if result == "" { + t.Errorf("%s returned empty string", tt.name) + } + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + func cleanupTestSnapshots(t *testing.T) { t.Helper() @@ -110,6 +216,6 @@ func cleanupTestSnapshots(t *testing.T) { return } - snapshotDir := filepath.Join(cwd, "__snapshots__") + snapshotDir := filepath.Join(cwd, "freeze_snapshots") _ = os.RemoveAll(snapshotDir) } diff --git a/review.go b/review.go new file mode 100644 index 0000000..66c3c0d --- /dev/null +++ b/review.go @@ -0,0 +1,161 @@ +package freeze + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type ReviewChoice int + +const ( + Accept ReviewChoice = iota + Reject + Skip + ToggleDiff + Quit +) + +func Review() error { + snapshots, err := ListNewSnapshots() + if err != nil { + return err + } + + if len(snapshots) == 0 { + fmt.Println(FormatSuccess("✓ No new snapshots to review")) + return nil + } + + fmt.Println(FormatHeader("🐦 Freeze - Snapshot Review")) + fmt.Printf("Found %d new snapshot(s) to review\n\n", len(snapshots)) + + return reviewLoop(snapshots) +} + +func reviewLoop(snapshots []string) error { + reader := bufio.NewReader(os.Stdin) + showDiff := false + + for i, testName := range snapshots { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), FormatHeader(testName)) + + newSnap, err := ReadSnapshot(testName, "new") + if err != nil { + fmt.Println(FormatError("✗ Failed to read new snapshot: " + err.Error())) + continue + } + + accepted, acceptErr := ReadSnapshot(testName, "accepted") + + if acceptErr == nil && showDiff { + fmt.Println(DiffSnapshotBox(accepted, newSnap)) + } else if acceptErr == nil { + fmt.Println(DiffSnapshotBox(accepted, newSnap)) + } else { + fmt.Println(NewSnapshotBox(newSnap)) + } + + for { + choice, err := askChoice(reader, i+1, len(snapshots)) + if err != nil { + return err + } + + switch choice { + case Accept: + if err := AcceptSnapshot(testName); err != nil { + fmt.Println(FormatError("✗ Failed to accept snapshot: " + err.Error())) + } else { + fmt.Println(FormatSuccess("✓ Snapshot accepted")) + } + break + case Reject: + if err := RejectSnapshot(testName); err != nil { + fmt.Println(FormatError("✗ Failed to reject snapshot: " + err.Error())) + } else { + fmt.Println(FormatWarning("⊘ Snapshot rejected")) + } + break + case Skip: + fmt.Println(FormatWarning("⊘ Snapshot skipped")) + break + case ToggleDiff: + showDiff = !showDiff + if acceptErr == nil { + fmt.Println(DiffSnapshotBox(accepted, newSnap)) + } else { + fmt.Println(NewSnapshotBox(newSnap)) + } + continue + case Quit: + fmt.Println("\nReview interrupted") + return nil + } + break + } + } + + fmt.Println("\n" + FormatSuccess("✓ Review complete")) + return nil +} + +func askChoice(reader *bufio.Reader, current, total int) (ReviewChoice, error) { + fmt.Printf("\nOptions: [a]ccept [r]eject [s]kip [d]iff [q]uit: ") + + input, err := reader.ReadString('\n') + if err != nil { + return Quit, err + } + + input = strings.ToLower(strings.TrimSpace(input)) + + switch input { + case "a", "accept": + return Accept, nil + case "r", "reject": + return Reject, nil + case "s", "skip": + return Skip, nil + case "d", "diff": + return ToggleDiff, nil + case "q", "quit": + return Quit, nil + default: + fmt.Println(FormatWarning("Invalid option, please try again")) + return askChoice(reader, current, total) + } +} + +func AcceptAll() error { + snapshots, err := ListNewSnapshots() + if err != nil { + return err + } + + for _, testName := range snapshots { + if err := AcceptSnapshot(testName); err != nil { + return err + } + } + + fmt.Printf(FormatSuccess("✓ Accepted %d snapshot(s)\n"), len(snapshots)) + return nil +} + +func RejectAll() error { + snapshots, err := ListNewSnapshots() + if err != nil { + return err + } + + for _, testName := range snapshots { + if err := RejectSnapshot(testName); err != nil { + return err + } + } + + fmt.Printf(FormatWarning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) + return nil +} -- 2.51.2