diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..3b82f57 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: daily + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + labels: + - "CI/CD" + commit-message: + prefix: "ci: " \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..931d9fe --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,27 @@ +on: + push: + branches: [ main, devel ] + pull_request: + branches: [ main ] + paths-ignore: + - "**/*.md" + - "**/*.py" + - "LICENSE" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Test + uses: robherley/go-test-action@v0 + with: + testArguments: -covermode=atomic -coverprofile=coverage.out ./... + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..c7f214d --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,71 @@ +on: + push: + branches: [main, devel] + pull_request: + branches: [main] + paths-ignore: + - "**/*.md" + - "**/*.py" + - "LICENSE" + workflow_dispatch: + inputs: + head: + description: "Git commit to publish documentation for." + required: true + type: string + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v1 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Setup Node + uses: actions/setup-node@v4 + - name: Generate documentation + run: | + npm install pagefind --prefix . + export PAGEFIND="./node_modules/.bin/pagefind" + + go install go.abhg.dev/doc2go@latest + doc2go -out docs -pagefind="${PAGEFIND}" ./... + - name: Upload documentation + uses: actions/upload-artifact@v1 + with: + name: documentation + path: docs + - name: Upload pages + if: github.ref == 'refs/heads/devel' + uses: actions/upload-pages-artifact@v1 + with: + path: docs + + publish: + name: Publish pages website + needs: build + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub pages + if: github.ref == 'refs/heads/devel' + id: deployment + uses: actions/deploy-pages@v1 + diff --git a/.gitignore b/.gitignore index de84c8d..2ccdfa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ +### Testing +# Local samples +samples/ + +### Documentation +# Generated docs +docs/ + +### Node.js +# Logs +npm-debug.log* + +# Dependency directories +node_modules/ + ### JetBrains # User-specific stuff .idea/**/workspace.xml diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c6403d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/johnlettman/oyster + +go 1.22.5 + +require ( + github.com/barweiss/go-tuple v1.1.2 + github.com/ungerik/go3d v0.0.0-20240502073936-1137f6adf7e9 + gonum.org/v1/gonum v0.15.0 +) + +require ( + github.com/agiledragon/gomonkey/v2 v2.12.0 + github.com/brianvoe/gofakeit/v7 v7.0.4 + github.com/google/gopacket v1.1.19 + github.com/gookit/goutil v0.6.15 + github.com/johnlettman/buffergenerics v0.0.0-20240713034920-8ec5b0a7ac46 + github.com/stretchr/testify v1.9.0 + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99a02d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/agiledragon/gomonkey/v2 v2.12.0 h1:ek0dYu9K1rSV+TgkW5LvNNPRWyDZVIxGMCFI6Pz9o38= +github.com/agiledragon/gomonkey/v2 v2.12.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= +github.com/barweiss/go-tuple v1.1.2 h1:ul9tIW0LZ5w+Vk/Hi3X9z3JyqkD0yaVGZp+nNTLW2YE= +github.com/barweiss/go-tuple v1.1.2/go.mod h1:SpoVilkI7ycNrIkQxcQfS1JG5A+R40sWwEUlPONlp3k= +github.com/brianvoe/gofakeit/v7 v7.0.4 h1:Mkxwz9jYg8Ad8NvT9HA27pCMZGFQo08MK6jD0QTKEww= +github.com/brianvoe/gofakeit/v7 v7.0.4/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo= +github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/johnlettman/buffergenerics v0.0.0-20240713034920-8ec5b0a7ac46 h1:wzEFdKuHGwzhKqyBAVZga3UBDTvVJhVMI2J9kmaITdY= +github.com/johnlettman/buffergenerics v0.0.0-20240713034920-8ec5b0a7ac46/go.mod h1:+MfOwzy5m3FiRgGnUs1uE1I7EMzu5obrrJaRkRYqCWA= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ungerik/go3d v0.0.0-20240502073936-1137f6adf7e9 h1:wMWP16Ijw+W+IXGcAzrwQDua1NBB4tP8iWECpg5DVRQ= +github.com/ungerik/go3d v0.0.0-20240502073936-1137f6adf7e9/go.mod h1:ipEjrk2uLK4xX8ivWBPIVOD0fMtKyPI0strluUfIlYQ= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ= +gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/types/auto-start-flag.go b/types/auto-start-flag.go new file mode 100644 index 0000000..e6dce0a --- /dev/null +++ b/types/auto-start-flag.go @@ -0,0 +1,95 @@ +package types + +import ( + "github.com/johnlettman/oyster/types/pseudo" + "strings" +) + +// AutoStartFlag represents a boolean flag used for enabling or disabling auto-start functionality. +// +// For additional information, refer to [Ouster docs: Standby Operating Mode Examples]. +// +// Warning: +// +// AutoStartFlag has been deprecated with firmware v2.4 and later. +// Usage of AutoStartFlag in firmware prior to v2.0.0 has unexpected behavior. +// +// [Ouster docs: Standby Operating Mode Examples]: https://static.ouster.dev/sensor-docs/image_route1/image_route2/sensor_operations/sensor-operations.html#standby-operating-mode-examples +type AutoStartFlag pseudo.IntBoolJSON + +const ( + AutoStartOn AutoStartFlag = true // Equivalent to OperatingMode "NORMAL" + AutoStartOff AutoStartFlag = false // Equivalent to OperatingMode "STANDBY" +) + +// String returns the string representation of an AutoStartFlag. +func (a AutoStartFlag) String() string { + switch a { + default: + fallthrough + case AutoStartOn: + return "auto start on" + case AutoStartOff: + return "auto start off" + } +} + +// GoString returns the Go syntax representation of an AutoStartFlag. +func (a AutoStartFlag) GoString() string { + switch a { + default: + fallthrough + case AutoStartOn: + return "AutoStartOn" + case AutoStartOff: + return "AutoStartOff" + } +} + +// MarshalText returns the text representation of an AutoStartFlag. +// - If the AutoStartFlag is AutoStartOff, it returns "off"; +// - otherwise, it returns "on". +// +// It always returns nil, indicating no error occurred. +func (a AutoStartFlag) MarshalText() ([]byte, error) { + switch a { + default: + fallthrough + case AutoStartOn: + return []byte("on"), nil + case AutoStartOff: + return []byte("off"), nil + } +} + +// UnmarshalText parses the provided text and assigns the corresponding value to the receiver. +// The method converts the input text to lowercase. +// - If the text is 'off', it assigns AutoStartOff to the receiver. +// - For any other text, it assigns AutoStartOn to the receiver. +// +// It always returns nil error, indicating no error occurred. +func (a *AutoStartFlag) UnmarshalText(text []byte) error { + switch strings.ToLower(string(text)) { + default: + fallthrough + case "on": + *a = AutoStartOn + case "off": + *a = AutoStartOff + } + + return nil +} + +// MarshalJSON converts AutoStartFlag to JSON format using pseudo.IntBoolJSON.MarshalJSON method. +// It returns the JSON bytes and any occurred error. +func (a AutoStartFlag) MarshalJSON() ([]byte, error) { + return (*pseudo.IntBoolJSON)(&a).MarshalJSON() +} + +// UnmarshalJSON converts the JSON data into the AutoStartFlag value. +// It leverages the pseudo.IntBoolJSON.UnmarshalJSON method to perform the actual unmarshaling. +// It returns any occurred error. +func (a *AutoStartFlag) UnmarshalJSON(data []byte) error { + return (*pseudo.IntBoolJSON)(a).UnmarshalJSON(data) +} diff --git a/types/auto-start-flag_test.go b/types/auto-start-flag_test.go new file mode 100644 index 0000000..1cea64a --- /dev/null +++ b/types/auto-start-flag_test.go @@ -0,0 +1,133 @@ +package types + +import ( + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestAutoStartFlag_String(t *testing.T) { + type TestCase struct { + a AutoStartFlag + want string + } + + cases := []TestCase{ + {AutoStartOn, "auto start on"}, + {AutoStartOff, "auto start off"}, + } + + for _, c := range cases { + got := c.a.String() + assert.Equal(t, c.want, got, "it should return the correct representation") + } +} + +func TestAutoStartFlag_GoString(t *testing.T) { + type TestCase struct { + a AutoStartFlag + want string + } + + cases := []TestCase{ + {AutoStartOn, "AutoStartOn"}, + {AutoStartOff, "AutoStartOff"}, + } + + for _, c := range cases { + got := c.a.GoString() + assert.Equal(t, c.want, got, "it should return the correct representation") + } +} + +func TestAutoStartFlag_MarshalText(t *testing.T) { + type TestCase struct { + a AutoStartFlag + want string + } + + cases := []TestCase{ + {AutoStartOn, "on"}, + {AutoStartOff, "off"}, + } + + for _, c := range cases { + got, err := c.a.MarshalText() + assert.NoError(t, err, "it should not error") + assert.Equal(t, []byte(c.want), got, "it should return the correct representation") + } +} + +func TestAutoStartFlag_UnmarshalText(t *testing.T) { + type TestCase struct { + name string + text string + want AutoStartFlag + } + + cases := []TestCase{ + {"lowercase 'on'", "on", AutoStartOn}, + {"lowercase 'off'", "off", AutoStartOff}, + {"uppercase 'ON'", "ON", AutoStartOn}, + {"uppercase 'OFF'", "OFF", AutoStartOff}, + {"random text", gofakeit.LoremIpsumSentence(4), AutoStartOn}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var a AutoStartFlag + err := a.UnmarshalText([]byte(c.text)) + assert.NoError(t, err, "it should not error") + assert.Equal(t, c.want, a, "it should assign the correct value") + }) + } + +} + +func TestAutoStartFlag_MarshalJSON(t *testing.T) { + type TestCase struct { + a AutoStartFlag + want string + } + + cases := []TestCase{ + {AutoStartOn, "1"}, + {AutoStartOff, "0"}, + } + + for _, c := range cases { + got, err := c.a.MarshalJSON() + assert.NoError(t, err, "it should not error") + assert.Equal(t, got, []byte(c.want), "it should provide the correct JSON using IntBool") + } +} + +func TestAutoStartFlag_UnmarshalJSON(t *testing.T) { + type TestCase struct { + name string + json string + want AutoStartFlag + wantErr bool + } + + cases := []TestCase{ + {name: "number 1", json: "1", want: true, wantErr: false}, + {name: "number 0", json: "0", want: false, wantErr: false}, + {name: "non-number", json: `"foo"`, want: false, wantErr: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var a AutoStartFlag + err := a.UnmarshalJSON([]byte(c.json)) + + if c.wantErr { + assert.Error(t, err, "it should error") + } else { + assert.NoError(t, err, "it should not error") + assert.Equal(t, c.want, a, "it should assign the correct value using IntBool") + } + + }) + } +} diff --git a/types/full-scale-range.go b/types/full-scale-range.go new file mode 100644 index 0000000..b68a78d --- /dev/null +++ b/types/full-scale-range.go @@ -0,0 +1,78 @@ +package types + +import ( + "strings" +) + +// FullScaleRange represents whether modification of the onboard gyroscope or accelerometer +// has been enabled with an extended programmable scale. +// +// For additional information, refer to [Ouster docs: gyro_fsr] and [Ouster docs: accel_fsr]. +// +// [Ouster docs: gyro_fsr]: https://static.ouster.dev/sensor-docs/image_route1/image_route2/common_sections/API/sensor_configuration_description.html#gyro-fsr +// [Ouster docs: accel_fsr]: https://static.ouster.dev/sensor-docs/image_route1/image_route2/common_sections/API/sensor_configuration_description.html#accel-fsr +type FullScaleRange uint8 + +const ( + FullScaleRangeNormal FullScaleRange = iota // Normal range. + FullScaleRangeExtended // Extended programmable scale. +) + +// String returns the string representation of a FullScaleRange value. +func (r FullScaleRange) String() string { + switch r { + default: + fallthrough + case FullScaleRangeNormal: + return "normal" + case FullScaleRangeExtended: + return "extended" + } +} + +// GoString returns the Go syntax representation of a FullScaleRange value. +func (r FullScaleRange) GoString() string { + switch r { + default: + fallthrough + case FullScaleRangeNormal: + return "FullScaleRangeNormal" + case FullScaleRangeExtended: + return "FullScaleRangeExtended" + } +} + +// MarshalText returns the text representation of an FullScaleRange. +// - If the FullScaleRange is FullScaleRangeExtended, it returns "EXTENDED"; +// - otherwise, it returns "NORMAL". +// +// It always returns nil error, indicating no error occurred. +func (r FullScaleRange) MarshalText() ([]byte, error) { + switch r { + default: + fallthrough + case FullScaleRangeNormal: + return []byte("NORMAL"), nil + case FullScaleRangeExtended: + return []byte("EXTENDED"), nil + } +} + +// UnmarshalText parses the provided text and assigns the corresponding value to the receiver. +// The method converts the input text to uppercase. +// - If the text is 'EXTENDED', it assigns FullScaleRangeNormal to the receiver. +// - For any other text, it assigns AutoStartOn to the receiver. +// +// It always returns nil error, indicating no error occurred. +func (r *FullScaleRange) UnmarshalText(text []byte) error { + switch strings.ToUpper(string(text)) { + default: + fallthrough + case "NORMAL": + *r = FullScaleRangeNormal + case "EXTENDED": + *r = FullScaleRangeExtended + } + + return nil +} diff --git a/types/full-scale-range_test.go b/types/full-scale-range_test.go new file mode 100644 index 0000000..3f1b255 --- /dev/null +++ b/types/full-scale-range_test.go @@ -0,0 +1,96 @@ +package types + +import ( + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestFullScaleRange_String(t *testing.T) { + type TestCase struct { + name string + r FullScaleRange + want string + } + + cases := []TestCase{ + {"FullScaleRangeNormal", FullScaleRangeNormal, "normal"}, + {"FullScaleRangeExtended", FullScaleRangeExtended, "extended"}, + {"unknown value", FullScaleRangeExtended + 1, "normal"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.r.String() + assert.Equal(t, c.want, got, "it should return the correct representation") + }) + } +} + +func TestFullScaleRange_GoString(t *testing.T) { + type TestCase struct { + name string + r FullScaleRange + want string + } + + cases := []TestCase{ + {"FullScaleRangeNormal", FullScaleRangeNormal, "FullScaleRangeNormal"}, + {"FullScaleRangeExtended", FullScaleRangeExtended, "FullScaleRangeExtended"}, + {"unknown value", FullScaleRangeExtended + 1, "FullScaleRangeNormal"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.r.GoString() + assert.Equal(t, c.want, got, "it should return the correct representation") + }) + } +} + +func TestFullScaleRange_MarshalText(t *testing.T) { + type TestCase struct { + name string + r FullScaleRange + want string + } + + cases := []TestCase{ + {"FullScaleRangeNormal", FullScaleRangeNormal, "NORMAL"}, + {"FullScaleRangeExtended", FullScaleRangeExtended, "EXTENDED"}, + {"unknown value", FullScaleRangeExtended + 1, "NORMAL"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := c.r.MarshalText() + assert.NoError(t, err, "it should not error") + assert.Equal(t, []byte(c.want), got, "it should return the correct representation") + }) + } +} + +func TestFullScaleRange_UnmarshalText(t *testing.T) { + type TestCase struct { + name string + text string + want FullScaleRange + } + + cases := []TestCase{ + {"lowercase 'normal'", "normal", FullScaleRangeNormal}, + {"lowercase 'extended'", "extended", FullScaleRangeExtended}, + {"uppercase 'NORMAL'", "NORMAL", FullScaleRangeNormal}, + {"uppercase 'EXTENDED'", "EXTENDED", FullScaleRangeExtended}, + {"random text", gofakeit.LoremIpsumSentence(4), FullScaleRangeNormal}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var a FullScaleRange + err := a.UnmarshalText([]byte(c.text)) + assert.NoError(t, err, "it should not error") + assert.Equal(t, c.want, a, "it should assign the correct value") + }) + } +} diff --git a/types/lidar-mode.go b/types/lidar-mode.go new file mode 100644 index 0000000..d49f42d --- /dev/null +++ b/types/lidar-mode.go @@ -0,0 +1,138 @@ +package types + +import ( + "github.com/johnlettman/oyster/util" +) + +// LIDARMode represents the horizontal resolution and rotation rate of the sensor. +// The effective range of the sensor is increased by 15-20% for every halving of the number of points gathered. +// For example, LidarMode512x10 has a 15-20% longer range than LidarMode512x20. +// +// LIDARMode implements encoding/json.Marshaller and encoding/json.Unmarshaller +// to simplify loading from ouster_meta.json +// +// For additional information, refer to [Ouster docs: lidar_mode]. +// +// [Ouster docs: lidar_mode]: https://static.ouster.dev/sensor-docs/image_route1/image_route2/common_sections/API/sensor_configuration_description.html?highlight=512x10#lidar-mode +type LIDARMode int + +const ( + LidarModeUnknown LIDARMode = iota // unspecified + LidarMode512x10 // 10 scans of 512 columns per second + LidarMode512x20 // 20 scans of 512 columns per second + LidarMode1024x10 // 10 scans of 1024 columns per second + LidarMode1024x20 // 20 scans of 1024 columns per second + LidarMode2048x10 // 10 scans of 2048 columns per second + LidarMode4096x5 // 5 scans of 4096 columns per second +) + +var lidarModeGoKV = map[LIDARMode]string{ + LidarModeUnknown: "LidarModeUnknown", + LidarMode512x10: "LidarMode512x10", + LidarMode512x20: "LidarMode512x20", + LidarMode1024x10: "LidarMode1024x10", + LidarMode1024x20: "LidarMode1024x20", + LidarMode2048x10: "LidarMode2048x10", + LidarMode4096x5: "LidarMode4096x5", +} + +// lidarModeKV maps LIDARMode values to their respective string representations. +var lidarModeKV = map[LIDARMode]string{ + LidarModeUnknown: "unknown", + LidarMode512x10: "512x10", + LidarMode512x20: "512x20", + LidarMode1024x10: "1024x10", + LidarMode1024x20: "1024x20", + LidarMode2048x10: "2048x10", + LidarMode4096x5: "4096x5", +} + +// lidarModeVK is a variable that stores the reverse mapping of the lidarModeKV map. +// It maps string representations of LIDARMode values to their respective LIDARMode values. +var lidarModeVK = util.ReverseMap(lidarModeKV) + +// String returns the string representation of a LIDARMode value. +// If no match is found, it returns "unknown" as the default string representation. +func (m LIDARMode) String() string { + if s, ok := lidarModeKV[m]; ok { + return s + } + + return lidarModeKV[LidarModeUnknown] +} + +// GoString returns the Go syntax representation of a LIDARMode value. +// If no match is found, it returns "LidarModeUnknown" as the default string representation. +func (m LIDARMode) GoString() string { + if s, ok := lidarModeGoKV[m]; ok { + return s + } + + return lidarModeGoKV[LidarModeUnknown] +} + +// MarshalText returns the text representation of a LIDARMode value. +// - If the LIDARMode has a matching string representation in the lidarModeKV map, +// it returns the byte slice of that string representation. +// - If no match is found, it returns nil. +// +// The error returned is always nil. +func (m LIDARMode) MarshalText() ([]byte, error) { + if s, ok := lidarModeKV[m]; ok { + return []byte(s), nil + } + + return []byte{}, nil +} + +// UnmarshalText unmarshals the given text into a LIDARMode value. +// - If the string representation of the text exists in the lidarModeVK map, +// it assigns the corresponding LIDARMode value to the receiver pointer. +// - Otherwise, it assigns LidarModeUnknown to the receiver pointer. +// +// The error returned is always nil. +func (m *LIDARMode) UnmarshalText(text []byte) error { + if mode, ok := lidarModeVK[string(text)]; ok { + *m = mode + } else { + *m = LidarModeUnknown + } + + return nil +} + +// Columns returns the number of columns for a given LIDARMode value. +// It returns 0 if the LIDARMode is unknown or not specified. +func (m LIDARMode) Columns() int { + switch m { + default: + fallthrough + case LidarModeUnknown: + return 0 + case LidarMode512x10, LidarMode512x20: + return 512 + case LidarMode1024x10, LidarMode1024x20: + return 1024 + case LidarMode2048x10: + return 2048 + case LidarMode4096x5: + return 4096 + } +} + +// Frequency returns the frequency (number of scans per second) for a given LIDARMode value. +// It returns 0 if the LIDARMode is unknown or not specified. +func (m LIDARMode) Frequency() int { + switch m { + default: + fallthrough + case LidarModeUnknown: + return 0 + case LidarMode512x20, LidarMode1024x20: + return 20 + case LidarMode512x10, LidarMode1024x10, LidarMode2048x10: + return 10 + case LidarMode4096x5: + return 5 + } +} diff --git a/types/lidar-mode_test.go b/types/lidar-mode_test.go new file mode 100644 index 0000000..2b93c30 --- /dev/null +++ b/types/lidar-mode_test.go @@ -0,0 +1,166 @@ +package types + +import ( + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestLIDARMode_String(t *testing.T) { + type TestCase struct { + name string + m LIDARMode + want string + } + + cases := []TestCase{ + {"LIDARModeUnknown", LidarModeUnknown, "unknown"}, + {"LidarMode512x10", LidarMode512x10, "512x10"}, + {"LidarMode512x20", LidarMode512x20, "512x20"}, + {"LidarMode1024x10", LidarMode1024x10, "1024x10"}, + {"LidarMode1024x20", LidarMode1024x20, "1024x20"}, + {"LidarMode2048x10", LidarMode2048x10, "2048x10"}, + {"LidarMode4096x5", LidarMode4096x5, "4096x5"}, + {"unknown value", LidarMode4096x5 + 1, "unknown"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.m.String() + assert.Equal(t, c.want, got, "it should return the correct representation") + }) + } +} + +func TestLIDARMode_GoString(t *testing.T) { + type TestCase struct { + name string + m LIDARMode + want string + } + + cases := []TestCase{ + {"LIDARModeUnknown", LidarModeUnknown, "LidarModeUnknown"}, + {"LidarMode512x10", LidarMode512x10, "LidarMode512x10"}, + {"LidarMode512x20", LidarMode512x20, "LidarMode512x20"}, + {"LidarMode1024x10", LidarMode1024x10, "LidarMode1024x10"}, + {"LidarMode1024x20", LidarMode1024x20, "LidarMode1024x20"}, + {"LidarMode2048x10", LidarMode2048x10, "LidarMode2048x10"}, + {"LidarMode4096x5", LidarMode4096x5, "LidarMode4096x5"}, + {"unknown value", LidarMode4096x5 + 1, "LidarModeUnknown"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.m.GoString() + assert.Equal(t, c.want, got, "it should return the correct representation") + }) + } +} + +func TestLIDARMode_MarshalText(t *testing.T) { + type TestCase struct { + name string + m LIDARMode + want string + } + + cases := []TestCase{ + {"LIDARModeUnknown", LidarModeUnknown, "unknown"}, + {"LidarMode512x10", LidarMode512x10, "512x10"}, + {"LidarMode512x20", LidarMode512x20, "512x20"}, + {"LidarMode1024x10", LidarMode1024x10, "1024x10"}, + {"LidarMode1024x20", LidarMode1024x20, "1024x20"}, + {"LidarMode2048x10", LidarMode2048x10, "2048x10"}, + {"LidarMode4096x5", LidarMode4096x5, "4096x5"}, + {"unknown value", LidarMode4096x5 + 1, ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := c.m.MarshalText() + assert.NoError(t, err, "it should not error") + assert.Equal(t, []byte(c.want), got, "it should return the correct representation") + }) + } +} + +func TestLIDARMode_UnmarshalText(t *testing.T) { + type TestCase struct { + name string + text string + want LIDARMode + } + + cases := []TestCase{ + {"LIDARModeUnknown", "unknown", LidarModeUnknown}, + {"LidarMode512x10", "512x10", LidarMode512x10}, + {"LidarMode512x20", "512x20", LidarMode512x20}, + {"LidarMode1024x10", "1024x10", LidarMode1024x10}, + {"LidarMode1024x20", "1024x20", LidarMode1024x20}, + {"LidarMode2048x10", "2048x10", LidarMode2048x10}, + {"LidarMode4096x5", "4096x5", LidarMode4096x5}, + {"random text", gofakeit.LoremIpsumSentence(4), LidarModeUnknown}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var m LIDARMode + err := m.UnmarshalText([]byte(c.text)) + assert.NoError(t, err, "it should not error") + assert.Equal(t, c.want, m, "it should assign the correct value") + }) + } +} + +func TestLIDARMode_Columns(t *testing.T) { + type TestCase struct { + name string + m LIDARMode + want int + } + + cases := []TestCase{ + {"LIDARModeUnknown", LidarModeUnknown, 0}, + {"LIDARMode512x10", LidarMode512x10, 512}, + {"LIDARMode512x20", LidarMode512x20, 512}, + {"LIDARMode1024x10", LidarMode1024x10, 1024}, + {"LIDARMode1024x20", LidarMode1024x20, 1024}, + {"LIDARMode2048x10", LidarMode2048x10, 2048}, + {"LIDARMode4096x5", LidarMode4096x5, 4096}, + {"unknown value", LidarMode4096x5 + 1, 0}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.m.Columns() + assert.Equal(t, c.want, got, "it should return the correct number of columns") + }) + } +} + +func TestLIDARMode_Frequency(t *testing.T) { + type TestCase struct { + name string + m LIDARMode + want int + } + + cases := []TestCase{ + {"LIDARModeUnknown", LidarModeUnknown, 0}, + {"LIDARMode512x10", LidarMode512x10, 10}, + {"LIDARMode512x20", LidarMode512x20, 20}, + {"LIDARMode1024x10", LidarMode1024x10, 10}, + {"LIDARMode1024x20", LidarMode1024x20, 20}, + {"LIDARMode2048x10", LidarMode2048x10, 10}, + {"LIDARMode4096x5", LidarMode4096x5, 5}, + {"unknown value", LidarMode4096x5 + 1, 0}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.m.Frequency() + assert.Equal(t, c.want, got, "it should return the correct number of columns") + }) + } +} diff --git a/types/pseudo/int-bool-json.go b/types/pseudo/int-bool-json.go new file mode 100644 index 0000000..f1b475f --- /dev/null +++ b/types/pseudo/int-bool-json.go @@ -0,0 +1,37 @@ +package pseudo + +import "encoding/json" + +// IntBoolJSON is a custom type that represents a boolean value as an integer. +// It is used for JSON marshaling and unmarshaling purposes. +type IntBoolJSON bool + +// MarshalJSON is a method that serializes the IntBoolJSON value to JSON format. +// It converts the boolean value to an integer (1 or 0), then marshals the integer to JSON. +// The method returns the marshaled JSON bytes and an error, if any. +func (b IntBoolJSON) MarshalJSON() ([]byte, error) { + var v int + if b { + v = 1 + } else { + v = 0 + } + + return json.Marshal(v) +} + +// UnmarshalJSON is a method that deserializes the JSON data into the IntBoolJSON value. +// It unmarshals the received JSON data into an integer value, then assigns the IntBoolJSON +// value based on whether the integer is non-zero or zero. The IntBoolJSON value is set to +// true if the integer is non-zero, and false if the integer is zero. +// The method returns an error if the unmarshaling process fails. +func (b *IntBoolJSON) UnmarshalJSON(data []byte) error { + var v int + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + + *b = v != 0 + return nil +} diff --git a/types/pseudo/int-bool-json_test.go b/types/pseudo/int-bool-json_test.go new file mode 100644 index 0000000..9e82c30 --- /dev/null +++ b/types/pseudo/int-bool-json_test.go @@ -0,0 +1,60 @@ +package pseudo + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestIntBoolJSON_MarshalJSON(t *testing.T) { + type TestCase struct { + name string + i IntBoolJSON + want string + wantErr bool + } + + cases := []TestCase{ + {name: "true", i: IntBoolJSON(true), want: "1", wantErr: false}, + {name: "false", i: IntBoolJSON(false), want: "0", wantErr: false}, + {name: "native true", i: true, want: "1", wantErr: false}, + {name: "native false", i: false, want: "0", wantErr: false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := c.i.MarshalJSON() + assert.NoError(t, err, "it should not error") + assert.Equal(t, got, []byte(c.want)) + }) + } +} + +func TestIntBoolJSON_UnmarshalJSON(t *testing.T) { + type TestCase struct { + name string + json string + want IntBoolJSON + wantErr bool + } + + cases := []TestCase{ + {name: "number 1", json: "1", want: true, wantErr: false}, + {name: "number 0", json: "0", want: false, wantErr: false}, + {name: "non-number", json: `"foo"`, want: false, wantErr: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var i IntBoolJSON + err := i.UnmarshalJSON([]byte(c.json)) + + if c.wantErr { + assert.Error(t, err, "it should error", c.json) + } else { + assert.NoError(t, err, "it should not error", c.json) + assert.Equal(t, c.want, i) + } + + }) + } +} diff --git a/types/pseudo/message-override-json.go b/types/pseudo/message-override-json.go new file mode 100644 index 0000000..94f1064 --- /dev/null +++ b/types/pseudo/message-override-json.go @@ -0,0 +1,51 @@ +package pseudo + +import ( + "encoding/json" + "fmt" + "strings" +) + +// MessageOverrideJSON represents a generic message structure that may be overridden by a string in the JSON representation. +type MessageOverrideJSON[T any] struct { + Value *T + Message string +} + +// MarshalJSON serializes the MessageOverrideJSON type to JSON representation. +// If MessageOverrideJSON has a non-empty Message or HasMessage is true, +// it marshals only the Message field as a string. Otherwise, it marshals the Value field. +// It returns a byte slice and an error. +func (m MessageOverrideJSON[T]) MarshalJSON() ([]byte, error) { + if strings.TrimSpace(m.Message) != "" { + return json.Marshal(m.Message) + } + + return json.Marshal(m.Value) +} + +// UnmarshalJSON deserializes JSON data into a MessageOverrideJSON pointer. +// If the JSON is a string, it assigns the string to the Message field. +// If the JSON is T, it assigns the value to the Value field. +// In case the JSON data cannot be unmarshaled into a string or T, it returns an error. +func (m *MessageOverrideJSON[T]) UnmarshalJSON(data []byte) error { + err := UnmarshalMessageOrStruct(data, &m.Message, &m.Value) + if err != nil { + return err + } + + return nil +} + +// UnmarshalMessageOrStruct will unmarshal JSON data into a target message or struct depending on the +// actual representation in the JSON data. +// This aids in conditions where the data overrides an anticipated structure with an error message. +func UnmarshalMessageOrStruct(data []byte, targetMessage *string, targetStruct interface{}) error { + if err := json.Unmarshal(data, targetMessage); err == nil { + return nil + } + if err := json.Unmarshal(data, targetStruct); err == nil { + return nil + } + return fmt.Errorf("unable to unmarshal data as string or struct") +} diff --git a/types/pseudo/message-override-json_test.go b/types/pseudo/message-override-json_test.go new file mode 100644 index 0000000..4c70fe9 --- /dev/null +++ b/types/pseudo/message-override-json_test.go @@ -0,0 +1,62 @@ +package pseudo + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMessageOverrideJSON_MarshalJSON(t *testing.T) { + type TestCase struct { + name string + m MessageOverrideJSON[int] + want string + } + + value := 5 + + cases := []TestCase{ + {name: "has Value has Message", m: MessageOverrideJSON[int]{Value: &value, Message: "Test"}, want: `"Test"`}, + {name: "has Value no Message", m: MessageOverrideJSON[int]{Value: &value, Message: ""}, want: `5`}, + {name: "no Value and Message", m: MessageOverrideJSON[int]{Value: nil, Message: ""}, want: `null`}, + {name: "no Value has Message", m: MessageOverrideJSON[int]{Value: nil, Message: "Test"}, want: `"Test"`}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := c.m.MarshalJSON() + assert.NoError(t, err, "it should not error") + assert.Equal(t, []byte(c.want), got) + }) + } +} + +func TestMessageOverrideJSON_UnmarshalJSON(t *testing.T) { + type TestCase struct { + name string + json string + want MessageOverrideJSON[int] + wantErr bool + } + + value := 5 + + cases := []TestCase{ + {name: "string message", json: `"here is a message"`, want: MessageOverrideJSON[int]{Message: "here is a message"}, wantErr: false}, + {name: "expected value", json: `5`, want: MessageOverrideJSON[int]{Value: &value, Message: ""}, wantErr: false}, + {name: "unexpected value", json: `{"not an int": true}`, wantErr: true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var got MessageOverrideJSON[int] + err := got.UnmarshalJSON([]byte(c.json)) + + if c.wantErr { + assert.Error(t, err, "it should return an error") + } else { + assert.NoError(t, err, "it should not return an error") + assert.Equal(t, got, c.want) + } + }) + } +} diff --git a/util/maps.go b/util/maps.go new file mode 100644 index 0000000..9e6f9d6 --- /dev/null +++ b/util/maps.go @@ -0,0 +1,10 @@ +package util + +// ReverseMap reverses the key-value pairs of the input map and returns a new map. +func ReverseMap[K, V comparable](in map[K]V) map[V]K { + out := make(map[V]K, len(in)) + for k, v := range in { + out[v] = k + } + return out +} diff --git a/util/maps_test.go b/util/maps_test.go new file mode 100644 index 0000000..5c0da09 --- /dev/null +++ b/util/maps_test.go @@ -0,0 +1,12 @@ +package util + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestReverseMap(t *testing.T) { + input := map[string]string{"a": "z"} + got := ReverseMap(input) + assert.Equal(t, got["z"], "a", "it should reverse the map") +} diff --git a/util/math.go b/util/math.go new file mode 100644 index 0000000..78b070d --- /dev/null +++ b/util/math.go @@ -0,0 +1,11 @@ +package util + +import "golang.org/x/exp/constraints" + +// Abs returns the absolute value of x. If x is negative, it returns -x. +func Abs[I constraints.Integer | constraints.Float](x I) I { + if x < 0 { + return -x + } + return x +} diff --git a/util/math_test.go b/util/math_test.go new file mode 100644 index 0000000..b04e3ae --- /dev/null +++ b/util/math_test.go @@ -0,0 +1,74 @@ +package util + +import ( + "github.com/stretchr/testify/assert" + "golang.org/x/exp/constraints" + "reflect" + "testing" +) + +import "github.com/brianvoe/gofakeit/v7" + +type TestCaseAbs[T constraints.Integer | constraints.Float] struct { + Input T + Want T +} + +const CaseCountAbs = 10 + +var testAbsFakeFunctions = map[string]func() interface{}{ + "uint8": func() interface{} { return gofakeit.Uint8() }, + "uint16": func() interface{} { return gofakeit.Uint16() }, + "uint32": func() interface{} { return gofakeit.Uint32() }, + "uint64": func() interface{} { return gofakeit.Uint64() }, + "int8": func() interface{} { return gofakeit.Int8() }, + "int16": func() interface{} { return gofakeit.Int16() }, + "int32": func() interface{} { return gofakeit.Int32() }, + "int64": func() interface{} { return gofakeit.Int64() }, + "float32": func() interface{} { return gofakeit.Float32() }, + "float64": func() interface{} { return gofakeit.Float64() }, +} + +func testAbsCase[T constraints.Integer | constraints.Float](t *testing.T, c TestCaseAbs[T]) { + got := Abs(c.Input) + assert.Equalf(t, got, c.Want, "it should return %v for %v with generic of type %T", c.Want, got, *new(T)) +} + +func testAbsGeneric[T constraints.Integer | constraints.Float](t *testing.T) { + n := reflect.TypeFor[T]().Name() + f, ok := testAbsFakeFunctions[n] + if !ok { + t.Fatalf("can not locate randomizer function for type %s", n) + return + } + + t.Run(n, func(t *testing.T) { + for i := 0; i < CaseCountAbs; i++ { + test := TestCaseAbs[T]{} + input := f().(T) + + test.Input = input + + if input < 0 { + test.Want = -input + } else { + test.Want = input + } + + testAbsCase(t, test) + } + }) +} + +func TestAbs(t *testing.T) { + testAbsGeneric[uint8](t) + testAbsGeneric[uint16](t) + testAbsGeneric[uint32](t) + testAbsGeneric[uint64](t) + testAbsGeneric[int8](t) + testAbsGeneric[int16](t) + testAbsGeneric[int32](t) + testAbsGeneric[int64](t) + testAbsGeneric[float32](t) + testAbsGeneric[float64](t) +} diff --git a/util/paths.go b/util/paths.go new file mode 100644 index 0000000..83d101c --- /dev/null +++ b/util/paths.go @@ -0,0 +1,49 @@ +package util + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// NormalizePath removes extraneous spaces, new lines, and carriage returns from the given path. +// - If the path starts with a tilde (~) character, +// it replaces the tilde with the user's home directory. +// - It then cleans the path to remove extra separators and dots. +// - Finally, it converts the path to its absolute form if possible. +// - If not, it returns the cleaned path. +func NormalizePath(path string) string { + // remove extraneous spaces + path = strings.TrimSpace(path) // extra space at start and end + path = strings.ReplaceAll(path, "\n", "") // new lines + path = strings.ReplaceAll(path, "\r", "") // carriage returns + + // replace instances of the tilde user home directory shorthand + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err == nil { + path = strings.Replace(path, "~", home, 1) + } + } + + // clean the path to remove extra separators and dots + cleaned := filepath.Clean(path) + + // convert the path to its absolute form + if normalized, err := filepath.Abs(cleaned); err == nil { + return normalized + } + + // use the cleaned path if we can't get an absolute + return cleaned +} + +func ProjectRootDir() string { + _, caller, _, _ := runtime.Caller(0) + return NormalizePath(filepath.Join(filepath.Dir(caller), "..")) +} + +func ProjectDir(elem ...string) string { + return NormalizePath(filepath.Join(ProjectRootDir(), filepath.Join(elem...))) +} diff --git a/util/paths_test.go b/util/paths_test.go new file mode 100644 index 0000000..2e13200 --- /dev/null +++ b/util/paths_test.go @@ -0,0 +1,110 @@ +package util + +import ( + "github.com/agiledragon/gomonkey/v2" + "github.com/stretchr/testify/assert" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" +) + +func gitProjectDir() (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + gitOutput, err := cmd.Output() + if err != nil { + return "", err + } + + return NormalizePath(string(gitOutput)), nil +} + +func TestProjectRootDir(t *testing.T) { + want, err := gitProjectDir() + if err != nil { + t.Fatalf("failed to determine git project dir: %v", err) + } + + got := ProjectRootDir() + assert.Equalf(t, got, want, "it should match the git project root %s", want) +} + +func TestProjectDir(t *testing.T) { + git, err := gitProjectDir() + if err != nil { + t.Fatalf("failed to determine git project dir: %v", err) + } + + sub := "util" + got := ProjectDir(sub) + want := filepath.Join(git, sub) + assert.Equalf(t, got, want, "it should match the git project directory for %s", sub) +} + +func TestNormalizePath(t *testing.T) { + type TestCase struct { + name string + path string + want string + } + + cases := []TestCase{ + { + name: "spaces around the path", + path: " /etc/os-release ", + want: "/etc/os-release", + }, + { + name: "new lines", + path: "/etc/\nlegal", + want: "/etc/legal", + }, + { + name: "carriage returns", + path: "/etc/\rlegal", + want: "/etc/legal", + }, + { + name: "tilde user home directory shorthand", + path: "~/Documents", + want: (func() string { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to determine user home: %v", err) + } + + return filepath.Join(home, "Documents") + })(), + }, + { + name: "dots", + path: "/root/../etc/../etc/././././os-release", + want: "/etc/os-release", + }, + { + name: "directory separators", + path: "///////etc/////os-release", + want: "/etc/os-release", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := NormalizePath(c.path) + assert.Equal(t, c.want, got, "it should normalize the path") + }) + } + + t.Run("filepath.Abs error", func(t *testing.T) { + assert.NotPanics(t, func() { + patches := gomonkey.ApplyFuncReturn(filepath.Abs, "", syscall.ENOTDIR) + defer patches.Reset() + + path := "/etc/os-release" + want := path + got := NormalizePath(path) + assert.Equal(t, want, got, "it should silently handle filepath.Abs error") + }, "it should not panic") + }) +} diff --git a/util/tuples.go b/util/tuples.go new file mode 100644 index 0000000..5a1cae8 --- /dev/null +++ b/util/tuples.go @@ -0,0 +1,17 @@ +package util + +import "github.com/barweiss/go-tuple" + +// MapTuples creates a map from a slice of tuples.Pair. +// The keys are the values in V1 field of the pairs, +// and the values are the values in V2 field of the pairs. +// The function returns the created map. +func MapTuples[A comparable, B any](t []tuple.Pair[A, B]) map[A]B { + m := make(map[A]B) + + for _, pair := range t { + m[pair.V1] = pair.V2 + } + + return m +} diff --git a/util/tuples_test.go b/util/tuples_test.go new file mode 100644 index 0000000..ea3b99c --- /dev/null +++ b/util/tuples_test.go @@ -0,0 +1,32 @@ +package util + +import ( + "github.com/barweiss/go-tuple" + "github.com/brianvoe/gofakeit/v7" + "github.com/stretchr/testify/assert" + "testing" +) + +func createRandomTuple() tuple.Pair[string, string] { + return tuple.Pair[string, string]{ + V1: gofakeit.FirstName(), + V2: gofakeit.LastName(), + } +} + +func TestMapTuples(t *testing.T) { + var pair tuple.Pair[string, string] + + count := 10 + list := make([]tuple.Pair[string, string], count) + want := make(map[string]string, count) + + for i := 0; i < count; i++ { + pair = createRandomTuple() + list[i] = pair + want[pair.V1] = pair.V2 + } + + got := MapTuples[string, string](list) + assert.Equal(t, want, got, "it should map the tuple") +}