diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8ad88fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Предложить улучшение +labels: ["enhancement", "needs-spec"] +body: + - type: textarea + id: problem + attributes: + label: Проблема/боль + description: Что хотите решить? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Предложение + description: Как это должно работать? (API-скиз) + render: go + - type: checkboxes + id: compatibility + attributes: + label: Совместимость + options: + - label: Изменение не ломает существующий API diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cf90271 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Вопросы по использованию / Q&A + url: https://github.com/{{MODULE_PATH}}/discussions + about: Задавайте вопросы здесь, если это не баг и не фича. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8ad88fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Предложить улучшение +labels: ["enhancement", "needs-spec"] +body: + - type: textarea + id: problem + attributes: + label: Проблема/боль + description: Что хотите решить? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Предложение + description: Как это должно работать? (API-скиз) + render: go + - type: checkboxes + id: compatibility + attributes: + label: Совместимость + options: + - label: Изменение не ломает существующий API diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..48eefca --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +### Что сделано +- + +### Зачем +- + +### Как тестировать +- + +### Чеклист +- [ ] Тесты добавлены/обновлены +- [ ] Документация обновлена при необходимости +- [ ] Нет ломающих изменений без обсуждения + +Fixes # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7fc6e5f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-test-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + - name: Go mod tidy (no diff) + run: go mod tidy && git diff --exit-code + - name: Lint + uses: golangci/golangci-lint-action@v7 + with: + version: latest + args: --timeout=5m + - name: Test (race + cover) + run: go test ./... -race -coverprofile=coverage.out -covermode=atomic + - name: Govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: '1.23' + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000..0fdf96c --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,20 @@ +name: Release Drafter +on: + push: + branches: [ main ] + pull_request: + types: [opened, reopened, synchronize, closed] +permissions: + contents: read +jobs: + update_release_draft: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: release-drafter/release-drafter@v6 + with: + disable-autolabeler: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02b908b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Binaries and build +/bin/ +/dist/ +/out/ +/coverage.out +*.test +*.exe +*.dll +*.so +*.dylib + +# IDE +.vscode/ +.idea/ +*.iml + +# Go +vendor/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ad5cbaf --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,41 @@ +version: "2" +run: + tests: true +linters: + enable: + - gocritic + - revive + settings: + revive: + severity: warning + rules: + - name: var-naming + - name: exported + - name: package-comments + - name: unused-parameter + - name: indent-error-flow + exclusions: + generated: lax + rules: + - linters: + - errcheck + path: _test\.go + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-issues-per-linter: 0 + max-same-issues: 3 +formatters: + enable: + - gofumpt + settings: + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..71d7107 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to barugoo/distribution + +Спасибо, что хотите помочь 💚 + +## Требования +- Go ≥ 1.22 +- `make`, `golangci-lint`, `govulncheck` + +## Как начать +1. Форк → ветка `feat/` или `fix/` +2. `make lint && make test` +3. Откройте PR с понятным описанием и ссылкой на Issue (если есть) + +## Коммиты +Используем Conventional Commits: +`feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...`, `chore: ...` + +## Проверки перед PR +- `go mod tidy` не создаёт диффов +- Линтеры зелёные +- Тесты зелёные, покрытие не просело для критических путей +- Обновлены README/доки при необходимости + +## Кодстайл +- `gofmt`, `go vet`, `golangci-lint` +- Экспортируемые типы и функции документируются Godoc-комментами +- Ошибки оборачиваем контекстом `fmt.Errorf("...: %w", err)` + +## Релизы +- SemVer. Ломающие изменения — только в major или через `v0.*` с чётким описанием. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4fe4d8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Dmitrii Shelamov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad60261 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PKG := ./... +GO ?= go + +.PHONY: all fmt vet lint test vuln tidy ci +all: fmt vet lint test vuln + +fmt: + $(GO) fmt $(PKG) + +vet: + $(GO) vet $(PKG) + +lint: + golangci-lint run + +test: + $(GO) test $(PKG) -race -coverprofile=coverage.out -covermode=atomic + +vuln: + govulncheck $(PKG) + +tidy: + $(GO) mod tidy + +ci: tidy lint test vuln diff --git a/README.md b/README.md index fab8957..dc9fb57 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,65 @@ -# Distribution package +# your-lib -## About -This package is used to split arbitrary decimal.Decimal values (usually money amounts) based on `distribution.Layout` that describes weights of each bucket, i.e.: -``` -{ - bucket1: 1/3, - bucket2: 1/6, - bucket3: 1/2 -} +Библиотека на Go для предсказуемого дробления произвольных чисел на заранее заданные доли. -sum of all fractions is always 1 -``` +[![Go Reference](https://pkg.go.dev/badge/github.com/barugoo/distribution.svg)](https://pkg.go.dev/github.com/barugoo/distribution) +[![CI](https://github.com/barugoo/distribution/actions/workflows/ci.yml/badge.svg)](https://github.com/barugoo/distribution/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/barugoo/distribution)](https://goreportcard.com/report/github.com/barugoo/distribution) -The result of "splitting" operation is a `distribution.Value` that contains the buckets with calculated shares of initial value (with specific precision) and the remainder. -For example distribution of `100` using the above `distribution.Layout` will produce this result: +--- + +## Установка + +```bash +go get github.com/barugoo/distribution@latest ``` -{ - precision: 2, - bucket1: 33.33, - bucket2: 16.66, - bucket3: 50, - remainder: 0.01 -} -sum is always 100 +--- + +## Быстрый старт + +```go +package main + +import ( + "fmt" + "github.com/barugoo/distribution" +) + +func main() { + c := yourlib.New(yourlib.WithPrefix("demo: ")) + out := c.Do("hello") + fmt.Println(out) +} ``` -The `remainder` will be added automatically based on which `distribution.Bucket` in variadic slice of `MakeLayout` was marked as `remaindable` -External code doesn't have access to internals of `Value` or `Layout` and there are no mutating methods in the public API - which means there's no way for an importing code to break the internal rules of this package. +--- + +## Почему your-lib? + +* Простое, предсказуемое API +* Минимум зависимостей +* Семантическое версионирование и стабильность API после v1 + +--- + +## Документация + +* [API на pkg.go.dev](https://pkg.go.dev/github.com/barugoo/distribution) +* Примеры использования: папка [`/examples`](./examples) +* Вопросы и обсуждения: [Discussions](https://github.com/barugoo/distribution/discussions) + +--- + +## Безопасность + +См. [SECURITY.md](./SECURITY.md). + +--- + +## Вклад + +См. [CONTRIBUTING.md](./CONTRIBUTING.md). +Будем рады любому участию! 🙌 + +``` diff --git a/core/bucket.go b/core/bucket.go index 268950d..3e3a90b 100644 --- a/core/bucket.go +++ b/core/bucket.go @@ -7,12 +7,14 @@ import ( "github.com/shopspring/decimal" ) +// Bucket represents a single bucket in a distribution; immutable type Bucket[T comparable] struct { Key T Value decimal.Decimal ShouldAddRemainder bool } +// BucketSlice is a slice of Buckets; immutable type BucketSlice[T comparable] []Bucket[T] func (s BucketSlice[T]) copy() BucketSlice[T] { @@ -21,6 +23,7 @@ func (s BucketSlice[T]) copy() BucketSlice[T] { return cloned } +// Total returns the total value of all buckets in the slice func (s BucketSlice[T]) Total() (sum decimal.Decimal) { for _, v := range s { sum = sum.Add(v.Value) @@ -28,6 +31,7 @@ func (s BucketSlice[T]) Total() (sum decimal.Decimal) { return sum } +// String returns a string representation of the BucketSlice func (s BucketSlice[T]) String() string { var str strings.Builder str.WriteString("[") diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..a3f3080 --- /dev/null +++ b/core/core.go @@ -0,0 +1,8 @@ +package core + +import "github.com/shopspring/decimal" + +// toDecimal is a shorthand for decimal.NewFromFloat(f) +func toDecimal(f float64) decimal.Decimal { + return decimal.NewFromFloat(f) +} diff --git a/core/layout.go b/core/layout.go index 76b8ad7..2dc8895 100644 --- a/core/layout.go +++ b/core/layout.go @@ -1,3 +1,4 @@ +// Package core provides the core functionality for the distribution library package core import ( @@ -7,8 +8,10 @@ import ( ) var ( + // ErrBucketsTotalValueIsZero is returned when the total value of all buckets is zero ErrBucketsTotalValueIsZero = errors.New("buckets total value is zero") - ErrDuplicateBucketKey = errors.New("duplicate bucket key") + // ErrDuplicateBucketKey is returned when there are duplicate bucket keys in the input slice + ErrDuplicateBucketKey = errors.New("duplicate bucket key") ) // Layout serves as a distribution reference. Sum of all numerators divided by denominator is always equal to 1. Is immutable @@ -105,6 +108,7 @@ func MakeLayout[T comparable](buckets BucketSlice[T]) (*Layout[T], error) { return &l, nil } +// Keys returns all bucket keys in the layout func (dl *Layout[T]) Keys() []T { keys := make([]T, 0, len(dl.fractions)) for _, f := range dl.fractions { diff --git a/core/layout_test.go b/core/layout_test.go index 72fdcb6..b1eebcf 100644 --- a/core/layout_test.go +++ b/core/layout_test.go @@ -5,8 +5,6 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" - - "github.com/barugoo/distribution/utils" ) func fromStr(str string) decimal.Decimal { @@ -126,7 +124,6 @@ func TestMakeLayout(t *testing.T) { assertLayout(t, tc.expectedLayout, res) } - } func assertDecimal(t *testing.T, expected, actual decimal.Decimal) bool { @@ -167,7 +164,7 @@ func TestDistributeDecimal(t *testing.T) { }{ { name: "simple", - input: utils.ToDecimal(12.95), + input: toDecimal(12.95), layout: newLayout([]fraction[string]{ { key: "15", @@ -184,14 +181,14 @@ func TestDistributeDecimal(t *testing.T) { ), expectedValue: &Value[string]{ bucketSlice: BucketSlice[string]{ - {"15", utils.ToDecimal(11), false}, - {"30", utils.ToDecimal(1.95), true}, + {"15", toDecimal(11), false}, + {"30", toDecimal(1.95), true}, }, }, }, { name: "divisor is dividable by 3 and dividend is not", - input: utils.ToDecimal(10), + input: toDecimal(10), layout: newLayout([]fraction[string]{ { key: "15", @@ -212,15 +209,15 @@ func TestDistributeDecimal(t *testing.T) { }), expectedValue: &Value[string]{ bucketSlice: BucketSlice[string]{ - {"15", utils.ToDecimal(33.33), false}, - {"30", utils.ToDecimal(33.33), false}, - {"45", utils.ToDecimal(33.34), true}, + {"15", toDecimal(33.33), false}, + {"30", toDecimal(33.33), false}, + {"45", toDecimal(33.34), true}, }, }, }, { name: "divisor and dividend are dividable by 3", - input: utils.ToDecimal(15), + input: toDecimal(15), layout: newLayout([]fraction[string]{ { key: "15", @@ -241,15 +238,15 @@ func TestDistributeDecimal(t *testing.T) { }), expectedValue: &Value[string]{ bucketSlice: BucketSlice[string]{ - {"15", utils.ToDecimal(5), false}, - {"30", utils.ToDecimal(5), false}, - {"45", utils.ToDecimal(5), true}, + {"15", toDecimal(5), false}, + {"30", toDecimal(5), false}, + {"45", toDecimal(5), true}, }, }, }, { name: "round bank case", - input: utils.ToDecimal(0.5), + input: toDecimal(0.5), layout: newLayout([]fraction[string]{ { key: "15", @@ -264,14 +261,14 @@ func TestDistributeDecimal(t *testing.T) { }), expectedValue: &Value[string]{ bucketSlice: BucketSlice[string]{ - {"15", utils.ToDecimal(0.50), false}, - {"30", utils.ToDecimal(0.00), true}, + {"15", toDecimal(0.50), false}, + {"30", toDecimal(0.00), true}, }, }, }, { name: "division by 2 with remainder", - input: utils.ToDecimal(325.01), + input: toDecimal(325.01), layout: newLayout([]fraction[string]{ { key: "15", @@ -286,8 +283,8 @@ func TestDistributeDecimal(t *testing.T) { }), expectedValue: &Value[string]{ bucketSlice: BucketSlice[string]{ - {"15", utils.ToDecimal(162.5), false}, - {"30", utils.ToDecimal(162.51), true}, + {"15", toDecimal(162.5), false}, + {"30", toDecimal(162.51), true}, }, }, }, @@ -299,5 +296,4 @@ func TestDistributeDecimal(t *testing.T) { res := tc.layout.DistributeDecimal(tc.input, 2) assertValue(t, tc.expectedValue, res) } - } diff --git a/core/value.go b/core/value.go index a722008..b1641cf 100644 --- a/core/value.go +++ b/core/value.go @@ -10,10 +10,12 @@ type Value[T comparable] struct { layout *Layout[T] } +// ToSlice returns a copy of the underlying BucketSlice func (v Value[T]) ToSlice() BucketSlice[T] { return v.bucketSlice.copy() } +// Get returns the value for a given bucket key. If the key does not exist, ok is false and res is zero func (v Value[T]) Get(key T) (res decimal.Decimal, ok bool) { idx, ok := v.layout.idxMap[key] if !ok || idx >= len(v.bucketSlice) { @@ -23,10 +25,12 @@ func (v Value[T]) Get(key T) (res decimal.Decimal, ok bool) { return v.bucketSlice[idx].Value, ok } +// Total returns the total value of all buckets in this Value func (v Value[T]) Total() decimal.Decimal { return v.bucketSlice.Total() } +// GetLayout returns the Layout used to create this Value func (v Value[T]) GetLayout() *Layout[T] { return v.layout } diff --git a/core/value_test.go b/core/value_test.go index ed9c1f7..a85521f 100644 --- a/core/value_test.go +++ b/core/value_test.go @@ -5,8 +5,6 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" - - "github.com/barugoo/distribution/utils" ) func TestValueToSlice(t *testing.T) { @@ -26,14 +24,14 @@ func TestValueToSlice(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, }, }, expected: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, }, }, @@ -55,7 +53,7 @@ func TestValueTotal(t *testing.T) { { name: "empty", value: &Value[string]{}, - expected: utils.ToDecimal(0), + expected: toDecimal(0), }, { name: "single", @@ -63,11 +61,11 @@ func TestValueTotal(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, }, }, - expected: utils.ToDecimal(10), + expected: toDecimal(10), }, { name: "multiple", @@ -75,15 +73,15 @@ func TestValueTotal(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, { Key: "20", - Value: utils.ToDecimal(20), + Value: toDecimal(20), }, }, }, - expected: utils.ToDecimal(30), + expected: toDecimal(30), }, } @@ -110,7 +108,7 @@ func TestValueGet(t *testing.T) { }, }, key: "10", - expected: utils.ToDecimal(0), + expected: toDecimal(0), expectedNotOK: true, }, { @@ -124,12 +122,12 @@ func TestValueGet(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, }, }, key: "10", - expected: utils.ToDecimal(10), + expected: toDecimal(10), }, { name: "multiple", @@ -143,16 +141,16 @@ func TestValueGet(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, { Key: "20", - Value: utils.ToDecimal(20), + Value: toDecimal(20), }, }, }, key: "20", - expected: utils.ToDecimal(20), + expected: toDecimal(20), }, { name: "idx out of range", @@ -167,16 +165,16 @@ func TestValueGet(t *testing.T) { bucketSlice: BucketSlice[string]{ { Key: "10", - Value: utils.ToDecimal(10), + Value: toDecimal(10), }, { Key: "20", - Value: utils.ToDecimal(20), + Value: toDecimal(20), }, }, }, key: "whatisthis", - expected: utils.ToDecimal(0), + expected: toDecimal(0), expectedNotOK: true, }, } diff --git a/examples/main.go b/examples/main.go index d0b3126..afb903e 100644 --- a/examples/main.go +++ b/examples/main.go @@ -1,3 +1,4 @@ +// Example of how to use the distribution core package package main import ( @@ -6,21 +7,20 @@ import ( "github.com/shopspring/decimal" "github.com/barugoo/distribution/examples/products" - "github.com/barugoo/distribution/utils" ) func main() { ps := []products.Product{ { - Price: utils.ToDecimal(10), + Price: decimal.NewFromFloat(10), VatRate: 5, }, { - Price: utils.ToDecimal(15), + Price: decimal.NewFromFloat(15), VatRate: 5, }, { - Price: utils.ToDecimal(25), + Price: decimal.NewFromFloat(25), VatRate: 7, }, } diff --git a/examples/products/products.go b/examples/products/products.go index a9445e4..7c690d0 100644 --- a/examples/products/products.go +++ b/examples/products/products.go @@ -1,3 +1,4 @@ +// Package products provides an example of how to use the distribution core package package products import ( @@ -11,16 +12,19 @@ import ( const distributionPrecision = 2 +// Layout wraps core.Layout with additional product information type Layout struct { *core.Layout[float64] productsTotal decimal.Decimal } +// Product describes a product with price and VAT rate type Product struct { Price decimal.Decimal VatRate float64 } +// MakeLayout creates a Layout from a slice of products. Products are grouped by VAT rate, and their prices are summed up. func MakeLayout(products []Product) (*Layout, error) { // groupping products by vat rate m := make(map[float64]decimal.Decimal) // vat rate -> sum of all products with that vat rate @@ -56,15 +60,18 @@ func MakeLayout(products []Product) (*Layout, error) { }, nil } +// DistributeDecimal overrides core.Layout.DistributeDecimal with predefined precision func (l *Layout) DistributeDecimal(d decimal.Decimal) Value { v := l.Layout.DistributeDecimal(d, distributionPrecision) return Value{v} } +// GetProductsTotal returns total price of all products in the layout func (l *Layout) GetProductsTotal() decimal.Decimal { return l.productsTotal } +// Value wraps core.Value with float64 keys type Value struct { *core.Value[float64] } diff --git a/utils/utils.go b/utils/utils.go deleted file mode 100644 index a5b9cb8..0000000 --- a/utils/utils.go +++ /dev/null @@ -1,8 +0,0 @@ -package utils - -import "github.com/shopspring/decimal" - -// ToDecimal is a shorthand for decimal.NewFromFloat(f) -func ToDecimal(f float64) decimal.Decimal { - return decimal.NewFromFloat(f) -}