diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..6862d2a --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,43 @@ +name: Go + +on: + push: + branches: [ "*" ] + pull_request: + + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v5 + with: + fetch-depth: 0 # needed because of the new-from-rev in golangci + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + args: --timeout 5m0s + + - id: govulncheck + uses: golang/govulncheck-action@v1 + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25.x" + + - name: Running Unit Tests + run: go test ./... diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ab58463 --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package csvheaders + +// ReplacerError is an error that can be returned when using the Replacer. +type ReplacerError string + +// Error implements the error interface. +func (e ReplacerError) Error() string { + return string(e) +} + +// ErrDuplicateHeader is returned when the header map registers the same header twice. +const ErrDuplicateHeader = ReplacerError("duplicate header") diff --git a/replacer.go b/replacer.go index 70ae407..17639c5 100644 --- a/replacer.go +++ b/replacer.go @@ -21,6 +21,22 @@ func NewReplacer(reader *csv.Reader, inToOut map[string]string) *Replacer { return &Replacer{Reader: reader, inToOut: inToOut} } +// NewReverseReplacer returns a replacer using mappings from struct definition to file contents. +// If there is a duplicate value in the headers mapping, this returns an error. +func NewReverseReplacer(reader *csv.Reader, outToIn map[string]string) (*Replacer, error) { + r := &Replacer{Reader: reader, inToOut: make(map[string]string)} + + for out, in := range outToIn { + if h, alreadyRegistered := r.inToOut[in]; alreadyRegistered { + return nil, fmt.Errorf("struct tag %s registered with CSV headers %s and %s: %w", in, out, h, ErrDuplicateHeader) + } + + r.inToOut[in] = out + } + + return r, nil +} + // Read a record from the reader. func (r *Replacer) Read() ([]string, error) { record, err := r.Reader.Read() diff --git a/replacer_test.go b/replacer_test.go index 4fb2061..c7d75e9 100644 --- a/replacer_test.go +++ b/replacer_test.go @@ -3,6 +3,7 @@ package csvheaders_test import ( "bytes" "encoding/csv" + "errors" "slices" "testing" @@ -14,6 +15,7 @@ func TestReplacer(t *testing.T) { t.Run("Read", testReplacerRead) t.Run("ReadAll", testReplacerReadAll) + t.Run("Reverse", testReplacerReverse) } func testReplacerRead(t *testing.T) { @@ -55,21 +57,20 @@ field 1,Charles,Monica func testReplacerReadAll(t *testing.T) { t.Parallel() - var rawRows = []byte(`recording artist,title,isrc -THE BEATLES,LET IT BE,US1239875 -recording artist,my band,FR789230987 + var rawRows = []byte(`recording artist,title +THE BEATLES,LET IT BE +recording artist,my band `) want := [][]string{ - {"ARTIST", "TITLE", "ISRC"}, - {"THE BEATLES", "LET IT BE", "US1239875"}, - {"recording artist", "my band", "FR789230987"}, + {"ARTIST", "TITLE"}, + {"THE BEATLES", "LET IT BE"}, + {"recording artist", "my band"}, } headerReplacements := map[string]string{ "recording artist": "ARTIST", "title": "TITLE", - "isrc": "ISRC", } r := csvheaders.NewReplacer(csv.NewReader(bytes.NewBuffer(rawRows)), headerReplacements) @@ -79,6 +80,65 @@ recording artist,my band,FR789230987 t.Fatal(err) } + compareResults(t, want, got) +} + +func testReplacerReverse(t *testing.T) { + t.Parallel() + + var rawRows = []byte(`field 1,field 2,field 10 +value 1,field 2,value 10 +`) + + testCases := map[string]struct { + headerMapping map[string]string + expectedError error + want [][]string + }{ + "duplicated header": { + headerMapping: map[string]string{ + "field 1": "FIELD", + "field 2": "FIELD", + }, + expectedError: csvheaders.ErrDuplicateHeader, + want: nil, + }, + "nil headers": { + headerMapping: nil, + expectedError: nil, + want: [][]string{{"field 1", "field 2", "field 10"}, {"value 1", "field 2", "value 10"}}, + }, + "no header conflict": { + headerMapping: map[string]string{"Monday": "field 10"}, + expectedError: nil, + want: [][]string{{"field 1", "field 2", "Monday"}, {"value 1", "field 2", "value 10"}}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r, err := csvheaders.NewReverseReplacer(csv.NewReader(bytes.NewBuffer(rawRows)), testCase.headerMapping) + if testCase.expectedError != nil { + if !errors.Is(err, testCase.expectedError) { + t.Fatalf("expected %s, got %s", testCase.expectedError, err) + } + + return + } + + got, err := r.ReadAll() + if err != nil { + t.Fatalf("unexpected error while reading: %s", err) + } + + compareResults(t, testCase.want, got) + }) + } +} + +func compareResults(t *testing.T, want, got [][]string) { if len(got) != len(want) { t.Fatalf("got %d records, want %d", len(got), len(want)) }