From ffaacbdefd54ad41e6cf9b08aa11c031567b6dca Mon Sep 17 00:00:00 2001 From: Patrick Dewey <57921252+ptdewey@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:59:04 -0500 Subject: [PATCH] feat: wip core implementation --- files.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++++ freeze.go | 97 ++++++++++++++++++++++++++++--- freeze_test.go | 115 ++++++++++++++++++++++++++++++++++++ justfile | 2 + snapshot.go | 53 +++++++++++++++++ 5 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 files.go create mode 100644 freeze_test.go create mode 100644 justfile create mode 100644 snapshot.go diff --git a/files.go b/files.go new file mode 100644 index 0000000..a4644d2 --- /dev/null +++ b/files.go @@ -0,0 +1,154 @@ +package freeze + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +func findProjectRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + current := cwd + for { + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { + return current, nil + } + + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("go.mod not found") + } + current = parent + } +} + +func getSnapshotDir() (string, error) { + root, err := findProjectRoot() + if err != nil { + return "", err + } + + // TODO: pull this from config. + // config should allow having snapshot dir at project root (with or w/o subdirs) + // or in a __snapshots__ dir inside of each package dir + snapshotDir := filepath.Join(root, "__snapshots__") + if err := os.MkdirAll(snapshotDir, 0755); err != nil { + return "", err + } + + return snapshotDir, nil +} + +func SnapshotFileName(testName string) string { + var result strings.Builder + for i, r := range testName { + if i > 0 && r >= 'A' && r <= 'Z' { + result.WriteRune('_') + } + result.WriteRune(r) + } + s := result.String() + s = strings.ToLower(s) + s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "_") + s = strings.Trim(s, "_") + return s +} + +func SaveSnapshot(snap *Snapshot, state string) error { + snapshotDir, err := getSnapshotDir() + if err != nil { + return err + } + + fileName := SnapshotFileName(snap.TestName) + "." + state + filePath := filepath.Join(snapshotDir, fileName) + + return os.WriteFile(filePath, []byte(snap.Serialize()), 0644) +} + +func ReadSnapshot(testName string, state string) (*Snapshot, error) { + snapshotDir, err := getSnapshotDir() + if err != nil { + return nil, err + } + + fileName := SnapshotFileName(testName) + "." + state + filePath := filepath.Join(snapshotDir, fileName) + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + return Deserialize(string(data)) +} + +func readAccepted(testName string) (*Snapshot, error) { + return ReadSnapshot(testName, "accepted") +} + +func readNew(testName string) (*Snapshot, error) { + return ReadSnapshot(testName, "new") +} + +func ListNewSnapshots() ([]string, error) { + snapshotDir, err := getSnapshotDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(snapshotDir) + if err != nil { + return nil, err + } + + var newSnapshots []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".new") { + name := strings.TrimSuffix(entry.Name(), ".new") + newSnapshots = append(newSnapshots, name) + } + } + + return newSnapshots, nil +} + +func AcceptSnapshot(testName string) error { + snapshotDir, err := getSnapshotDir() + if err != nil { + return err + } + + fileName := SnapshotFileName(testName) + newPath := filepath.Join(snapshotDir, fileName+".new") + acceptedPath := filepath.Join(snapshotDir, fileName+".accepted") + + data, err := os.ReadFile(newPath) + if err != nil { + return err + } + + if err := os.WriteFile(acceptedPath, data, 0644); err != nil { + return err + } + + return os.Remove(newPath) +} + +func RejectSnapshot(testName string) error { + snapshotDir, err := getSnapshotDir() + if err != nil { + return err + } + + fileName := SnapshotFileName(testName) + ".new" + filePath := filepath.Join(snapshotDir, fileName) + + return os.Remove(filePath) +} diff --git a/freeze.go b/freeze.go index 7cb8c21..6ce3b47 100644 --- a/freeze.go +++ b/freeze.go @@ -1,20 +1,99 @@ package freeze -type Snapshot struct { - Version string - TestName string - Content string +import ( + "fmt" + "reflect" +) + +const version = "0.1.0" + +func SnapString(t testingT, content string) { + t.Helper() + snap(t, content) +} + +func Snap(t testingT, values ...any) { + t.Helper() + content := formatValues(values...) + snap(t, content) } -type Config struct { - snapshotDir string - extension string +func SnapWithTitle(t testingT, title string, values ...any) { + t.Helper() + content := formatValues(values...) + snapWithTitle(t, title, content) +} + +func snap(t testingT, content string) { + t.Helper() + testName := t.Name() + snapWithTitle(t, testName, content) } -func Frame(t testingT, vals ...any) { +func snapWithTitle(t testingT, title string, content string) { t.Helper() + + snapshot := &Snapshot{ + Version: version, + TestName: title, + Content: content, + } + + accepted, err := readAccepted(title) + if err == nil { + if accepted.Content == content { + return + } + } + + if err := SaveSnapshot(snapshot, "new"); err != nil { + t.Error("failed to save snapshot:", err) + return + } + + t.Error("snapshot mismatch - run 'freeze review' to update") +} + +func formatValues(values ...any) string { + if len(values) == 0 { + return "" + } + + if len(values) == 1 { + return formatValue(values[0]) + } + + var result string + for i, v := range values { + if i > 0 { + result += "\n" + } + result += formatValue(v) + } + return result } -func newSnapshot(name, content string, cfg Config) { +func formatValue(v any) string { + if v == nil { + return "" + } + + if formattable, ok := v.(interface{ Format() string }); ok { + return formattable.Format() + } + + if stringer, ok := v.(interface{ String() string }); ok { + return stringer.String() + } + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.String: + return v.(string) + case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map: + // TODO: make this better probably + return fmt.Sprintf("%#v", v) + default: + return fmt.Sprint(v) + } } diff --git a/freeze_test.go b/freeze_test.go new file mode 100644 index 0000000..fbf2685 --- /dev/null +++ b/freeze_test.go @@ -0,0 +1,115 @@ +package freeze_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ptdewey/freeze" +) + +func TestSnapString(t *testing.T) { + freeze.SnapString(t, "hello world") +} + +func TestSnapMultiple(t *testing.T) { + freeze.Snap(t, "value1", "value2", 42) +} + +type CustomStruct struct { + Name string + Age int +} + +func (c CustomStruct) Format() string { + return "CustomStruct{Name: " + c.Name + ", Age: " + string(rune(c.Age)) + "}" +} + +func TestSnapCustomType(t *testing.T) { + cs := CustomStruct{Name: "Alice", Age: 30} + freeze.Snap(t, cs) +} + +func TestSerializeDeserialize(t *testing.T) { + snap := &freeze.Snapshot{ + Version: "1.0.0", + TestName: "TestExample", + Content: "test content\nmultiline", + } + + serialized := snap.Serialize() + expected := "---\nversion: 1.0.0\ntest_name: TestExample\n---\ntest content\nmultiline" + if serialized != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) + } + + deserialized, err := freeze.Deserialize(serialized) + if err != nil { + t.Fatalf("failed to deserialize: %v", err) + } + + if deserialized.Version != snap.Version { + t.Errorf("version mismatch: %s != %s", deserialized.Version, snap.Version) + } + if deserialized.TestName != snap.TestName { + t.Errorf("test name mismatch: %s != %s", deserialized.TestName, snap.TestName) + } + if deserialized.Content != snap.Content { + t.Errorf("content mismatch: %s != %s", deserialized.Content, snap.Content) + } +} + +func TestFileOperations(t *testing.T) { + snap := &freeze.Snapshot{ + Version: "0.1.0", + TestName: "TestFileOps", + Content: "file test content", + } + + if err := freeze.SaveSnapshot(snap, "test"); err != nil { + t.Fatalf("failed to save snapshot: %v", err) + } + + read, err := freeze.ReadSnapshot("TestFileOps", "test") + if err != nil { + t.Fatalf("failed to read snapshot: %v", err) + } + + if read.Content != snap.Content { + t.Errorf("content mismatch: %s != %s", read.Content, snap.Content) + } + + cleanupTestSnapshots(t) +} + +func TestSnapshotFileName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"TestMyFunction", "test_my_function"}, + {"test_another_one", "test_another_one"}, + {"TestCamelCase", "test_camel_case"}, + {"TestWithNumbers123", "test_with_numbers123"}, + } + + for _, tt := range tests { + result := freeze.SnapshotFileName(tt.input) + if result != tt.expected { + t.Errorf("SnapshotFileName(%s) = %s, want %s", tt.input, result, tt.expected) + } + } +} + +func cleanupTestSnapshots(t *testing.T) { + t.Helper() + + cwd, err := os.Getwd() + if err != nil { + t.Logf("failed to get cwd: %v", err) + return + } + + snapshotDir := filepath.Join(cwd, "__snapshots__") + _ = os.RemoveAll(snapshotDir) +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..d68126e --- /dev/null +++ b/justfile @@ -0,0 +1,2 @@ +test: + @go test ./... -cover -coverprofile=cover.out diff --git a/snapshot.go b/snapshot.go new file mode 100644 index 0000000..1b59f94 --- /dev/null +++ b/snapshot.go @@ -0,0 +1,53 @@ +package freeze + +import ( + "fmt" + "strings" +) + +type Snapshot struct { + Version string + TestName string + Content string +} + +func (s *Snapshot) Serialize() string { + header := fmt.Sprintf("---\nversion: %s\ntest_name: %s\n---\n", s.Version, s.TestName) + return header + s.Content +} + +func Deserialize(raw string) (*Snapshot, error) { + parts := strings.SplitN(raw, "---\n", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid snapshot format") + } + + header := parts[1] + content := parts[2] + + snap := &Snapshot{ + Content: content, + } + + for _, line := range strings.Split(header, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + kv := strings.SplitN(line, ": ", 2) + if len(kv) != 2 { + continue + } + + key, value := kv[0], kv[1] + switch key { + case "version": + snap.Version = value + case "test_name": + snap.TestName = value + } + } + + return snap, nil +} -- 2.51.2