···11package shutter
2233import (
44+ "fmt"
55+46 "github.com/kortschak/utter"
57 "github.com/ptdewey/shutter/internal/review"
68 "github.com/ptdewey/shutter/internal/snapshots"
79 "github.com/ptdewey/shutter/internal/transform"
810)
9111010-const version = "0.1.0"
1212+// snapshotFormatVersion indicates the snapshot format version used by this library.
1313+// This is automatically included in snapshot metadata for compatibility checking
1414+// when the snapshot format changes in future versions.
1515+const snapshotFormatVersion = "0.1.0"
1616+1717+// utterConfig is a configured instance of utter for consistent formatting.
1818+// This avoids modifying global state and ensures snapshot formatting is isolated.
1919+var utterConfig = &utter.ConfigState{
2020+ Indent: " ",
2121+ ElideType: true,
2222+ SortKeys: true,
2323+}
2424+2525+// Option is a marker interface for all snapshot options.
2626+// This allows compile-time type safety while supporting different option types.
2727+type Option interface {
2828+ isOption()
2929+}
11301212-func init() {
1313- utter.Config.ElideType = true
1414- utter.Config.SortKeys = true
3131+// Scrubber transforms content before snapshotting, typically to replace
3232+// dynamic or sensitive data with stable placeholders.
3333+//
3434+// Scrubbers are applied in the order they are provided. Later scrubbers
3535+// can transform the output of earlier scrubbers.
3636+//
3737+// Example:
3838+//
3939+// shutter.Snap(t, "user data", user,
4040+// shutter.ScrubUUID(), // First: UUIDs -> <UUID>
4141+// shutter.ScrubEmail(), // Second: emails -> <EMAIL>
4242+// )
4343+type Scrubber interface {
4444+ Option
4545+ Scrub(content string) string
1546}
16471717-// Snap takes any values, formats them, and creates a snapshot with the given title.
1818-// For complex types, values are formatted using a pretty-printer.
1919-// The last parameters can be SnapshotOptions to apply scrubbers before snapshotting.
4848+// IgnorePattern determines whether a key-value pair should be excluded
4949+// from JSON snapshots. This is useful for removing fields that change
5050+// frequently or contain sensitive data.
2051//
2121-// shutter.Snap(t, "title", any(value1), any(value2), shutter.ScrubUUIDs())
5252+// IgnorePatterns only work with SnapJSON. Using them with Snap or SnapString
5353+// will result in an error.
5454+type IgnorePattern interface {
5555+ Option
5656+ ShouldIgnore(key, value string) bool
5757+}
5858+5959+// Snap takes a single value, formats it, and creates a snapshot with the given title.
6060+// Complex types are formatted using a pretty-printer for readability.
2261//
2323-// REFACTOR: should this take in _one_ value, and then allow options as additional inputs?
2424-func Snap(t snapshots.T, title string, values ...any) {
6262+// Options can be provided to scrub sensitive or dynamic data before snapshotting.
6363+// Only Scrubber options are supported; IgnorePattern options will cause an error.
6464+//
6565+// Example:
6666+//
6767+// user := User{ID: "123", Email: "user@example.com"}
6868+// shutter.Snap(t, "user data", user,
6969+// shutter.ScrubUUID(),
7070+// shutter.ScrubEmail(),
7171+// )
7272+func Snap(t snapshots.T, title string, value any, opts ...Option) {
2573 t.Helper()
26742727- // Separate options from values
2828- var opts []SnapshotOption
2929- var actualValues []any
7575+ scrubbers, ignores := separateOptions(opts)
30763131- for _, v := range values {
3232- if opt, ok := v.(SnapshotOption); ok {
3333- opts = append(opts, opt)
3434- } else {
3535- actualValues = append(actualValues, v)
3636- }
7777+ if len(ignores) > 0 {
7878+ t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with Snap; use SnapJSON instead", title))
7979+ return
3780 }
38813939- content := snapshots.FormatValues(actualValues...)
8282+ content := formatValue(value)
8383+ scrubbedContent := applyScrubbers(content, scrubbers)
8484+8585+ snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
8686+}
40874141- // Apply scrubber options directly to the formatted content
4242- scrubbers, _ := extractOptions(opts)
4343- scrubbedContent := applyOptions(content, scrubbers)
8888+// SnapMany takes multiple values, formats them, and creates a snapshot with the given title.
8989+// This is useful when you want to snapshot multiple related values together.
9090+//
9191+// Options can be provided to scrub sensitive or dynamic data before snapshotting.
9292+// Only Scrubber options are supported; IgnorePattern options will cause an error.
9393+//
9494+// Example:
9595+//
9696+// shutter.SnapMany(t, "request and response",
9797+// []any{request, response},
9898+// shutter.ScrubUUID(),
9999+// shutter.ScrubTimestamp(),
100100+// )
101101+func SnapMany(t snapshots.T, title string, values []any, opts ...Option) {
102102+ t.Helper()
103103+104104+ scrubbers, ignores := separateOptions(opts)
105105+106106+ if len(ignores) > 0 {
107107+ t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapMany; use SnapJSON instead", title))
108108+ return
109109+ }
110110+111111+ content := formatValues(values...)
112112+ scrubbedContent := applyScrubbers(content, scrubbers)
441134545- snapshots.Snap(t, title, version, scrubbedContent)
114114+ snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
46115}
4711648117// SnapString takes a string value and creates a snapshot with the given title.
4949-// Options can be provided to apply scrubbers before snapshotting.
5050-func SnapString(t snapshots.T, title string, content string, opts ...SnapshotOption) {
118118+// This is useful for snapshotting generated text, logs, or other string content.
119119+//
120120+// Options can be provided to scrub sensitive or dynamic data before snapshotting.
121121+// Only Scrubber options are supported; IgnorePattern options will cause an error.
122122+//
123123+// Example:
124124+//
125125+// output := generateReport()
126126+// shutter.SnapString(t, "report output", output,
127127+// shutter.ScrubTimestamp(),
128128+// )
129129+func SnapString(t snapshots.T, title string, content string, opts ...Option) {
51130 t.Helper()
521315353- // Apply scrubber options directly to the content
5454- scrubbers, _ := extractOptions(opts)
5555- scrubbedContent := applyOptions(content, scrubbers)
132132+ scrubbers, ignores := separateOptions(opts)
561335757- snapshots.Snap(t, title, version, scrubbedContent)
134134+ if len(ignores) > 0 {
135135+ t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapString; use SnapJSON instead", title))
136136+ return
137137+ }
138138+139139+ scrubbedContent := applyScrubbers(content, scrubbers)
140140+141141+ snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
58142}
5914360144// SnapJSON takes a JSON string, validates it, and pretty-prints it with
61145// consistent formatting before snapshotting. This preserves the raw JSON
62146// format while ensuring valid JSON structure.
6363-// Options can be provided to apply scrubbers and ignore patterns.
6464-func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...SnapshotOption) {
147147+//
148148+// Options can be provided to apply both Scrubbers and IgnorePatterns.
149149+// IgnorePatterns remove fields from the JSON structure before scrubbing.
150150+// Scrubbers then transform the remaining content.
151151+//
152152+// Example:
153153+//
154154+// jsonStr := `{"id": "550e8400-...", "email": "user@example.com", "password": "secret"}`
155155+// shutter.SnapJSON(t, "user response", jsonStr,
156156+// shutter.IgnoreKey("password"), // First: remove password field
157157+// shutter.ScrubUUID(), // Second: scrub remaining UUIDs
158158+// shutter.ScrubEmail(), // Third: scrub emails
159159+// )
160160+func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...Option) {
65161 t.Helper()
661626767- scrubbers, ignores := extractOptions(opts)
163163+ scrubbers, ignores := separateOptions(opts)
6816469165 // Transform the JSON with ignore patterns and scrubbers
70166 transformConfig := &transform.Config{
···7417075171 transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig)
76172 if err != nil {
7777- t.Error("failed to transform JSON:", err)
173173+ t.Error(fmt.Sprintf("snapshot %q: failed to transform JSON: %v", title, err))
78174 return
79175 }
801768181- snapshots.Snap(t, title, version, transformedJSON)
177177+ snapshots.Snap(t, title, snapshotFormatVersion, transformedJSON)
82178}
8317984180// Review launches an interactive review session to accept or reject snapshot changes.
···96192 return review.RejectAll()
97193}
981949999-// SnapshotOption represents a transformation that can be applied to snapshot content.
100100-// Options are applied in the order they are provided.
101101-type SnapshotOption interface {
102102- Apply(content string) string
195195+// formatValue formats a single value using the configured utter instance.
196196+func formatValue(v any) string {
197197+ return utterConfig.Sdump(v)
103198}
104199105105-// IgnoreOption represents a pattern for ignoring key-value pairs in JSON structures.
106106-type IgnoreOption interface {
107107- ShouldIgnore(key, value string) bool
200200+// formatValues formats multiple values using the configured utter instance.
201201+func formatValues(values ...any) string {
202202+ var result string
203203+ for _, v := range values {
204204+ result += formatValue(v)
205205+ }
206206+ return result
108207}
109208110110-// extractOptions separates scrubbers and ignore patterns from options.
111111-func extractOptions(opts []SnapshotOption) (scrubbers []SnapshotOption, ignores []IgnoreOption) {
209209+// separateOptions splits options into scrubbers and ignore patterns.
210210+func separateOptions(opts []Option) (scrubbers []Scrubber, ignores []IgnorePattern) {
112211 for _, opt := range opts {
113113- if ignore, ok := opt.(IgnoreOption); ok {
114114- ignores = append(ignores, ignore)
115115- } else {
116116- scrubbers = append(scrubbers, opt)
212212+ switch o := opt.(type) {
213213+ case IgnorePattern:
214214+ ignores = append(ignores, o)
215215+ case Scrubber:
216216+ scrubbers = append(scrubbers, o)
217217+ default:
218218+ // This shouldn't happen if Option interface is properly implemented
219219+ panic(fmt.Sprintf("unknown option type: %T", opt))
117220 }
118221 }
119222 return scrubbers, ignores
120223}
121224122122-// applyOptions applies all scrubber options to content in sequence.
123123-func applyOptions(content string, opts []SnapshotOption) string {
124124- for _, opt := range opts {
125125- content = opt.Apply(content)
225225+// applyScrubbers applies all scrubbers to content in sequence.
226226+func applyScrubbers(content string, scrubbers []Scrubber) string {
227227+ for _, scrubber := range scrubbers {
228228+ content = scrubber.Scrub(content)
126229 }
127230 return content
128231}
129232130130-// scrubberAdapter adapts a SnapshotOption to the transform.Scrubber interface.
233233+// scrubberAdapter adapts a Scrubber to the transform.Scrubber interface.
131234type scrubberAdapter struct {
132132- opt SnapshotOption
235235+ scrubber Scrubber
133236}
134237135238func (s *scrubberAdapter) Scrub(content string) string {
136136- return s.opt.Apply(content)
239239+ return s.scrubber.Scrub(content)
137240}
138241139139-func toTransformScrubbers(opts []SnapshotOption) []transform.Scrubber {
140140- result := make([]transform.Scrubber, len(opts))
141141- for i, opt := range opts {
142142- result[i] = &scrubberAdapter{opt: opt}
242242+func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber {
243243+ result := make([]transform.Scrubber, len(scrubbers))
244244+ for i, scrubber := range scrubbers {
245245+ result[i] = &scrubberAdapter{scrubber: scrubber}
143246 }
144247 return result
145248}
146249147147-// ignoreAdapter adapts an IgnoreOption to the transform.IgnorePattern interface.
250250+// ignoreAdapter adapts an IgnorePattern to the transform.IgnorePattern interface.
148251type ignoreAdapter struct {
149149- ignore IgnoreOption
252252+ ignore IgnorePattern
150253}
151254152255func (i *ignoreAdapter) ShouldIgnore(key, value string) bool {
153256 return i.ignore.ShouldIgnore(key, value)
154257}
155258156156-func toTransformIgnorePatterns(ignores []IgnoreOption) []transform.IgnorePattern {
259259+func toTransformIgnorePatterns(ignores []IgnorePattern) []transform.IgnorePattern {
157260 result := make([]transform.IgnorePattern, len(ignores))
158261 for i, ignore := range ignores {
159262 result[i] = &ignoreAdapter{ignore: ignore}