From 8c4ee93dfa8ee70e8be2e07c855fe550b0aa76ef Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Fri, 5 Jul 2024 14:08:48 -0700 Subject: [PATCH] Add Copy/CopyIndexer methods --- helpers.go | 20 +++++++ helpers_test.go | 22 +++++++ orbit/copier.go | 98 ++++++++++++++++++++++++++++++ orbit/copier_test.go | 139 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 helpers_test.go create mode 100644 orbit/copier.go create mode 100644 orbit/copier_test.go diff --git a/helpers.go b/helpers.go index 3e5a7a1..ef82f68 100644 --- a/helpers.go +++ b/helpers.go @@ -85,6 +85,26 @@ func Str[I int | int64 | float64 | bool](val I) string { } } +// None can be used to return only an error condition. The opposite of Must(). +// If the last argument is an error, that's what gets returned, otherwise nil. +func None(input ...any) error { + if len(input) > 0 { + err, _ := input[len(input)-1].(error) + return err + } + + return nil +} + +// Must can be used to avoid checking an error you'll never run into. +func Must[S any](input S, err error) S { + if err != nil { + panic("Must failed: " + err.Error()) + } + + return input +} + // Ptr returns a pointer to the provided "whatever". func Ptr[P any](p P) *P { return &p diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..efe59dd --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,22 @@ +package starr_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golift.io/starr" +) + +//nolint:testifylint // we want to test each one and not fail on an error. +func TestNone(t *testing.T) { + t.Parallel() + assert.ErrorIs(t, starr.None(starr.ErrNilClient), starr.ErrNilClient) + assert.ErrorIs(t, starr.None("string", starr.ErrNilClient), starr.ErrNilClient) + assert.ErrorIs(t, starr.None(uint(1), starr.ErrNilClient), starr.ErrNilClient) + assert.ErrorIs(t, starr.None("string", uint(1), starr.ErrNilClient), starr.ErrNilClient) + assert.ErrorIs(t, starr.None(1.0, "string", starr.ErrNilClient), starr.ErrNilClient) + assert.NoError(t, starr.None(1.0, "string")) + assert.NoError(t, starr.None("string")) + assert.NoError(t, starr.None(1.0)) + assert.NoError(t, starr.None()) +} diff --git a/orbit/copier.go b/orbit/copier.go new file mode 100644 index 0000000..51d795d --- /dev/null +++ b/orbit/copier.go @@ -0,0 +1,98 @@ +// Package orbit provides functions to modify data structures among the various starr libraries. +// These functions cannot live in the starr library without causing an import cycle. +// These are wrappers around the starr library and other sub modules. +package orbit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "reflect" + + "golift.io/starr/lidarr" + "golift.io/starr/prowlarr" + "golift.io/starr/radarr" + "golift.io/starr/readarr" + "golift.io/starr/sonarr" +) + +var ErrNotPtr = errors.New("must provide a pointer to a non-nil value") + +// Copy is an easy way to copy one data structure to another. +func Copy(src, dst any) error { + if src == nil || reflect.TypeOf(src).Kind() != reflect.Ptr { + return fmt.Errorf("copy source: %w", ErrNotPtr) + } else if dst == nil || reflect.TypeOf(dst).Kind() != reflect.Ptr { + return fmt.Errorf("copy destination: %w", ErrNotPtr) + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(src); err != nil { + return fmt.Errorf("encoding: %w", err) + } + + if err := json.NewDecoder(&buf).Decode(dst); err != nil { + return fmt.Errorf("decoding: %w", err) + } + + return nil +} + +// IndexerInput represents all possible Indexer inputs. +type IndexerInput interface { + lidarr.IndexerInput | prowlarr.IndexerInput | radarr.IndexerInput | + readarr.IndexerInput | sonarr.IndexerInput +} + +// IndexerOutput represents all possible Indexer outputs. +type IndexerOutput interface { + lidarr.IndexerOutput | prowlarr.IndexerOutput | radarr.IndexerOutput | + readarr.IndexerOutput | sonarr.IndexerOutput +} + +// CopyIndexers copies a slice of indexers from one type to another, so you may copy them among instances. +// The destination must be a pointer to a slice, so it can be updated in place. +// The destination slice may be empty but the pointer to it must not be nil. +func CopyIndexers[S IndexerInput | IndexerOutput, D IndexerInput](src []*S, dst *[]*D, keepTags bool) ([]*D, error) { + if dst == nil { + return nil, ErrNotPtr + } + + var err error + + for idx, indexer := range src { + if len(*dst)-1 >= idx { // The destination slice location exists, so update it in place. + _, err = CopyIndexer(indexer, (*dst)[idx], keepTags) + } else { // The destination slice is shorter than the source, so append to it. + newIndexer := new(D) + newIndexer, err = CopyIndexer(indexer, newIndexer, keepTags) + *dst = append(*dst, newIndexer) // This happens before checking the error. + } + + if err != nil { + break + } + } + + return *dst, err +} + +// CopyIndexer copies an indexer from one type to another, so you may copy them among instances. +func CopyIndexer[S IndexerInput | IndexerOutput, D IndexerInput](src *S, dst *D, keepTags bool) (*D, error) { + if err := Copy(src, dst); err != nil { + return dst, err + } + + element := reflect.ValueOf(dst).Elem() + zeroField(element.FieldByName("ID"), true) + zeroField(element.FieldByName("Tags"), !keepTags) + + return dst, nil +} + +func zeroField(field reflect.Value, really bool) { + if really && field.CanSet() { + field.SetZero() + } +} diff --git a/orbit/copier_test.go b/orbit/copier_test.go new file mode 100644 index 0000000..32311a4 --- /dev/null +++ b/orbit/copier_test.go @@ -0,0 +1,139 @@ +package orbit_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golift.io/starr" + "golift.io/starr/orbit" + "golift.io/starr/prowlarr" + "golift.io/starr/sonarr" +) + +func copyData(t *testing.T) (*prowlarr.IndexerOutput, *sonarr.IndexerInput) { + t.Helper() + + return &prowlarr.IndexerOutput{ + ID: 2, + Priority: 3, + Name: "yes", + Protocol: "usenet", + Implementation: "core", + ConfigContract: "hancock", + Tags: []int{1, 2, 5}, + Fields: []*starr.FieldOutput{ + {Name: "One", Value: "one"}, + {Name: "Two", Value: 2.0}, + {Name: "Three", Value: uint(3)}, + {Name: "Five", Value: 5}, + }, + }, + &sonarr.IndexerInput{ + // These are not part of the used input, so set them before copying. + EnableAutomaticSearch: true, + EnableInteractiveSearch: true, + EnableRss: true, + DownloadClientID: 15, + } +} + +func TestCopyIndexers(t *testing.T) { + t.Parallel() + src1, dst1 := copyData(t) + src2, dst2 := copyData(t) + src3, dst3 := copyData(t) + src4, _ := copyData(t) + src5, _ := copyData(t) + // We test for these. + src1.Priority = 1 + src2.Priority = 2 + src3.Priority = 3 + src4.Priority = 4 + src5.Priority = 5 + // Make two lists. + srcs := append([]*prowlarr.IndexerOutput{}, src1, src2, src3, src4, src5) + dsts := append([]*sonarr.IndexerInput{}, dst1, dst2, dst3) // Short by 2. + // Copy the lists. + dsts2, err := orbit.CopyIndexers(srcs, &dsts, true) + require.NoError(t, err) + // Make sure both outputs have a length matching the input. + assert.Len(t, dsts, len(srcs)) + assert.Len(t, dsts2, len(srcs)) + // Test that values got copied. + for idx, src := range srcs { + assert.Zero(t, dsts[idx].ID) + assert.Equal(t, src.Priority, dsts[idx].Priority) + assert.Equal(t, src.Tags, dsts[idx].Tags) + } +} + +// TestCopyIndexersNilDest test a nil destination pointer and slice. +func TestCopyIndexersNilDest(t *testing.T) { + t.Parallel() + src1, _ := copyData(t) + src2, _ := copyData(t) + // Make two lists. + srcs := append([]*prowlarr.IndexerOutput{}, src1, src2) + dsts := new([]*sonarr.IndexerInput) // Super empty. + *dsts = nil // Nil the slice. + // Copy the lists. + dsts2, err := orbit.CopyIndexers(srcs, dsts, false) + require.NoError(t, err) + // Make sure both outputs have a length matching the input. + assert.Len(t, *dsts, len(srcs)) + assert.Len(t, dsts2, len(srcs)) + // Test that tags got removed. + for idx, src := range srcs { + assert.Zero(t, (*dsts)[idx].ID) + assert.Equal(t, src.Priority, (*dsts)[idx].Priority) + assert.NotEqual(t, src.Tags, (*dsts)[idx].Tags) + } + + // Make an error. + dsts = nil // This is a no-no. + require.ErrorIs(t, starr.None(orbit.CopyIndexers(srcs, dsts, false)), orbit.ErrNotPtr) +} + +func TestCopyIndexer(t *testing.T) { + t.Parallel() + + src, dst := copyData(t) + // Verify everything copies over. + require.NoError(t, starr.None(orbit.CopyIndexer(src, dst, true))) + assert.Equal(t, src.Fields[0].Value, dst.Fields[0].Value) + assert.Equal(t, src.Fields[1].Value, dst.Fields[1].Value) + assert.EqualValues(t, src.Fields[2].Value, dst.Fields[2].Value) + assert.EqualValues(t, src.Fields[3].Value, dst.Fields[3].Value) + assert.Equal(t, src.Fields[0].Name, dst.Fields[0].Name) + assert.Equal(t, src.Fields[1].Name, dst.Fields[1].Name) + assert.Equal(t, src.Fields[2].Name, dst.Fields[2].Name) + assert.Equal(t, src.Fields[3].Name, dst.Fields[3].Name) + assert.Zero(t, dst.ID) + assert.Equal(t, src.Priority, dst.Priority) + assert.Equal(t, src.Name, dst.Name) + assert.Equal(t, src.Protocol, dst.Protocol) + assert.Equal(t, src.Implementation, dst.Implementation) + assert.Equal(t, src.ConfigContract, dst.ConfigContract) + assert.Equal(t, src.Tags[0], dst.Tags[0]) + assert.Equal(t, src.Tags[1], dst.Tags[1]) + assert.Equal(t, src.Tags[2], dst.Tags[2]) + // Check passed in values. + assert.Equal(t, int64(15), dst.DownloadClientID) + assert.True(t, dst.EnableAutomaticSearch) + assert.True(t, dst.EnableInteractiveSearch) + assert.True(t, dst.EnableRss) + // Make sure tags get depleted. + starr.Must(orbit.CopyIndexer(src, dst, false)) + assert.Zero(t, dst.Tags) +} + +func TestCopy(t *testing.T) { + t.Parallel() + + broken := struct{}{} + good := &prowlarr.IndexerOutput{} + + require.ErrorIs(t, orbit.Copy(broken, good), orbit.ErrNotPtr) + require.ErrorIs(t, orbit.Copy(good, broken), orbit.ErrNotPtr) +}