43 Commits
set ... master

Author SHA1 Message Date
54aae5f242 big updates: tests, bug fixed, documentation. oh my
All checks were successful
Go / build (1.23) (push) Successful in 3m51s
2026-01-03 15:53:50 -08:00
be25fab6ab added tests, examples, and direct attribution 2025-12-27 14:23:54 -08:00
a4ab096e00 added image filter capabilities courtesey of nobloat.org 2025-12-27 13:44:23 -08:00
Colin Henry
3d71b65428 added trie
Some checks failed
Go / build (1.23) (push) Failing after 2m42s
2025-07-05 11:56:37 -07:00
Colin Henry
4cf0b874d6 fixed tio arvelie 2025-07-05 11:56:37 -07:00
Colin Henry
475e091d21 remove drone.yml
All checks were successful
Go / build (1.23) (push) Successful in 2m53s
2025-06-20 00:07:25 -07:00
Colin Henry
56752521c2 added doc
All checks were successful
Go / build (1.23) (push) Successful in 2m31s
2025-03-18 22:28:20 -07:00
Colin Henry
6aa7788fd0 added transactional wrapper, and support for it 2025-03-10 22:59:03 -07:00
Colin Henry
f88f00e78c fixed release target
All checks were successful
Go / build (1.23) (push) Successful in 17s
2024-10-27 12:30:45 -07:00
Colin Henry
b7969f89b5 fixed release target
All checks were successful
Go / build (1.23) (push) Successful in 17s
2024-10-27 12:28:57 -07:00
Colin Henry
7e1daaf7d5 added makefile to do releases
All checks were successful
Go / build (1.23) (push) Successful in 28s
2024-10-27 12:04:01 -07:00
Colin Henry
40bc496b20 added makefile to do releases 2024-10-27 12:03:34 -07:00
Colin Henry
401f5c3848 WIP: added container/graph
All checks were successful
Go / build (1.23) (push) Successful in 2m8s
2024-10-27 11:58:47 -07:00
Colin Henry
f0fd3f8df1 added come comments
Some checks failed
Go / build (1.23) (push) Has been cancelled
2024-10-27 11:58:07 -07:00
Colin Henry
8de3b04032 fix(ci): updated support matrix
All checks were successful
Go / build (1.23) (push) Successful in 3m3s
2024-09-29 12:02:35 -07:00
Colin Henry
f44e43174c added wrapper error that will provide file/line origin of an error
Some checks failed
Go / build (1.19) (push) Failing after 13s
2024-09-29 11:59:01 -07:00
Colin Henry
fe49018479 added new check capability and added assets to a few other packages
Some checks failed
Go / build (1.19) (push) Failing after 22s
2024-09-28 15:19:00 -07:00
Colin Henry
94bc398ea7 added assert function
Some checks failed
Go / build (1.19) (push) Failing after 4m5s
2024-09-06 20:29:29 -07:00
Colin Henry
2973912fde updated minimum go version 2024-09-06 20:28:23 -07:00
Colin Henry
b738f78e6e reomved now unnecessary libraries 2024-09-06 20:27:26 -07:00
Colin Henry
74f9fc64f4 removed deprecated log package 2024-09-05 23:36:49 -07:00
Colin Henry
612a5c8387 removing deprecated rest package 2024-09-05 22:32:04 -07:00
Colin Henry
a1e52a7399 changing module name 2024-09-05 22:30:30 -07:00
Colin Henry
79e3d1f0b9 fixed busted test step
All checks were successful
Go / build (1.19) (push) Successful in 46s
2024-04-27 15:43:57 -07:00
Colin Henry
8b77b5acc8 added gitea action
Some checks failed
Go / build (1.19) (push) Failing after 3m29s
2024-04-26 23:20:51 -07:00
Colin Henry
4665f2ccf9 deprecating packages 2023-08-11 21:59:16 -07:00
Colin Henry
7154029136 linted 2023-04-05 21:20:45 -07:00
Colin Henry
9f88dc9530 added ISO8601 constant 2023-04-04 23:19:22 -07:00
Colin Henry
1ec6f3c5c3 updated doc 2023-04-04 23:19:05 -07:00
Colin Henry
390a54eb96 updated log interface name 2023-04-04 23:17:39 -07:00
Colin Henry
d312b58fdf updated go.mod for compatability with generics, as used by the cache 2023-01-14 00:12:30 -08:00
Colin Henry
b19ba53491 added appropriate attribution
for tierd cache stub
2023-01-14 00:09:00 -08:00
Colin Henry
859849bd47 stub of tiered cache 2022-12-15 22:00:51 -08:00
Colin Henry
7cf53a2f2d sped up mappedParam by removing joins and splits 2022-09-15 21:28:19 -07:00
Colin Henry
593c8db66e fixed unit tests 2022-09-15 21:21:51 -07:00
Colin Henry
74c99d013c well, maybe a little 2022-09-14 15:43:05 -07:00
Colin Henry
618cdcc095 no mo way 2022-09-14 15:41:43 -07:00
Colin Henry
32337f4bc8 fixed non-standard return code 2022-09-14 15:41:15 -07:00
Colin Henry
888e1b9c7d new functions to extract certain types of parameters 2022-09-14 15:39:41 -07:00
Colin Henry
086c404610 changed parameter identifier to brackets over a colon, is more compatable with OpenAPI 2022-08-20 13:43:39 -07:00
Colin Henry
f9a712bc33 added to the logger interface 2022-08-05 22:04:20 -07:00
Colin Henry
dc5f8b6e5a new ServeMux based on Mat Ryer's Way. removed method support in the mux to allow it to be handled by the handlers themselves. Keeps the standard library net/http handler contract and provides better support for the handlers provided by x's net/http. 2022-07-08 19:32:45 -07:00
Colin Henry
62911f042d update library to build for 1.18 2022-06-28 20:23:08 -07:00
64 changed files with 4249 additions and 414 deletions

View File

@@ -1,10 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
- name: test
image: golang
commands:
- go test ./...
- go build ./...

View File

@@ -0,0 +1,18 @@
name: Go
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ '1.23' ]
steps:
- uses: actions/checkout@v4
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

View File

@@ -1,9 +0,0 @@
Copyright 2020 Colin Henry
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.

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
# Variables
REPO_REMOTE := origin
# Helper function to get the latest tag and increment the version
define next_version
latest_tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \
major=$$(echo $$latest_tag | sed -E 's/^v([0-9]+)\.[0-9]+\.[0-9]+/\1/'); \
minor=$$(echo $$latest_tag | sed -E 's/^v[0-9]+\.([0-9]+)\.[0-9]+/\1/'); \
patch=$$(echo $$latest_tag | sed -E 's/^v[0-9]+\.[0-9]+\.([0-9]+)/\1/'); \
next_patch=$$(($$patch + 1)); \
echo "v$$major.$$minor.$$next_patch"
endef
# Target to tag and push the next version
release:
next_version=$$($(next_version)); \
echo "Tagging with version $$next_version"; \
git tag $$next_version; \
git push $(REPO_REMOTE) $$next_version

View File

@@ -1,19 +1,21 @@
# x
A collection of idiomatic Go utility packages for common programming tasks.
A mix of useful packages, some are works in progress.
[![Build Status](https://git.sdf.org/jchenry/x/actions/workflows/build.yaml/badge.svg?branch=main)](git.sdf.org/jchenry/x)
[![Build Status](https://ci.j5y.xyz/api/badges/jchenry/x/status.svg)](https://ci.j5y.xyz/jchenry/x)
## Installation
## Install
```
go get github.com/jchenry/x
```bash
go get git.sdf.org/jchenry/x
```
## Contributing
PRs accepted.
PRs accepted. Please ensure:
- Tests pass with `go test -race ./...`
- Code is formatted with `gofmt`
- New code includes tests and GoDoc comments
## License
MIT © Colin Henry
MIT © Colin Henry et al.

3
cache/doc.go vendored Normal file
View File

@@ -0,0 +1,3 @@
package cache
// Cache package cribbed from https://varunksaini.com/tiered-cache-in-go/ whom i have i high respect for as a former colleague.

9
cache/interface.go vendored Normal file
View File

@@ -0,0 +1,9 @@
package cache
// Interface defines a generic cache with get and put operations.
type Interface[K comparable, V any] interface {
// Get retrieves a value from the cache by key, returns zero value if not found
Get(key K) V
// Put stores a key-value pair in the cache
Put(key K, value V)
}

45
cache/tiered.go vendored Normal file
View File

@@ -0,0 +1,45 @@
package cache
import (
"reflect"
"git.sdf.org/jchenry/x"
)
type tieredCache[K comparable, V any] struct {
inner Interface[K, V]
outer Interface[K, V]
}
// NewTieredCache creates a two-tier cache where inner is checked first, then outer.
// Get operations check inner first, falling back to outer if not found.
// Put operations write to inner immediately and to outer asynchronously.
// Both inner and outer must not be nil or this function will panic.
func NewTieredCache[K comparable, V any](inner, outer Interface[K, V]) Interface[K, V] {
x.Assert(inner != nil, "cache.NewTieredCache: inner cannot be nil")
x.Assert(outer != nil, "cache.NewTieredCache: outer cannot be nil")
return &tieredCache[K, V]{
inner: inner,
outer: outer,
}
}
func (t *tieredCache[K, V]) Get(key K) V {
var zero, value V
value = t.inner.Get(key)
if reflect.DeepEqual(value, zero) {
value = t.outer.Get(key)
// if required, add value to inner cache for future requests
}
return value
}
func (t *tieredCache[K, V]) Put(key K, value V) {
t.inner.Put(key, value)
// add key to outer cache asynchronously
// pass value as parameter to avoid capturing by reference
go func(k K, v V) {
t.outer.Put(k, v)
}(key, value)
}

376
cache/tiered_test.go vendored Normal file
View File

@@ -0,0 +1,376 @@
package cache
import (
"sync"
"testing"
"time"
)
// mockCache is a simple in-memory cache implementation for testing
type mockCache[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
calls struct {
gets int
puts int
}
}
func newMockCache[K comparable, V any]() *mockCache[K, V] {
return &mockCache[K, V]{
data: make(map[K]V),
}
}
func (m *mockCache[K, V]) Get(key K) V {
m.mu.RLock()
defer m.mu.RUnlock()
m.calls.gets++
return m.data[key]
}
func (m *mockCache[K, V]) Put(key K, value V) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls.puts++
m.data[key] = value
}
func (m *mockCache[K, V]) getCallCounts() (gets, puts int) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.calls.gets, m.calls.puts
}
func (m *mockCache[K, V]) has(key K) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, exists := m.data[key]
return exists
}
func TestNewTieredCache(t *testing.T) {
t.Run("successful creation", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
if cache == nil {
t.Fatal("expected non-nil cache")
}
// Verify it's the right type
if _, ok := cache.(*tieredCache[string, int]); !ok {
t.Error("expected cache to be *tieredCache")
}
})
t.Run("panic on nil inner cache", func(t *testing.T) {
outer := newMockCache[string, int]()
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for nil inner cache")
}
}()
NewTieredCache[string, int](nil, outer)
})
t.Run("panic on nil outer cache", func(t *testing.T) {
inner := newMockCache[string, int]()
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for nil outer cache")
}
}()
NewTieredCache[string, int](inner, nil)
})
t.Run("panic on both nil caches", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for nil caches")
}
}()
NewTieredCache[string, int](nil, nil)
})
}
func TestTieredCacheGet(t *testing.T) {
t.Run("get from inner cache", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
// Put directly in inner cache
inner.Put("key1", 42)
value := cache.Get("key1")
if value != 42 {
t.Errorf("expected value 42, got %d", value)
}
// Verify only inner was accessed
innerGets, _ := inner.getCallCounts()
outerGets, _ := outer.getCallCounts()
if innerGets != 1 {
t.Errorf("expected 1 inner get, got %d", innerGets)
}
if outerGets != 0 {
t.Errorf("expected 0 outer gets, got %d", outerGets)
}
})
t.Run("get from outer cache when not in inner", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
// Put only in outer cache
outer.Put("key2", 99)
value := cache.Get("key2")
if value != 99 {
t.Errorf("expected value 99, got %d", value)
}
// Verify both were accessed
innerGets, _ := inner.getCallCounts()
outerGets, _ := outer.getCallCounts()
if innerGets != 1 {
t.Errorf("expected 1 inner get, got %d", innerGets)
}
if outerGets != 1 {
t.Errorf("expected 1 outer get, got %d", outerGets)
}
})
t.Run("get missing key returns zero value", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
value := cache.Get("nonexistent")
if value != 0 {
t.Errorf("expected zero value 0, got %d", value)
}
// Verify both were accessed
innerGets, _ := inner.getCallCounts()
outerGets, _ := outer.getCallCounts()
if innerGets != 1 {
t.Errorf("expected 1 inner get, got %d", innerGets)
}
if outerGets != 1 {
t.Errorf("expected 1 outer get, got %d", outerGets)
}
})
t.Run("get with string values", func(t *testing.T) {
inner := newMockCache[int, string]()
outer := newMockCache[int, string]()
cache := NewTieredCache[int, string](inner, outer)
inner.Put(1, "hello")
outer.Put(2, "world")
if cache.Get(1) != "hello" {
t.Error("expected 'hello' from inner cache")
}
if cache.Get(2) != "world" {
t.Error("expected 'world' from outer cache")
}
if cache.Get(3) != "" {
t.Error("expected empty string for missing key")
}
})
}
func TestTieredCachePut(t *testing.T) {
t.Run("put to both caches", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
cache.Put("key1", 42)
// Inner should have it immediately
if !inner.has("key1") {
t.Error("expected key1 to be in inner cache")
}
// Outer is written asynchronously, so wait a bit
time.Sleep(50 * time.Millisecond)
if !outer.has("key1") {
t.Error("expected key1 to be in outer cache")
}
// Verify both caches have the same value
if inner.Get("key1") != 42 {
t.Error("expected inner cache to have value 42")
}
if outer.Get("key1") != 42 {
t.Error("expected outer cache to have value 42")
}
})
t.Run("put multiple values", func(t *testing.T) {
inner := newMockCache[string, string]()
outer := newMockCache[string, string]()
cache := NewTieredCache[string, string](inner, outer)
cache.Put("a", "alpha")
cache.Put("b", "beta")
cache.Put("c", "gamma")
// Wait for async writes
time.Sleep(50 * time.Millisecond)
// Verify all values in both caches
for _, key := range []string{"a", "b", "c"} {
if !inner.has(key) {
t.Errorf("expected key %s to be in inner cache", key)
}
if !outer.has(key) {
t.Errorf("expected key %s to be in outer cache", key)
}
}
})
t.Run("put overwrites existing values", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
cache.Put("key", 1)
time.Sleep(50 * time.Millisecond)
cache.Put("key", 2)
time.Sleep(50 * time.Millisecond)
if inner.Get("key") != 2 {
t.Errorf("expected inner cache to have value 2, got %d", inner.Get("key"))
}
if outer.Get("key") != 2 {
t.Errorf("expected outer cache to have value 2, got %d", outer.Get("key"))
}
})
t.Run("concurrent puts", func(t *testing.T) {
inner := newMockCache[int, int]()
outer := newMockCache[int, int]()
cache := NewTieredCache[int, int](inner, outer)
var wg sync.WaitGroup
n := 100
// Launch concurrent Put operations
for i := 0; i < n; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
cache.Put(val, val*2)
}(i)
}
wg.Wait()
time.Sleep(100 * time.Millisecond)
// Verify all values are present
_, innerPuts := inner.getCallCounts()
_, outerPuts := outer.getCallCounts()
if innerPuts != n {
t.Errorf("expected %d inner puts, got %d", n, innerPuts)
}
if outerPuts != n {
t.Errorf("expected %d outer puts, got %d", n, outerPuts)
}
})
}
func TestTieredCacheIntegration(t *testing.T) {
t.Run("full workflow", func(t *testing.T) {
inner := newMockCache[string, int]()
outer := newMockCache[string, int]()
cache := NewTieredCache[string, int](inner, outer)
// Put some values
cache.Put("a", 1)
cache.Put("b", 2)
cache.Put("c", 3)
// Wait for async writes to outer
time.Sleep(100 * time.Millisecond)
// Get values (should hit inner cache)
if cache.Get("a") != 1 {
t.Error("expected a=1")
}
if cache.Get("b") != 2 {
t.Error("expected b=2")
}
if cache.Get("c") != 3 {
t.Error("expected c=3")
}
// Simulate inner cache eviction by clearing it
inner.data = make(map[string]int)
// Get values again (should hit outer cache)
if cache.Get("a") != 1 {
t.Error("expected a=1 from outer cache")
}
if cache.Get("b") != 2 {
t.Error("expected b=2 from outer cache")
}
if cache.Get("c") != 3 {
t.Error("expected c=3 from outer cache")
}
})
}
func TestTieredCacheWithStructs(t *testing.T) {
type User struct {
ID int
Name string
}
t.Run("cache structs", func(t *testing.T) {
inner := newMockCache[int, User]()
outer := newMockCache[int, User]()
cache := NewTieredCache[int, User](inner, outer)
user := User{ID: 1, Name: "Alice"}
cache.Put(1, user)
retrieved := cache.Get(1)
if retrieved.ID != 1 || retrieved.Name != "Alice" {
t.Errorf("expected user {1, Alice}, got {%d, %s}", retrieved.ID, retrieved.Name)
}
})
t.Run("cache pointers", func(t *testing.T) {
inner := newMockCache[int, *User]()
outer := newMockCache[int, *User]()
cache := NewTieredCache[int, *User](inner, outer)
user := &User{ID: 2, Name: "Bob"}
cache.Put(2, user)
time.Sleep(50 * time.Millisecond)
retrieved := cache.Get(2)
if retrieved == nil {
t.Fatal("expected non-nil user")
}
if retrieved.ID != 2 || retrieved.Name != "Bob" {
t.Errorf("expected user {2, Bob}, got {%d, %s}", retrieved.ID, retrieved.Name)
}
})
}

44
container/graph/topo.go Normal file
View File

@@ -0,0 +1,44 @@
// Package sort provides topological sorting for directed acyclic graphs.
package sort
// Node is a generic interface representing a graph node.
type Node[T any] interface {
// Value returns the value of the node.
Value() T
// Adjacencies returns a slice of nodes adjacent to this node (outgoing edges).
Adjacencies() []Node[T]
}
// TopoSort performs a topological sort on a slice of nodes in place using depth-first search.
// Nodes are sorted such that for every directed edge from node A to node B, A comes before B in the result.
func TopoSort[T any](nodes []Node[T]) {
v := make(map[Node[T]]bool)
pos := 0
var dfs func(Node[T])
dfs = func(n Node[T]) {
v[n] = true
for _, e := range n.Adjacencies() {
if !v[e] {
dfs(e)
}
}
nodes[pos] = n
pos++
}
for _, n := range nodes {
if !v[n] {
dfs(n)
}
}
}
// RTopoSort performs a reverse topological sort on a slice of nodes in place.
// Nodes are sorted such that for every directed edge from node A to node B, B comes before A in the result.
func RTopoSort[T any](nodes []Node[T]) {
TopoSort(nodes)
for i, j := 0, len(nodes)-1; i < j; i, j = i+1, j-1 {
nodes[i], nodes[j] = nodes[j], nodes[i]
}
}

View File

@@ -0,0 +1,212 @@
package sort
import (
"testing"
)
// testNode implements the Node interface for testing
type testNode struct {
value int
edges []*testNode
}
func (n *testNode) Value() int {
return n.value
}
func (n *testNode) Adjacencies() []Node[int] {
result := make([]Node[int], len(n.edges))
for i, edge := range n.edges {
result[i] = edge
}
return result
}
func TestTopoSort(t *testing.T) {
t.Run("simple DAG", func(t *testing.T) {
// Create a simple DAG: 1 -> 2 -> 3
node3 := &testNode{value: 3}
node2 := &testNode{value: 2, edges: []*testNode{node3}}
node1 := &testNode{value: 1, edges: []*testNode{node2}}
nodes := []Node[int]{node1, node2, node3}
TopoSort(nodes)
// After topo sort, nodes should be in reverse topological order
// The leaf (3) should come first, then 2, then 1
if nodes[0].Value() != 3 {
t.Errorf("expected first node to be 3, got %d", nodes[0].Value())
}
if nodes[1].Value() != 2 {
t.Errorf("expected second node to be 2, got %d", nodes[1].Value())
}
if nodes[2].Value() != 1 {
t.Errorf("expected third node to be 1, got %d", nodes[2].Value())
}
})
t.Run("diamond DAG", func(t *testing.T) {
// Create a diamond DAG:
// 1
// / \
// 2 3
// \ /
// 4
node4 := &testNode{value: 4}
node2 := &testNode{value: 2, edges: []*testNode{node4}}
node3 := &testNode{value: 3, edges: []*testNode{node4}}
node1 := &testNode{value: 1, edges: []*testNode{node2, node3}}
nodes := []Node[int]{node1, node2, node3, node4}
TopoSort(nodes)
// Node 4 should come first (leaf), node 1 should come last (root)
if nodes[0].Value() != 4 {
t.Errorf("expected first node to be 4, got %d", nodes[0].Value())
}
if nodes[3].Value() != 1 {
t.Errorf("expected last node to be 1, got %d", nodes[3].Value())
}
})
t.Run("single node", func(t *testing.T) {
node := &testNode{value: 42}
nodes := []Node[int]{node}
TopoSort(nodes)
if nodes[0].Value() != 42 {
t.Errorf("expected node value 42, got %d", nodes[0].Value())
}
})
t.Run("empty slice", func(t *testing.T) {
nodes := []Node[int]{}
TopoSort(nodes)
if len(nodes) != 0 {
t.Error("expected empty slice to remain empty")
}
})
t.Run("disconnected nodes", func(t *testing.T) {
node1 := &testNode{value: 1}
node2 := &testNode{value: 2}
node3 := &testNode{value: 3}
nodes := []Node[int]{node1, node2, node3}
TopoSort(nodes)
// All nodes are independent, order might change but all should be present
if len(nodes) != 3 {
t.Errorf("expected 3 nodes, got %d", len(nodes))
}
values := make(map[int]bool)
for _, n := range nodes {
values[n.Value()] = true
}
for i := 1; i <= 3; i++ {
if !values[i] {
t.Errorf("expected value %d to be present", i)
}
}
})
t.Run("complex DAG", func(t *testing.T) {
// More complex graph:
// 1
// / \
// 2 3
// | |\
// 4 5 6
// \ /
// 7
node7 := &testNode{value: 7}
node4 := &testNode{value: 4, edges: []*testNode{node7}}
node5 := &testNode{value: 5, edges: []*testNode{node7}}
node6 := &testNode{value: 6}
node2 := &testNode{value: 2, edges: []*testNode{node4}}
node3 := &testNode{value: 3, edges: []*testNode{node5, node6}}
node1 := &testNode{value: 1, edges: []*testNode{node2, node3}}
nodes := []Node[int]{node1, node2, node3, node4, node5, node6, node7}
TopoSort(nodes)
// Create a map to track the position of each node
positions := make(map[int]int)
for i, n := range nodes {
positions[n.Value()] = i
}
// Verify topological ordering constraints (reverse order - leaves first)
// If there's an edge from A to B, B should come before A in the result
if positions[2] >= positions[1] {
t.Error("node 2 should come before node 1 (reverse topo order)")
}
if positions[4] >= positions[2] {
t.Error("node 4 should come before node 2 (reverse topo order)")
}
if positions[7] >= positions[4] {
t.Error("node 7 should come before node 4 (reverse topo order)")
}
})
}
func TestRTopoSort(t *testing.T) {
t.Run("simple DAG", func(t *testing.T) {
// Create a simple DAG: 1 -> 2 -> 3
node3 := &testNode{value: 3}
node2 := &testNode{value: 2, edges: []*testNode{node3}}
node1 := &testNode{value: 1, edges: []*testNode{node2}}
nodes := []Node[int]{node1, node2, node3}
RTopoSort(nodes)
// After reverse topo sort, nodes should be in topological order
// The root (1) should come first, then 2, then 3
if nodes[0].Value() != 1 {
t.Errorf("expected first node to be 1, got %d", nodes[0].Value())
}
if nodes[1].Value() != 2 {
t.Errorf("expected second node to be 2, got %d", nodes[1].Value())
}
if nodes[2].Value() != 3 {
t.Errorf("expected third node to be 3, got %d", nodes[2].Value())
}
})
t.Run("single node", func(t *testing.T) {
node := &testNode{value: 42}
nodes := []Node[int]{node}
RTopoSort(nodes)
if nodes[0].Value() != 42 {
t.Errorf("expected node value 42, got %d", nodes[0].Value())
}
})
t.Run("empty slice", func(t *testing.T) {
nodes := []Node[int]{}
RTopoSort(nodes)
if len(nodes) != 0 {
t.Error("expected empty slice to remain empty")
}
})
t.Run("two nodes", func(t *testing.T) {
node2 := &testNode{value: 2}
node1 := &testNode{value: 1, edges: []*testNode{node2}}
nodes := []Node[int]{node1, node2}
RTopoSort(nodes)
if nodes[0].Value() != 1 {
t.Errorf("expected first node to be 1, got %d", nodes[0].Value())
}
if nodes[1].Value() != 2 {
t.Errorf("expected second node to be 2, got %d", nodes[1].Value())
}
})
}

View File

@@ -1,28 +0,0 @@
package set
var x = struct{}{}
type Set map[any]struct{}
func (s *Set) Init() {
for k := range *s {
delete(*s, k)
}
}
func (s *Set) Add(e any) {
(*s)[e] = x
}
func (s *Set) Remove(e any) {
delete(*s, e)
}
func (s *Set) Contains(e any) bool {
_, c := (*s)[e]
return c
}
func New() *Set {
return new(Set)
}

106
container/trie/trie.go Normal file
View File

@@ -0,0 +1,106 @@
// Package trie provides a prefix tree (trie) data structure for efficient string operations.
package trie
import (
"sort"
)
// Node represents a node in the trie data structure.
type Node struct {
children map[rune]*Node
isEnd bool
}
// New creates and returns a new empty trie root node.
func New() *Node {
return &Node{
children: make(map[rune]*Node),
isEnd: false,
}
}
// Add inserts a word into the trie.
func (n *Node) Add(word string) {
current := n
for _, char := range word {
if _, exists := current.children[char]; !exists {
current.children[char] = &Node{
children: make(map[rune]*Node),
isEnd: false,
}
}
current = current.children[char]
}
current.isEnd = true
}
// countWords recursively counts the number of words in the subtree
func (n *Node) countWords() int {
count := 0
if n.isEnd {
count++
}
for _, child := range n.children {
count += child.countWords()
}
return count
}
// Count returns the number of words in the trie that have the given prefix.
// Returns 0 if the prefix is not found.
func (n *Node) Count(prefix string) int {
current := n
for _, char := range prefix {
if _, exists := current.children[char]; !exists {
return 0
}
current = current.children[char]
}
return current.countWords()
}
// PrefixCount represents a prefix string and the number of words with that prefix.
type PrefixCount struct {
// Prefix is the string prefix
Prefix string
// Count is the number of words with this prefix
Count int
}
// collectPrefixes recursively collects all prefixes and their counts
func collectPrefixes(n *Node, currentPrefix string, results *[]PrefixCount) {
count := n.countWords()
if count > 0 {
*results = append(*results, PrefixCount{
Prefix: currentPrefix,
Count: count,
})
}
// Sort children by rune to ensure consistent traversal order
chars := make([]rune, 0, len(n.children))
for char := range n.children {
chars = append(chars, char)
}
sort.Slice(chars, func(i, j int) bool {
return chars[i] < chars[j]
})
for _, char := range chars {
collectPrefixes(n.children[char], currentPrefix+string(char), results)
}
}
// TopPrefixes returns all prefixes in the trie sorted by count (descending), then alphabetically.
// Higher counts appear first; equal counts are sorted alphabetically.
func TopPrefixes(n *Node) []PrefixCount {
var results []PrefixCount
collectPrefixes(n, "", &results)
sort.Slice(results, func(i, j int) bool {
if results[i].Count == results[j].Count {
return results[i].Prefix < results[j].Prefix
}
return results[i].Count > results[j].Count
})
return results
}

163
container/trie/trie_test.go Normal file
View File

@@ -0,0 +1,163 @@
package trie
import (
"testing"
)
func TestNew(t *testing.T) {
root := New()
if root == nil {
t.Error("New() returned nil")
}
if root.children == nil {
t.Error("New() root node has nil children map")
}
if root.isEnd {
t.Error("New() root node should not be marked as end")
}
}
func TestAddAndCount(t *testing.T) {
tests := []struct {
name string
words []string
prefix string
expected int
}{
{
name: "empty trie",
words: []string{},
prefix: "test",
expected: 0,
},
{
name: "single word",
words: []string{"hello"},
prefix: "hel",
expected: 1,
},
{
name: "multiple words same prefix",
words: []string{"hello", "help", "hell"},
prefix: "hel",
expected: 3,
},
{
name: "prefix not found",
words: []string{"hello", "help", "hell"},
prefix: "xyz",
expected: 0,
},
{
name: "empty prefix",
words: []string{"hello", "help", "hell"},
prefix: "",
expected: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := New()
for _, word := range tt.words {
root.Add(word)
}
if got := root.Count(tt.prefix); got != tt.expected {
t.Errorf("Count(%q) = %d, want %d", tt.prefix, got, tt.expected)
}
})
}
}
func TestTopPrefixes(t *testing.T) {
tests := []struct {
name string
words []string
expected []PrefixCount
}{
{
name: "empty trie",
words: []string{},
expected: []PrefixCount{},
},
{
name: "single word",
words: []string{"hello"},
expected: []PrefixCount{
{Prefix: "", Count: 1},
{Prefix: "h", Count: 1},
{Prefix: "he", Count: 1},
{Prefix: "hel", Count: 1},
{Prefix: "hell", Count: 1},
{Prefix: "hello", Count: 1},
},
},
{
name: "multiple words",
words: []string{"hello", "help", "hell", "helicopter"},
expected: []PrefixCount{
{Prefix: "", Count: 4},
{Prefix: "h", Count: 4},
{Prefix: "he", Count: 4},
{Prefix: "hel", Count: 4},
{Prefix: "hell", Count: 2},
{Prefix: "heli", Count: 1},
{Prefix: "helic", Count: 1},
{Prefix: "helico", Count: 1},
{Prefix: "helicop", Count: 1},
{Prefix: "helicopt", Count: 1},
{Prefix: "helicopte", Count: 1},
{Prefix: "helicopter", Count: 1},
{Prefix: "hello", Count: 1},
{Prefix: "help", Count: 1},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := New()
for _, word := range tt.words {
root.Add(word)
}
got := TopPrefixes(root)
if len(got) != len(tt.expected) {
t.Errorf("TopPrefixes() returned %d results, want %d", len(got), len(tt.expected))
return
}
for i, want := range tt.expected {
if i >= len(got) {
break
}
if got[i].Prefix != want.Prefix || got[i].Count != want.Count {
t.Errorf("TopPrefixes()[%d] = {Prefix: %q, Count: %d}, want {Prefix: %q, Count: %d}",
i, got[i].Prefix, got[i].Count, want.Prefix, want.Count)
}
}
})
}
}
func TestTopPrefixesSorting(t *testing.T) {
root := New()
words := []string{"a", "ab", "abc", "abcd", "abcde"}
for _, word := range words {
root.Add(word)
}
prefixes := TopPrefixes(root)
// Verify sorting by count (descending)
for i := 1; i < len(prefixes); i++ {
if prefixes[i].Count > prefixes[i-1].Count {
t.Errorf("Prefixes not sorted by count: %v", prefixes)
}
}
// Verify alphabetical sorting for equal counts
for i := 1; i < len(prefixes); i++ {
if prefixes[i].Count == prefixes[i-1].Count && prefixes[i].Prefix < prefixes[i-1].Prefix {
t.Errorf("Prefixes with equal counts not sorted alphabetically: %v", prefixes)
}
}
}

View File

@@ -1,22 +1,37 @@
// Package database provides an actor pattern implementation for sequential database operations.
package database
import (
"context"
"database/sql"
"git.sdf.org/jchenry/x"
)
type Func func(db *sql.DB)
// Func is a function type that performs a database operation.
// It receives a context and database connection, returning an error if the operation fails.
type Func func(ctx context.Context, db *sql.DB) error
// Actor provides a sequential executor for database operations via a channel.
// Operations sent to ActionChan are executed sequentially in the order received.
type Actor struct {
DB *sql.DB
// DB is the database connection used by all operations
DB *sql.DB
// ActionChan receives database operations to execute sequentially
ActionChan chan Func
}
// Run starts the actor's event loop, processing database operations from ActionChan.
// It blocks until ctx is canceled or an operation returns an error.
// The context must not be nil or this function will panic.
func (a *Actor) Run(ctx context.Context) error {
x.Assert(ctx != nil, "Actor.Run: context cannot be nil")
for {
select {
case f := <-a.ActionChan:
f(a.DB)
if err := f(ctx, a.DB); err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}

392
database/database_test.go Normal file
View File

@@ -0,0 +1,392 @@
package database
import (
"context"
"database/sql"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestActorRun(t *testing.T) {
t.Run("successful function execution", func(t *testing.T) {
actor := &Actor{
DB: nil, // We don't need a real DB for this test
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the actor
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
// Send a function to execute
var executed atomic.Bool
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
executed.Store(true)
return nil
}
// Give it time to execute
time.Sleep(50 * time.Millisecond)
if !executed.Load() {
t.Error("expected function to be executed")
}
// Cancel context to stop actor
cancel()
// Wait for actor to finish
if err := <-errChan; err != context.Canceled {
t.Errorf("expected context.Canceled, got %v", err)
}
})
t.Run("function returns error", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the actor
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
// Send a function that returns an error
expectedErr := errors.New("test error")
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
return expectedErr
}
// Wait for actor to finish with error
err := <-errChan
if err != expectedErr {
t.Errorf("expected error %v, got %v", expectedErr, err)
}
})
t.Run("multiple functions executed sequentially", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 10),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the actor
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
// Track execution count
var count atomic.Int32
// Send multiple functions
for i := 0; i < 5; i++ {
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count.Add(1)
return nil
}
}
// Give time for all to execute
time.Sleep(100 * time.Millisecond)
if count.Load() != 5 {
t.Errorf("expected 5 executions, got %d", count.Load())
}
// Cancel to stop
cancel()
err := <-errChan
if err != context.Canceled {
t.Errorf("expected context.Canceled, got %v", err)
}
})
t.Run("context cancellation stops actor", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithCancel(context.Background())
// Start the actor
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
// Cancel immediately
cancel()
// Should return context.Canceled
err := <-errChan
if err != context.Canceled {
t.Errorf("expected context.Canceled, got %v", err)
}
})
t.Run("context deadline exceeded", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
// Start the actor
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
// Wait for timeout
err := <-errChan
if err != context.DeadlineExceeded {
t.Errorf("expected context.DeadlineExceeded, got %v", err)
}
})
t.Run("panic on nil context", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for nil context")
}
}()
actor.Run(nil)
})
t.Run("actor processes db parameter", func(t *testing.T) {
// Create a fake DB pointer (we won't use it, just verify it's passed through)
fakeDB := &sql.DB{}
actor := &Actor{
DB: fakeDB,
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
type result struct {
db *sql.DB
}
resultChan := make(chan result, 1)
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
resultChan <- result{db: db}
return nil
}
res := <-resultChan
if res.db != fakeDB {
t.Error("expected function to receive the actor's DB")
}
cancel()
<-errChan
})
}
func TestWithinTransaction(t *testing.T) {
t.Run("wraps function correctly", func(t *testing.T) {
innerFunc := func(ctx context.Context, db *sql.DB) error {
return nil
}
wrappedFunc := WithinTransaction(innerFunc)
// Verify wrappedFunc is not nil
if wrappedFunc == nil {
t.Fatal("expected non-nil wrapped function")
}
// We can't actually call it without a real DB that supports transactions,
// but we can verify the type is correct
var _ Func = wrappedFunc
})
t.Run("returns a Func type", func(t *testing.T) {
innerFunc := func(ctx context.Context, db *sql.DB) error {
return nil
}
wrappedFunc := WithinTransaction(innerFunc)
// Type assertion to verify it returns Func
if _, ok := interface{}(wrappedFunc).(Func); !ok {
t.Error("expected WithinTransaction to return Func type")
}
})
t.Run("preserves error from inner function", func(t *testing.T) {
expectedErr := errors.New("inner function error")
innerFunc := func(ctx context.Context, db *sql.DB) error {
return expectedErr
}
wrappedFunc := WithinTransaction(innerFunc)
// We can verify the function signature is preserved
if wrappedFunc == nil {
t.Fatal("expected non-nil wrapped function")
}
})
}
func TestActorIntegration(t *testing.T) {
t.Run("multiple actors can run concurrently", func(t *testing.T) {
actor1 := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
actor2 := &Actor{
DB: nil,
ActionChan: make(chan Func, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errChan1 := make(chan error, 1)
errChan2 := make(chan error, 1)
go func() {
errChan1 <- actor1.Run(ctx)
}()
go func() {
errChan2 <- actor2.Run(ctx)
}()
var count1, count2 atomic.Int32
// Send work to both actors
actor1.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count1.Add(1)
return nil
}
actor2.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count2.Add(1)
return nil
}
time.Sleep(50 * time.Millisecond)
if count1.Load() != 1 {
t.Errorf("expected actor1 to execute 1 function, got %d", count1.Load())
}
if count2.Load() != 1 {
t.Errorf("expected actor2 to execute 1 function, got %d", count2.Load())
}
cancel()
<-errChan1
<-errChan2
})
t.Run("actor stops on first error", func(t *testing.T) {
actor := &Actor{
DB: nil,
ActionChan: make(chan Func, 10),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errChan := make(chan error, 1)
go func() {
errChan <- actor.Run(ctx)
}()
var count atomic.Int32
expectedErr := errors.New("stop error")
// Queue multiple functions
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count.Add(1)
return nil
}
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count.Add(1)
return expectedErr // This should stop the actor
}
actor.ActionChan <- func(ctx context.Context, db *sql.DB) error {
count.Add(1)
return nil // Should not be executed
}
// Wait for error
err := <-errChan
if err != expectedErr {
t.Errorf("expected error %v, got %v", expectedErr, err)
}
// Should have executed 2 functions (stopped on the error)
if count.Load() != 2 {
t.Errorf("expected 2 executions before stop, got %d", count.Load())
}
})
}
func TestFuncType(t *testing.T) {
t.Run("Func type signature", func(t *testing.T) {
// Verify Func type can be used
var f Func = func(ctx context.Context, db *sql.DB) error {
return nil
}
if f == nil {
t.Error("expected non-nil Func")
}
})
t.Run("Func with error", func(t *testing.T) {
testErr := errors.New("test error")
var f Func = func(ctx context.Context, db *sql.DB) error {
return testErr
}
err := f(context.Background(), nil)
if err != testErr {
t.Errorf("expected error %v, got %v", testErr, err)
}
})
t.Run("Func with nil error", func(t *testing.T) {
var f Func = func(ctx context.Context, db *sql.DB) error {
return nil
}
err := f(context.Background(), nil)
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
})
}

View File

@@ -7,9 +7,21 @@ Example usage:
ctx, _ := context.WithCancel(context.Background())
dba = &db.Actor{
DB: s.db,
ActionChan: make(chan db.Func),
ActionChan: make(chan database.Func),
}
go dba.Run(ctx)
*/
/*
Transactor Example:
func insert(ctx context.Context, db *sql.DB) error{
// SQL HERE
}
...
dba.ActionChan <- db.WithTransaction(insert)
*/

40
database/transactor.go Normal file
View File

@@ -0,0 +1,40 @@
package database
import (
"context"
"database/sql"
"fmt"
)
// WithinTransaction wraps a database function to execute within a transaction.
// It begins a transaction, executes the function, and commits on success or rolls back on error.
//
// This is a functional equivalent of the Transactor interface pattern described by Thibaut Rousseau:
// https://blog.thibaut-rousseau.com/blog/sql-transactions-in-go-the-good-way/
//
// Example:
//
// insert := func(ctx context.Context, db *sql.DB) error {
// _, err := db.ExecContext(ctx, "INSERT INTO users (...) VALUES (...)")
// return err
// }
// actor.ActionChan <- WithinTransaction(insert)
func WithinTransaction(f Func) Func {
return func(ctx context.Context, db *sql.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
if err := f(ctx, db); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
}

View File

@@ -1,6 +1,10 @@
// Package encoding provides generic encoder and decoder function types.
package encoding
import "io"
// Encoder is a function that encodes data to a writer.
type Encoder func(io.Writer, interface{}) error
// Decoder is a function that decodes data from a reader.
type Decoder func(io.Reader, interface{}) error

54
encoding/csv/csv.go Executable file
View File

@@ -0,0 +1,54 @@
// Package csv provides utilities for loading and working with CSV data.
package csv
import (
"encoding/csv"
"io"
"os"
"git.sdf.org/jchenry/x"
osx "git.sdf.org/jchenry/x/os"
)
// CSV represents parsed CSV data with a header row and data rows.
type CSV struct {
// Header contains the column names from the first row
Header []string
// Data contains all remaining rows
Data [][]string
}
// LoadCSVFromFile loads CSV data from a file, treating the first row as a header.
// The file must not be nil or this function will panic.
// Returns the parsed CSV data or an error if reading fails.
func LoadCSVFromFile(f *os.File) (c *CSV, err error) {
x.Assert(f != nil, "LoadCSVFromFile: f is nil")
csvFile := osx.FileOrStdin(f)
defer csvFile.Close()
return LoadCSVFromReader(csvFile)
}
// LoadCSVFromReader loads CSV data from a reader, treating the first row as a header.
// The reader must not be nil or this function will panic.
// Returns the parsed CSV data or an error if reading fails.
func LoadCSVFromReader(r io.Reader) (c *CSV, err error) {
x.Assert(r != nil, "LoadCSVFromReader: r is nil")
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return nil, err
}
data, err := reader.ReadAll()
if err != nil {
return nil, err
}
csvData := &CSV{
Header: header,
Data: data,
}
return csvData, nil
}

195
encoding/csv/csv_test.go Normal file
View File

@@ -0,0 +1,195 @@
package csv
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadCSVFromReader(t *testing.T) {
t.Run("successful load", func(t *testing.T) {
input := `Name,Age,City
Alice,30,NYC
Bob,25,LA`
reader := strings.NewReader(input)
csv, err := LoadCSVFromReader(reader)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(csv.Header) != 3 {
t.Fatalf("expected 3 header columns, got %d", len(csv.Header))
}
expectedHeaders := []string{"Name", "Age", "City"}
for i, expected := range expectedHeaders {
if csv.Header[i] != expected {
t.Errorf("header[%d]: expected %q, got %q", i, expected, csv.Header[i])
}
}
if len(csv.Data) != 2 {
t.Fatalf("expected 2 data rows, got %d", len(csv.Data))
}
if csv.Data[0][0] != "Alice" {
t.Errorf("expected first row name to be 'Alice', got %q", csv.Data[0][0])
}
if csv.Data[1][0] != "Bob" {
t.Errorf("expected second row name to be 'Bob', got %q", csv.Data[1][0])
}
})
t.Run("empty CSV", func(t *testing.T) {
input := `Header`
reader := strings.NewReader(input)
csv, err := LoadCSVFromReader(reader)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(csv.Header) != 1 {
t.Fatalf("expected 1 header column, got %d", len(csv.Header))
}
if csv.Header[0] != "Header" {
t.Errorf("expected header 'Header', got %q", csv.Header[0])
}
if len(csv.Data) != 0 {
t.Errorf("expected empty data, got %d rows", len(csv.Data))
}
})
t.Run("CSV with quoted fields", func(t *testing.T) {
input := `Name,Description
"John Doe","A person with a comma, in description"`
reader := strings.NewReader(input)
csv, err := LoadCSVFromReader(reader)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if csv.Data[0][0] != "John Doe" {
t.Errorf("expected quoted name 'John Doe', got %q", csv.Data[0][0])
}
if csv.Data[0][1] != "A person with a comma, in description" {
t.Errorf("expected quoted description, got %q", csv.Data[0][1])
}
})
t.Run("malformed CSV", func(t *testing.T) {
input := `Name,Age
Alice,30
Bob,25,ExtraField`
reader := strings.NewReader(input)
csv, err := LoadCSVFromReader(reader)
if err == nil {
t.Fatal("expected error for malformed CSV, got nil")
}
if csv != nil {
t.Error("expected nil CSV on error")
}
})
t.Run("empty input", func(t *testing.T) {
reader := strings.NewReader("")
csv, err := LoadCSVFromReader(reader)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
if csv != nil {
t.Error("expected nil CSV on error")
}
})
}
func TestLoadCSVFromFile(t *testing.T) {
t.Run("successful load from file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.csv")
content := `Name,Age
Alice,30
Bob,25`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
defer file.Close()
csv, err := LoadCSVFromFile(file)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(csv.Header) != 2 {
t.Fatalf("expected 2 header columns, got %d", len(csv.Header))
}
if csv.Header[0] != "Name" || csv.Header[1] != "Age" {
t.Errorf("unexpected headers: %v", csv.Header)
}
if len(csv.Data) != 2 {
t.Fatalf("expected 2 data rows, got %d", len(csv.Data))
}
})
t.Run("load from nil file uses stdin", func(t *testing.T) {
// This test documents the behavior - passing nil uses stdin
// We can't easily test stdin, so we just verify it doesn't panic
// The actual assertion is in the LoadCSVFromFile function
defer func() {
if r := recover(); r != nil {
t.Errorf("unexpected panic: %v", r)
}
}()
// Note: This will try to read from stdin and likely error or hang
// In a real scenario, we would mock stdin
// For now, we just test with an actual file
})
t.Run("file with single header", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.csv")
content := `SingleColumn`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
defer file.Close()
csv, err := LoadCSVFromFile(file)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(csv.Header) != 1 || csv.Header[0] != "SingleColumn" {
t.Errorf("unexpected header: %v", csv.Header)
}
if len(csv.Data) != 0 {
t.Errorf("expected no data rows, got %d", len(csv.Data))
}
})
}

View File

@@ -1,3 +1,4 @@
// Package json provides JSON encoder and decoder functions.
package json
import (
@@ -5,10 +6,12 @@ import (
"io"
)
// Encoder encodes data as JSON to a writer.
func Encoder(w io.Writer, e interface{}) error {
return json.NewEncoder(w).Encode(e)
}
// Decoder decodes JSON data from a reader.
func Decoder(r io.Reader, e interface{}) error {
return json.NewDecoder(r).Decode(e)
}

405
encoding/json/json_test.go Normal file
View File

@@ -0,0 +1,405 @@
package json
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
type testStruct struct {
Name string `json:"name"`
Value int `json:"value"`
}
func TestEncoder(t *testing.T) {
t.Run("successful encode", func(t *testing.T) {
var buf bytes.Buffer
data := testStruct{Name: "test", Value: 42}
err := Encoder(&buf, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expected := `{"name":"test","value":42}`
result := strings.TrimSpace(buf.String())
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
})
t.Run("encode nil", func(t *testing.T) {
var buf bytes.Buffer
err := Encoder(&buf, nil)
if err != nil {
t.Fatalf("expected no error encoding nil, got %v", err)
}
result := strings.TrimSpace(buf.String())
if result != "null" {
t.Errorf("expected 'null', got %q", result)
}
})
t.Run("encode slice", func(t *testing.T) {
var buf bytes.Buffer
data := []int{1, 2, 3}
err := Encoder(&buf, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expected := `[1,2,3]`
result := strings.TrimSpace(buf.String())
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
})
t.Run("encode map", func(t *testing.T) {
var buf bytes.Buffer
data := map[string]string{"key": "value"}
err := Encoder(&buf, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expected := `{"key":"value"}`
result := strings.TrimSpace(buf.String())
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
})
}
func TestDecoder(t *testing.T) {
t.Run("successful decode", func(t *testing.T) {
input := `{"name":"test","value":42}`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Name != "test" {
t.Errorf("expected name 'test', got %q", result.Name)
}
if result.Value != 42 {
t.Errorf("expected value 42, got %d", result.Value)
}
})
t.Run("decode empty object", func(t *testing.T) {
input := `{}`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Name != "" {
t.Errorf("expected empty name, got %q", result.Name)
}
if result.Value != 0 {
t.Errorf("expected zero value, got %d", result.Value)
}
})
t.Run("decode array", func(t *testing.T) {
input := `[1,2,3]`
reader := strings.NewReader(input)
var result []int
err := Decoder(reader, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 elements, got %d", len(result))
}
for i, expected := range []int{1, 2, 3} {
if result[i] != expected {
t.Errorf("element %d: expected %d, got %d", i, expected, result[i])
}
}
})
t.Run("decode invalid JSON", func(t *testing.T) {
input := `{invalid json}`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
})
t.Run("decode empty input", func(t *testing.T) {
reader := strings.NewReader("")
var result testStruct
err := Decoder(reader, &result)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
})
}
func TestUnknown(t *testing.T) {
t.Run("unmarshal object", func(t *testing.T) {
data := []byte(`{"key":"value"}`)
var u Unknown
err := u.UnmarshalJSON(data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if u.Object == nil {
t.Fatal("expected Object to be set")
}
if u.Array != nil {
t.Error("expected Array to be nil")
}
obj := *u.Object
if obj["key"] != "value" {
t.Errorf("expected key='value', got %v", obj["key"])
}
})
t.Run("unmarshal array", func(t *testing.T) {
data := []byte(`[1,2,3]`)
var u Unknown
err := u.UnmarshalJSON(data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if u.Array == nil {
t.Fatal("expected Array to be set")
}
if u.Object != nil {
t.Error("expected Object to be nil")
}
arr := *u.Array
if len(arr) != 3 {
t.Errorf("expected 3 elements, got %d", len(arr))
}
})
t.Run("resolve object", func(t *testing.T) {
obj := Object{"test": "value"}
u := Unknown{Object: &obj}
result := u.Resolve()
if result == nil {
t.Fatal("expected non-nil result")
}
resolved, ok := result.(*Object)
if !ok {
t.Fatal("expected result to be *Object")
}
if (*resolved)["test"] != "value" {
t.Error("unexpected resolved value")
}
})
t.Run("resolve array", func(t *testing.T) {
arr := Array{1, 2, 3}
u := Unknown{Array: &arr}
result := u.Resolve()
if result == nil {
t.Fatal("expected non-nil result")
}
resolved, ok := result.(*Array)
if !ok {
t.Fatal("expected result to be *Array")
}
if len(*resolved) != 3 {
t.Error("unexpected resolved length")
}
})
t.Run("resolve uninitialized panics", func(t *testing.T) {
u := Unknown{}
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for uninitialized Unknown")
}
}()
u.Resolve()
})
}
func TestLoadJSONFromReader(t *testing.T) {
t.Run("successful load", func(t *testing.T) {
input := `{"name":"test","value":42}`
reader := strings.NewReader(input)
data, err := LoadJSONFromReader(reader)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(data) != input {
t.Errorf("expected %q, got %q", input, string(data))
}
})
t.Run("empty input", func(t *testing.T) {
reader := strings.NewReader("")
data, err := LoadJSONFromReader(reader)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(data) != 0 {
t.Errorf("expected empty data, got %d bytes", len(data))
}
})
}
func TestLoadJSONFromFile(t *testing.T) {
t.Run("successful load", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.json")
content := `{"name":"test","value":42}`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
data, err := LoadJSONFromFile(file)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(data) != content {
t.Errorf("expected %q, got %q", content, string(data))
}
})
t.Run("empty file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "empty.json")
err := os.WriteFile(filePath, []byte(""), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
data, err := LoadJSONFromFile(file)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(data) != 0 {
t.Errorf("expected empty data, got %d bytes", len(data))
}
})
}
func TestLoadJSONFromFileToType(t *testing.T) {
t.Run("successful load and unmarshal", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.json")
content := `{"name":"test","value":42}`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
var result testStruct
err = LoadJSONFromFileToType(file, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Name != "test" {
t.Errorf("expected name 'test', got %q", result.Name)
}
if result.Value != 42 {
t.Errorf("expected value 42, got %d", result.Value)
}
})
t.Run("invalid JSON", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "invalid.json")
content := `{invalid json}`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
var result testStruct
err = LoadJSONFromFileToType(file, &result)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
})
t.Run("empty file", func(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "empty.json")
err := os.WriteFile(filePath, []byte(""), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
t.Fatalf("failed to open test file: %v", err)
}
var result testStruct
err = LoadJSONFromFileToType(file, &result)
if err == nil {
t.Fatal("expected error for empty JSON, got nil")
}
})
}

70
encoding/json/obj.go Executable file
View File

@@ -0,0 +1,70 @@
package json
import (
"encoding/json"
std "encoding/json"
"io"
"os"
"git.sdf.org/jchenry/x"
)
type Object map[string]any
type Array []any
type Unknown struct {
Object *Object
Array *Array
}
func (u *Unknown) UnmarshalJSON(data []byte) error {
var err error
switch data[0] {
case '{':
u.Object = &Object{}
err = std.Unmarshal(data, u.Object)
case '[':
u.Array = &Array{}
err = std.Unmarshal(data, u.Array)
}
return err
}
func (u *Unknown) Resolve() any {
if u.Object != nil {
return u.Object
} else if u.Array != nil {
return u.Array
} else {
panic("unresolvable type")
}
}
func LoadJSONFromFileToType(jsn *os.File, v any) error {
x.Assert(jsn != nil, "LoadJSONFromFileToType: jsn is nil")
x.Assert(v != nil, "LoadJSONFromFileToType: v is nil")
jsnBytes, err := LoadJSONFromFile(jsn)
if err != nil {
return err
}
err = json.Unmarshal(jsnBytes, v)
if err != nil {
return err
}
return nil
}
func LoadJSONFromFile(jsn *os.File) (jsnString []byte, err error) {
x.Assert(jsn != nil, "LoadJSONFromFile: jsn is nil")
defer jsn.Close()
return LoadJSONFromReader(jsn)
}
func LoadJSONFromReader(r io.Reader) (jsnString []byte, err error) {
x.Assert(r != nil, "LoadJSONFromReader: r is nil")
jsnBytes, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return jsnBytes, nil
}

View File

@@ -1,3 +1,4 @@
// Package xml provides XML encoder and decoder functions.
package xml
import (
@@ -5,10 +6,12 @@ import (
"io"
)
// Encoder encodes data as XML to a writer.
func Encoder(w io.Writer, e interface{}) error {
return xml.NewEncoder(w).Encode(e)
}
// Decoder decodes XML data from a reader.
func Decoder(r io.Reader, e interface{}) error {
return xml.NewDecoder(r).Decode(e)
}

113
encoding/xml/xml_test.go Normal file
View File

@@ -0,0 +1,113 @@
package xml
import (
"bytes"
"strings"
"testing"
)
type testStruct struct {
XMLName struct{} `xml:"root"`
Name string `xml:"name"`
Value int `xml:"value"`
}
func TestEncoder(t *testing.T) {
t.Run("successful encode", func(t *testing.T) {
var buf bytes.Buffer
data := testStruct{Name: "test", Value: 42}
err := Encoder(&buf, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
result := buf.String()
if !strings.Contains(result, "<name>test</name>") {
t.Errorf("expected result to contain '<name>test</name>', got %q", result)
}
if !strings.Contains(result, "<value>42</value>") {
t.Errorf("expected result to contain '<value>42</value>', got %q", result)
}
})
t.Run("encode slice", func(t *testing.T) {
type Item struct {
Value int `xml:"value"`
}
var buf bytes.Buffer
data := []Item{{Value: 1}, {Value: 2}}
err := Encoder(&buf, data)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
result := buf.String()
if !strings.Contains(result, "<value>1</value>") {
t.Error("expected result to contain first item")
}
if !strings.Contains(result, "<value>2</value>") {
t.Error("expected result to contain second item")
}
})
}
func TestDecoder(t *testing.T) {
t.Run("successful decode", func(t *testing.T) {
input := `<root><name>test</name><value>42</value></root>`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Name != "test" {
t.Errorf("expected name 'test', got %q", result.Name)
}
if result.Value != 42 {
t.Errorf("expected value 42, got %d", result.Value)
}
})
t.Run("decode empty XML", func(t *testing.T) {
input := `<root></root>`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Name != "" {
t.Errorf("expected empty name, got %q", result.Name)
}
if result.Value != 0 {
t.Errorf("expected zero value, got %d", result.Value)
}
})
t.Run("decode invalid XML", func(t *testing.T) {
input := `<invalid><unclosed>`
reader := strings.NewReader(input)
var result testStruct
err := Decoder(reader, &result)
if err == nil {
t.Fatal("expected error for invalid XML, got nil")
}
})
t.Run("decode empty input", func(t *testing.T) {
reader := strings.NewReader("")
var result testStruct
err := Decoder(reader, &result)
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
})
}

4
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/jchenry/x
module git.sdf.org/jchenry/x
go 1.18
go 1.22

29
image/filter/blur.go Normal file
View File

@@ -0,0 +1,29 @@
package filter
import (
"image"
"image/color"
)
func BoxBlur(src *image.Gray, r int) *image.Gray {
b := src.Bounds()
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
var sum, n float64
for dy := -r; dy <= r; dy++ {
for dx := -r; dx <= r; dx++ {
nx, ny := x+dx, y+dy
if nx >= b.Min.X && nx < b.Max.X &&
ny >= b.Min.Y && ny < b.Max.Y {
sum += float64(src.GrayAt(nx, ny).Y)
n++
}
}
}
out.SetGray(x, y, color.Gray{Y: uint8(sum / n)})
}
}
return out
}

55
image/filter/coverage.out Normal file
View File

@@ -0,0 +1,55 @@
mode: set
git.sdf.org/jchenry/x/image/filter/blur.go:8.50,12.40 3 1
git.sdf.org/jchenry/x/image/filter/blur.go:12.40,13.44 1 1
git.sdf.org/jchenry/x/image/filter/blur.go:13.44,15.41 2 1
git.sdf.org/jchenry/x/image/filter/blur.go:15.41,16.45 1 1
git.sdf.org/jchenry/x/image/filter/blur.go:16.45,19.55 2 1
git.sdf.org/jchenry/x/image/filter/blur.go:19.55,22.22 2 1
git.sdf.org/jchenry/x/image/filter/blur.go:25.13,25.61 1 1
git.sdf.org/jchenry/x/image/filter/blur.go:28.5,28.15 1 1
git.sdf.org/jchenry/x/image/filter/dither.go:9.59,17.40 5 1
git.sdf.org/jchenry/x/image/filter/dither.go:17.40,18.44 1 1
git.sdf.org/jchenry/x/image/filter/dither.go:18.44,21.32 3 1
git.sdf.org/jchenry/x/image/filter/dither.go:21.32,23.14 1 1
git.sdf.org/jchenry/x/image/filter/dither.go:23.19,25.14 1 1
git.sdf.org/jchenry/x/image/filter/dither.go:26.13,30.44 3 1
git.sdf.org/jchenry/x/image/filter/dither.go:30.44,33.51 2 1
git.sdf.org/jchenry/x/image/filter/dither.go:33.51,36.18 2 1
git.sdf.org/jchenry/x/image/filter/dither.go:39.13,42.29 4 1
git.sdf.org/jchenry/x/image/filter/dither.go:45.5,45.15 1 1
git.sdf.org/jchenry/x/image/filter/grayscale.go:8.45,12.40 3 1
git.sdf.org/jchenry/x/image/filter/grayscale.go:12.40,13.44 1 1
git.sdf.org/jchenry/x/image/filter/grayscale.go:13.44,21.10 3 1
git.sdf.org/jchenry/x/image/filter/grayscale.go:23.5,23.15 1 1
git.sdf.org/jchenry/x/image/filter/resize.go:5.59,10.14 4 1
git.sdf.org/jchenry/x/image/filter/resize.go:10.14,13.6 2 1
git.sdf.org/jchenry/x/image/filter/resize.go:13.11,16.6 2 1
git.sdf.org/jchenry/x/image/filter/resize.go:18.5,19.29 2 1
git.sdf.org/jchenry/x/image/filter/resize.go:19.29,20.33 1 1
git.sdf.org/jchenry/x/image/filter/resize.go:20.33,24.10 3 1
git.sdf.org/jchenry/x/image/filter/resize.go:26.5,26.15 1 1
git.sdf.org/jchenry/x/image/filter/sigmoid.go:9.71,13.40 3 1
git.sdf.org/jchenry/x/image/filter/sigmoid.go:13.40,14.44 1 1
git.sdf.org/jchenry/x/image/filter/sigmoid.go:14.44,18.10 3 1
git.sdf.org/jchenry/x/image/filter/sigmoid.go:20.5,20.15 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:8.65,12.40 3 1
git.sdf.org/jchenry/x/image/filter/stretch.go:12.40,13.44 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:13.44,15.24 2 1
git.sdf.org/jchenry/x/image/filter/stretch.go:15.24,17.14 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:18.13,18.24 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:18.24,20.14 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:24.5,27.17 4 1
git.sdf.org/jchenry/x/image/filter/stretch.go:27.17,29.6 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:31.5,32.40 2 1
git.sdf.org/jchenry/x/image/filter/stretch.go:32.40,33.44 1 1
git.sdf.org/jchenry/x/image/filter/stretch.go:33.44,38.10 2 1
git.sdf.org/jchenry/x/image/filter/stretch.go:40.5,40.15 1 1
git.sdf.org/jchenry/x/image/filter/unsharp.go:8.71,13.40 4 1
git.sdf.org/jchenry/x/image/filter/unsharp.go:13.40,14.44 1 1
git.sdf.org/jchenry/x/image/filter/unsharp.go:14.44,20.10 3 1
git.sdf.org/jchenry/x/image/filter/unsharp.go:22.5,22.15 1 1
git.sdf.org/jchenry/x/image/filter/util.go:3.29,4.14 1 1
git.sdf.org/jchenry/x/image/filter/util.go:4.14,6.6 1 1
git.sdf.org/jchenry/x/image/filter/util.go:7.5,7.16 1 1
git.sdf.org/jchenry/x/image/filter/util.go:7.16,9.6 1 1
git.sdf.org/jchenry/x/image/filter/util.go:10.5,10.20 1 1

46
image/filter/dither.go Normal file
View File

@@ -0,0 +1,46 @@
package filter
import (
"image"
"image/color"
"image/draw"
)
func Dither(src *image.Gray, threshold uint8) *image.Gray {
b := src.Bounds()
work := image.NewGray(b)
draw.Draw(work, b, src, b.Min, draw.Src)
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
old := work.GrayAt(x, y).Y
var new uint8
if old > threshold {
new = 255
} else {
new = 0
}
out.SetGray(x, y, color.Gray{Y: new})
err := int(old) - int(new)
diffuse := func(dx, dy, w int) {
nx, ny := x+dx, y+dy
if nx >= b.Min.X && nx < b.Max.X &&
ny >= b.Min.Y && ny < b.Max.Y {
v := int(work.GrayAt(nx, ny).Y) + err*w/16
work.SetGray(nx, ny, color.Gray{Y: clamp(float64(v))})
}
}
diffuse(1, 0, 7)
diffuse(-1, 1, 3)
diffuse(0, 1, 5)
diffuse(1, 1, 1)
}
}
return out
}

3
image/filter/doc.go Normal file
View File

@@ -0,0 +1,3 @@
package filter
// boosted with gratitude from https://github.com/nobloat/blog

View File

@@ -0,0 +1,63 @@
package filter_test
import (
"image"
"image/png"
"log"
"os"
"path"
"path/filepath"
"strings"
"git.sdf.org/jchenry/x/image/filter"
)
func Example_pipeline() {
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
gray := filter.Grayscale(img)
gray = filter.Unsharp(gray, 2, 1.2)
gray = filter.Stretch(gray, 0.05, 0.95)
bw := filter.Dither(gray, 127)
_ = bw
}
func Example_fullcode() {
maxLongEdge := 400
in := ""
out := path.Join(
"public",
"images",
strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))+".png",
)
f, err := os.Open(in)
if err != nil {
log.Fatal(err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
log.Fatal(err)
}
img = filter.ResizeLongEdge(img, maxLongEdge)
gray := filter.Grayscale(img)
bw := filter.Dither(gray, 127)
o, err := os.Create(out)
if err != nil {
log.Fatal(err)
}
defer o.Close()
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(o, bw); err != nil {
log.Fatal(err)
}
}

459
image/filter/filter_test.go Normal file
View File

@@ -0,0 +1,459 @@
package filter
import (
"image"
"image/color"
"testing"
)
// Helper function to create a test grayscale image
func createTestGray(w, h int, fill uint8) *image.Gray {
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetGray(x, y, color.Gray{Y: fill})
}
}
return img
}
// Helper function to create a gradient test grayscale image
func createGradientGray(w, h int) *image.Gray {
img := image.NewGray(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := uint8(float64(x) / float64(w) * 255)
img.SetGray(x, y, color.Gray{Y: v})
}
}
return img
}
// Helper function to create a test RGBA image
func createTestRGBA(w, h int, c color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
return img
}
func TestClamp(t *testing.T) {
tests := []struct {
name string
input float64
want uint8
}{
{"negative value", -10.0, 0},
{"zero", 0.0, 0},
{"normal value", 127.5, 127},
{"max value", 255.0, 255},
{"over max", 300.0, 255},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clamp(tt.input)
if got != tt.want {
t.Errorf("clamp(%v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestBoxBlur(t *testing.T) {
t.Run("blur uniform image", func(t *testing.T) {
src := createTestGray(10, 10, 128)
result := BoxBlur(src, 1)
if result == nil {
t.Fatal("BoxBlur returned nil")
}
// Uniform image should remain uniform after blur
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
if result.GrayAt(x, y).Y != 128 {
t.Errorf("Expected uniform value 128, got %d at (%d,%d)",
result.GrayAt(x, y).Y, x, y)
return
}
}
}
})
t.Run("blur with radius 0", func(t *testing.T) {
src := createGradientGray(10, 10)
result := BoxBlur(src, 0)
if result == nil {
t.Fatal("BoxBlur returned nil")
}
// Radius 0 should return similar image
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
if result.GrayAt(x, y).Y != src.GrayAt(x, y).Y {
t.Errorf("Radius 0 changed pixel at (%d,%d)", x, y)
return
}
}
}
})
t.Run("blur preserves bounds", func(t *testing.T) {
src := createTestGray(20, 15, 100)
result := BoxBlur(src, 2)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
}
func TestDither(t *testing.T) {
t.Run("dither produces binary output", func(t *testing.T) {
src := createGradientGray(10, 10)
result := Dither(src, 128)
if result == nil {
t.Fatal("Dither returned nil")
}
// All pixels should be either 0 or 255
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
v := result.GrayAt(x, y).Y
if v != 0 && v != 255 {
t.Errorf("Expected binary value (0 or 255), got %d at (%d,%d)", v, x, y)
}
}
}
})
t.Run("dither white image stays white", func(t *testing.T) {
src := createTestGray(10, 10, 255)
result := Dither(src, 128)
// All pixels should be white
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
if result.GrayAt(x, y).Y != 255 {
t.Errorf("Expected white (255), got %d at (%d,%d)",
result.GrayAt(x, y).Y, x, y)
return
}
}
}
})
t.Run("dither black image stays black", func(t *testing.T) {
src := createTestGray(10, 10, 0)
result := Dither(src, 128)
// All pixels should be black
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
if result.GrayAt(x, y).Y != 0 {
t.Errorf("Expected black (0), got %d at (%d,%d)",
result.GrayAt(x, y).Y, x, y)
return
}
}
}
})
t.Run("dither preserves bounds", func(t *testing.T) {
src := createTestGray(20, 15, 100)
result := Dither(src, 128)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
}
func TestGrayscale(t *testing.T) {
t.Run("convert RGBA to grayscale", func(t *testing.T) {
src := createTestRGBA(10, 10, color.RGBA{R: 255, G: 0, B: 0, A: 255})
result := Grayscale(src)
if result == nil {
t.Fatal("Grayscale returned nil")
}
// Red should convert to specific luma value (based on 0.2126 weight)
// RGBA returns values in 0-65535 range, so R=255 becomes 65535
// Just verify it's in a reasonable range instead of exact value
v := result.GrayAt(0, 0).Y
if v < 50 || v > 60 {
t.Errorf("Red conversion out of expected range: got %d, want ~54", v)
}
})
t.Run("convert white to grayscale", func(t *testing.T) {
src := createTestRGBA(5, 5, color.RGBA{R: 255, G: 255, B: 255, A: 255})
result := Grayscale(src)
// White should stay white
for y := 0; y < 5; y++ {
for x := 0; x < 5; x++ {
if result.GrayAt(x, y).Y != 255 {
t.Errorf("Expected 255, got %d at (%d,%d)",
result.GrayAt(x, y).Y, x, y)
return
}
}
}
})
t.Run("convert black to grayscale", func(t *testing.T) {
src := createTestRGBA(5, 5, color.RGBA{R: 0, G: 0, B: 0, A: 255})
result := Grayscale(src)
// Black should stay black
for y := 0; y < 5; y++ {
for x := 0; x < 5; x++ {
if result.GrayAt(x, y).Y != 0 {
t.Errorf("Expected 0, got %d at (%d,%d)",
result.GrayAt(x, y).Y, x, y)
return
}
}
}
})
t.Run("grayscale preserves bounds", func(t *testing.T) {
src := createTestRGBA(20, 15, color.RGBA{R: 100, G: 100, B: 100, A: 255})
result := Grayscale(src)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
}
func TestResizeLongEdge(t *testing.T) {
t.Run("resize landscape image", func(t *testing.T) {
src := createTestRGBA(100, 50, color.RGBA{R: 128, G: 128, B: 128, A: 255})
result := ResizeLongEdge(src, 50)
if result == nil {
t.Fatal("ResizeLongEdge returned nil")
}
// Width should be 50, height should be 25
bounds := result.Bounds()
if bounds.Dx() != 50 || bounds.Dy() != 25 {
t.Errorf("Expected 50x25, got %dx%d", bounds.Dx(), bounds.Dy())
}
})
t.Run("resize portrait image", func(t *testing.T) {
src := createTestRGBA(50, 100, color.RGBA{R: 128, G: 128, B: 128, A: 255})
result := ResizeLongEdge(src, 50)
if result == nil {
t.Fatal("ResizeLongEdge returned nil")
}
// Height should be 50, width should be 25
bounds := result.Bounds()
if bounds.Dx() != 25 || bounds.Dy() != 50 {
t.Errorf("Expected 25x50, got %dx%d", bounds.Dx(), bounds.Dy())
}
})
t.Run("resize square image", func(t *testing.T) {
src := createTestRGBA(100, 100, color.RGBA{R: 128, G: 128, B: 128, A: 255})
result := ResizeLongEdge(src, 50)
if result == nil {
t.Fatal("ResizeLongEdge returned nil")
}
// Both dimensions should be 50
bounds := result.Bounds()
if bounds.Dx() != 50 || bounds.Dy() != 50 {
t.Errorf("Expected 50x50, got %dx%d", bounds.Dx(), bounds.Dy())
}
})
t.Run("resize bounds start at origin", func(t *testing.T) {
src := createTestRGBA(100, 80, color.RGBA{R: 128, G: 128, B: 128, A: 255})
result := ResizeLongEdge(src, 40)
bounds := result.Bounds()
if bounds.Min.X != 0 || bounds.Min.Y != 0 {
t.Errorf("Expected bounds to start at (0,0), got (%d,%d)",
bounds.Min.X, bounds.Min.Y)
}
})
}
func TestSigmoid(t *testing.T) {
t.Run("sigmoid with zero contrast", func(t *testing.T) {
src := createGradientGray(10, 10)
result := Sigmoid(src, 0, 0.5)
if result == nil {
t.Fatal("Sigmoid returned nil")
}
// With zero contrast, all values should map to near midpoint
midValue := result.GrayAt(5, 5).Y
if midValue < 125 || midValue > 130 {
t.Errorf("Expected value near 127, got %d", midValue)
}
})
t.Run("sigmoid preserves bounds", func(t *testing.T) {
src := createTestGray(20, 15, 100)
result := Sigmoid(src, 5, 0.5)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
t.Run("sigmoid produces valid range", func(t *testing.T) {
src := createGradientGray(20, 20)
result := Sigmoid(src, 10, 0.5)
// All values should be in valid range
for y := 0; y < 20; y++ {
for x := 0; x < 20; x++ {
v := result.GrayAt(x, y).Y
if v > 255 {
t.Errorf("Value out of range: %d at (%d,%d)", v, x, y)
return
}
}
}
})
}
func TestStretch(t *testing.T) {
t.Run("stretch with zero range returns source", func(t *testing.T) {
src := createTestGray(10, 10, 128)
result := Stretch(src, 0, 1)
if result != src {
t.Error("Expected source image to be returned for zero range")
}
})
t.Run("stretch full range", func(t *testing.T) {
// Create image with limited range (50-200)
img := image.NewGray(image.Rect(0, 0, 10, 10))
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
v := uint8(50 + float64(x)/9.0*150)
img.SetGray(x, y, color.Gray{Y: v})
}
}
result := Stretch(img, 0, 1)
if result == nil {
t.Fatal("Stretch returned nil")
}
// First pixel should be near 0, last should be near 255
first := result.GrayAt(0, 0).Y
last := result.GrayAt(9, 0).Y
if first > 5 {
t.Errorf("Expected first pixel near 0, got %d", first)
}
if last < 250 {
t.Errorf("Expected last pixel near 255, got %d", last)
}
})
t.Run("stretch preserves bounds", func(t *testing.T) {
src := createGradientGray(20, 15)
result := Stretch(src, 0, 1)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
t.Run("stretch with partial range", func(t *testing.T) {
src := createGradientGray(20, 20)
result := Stretch(src, 0.25, 0.75)
if result == nil {
t.Fatal("Stretch returned nil")
}
// Result should have values across full range
hasLow := false
hasHigh := false
for y := 0; y < 20; y++ {
for x := 0; x < 20; x++ {
v := result.GrayAt(x, y).Y
if v < 50 {
hasLow = true
}
if v > 200 {
hasHigh = true
}
}
}
if !hasLow || !hasHigh {
t.Error("Expected stretched values across full range")
}
})
}
func TestUnsharp(t *testing.T) {
t.Run("unsharp with zero amount returns original", func(t *testing.T) {
src := createGradientGray(10, 10)
result := Unsharp(src, 1, 0)
if result == nil {
t.Fatal("Unsharp returned nil")
}
// With zero amount, formula is: orig + 0*(orig-blur) = orig
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
if result.GrayAt(x, y).Y != src.GrayAt(x, y).Y {
t.Errorf("Expected original value at (%d,%d): got %d, want %d",
x, y, result.GrayAt(x, y).Y, src.GrayAt(x, y).Y)
return
}
}
}
})
t.Run("unsharp preserves bounds", func(t *testing.T) {
src := createTestGray(20, 15, 100)
result := Unsharp(src, 2, 1.0)
if !result.Bounds().Eq(src.Bounds()) {
t.Errorf("Bounds changed: got %v, want %v", result.Bounds(), src.Bounds())
}
})
t.Run("unsharp clamps values", func(t *testing.T) {
src := createGradientGray(20, 20)
result := Unsharp(src, 1, 5.0)
// All values should be in valid range despite high amount
for y := 0; y < 20; y++ {
for x := 0; x < 20; x++ {
v := result.GrayAt(x, y).Y
if v > 255 {
t.Errorf("Value out of range: %d at (%d,%d)", v, x, y)
return
}
}
}
})
}

24
image/filter/grayscale.go Normal file
View File

@@ -0,0 +1,24 @@
package filter
import (
"image"
"image/color"
)
func Grayscale(src image.Image) *image.Gray {
b := src.Bounds()
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, b2, _ := src.At(x, y).RGBA()
luma := uint8(
(0.2126*float64(r) +
0.7152*float64(g) +
0.0722*float64(b2)) / 256,
)
out.SetGray(x, y, color.Gray{Y: luma})
}
}
return out
}

27
image/filter/resize.go Normal file
View File

@@ -0,0 +1,27 @@
package filter
import "image"
func ResizeLongEdge(src image.Image, max int) *image.RGBA {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
var nw, nh int
if w > h {
nw = max
nh = h * max / w
} else {
nh = max
nw = w * max / h
}
out := image.NewRGBA(image.Rect(0, 0, nw, nh))
for y := 0; y < nh; y++ {
for x := 0; x < nw; x++ {
sx := x * w / nw
sy := y * h / nh
out.Set(x, y, src.At(sx, sy))
}
}
return out
}

21
image/filter/sigmoid.go Normal file
View File

@@ -0,0 +1,21 @@
package filter
import (
"image"
"image/color"
"math"
)
func Sigmoid(src *image.Gray, contrast, midpoint float64) *image.Gray {
b := src.Bounds()
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
v := float64(src.GrayAt(x, y).Y) / 255.0
adj := 1.0 / (1.0 + math.Exp(contrast*(midpoint-v)))
out.SetGray(x, y, color.Gray{Y: uint8(adj * 255)})
}
}
return out
}

41
image/filter/stretch.go Normal file
View File

@@ -0,0 +1,41 @@
package filter
import (
"image"
"image/color"
)
func Stretch(src *image.Gray, black, white float64) *image.Gray {
b := src.Bounds()
min, max := uint8(255), uint8(0)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
v := src.GrayAt(x, y).Y
if v < min {
min = v
}
if v > max {
max = v
}
}
}
minF := float64(min) + float64(max-min)*black
maxF := float64(min) + float64(max-min)*white
rng := maxF - minF
if rng == 0 {
return src
}
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
v := float64(src.GrayAt(x, y).Y)
out.SetGray(x, y, color.Gray{
Y: clamp((v - minF) / rng * 255),
})
}
}
return out
}

23
image/filter/unsharp.go Normal file
View File

@@ -0,0 +1,23 @@
package filter
import (
"image"
"image/color"
)
func Unsharp(src *image.Gray, radius int, amount float64) *image.Gray {
b := src.Bounds()
blur := BoxBlur(src, radius)
out := image.NewGray(b)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
orig := float64(src.GrayAt(x, y).Y)
blr := float64(blur.GrayAt(x, y).Y)
out.SetGray(x, y, color.Gray{
Y: clamp(orig + amount*(orig-blr)),
})
}
}
return out
}

11
image/filter/util.go Normal file
View File

@@ -0,0 +1,11 @@
package filter
func clamp(v float64) uint8 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return uint8(v)
}

29
io/io.go Executable file
View File

@@ -0,0 +1,29 @@
// Package io provides utility functions for working with io.ReadCloser.
package io
import (
std "io"
"git.sdf.org/jchenry/x"
)
// ReadAllAndClose reads all data from a ReadCloser and closes it.
// The ReadCloser must not be nil or this function will panic.
// Returns the bytes read or an error if the read fails.
func ReadAllAndClose(rc std.ReadCloser) ([]byte, error) {
x.Assert(rc != nil, "ReadAllAndClose: rc is nil")
defer rc.Close()
return std.ReadAll(rc)
}
// ReadAllAndCloseAsString reads all data from a ReadCloser, closes it, and returns the content as a string.
// The ReadCloser must not be nil or this function will panic.
// Returns the string content or an error if the read fails.
func ReadAllAndCloseAsString(rc std.ReadCloser) (string, error) {
x.Assert(rc != nil, "ReadAllAndCloseAsString: rc is nil")
buf, err := ReadAllAndClose(rc)
if err != nil {
return "", err
}
return string(buf), nil
}

148
io/io_test.go Normal file
View File

@@ -0,0 +1,148 @@
package io
import (
"errors"
std "io"
"strings"
"testing"
)
type mockReadCloser struct {
reader std.Reader
closed bool
}
func (m *mockReadCloser) Read(p []byte) (n int, err error) {
return m.reader.Read(p)
}
func (m *mockReadCloser) Close() error {
m.closed = true
return nil
}
type errorReadCloser struct {
closeErr error
}
func (e *errorReadCloser) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
}
func (e *errorReadCloser) Close() error {
return e.closeErr
}
func TestReadAllAndClose(t *testing.T) {
t.Run("successful read and close", func(t *testing.T) {
content := "hello world"
mock := &mockReadCloser{reader: strings.NewReader(content)}
data, err := ReadAllAndClose(mock)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(data) != content {
t.Errorf("expected %q, got %q", content, string(data))
}
if !mock.closed {
t.Error("expected ReadCloser to be closed")
}
})
t.Run("read error", func(t *testing.T) {
errRC := &errorReadCloser{}
data, err := ReadAllAndClose(errRC)
if err == nil {
t.Fatal("expected error, got nil")
}
if len(data) != 0 {
t.Errorf("expected empty data on error, got %v", data)
}
})
t.Run("empty content", func(t *testing.T) {
mock := &mockReadCloser{reader: strings.NewReader("")}
data, err := ReadAllAndClose(mock)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(data) != 0 {
t.Errorf("expected empty data, got %v", data)
}
if !mock.closed {
t.Error("expected ReadCloser to be closed")
}
})
}
func TestReadAllAndCloseAsString(t *testing.T) {
t.Run("successful read and close", func(t *testing.T) {
content := "hello world"
mock := &mockReadCloser{reader: strings.NewReader(content)}
str, err := ReadAllAndCloseAsString(mock)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if str != content {
t.Errorf("expected %q, got %q", content, str)
}
if !mock.closed {
t.Error("expected ReadCloser to be closed")
}
})
t.Run("read error propagates", func(t *testing.T) {
errRC := &errorReadCloser{}
str, err := ReadAllAndCloseAsString(errRC)
if err == nil {
t.Fatal("expected error, got nil")
}
if str != "" {
t.Errorf("expected empty string on error, got %q", str)
}
})
t.Run("empty content", func(t *testing.T) {
mock := &mockReadCloser{reader: strings.NewReader("")}
str, err := ReadAllAndCloseAsString(mock)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if str != "" {
t.Errorf("expected empty string, got %q", str)
}
if !mock.closed {
t.Error("expected ReadCloser to be closed")
}
})
t.Run("unicode content", func(t *testing.T) {
content := "Hello 世界 🌍"
mock := &mockReadCloser{reader: strings.NewReader(content)}
str, err := ReadAllAndCloseAsString(mock)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if str != content {
t.Errorf("expected %q, got %q", content, str)
}
})
}

View File

@@ -1,15 +0,0 @@
package log
// Logger is a logging interface with only the essentials that a function that needs to log should care about. Compatible with standard Go logger.
type Logger interface {
Print(v ...interface{})
Printf(format string, v ...interface{})
Println(v ...interface{})
}
// None provides a logger that doesnt log anything
type None struct{}
func (n None) Print(v ...interface{}) {}
func (n None) Printf(format string, v ...interface{}) {}
func (n None) Println(v ...interface{}) {}

View File

@@ -5,10 +5,17 @@ import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"git.sdf.org/jchenry/x"
)
// BasicAuth wraps an HTTP handler with SHA1-hashed basic authentication.
// The htpasswd map contains username-to-password mappings (passwords are hashed with SHA1).
// The realm is used in the WWW-Authenticate header for unauthorized responses.
// Both htpasswd and realm must be non-empty or this function will panic.
func BasicAuth(h http.Handler, htpasswd map[string]string, realm string) http.HandlerFunc {
x.Assert(len(htpasswd) > 0, "http.BasicAuth: htpassword cannot be empty")
x.Assert(len(realm) > 0, "http.BasicAuth: realm cannot be empty")
rlm := fmt.Sprintf(`Basic realm="%s"`, realm)
sha1 := func(password string) string {
s := sha1.New()
@@ -18,9 +25,9 @@ func BasicAuth(h http.Handler, htpasswd map[string]string, realm string) http.Ha
}
return func(w http.ResponseWriter, r *http.Request) {
user, pass, _ := r.BasicAuth()
if pw, ok := htpasswd[user]; !ok || !strings.EqualFold(pass, sha1(pw)) {
if pw, ok := htpasswd[user]; !ok || pass != sha1(pw) {
w.Header().Set("WWW-Authenticate", rlm)
http.Error(w, "Unauthorized", 401)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)

29
net/http/download.go Executable file
View File

@@ -0,0 +1,29 @@
package http
import (
"fmt"
std "net/http"
osx "git.sdf.org/jchenry/x/os"
)
// DownloadFile downloads a file from the given URL and saves it to the specified path.
// Returns an error if the HTTP request fails, the response status is not 200 OK, or file writing fails.
func DownloadFile(url, path string) error {
resp, err := std.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != std.StatusOK {
return fmt.Errorf("download failed: unexpected status code %d: %s", resp.StatusCode, resp.Status)
}
err = osx.CopyToNewFile(path, resp.Body)
if err != nil {
return err
}
return nil
}

354
net/http/http_test.go Normal file
View File

@@ -0,0 +1,354 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStatusHandler(t *testing.T) {
tests := []struct {
name string
handler StatusHandler
expectedCode int
expectedBody string
}{
{
name: "not found handler",
handler: NotFoundHandler,
expectedCode: http.StatusNotFound,
expectedBody: "Not Found",
},
{
name: "not implemented handler",
handler: NotImplementedHandler,
expectedCode: http.StatusNotImplemented,
expectedBody: "Not Implemented",
},
{
name: "not allowed handler",
handler: NotAllowedHandler,
expectedCode: http.StatusMethodNotAllowed,
expectedBody: "Method Not Allowed",
},
{
name: "not legal handler",
handler: NotLegalHandler,
expectedCode: http.StatusUnavailableForLegalReasons,
expectedBody: "Unavailable For Legal Reasons",
},
{
name: "custom status",
handler: StatusHandler(http.StatusTeapot),
expectedCode: http.StatusTeapot,
expectedBody: "I'm a teapot",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
tt.handler.ServeHTTP(w, req)
if w.Code != tt.expectedCode {
t.Errorf("expected status code %d, got %d", tt.expectedCode, w.Code)
}
if w.Body.String() != tt.expectedBody {
t.Errorf("expected body %q, got %q", tt.expectedBody, w.Body.String())
}
})
}
}
func TestBasicAuth(t *testing.T) {
protectedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
})
t.Run("successful authentication", func(t *testing.T) {
// Note: The BasicAuth implementation uses SHA1 hash of the stored password
// and compares it (case-insensitive) with the incoming password
// So to authenticate, the password sent must be the base64 SHA1 hash of the stored value
htpasswd := map[string]string{
"user": "pass",
}
handler := BasicAuth(protectedHandler, htpasswd, "test realm")
req := httptest.NewRequest(http.MethodGet, "/", nil)
// SHA1 hash of "pass" as base64: nU4eI71bcnBGqeO0t9tXvY1u5oQ=
req.SetBasicAuth("user", "nU4eI71bcnBGqeO0t9tXvY1u5oQ=")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
if w.Body.String() != "success" {
t.Errorf("expected body 'success', got %q", w.Body.String())
}
})
t.Run("missing credentials", func(t *testing.T) {
htpasswd := map[string]string{
"user": "pass",
}
handler := BasicAuth(protectedHandler, htpasswd, "test realm")
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
authHeader := w.Header().Get("WWW-Authenticate")
expectedHeader := `Basic realm="test realm"`
if authHeader != expectedHeader {
t.Errorf("expected WWW-Authenticate header %q, got %q", expectedHeader, authHeader)
}
})
t.Run("wrong username", func(t *testing.T) {
htpasswd := map[string]string{
"user": "pass",
}
handler := BasicAuth(protectedHandler, htpasswd, "test realm")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("wronguser", "pass")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
})
t.Run("wrong password", func(t *testing.T) {
htpasswd := map[string]string{
"user": "pass",
}
handler := BasicAuth(protectedHandler, htpasswd, "test realm")
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("user", "wrongpass")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
})
}
func TestMultiHandler(t *testing.T) {
getHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("GET response"))
})
postHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("POST response"))
})
t.Run("valid methods", func(t *testing.T) {
handlers := map[string]http.Handler{
http.MethodGet: getHandler,
http.MethodPost: postHandler,
}
handler, err := MultiHandler(handlers)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
t.Run("GET request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Body.String() != "GET response" {
t.Errorf("expected 'GET response', got %q", w.Body.String())
}
})
t.Run("POST request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Body.String() != "POST response" {
t.Errorf("expected 'POST response', got %q", w.Body.String())
}
})
t.Run("unsupported method", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
})
})
t.Run("invalid method returns error", func(t *testing.T) {
handlers := map[string]http.Handler{
"INVALID": getHandler,
}
handler, err := MultiHandler(handlers)
if err == nil {
t.Fatal("expected error for invalid method, got nil")
}
if handler != nil {
t.Error("expected nil handler when error occurs")
}
expectedErr := "invalid HTTP method: INVALID"
if err.Error() != expectedErr {
t.Errorf("expected error %q, got %q", expectedErr, err.Error())
}
})
t.Run("all standard methods", func(t *testing.T) {
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "handled %s", r.Method)
})
handlers := map[string]http.Handler{
http.MethodGet: testHandler,
http.MethodHead: testHandler,
http.MethodPost: testHandler,
http.MethodPut: testHandler,
http.MethodPatch: testHandler,
http.MethodDelete: testHandler,
http.MethodConnect: testHandler,
http.MethodOptions: testHandler,
http.MethodTrace: testHandler,
}
handler, err := MultiHandler(handlers)
if err != nil {
t.Fatalf("expected no error for standard methods, got %v", err)
}
methods := []string{
http.MethodGet, http.MethodHead, http.MethodPost,
http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodConnect, http.MethodOptions, http.MethodTrace,
}
for _, method := range methods {
req := httptest.NewRequest(method, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("method %s: expected status 200, got %d", method, w.Code)
}
}
})
t.Run("empty handlers map", func(t *testing.T) {
handlers := map[string]http.Handler{}
handler, err := MultiHandler(handlers)
if err != nil {
t.Fatalf("expected no error for empty map, got %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
})
}
func TestDownloadFile(t *testing.T) {
t.Run("successful download", func(t *testing.T) {
content := "test file content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(content))
}))
defer server.Close()
tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "downloaded.txt")
err := DownloadFile(server.URL, destPath)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(data) != content {
t.Errorf("expected %q, got %q", content, string(data))
}
})
t.Run("non-200 status code", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "downloaded.txt")
err := DownloadFile(server.URL, destPath)
if err == nil {
t.Fatal("expected error for non-200 status code, got nil")
}
expectedMsg := "download failed: unexpected status code 404"
if !strings.Contains(err.Error(), expectedMsg) {
t.Errorf("expected error to contain %q, got %q", expectedMsg, err.Error())
}
})
t.Run("invalid URL", func(t *testing.T) {
tmpDir := t.TempDir()
destPath := filepath.Join(tmpDir, "downloaded.txt")
err := DownloadFile("http://invalid.nonexistent.domain.test", destPath)
if err == nil {
t.Fatal("expected error for invalid URL, got nil")
}
})
t.Run("invalid destination path", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("content"))
}))
defer server.Close()
err := DownloadFile(server.URL, "/invalid/nonexistent/path/file.txt")
if err == nil {
t.Fatal("expected error for invalid path, got nil")
}
})
}

View File

@@ -5,10 +5,10 @@ import (
"net/http"
)
// MutliHandler takes a map of http methods to handlers
// and returns a handler which will run the the mapped hander
// MultiHandler takes a map of http methods to handlers
// and returns a handler which will run the mapped handler
// based on a request's method
func MutliHandler(h map[string]http.Handler) (http.HandlerFunc, error) {
func MultiHandler(h map[string]http.Handler) (http.HandlerFunc, error) {
m := map[string]bool{
http.MethodGet: true,
http.MethodHead: true,
@@ -30,7 +30,7 @@ func MutliHandler(h map[string]http.Handler) (http.HandlerFunc, error) {
if hdlr, ok := h[r.Method]; ok {
hdlr.ServeHTTP(w, r)
} else {
NotImplementedHandler.ServeHTTP(w, r)
NotAllowedHandler.ServeHTTP(w, r)
}
}, nil
}

View File

@@ -1,95 +0,0 @@
package http
import (
"net/http"
"strconv"
"strings"
)
type ServeMux struct {
routes []route
}
func (mux *ServeMux) Handle(pattern string, handler http.Handler, pathParams ...any) {
mux.routes = append(mux.routes, newRoute(pattern, handler, pathParams...))
}
func (mux *ServeMux) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), pathParams ...any) {
mux.routes = append(mux.routes, newRoute(pattern, http.HandlerFunc(handler), pathParams...))
}
func (mux *ServeMux) Handler(r *http.Request) (h http.Handler, pattern string) {
for _, rte := range mux.routes {
switch {
case rte.matcher(r):
return rte.handler, rte.pattern
}
}
return http.HandlerFunc(http.NotFound), ""
}
func (mux *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(http.StatusBadRequest)
return
}
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}
type route struct {
pattern string
matcher func(r *http.Request) bool
handler http.HandlerFunc
}
func newRoute(pattern string, handler http.Handler, vars ...interface{}) route {
return route{
pattern,
func(r *http.Request) bool {
return match(r.URL.Path, pattern, vars...)
},
handler.ServeHTTP,
}
}
// match reports whether path matches the given pattern, which is a
// path with '+' wildcards wherever you want to use a parameter. Path
// parameters are assigned to the pointers in vars (len(vars) must be
// the number of wildcards), which must be of type *string or *int.
func match(path, pattern string, vars ...interface{}) bool {
for ; pattern != "" && path != ""; pattern = pattern[1:] {
switch pattern[0] {
case '+':
// '+' matches till next slash in path
slash := strings.IndexByte(path, '/')
if slash < 0 {
slash = len(path)
}
segment := path[:slash]
path = path[slash:]
switch p := vars[0].(type) {
case *string:
*p = segment
case *int:
n, err := strconv.Atoi(segment)
if err != nil || n < 0 {
return false
}
*p = n
default:
panic("vars must be *string or *int")
}
vars = vars[1:]
case path[0]:
// non-'+' pattern byte must match path byte
path = path[1:]
default:
return false
}
}
return path == "" && pattern == ""
}

View File

@@ -1,3 +1,4 @@
// Package http provides HTTP utility functions and handlers.
package http
import (
@@ -5,9 +6,11 @@ import (
"net/http"
)
// boosted from @matryer
// StatusHandler is an http.Handler that responds with a specific HTTP status code.
// Inspired by @matryer.
type StatusHandler int
// ServeHTTP writes the status code and its text representation to the response.
func (s StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
code := int(s)
w.WriteHeader(code)
@@ -15,7 +18,12 @@ func (s StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
var (
NotFoundHandler = StatusHandler(404)
NotImplementedHandler = StatusHandler(501)
NotLegalHandler = StatusHandler(451)
// NotFoundHandler returns HTTP 404 Not Found
NotFoundHandler = StatusHandler(http.StatusNotFound)
// NotImplementedHandler returns HTTP 501 Not Implemented
NotImplementedHandler = StatusHandler(http.StatusNotImplemented)
// NotLegalHandler returns HTTP 451 Unavailable For Legal Reasons
NotLegalHandler = StatusHandler(http.StatusUnavailableForLegalReasons)
// NotAllowedHandler returns HTTP 405 Method Not Allowed
NotAllowedHandler = StatusHandler(http.StatusMethodNotAllowed)
)

59
os/os.go Executable file
View File

@@ -0,0 +1,59 @@
// Package os provides utility functions for file operations.
package os
import (
"io"
std "os"
"path/filepath"
"git.sdf.org/jchenry/x"
)
// FileOrStdin returns the given file if not nil, otherwise returns os.Stdin.
func FileOrStdin(f *std.File) (fileReader io.ReadCloser) {
if f == nil {
return std.Stdin
}
return f
}
// CopyToNewFile creates a new file at the given path and copies data from the reader to it.
// Returns an error if file creation or copying fails.
func CopyToNewFile(path string, r io.Reader) error {
out, err := std.Create(path)
if err != nil {
return err
}
defer out.Close()
return CopyToFile(out, r)
}
// CopyToFile copies data from the reader to the given file.
// Returns an error if the copy operation fails.
func CopyToFile(f *std.File, r io.Reader) error {
if _, err := io.Copy(f, r); err != nil {
return err
}
return nil
}
// FileExists checks if a file exists at the given path.
// Returns true if the file exists and can be accessed, false otherwise.
func FileExists(filePath string) bool {
_, err := std.Stat(filePath)
if std.IsNotExist(err) {
return false
}
return err == nil
}
// Mkdir creates a directory (and any necessary parent directories) from the given path segments.
// At least one path segment must be provided or this function will panic.
// Returns an error if directory creation fails.
func Mkdir(p ...string) error {
x.Assert(len(p) > 0, "Mkdir: p <= 0")
newpath := filepath.Join(p...)
return std.MkdirAll(newpath, 0711)
}

213
os/os_test.go Normal file
View File

@@ -0,0 +1,213 @@
package os
import (
"io"
std "os"
"path/filepath"
"strings"
"testing"
)
func TestFileOrStdin(t *testing.T) {
t.Run("nil file returns stdin", func(t *testing.T) {
result := FileOrStdin(nil)
if result != std.Stdin {
t.Error("expected stdin when file is nil")
}
})
t.Run("non-nil file returns file", func(t *testing.T) {
tmpFile, err := std.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer std.Remove(tmpFile.Name())
defer tmpFile.Close()
result := FileOrStdin(tmpFile)
if result != tmpFile {
t.Error("expected the provided file to be returned")
}
})
}
func TestCopyToNewFile(t *testing.T) {
t.Run("successful copy", func(t *testing.T) {
tmpDir := t.TempDir()
dstPath := filepath.Join(tmpDir, "output.txt")
content := "test content"
err := CopyToNewFile(dstPath, strings.NewReader(content))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := std.ReadFile(dstPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if string(data) != content {
t.Errorf("expected %q, got %q", content, string(data))
}
})
t.Run("invalid path error", func(t *testing.T) {
err := CopyToNewFile("/invalid/nonexistent/path/file.txt", strings.NewReader("test"))
if err == nil {
t.Fatal("expected error for invalid path, got nil")
}
})
t.Run("empty content", func(t *testing.T) {
tmpDir := t.TempDir()
dstPath := filepath.Join(tmpDir, "empty.txt")
err := CopyToNewFile(dstPath, strings.NewReader(""))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := std.ReadFile(dstPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) != 0 {
t.Errorf("expected empty file, got %d bytes", len(data))
}
})
}
func TestCopyToFile(t *testing.T) {
t.Run("successful copy", func(t *testing.T) {
tmpFile, err := std.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer std.Remove(tmpFile.Name())
defer tmpFile.Close()
content := "test content"
err = CopyToFile(tmpFile, strings.NewReader(content))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
tmpFile.Seek(0, 0)
data, err := io.ReadAll(tmpFile)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if string(data) != content {
t.Errorf("expected %q, got %q", content, string(data))
}
})
t.Run("large content", func(t *testing.T) {
tmpFile, err := std.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer std.Remove(tmpFile.Name())
defer tmpFile.Close()
content := strings.Repeat("x", 10000)
err = CopyToFile(tmpFile, strings.NewReader(content))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
tmpFile.Seek(0, 0)
data, err := io.ReadAll(tmpFile)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if string(data) != content {
t.Errorf("content length mismatch: expected %d, got %d", len(content), len(data))
}
})
}
func TestFileExists(t *testing.T) {
t.Run("existing file", func(t *testing.T) {
tmpFile, err := std.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer std.Remove(tmpFile.Name())
tmpFile.Close()
if !FileExists(tmpFile.Name()) {
t.Error("expected file to exist")
}
})
t.Run("non-existing file", func(t *testing.T) {
if FileExists("/nonexistent/file/path.txt") {
t.Error("expected file not to exist")
}
})
t.Run("existing directory", func(t *testing.T) {
tmpDir := t.TempDir()
if !FileExists(tmpDir) {
t.Error("expected directory to exist")
}
})
}
func TestMkdir(t *testing.T) {
t.Run("single directory", func(t *testing.T) {
tmpDir := t.TempDir()
newDir := filepath.Join(tmpDir, "testdir")
err := Mkdir(newDir)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !FileExists(newDir) {
t.Error("expected directory to be created")
}
})
t.Run("nested directories", func(t *testing.T) {
tmpDir := t.TempDir()
newDir := filepath.Join(tmpDir, "a", "b", "c")
err := Mkdir(newDir)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !FileExists(newDir) {
t.Error("expected nested directories to be created")
}
})
t.Run("multiple path parts", func(t *testing.T) {
tmpDir := t.TempDir()
err := Mkdir(tmpDir, "a", "b", "c")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
expectedPath := filepath.Join(tmpDir, "a", "b", "c")
if !FileExists(expectedPath) {
t.Error("expected joined path to be created")
}
})
t.Run("already exists", func(t *testing.T) {
tmpDir := t.TempDir()
err := Mkdir(tmpDir)
if err != nil {
t.Fatalf("expected no error for existing directory, got %v", err)
}
})
}

101
pkg.go Normal file
View File

@@ -0,0 +1,101 @@
// Package x provides common utility functions for error handling and assertions.
package x
import (
"errors"
"fmt"
"runtime"
)
// Assert evaluates a condition and if it fails, panics with the given message.
func Assert(cond bool, msg string) {
if !cond {
panic(errors.New(msg))
}
}
// Check evaluates a condition, adds an error to its list and continues.
// Returns an I interface that can be used to chain multiple checks.
func Check(cond bool, err error) I {
return new(invariants).Check(cond, err)
}
// I is an interface for chaining multiple condition checks and collecting errors.
type I interface {
// Check evaluates a condition and adds an error to the list if false
Check(cond bool, err error) I
// Join returns a single error wrapping all collected errors
Join() error
// First returns the first error encountered
First() error
// All returns all collected errors as a slice
All() []error
}
type invariants struct {
errs []error
}
// Check evaluates a condition, adds an error to its list and continues
func (i *invariants) Check(cond bool, err error) I {
if !cond {
i.errs = append(i.errs, err)
}
return i
}
// Join returns a single error wrapping all errors that have been collected by the checks.
func (i *invariants) Join() error {
return errors.Join(i.errs...)
}
// First returns the first error found by the checks, or nil if no errors were found.
func (i *invariants) First() error {
if len(i.errs) > 0 {
return i.errs[0]
}
return nil
}
// All returns all errors found by the checks as a slice.
func (i *invariants) All() []error {
return i.errs
}
type xError struct {
LineNo int
File string
E error
Debug bool
}
func (e *xError) Error() string {
if e.Debug {
return fmt.Sprintf(
"%s\n\t%s:%d", e.E, e.File, e.LineNo,
)
}
return e.E.Error()
}
func (e *xError) Unwrap() error {
return e.E
}
// NewError returns an error that wraps an error and optionally provides the file and line number where the error occurred.
// If debug is true, the error message will include file and line information.
func NewError(unwrapped error, debug bool) error {
_, file, line, ok := runtime.Caller(1)
if !ok {
file = "unknown"
line = 0
}
return &xError{
LineNo: line,
File: file,
E: unwrapped,
Debug: debug,
}
}

125
pkg_test.go Executable file
View File

@@ -0,0 +1,125 @@
package x_test
import (
"errors"
"testing"
"git.sdf.org/jchenry/x"
)
func TestCheck(t *testing.T) {
t.Run("single failing check", func(t *testing.T) {
testErr := errors.New("test error")
err := x.Check(false, testErr).Join()
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, testErr) {
t.Fatalf("expected error to contain %v, got %v", testErr, err)
}
})
t.Run("single passing check", func(t *testing.T) {
err := x.Check(true, errors.New("should not appear")).Join()
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
t.Run("multiple checks", func(t *testing.T) {
err1 := errors.New("error 1")
err2 := errors.New("error 2")
err := x.Check(false, err1).Check(false, err2).Join()
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, err1) {
t.Errorf("expected error to contain %v", err1)
}
if !errors.Is(err, err2) {
t.Errorf("expected error to contain %v", err2)
}
})
t.Run("First method", func(t *testing.T) {
err1 := errors.New("first error")
err2 := errors.New("second error")
err := x.Check(false, err1).Check(false, err2).First()
if err != err1 {
t.Fatalf("expected first error %v, got %v", err1, err)
}
})
t.Run("All method", func(t *testing.T) {
err1 := errors.New("error 1")
err2 := errors.New("error 2")
errs := x.Check(false, err1).Check(false, err2).All()
if len(errs) != 2 {
t.Fatalf("expected 2 errors, got %d", len(errs))
}
if errs[0] != err1 {
t.Errorf("expected first error %v, got %v", err1, errs[0])
}
if errs[1] != err2 {
t.Errorf("expected second error %v, got %v", err2, errs[1])
}
})
}
func TestAssert(t *testing.T) {
t.Run("passing assertion", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected no panic, got %v", r)
}
}()
x.Assert(true, "should not panic")
})
t.Run("failing assertion", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic, got none")
}
}()
x.Assert(false, "test panic")
})
}
func TestNewError(t *testing.T) {
t.Run("with debug enabled", func(t *testing.T) {
original := errors.New("original error")
err := x.NewError(original, true)
if err == nil {
t.Fatal("expected error, got nil")
}
errStr := err.Error()
if errStr == "original error" {
t.Error("expected error string to include file and line info")
}
if !errors.Is(err, original) {
t.Error("expected error to unwrap to original")
}
})
t.Run("with debug disabled", func(t *testing.T) {
original := errors.New("original error")
err := x.NewError(original, false)
if err == nil {
t.Fatal("expected error, got nil")
}
errStr := err.Error()
if errStr != "original error" {
t.Errorf("expected error string to be 'original error', got %q", errStr)
}
if !errors.Is(err, original) {
t.Error("expected error to unwrap to original")
}
})
}

View File

@@ -1,95 +0,0 @@
package rest
import (
"net/http"
"net/url"
"path/filepath"
"sync"
"github.com/jchenry/x/encoding"
"github.com/jchenry/x/log"
)
// Example: Resource(p, c, JSONEncoder, json.Decode(func()interface{}{return &foo{}}), log.None{})
func Resource(p *sync.Pool, g Gateway, e EntityEncoder, d encoding.Decoder, l log.Logger) http.HandlerFunc {
return restVerbHandler(
GetResource(g, e, l),
PostResource(g, d, p, l),
PutResource(g, e, d, p, l),
DeleteResource(g, l),
)
}
func GetResource(store Readable, encode EntityEncoder, log log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { // GET
if id := filepath.Base(r.URL.Path); id != "" {
if e, err := store.Read(id); err == nil { // handle individual entity
encode(w, e)
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Error: %s", err)
}
} else {
if params, err := url.ParseQuery(r.URL.RawQuery); err == nil {
if e, err := store.All(params); err == nil { // handle all entities
encode(w, e)
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Error: %s", err)
}
} else {
w.WriteHeader(http.StatusBadRequest)
}
}
}
}
func PostResource(store Creatable, decode encoding.Decoder, pool *sync.Pool, log log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { // POST TODO
e := pool.Get()
defer pool.Put(e)
if err := decode(r.Body, e); err == nil {
if err = store.Create(e); err == nil {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Error: %s", err)
}
} else {
w.WriteHeader(http.StatusBadRequest)
}
}
}
func PutResource(store Updatable, encode EntityEncoder, decode encoding.Decoder, pool *sync.Pool, log log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { // PUT TODO
e := pool.Get()
defer pool.Put(e)
if err := decode(r.Body, e); err == nil {
if err = store.Update(e); err == nil {
w.WriteHeader(http.StatusAccepted)
encode(w, e)
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Error: %s", err)
}
} else {
w.WriteHeader(http.StatusBadRequest)
}
}
}
func DeleteResource(store Deletable, log log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { // DELETE TODO
if id := filepath.Base(r.URL.Path); id != "" {
if err := store.Delete(id); err == nil {
w.WriteHeader(http.StatusNoContent)
} else {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("Error: %s", err)
}
} else {
w.WriteHeader(http.StatusBadRequest)
}
}
}

View File

@@ -1,23 +0,0 @@
package rest
import (
gohttp "net/http"
"github.com/jchenry/x/net/http"
)
// EntityHandler returns a handler that provides restful verbs, following a CRUD model
func restVerbHandler(
get gohttp.Handler,
post gohttp.Handler,
put gohttp.Handler,
delete gohttp.Handler,
) gohttp.HandlerFunc {
h, _ := http.MutliHandler(map[string]gohttp.Handler{
gohttp.MethodGet: get,
gohttp.MethodPost: post,
gohttp.MethodPut: put,
gohttp.MethodDelete: delete,
})
return h
}

View File

@@ -1,25 +0,0 @@
package rest
type Creatable interface {
Create(e interface{}) error
}
type Updatable interface {
Update(e interface{}) error
}
type Deletable interface {
Delete(id string) error
}
type Readable interface {
All(filters map[string][]string) (interface{}, error)
Read(id string) (interface{}, error)
}
type Gateway interface {
Creatable
Updatable
Deletable
Readable
}

View File

@@ -1,32 +0,0 @@
package rest
import (
"net/http"
"github.com/jchenry/x/encoding"
"github.com/jchenry/x/encoding/json"
"github.com/jchenry/x/encoding/xml"
)
type EntityEncoder func(w http.ResponseWriter, e interface{})
func JSONEncoder(w http.ResponseWriter, e interface{}) error {
return EntityResponseEncoder(w, "application/json", json.Encoder, e)
}
func XMLEncoder(w http.ResponseWriter, e interface{}) error {
return EntityResponseEncoder(w, "application/xml", xml.Encoder, e)
}
func EntityResponseEncoder(w http.ResponseWriter, contentType string, encoder encoding.Encoder, e interface{}) error {
w.Header().Set("content-type", contentType)
return encoder(w, e)
}
func ErrorResponseEncoder(w http.ResponseWriter, contentType string, encoder encoding.Encoder, status int, err error) error {
w.WriteHeader(status)
return EntityResponseEncoder(w, contentType, encoder, map[string]interface{}{
"status": status,
"message": err.Error,
})
}

View File

@@ -1,27 +0,0 @@
# snowflake
A snowflake ID generator based on instagram and twitter's snowflake concept
## Install
```
go get github.com/jchenry/snowflake
```
## Usage
```
import "github.com/jchenry/snowflake"
func main(){
snowflake.Next()
}
```
## Contributing
PRs accepted.
## License
MIT © Colin Henry

View File

@@ -1,7 +1,9 @@
// Package snowflake provides a distributed unique ID generator using Twitter's Snowflake algorithm.
// It generates 64-bit IDs composed of: timestamp (32 bits) + node ID (10 bits) + sequence (12 bits).
// The IDs are sortable by time and guaranteed to be unique within a single node.
package snowflake
import (
"fmt"
"hash/fnv"
"math"
"net"
@@ -15,48 +17,55 @@ const (
nodeIDBits = 10
sequenceBits = 12
// Custom Epoch (January 1, 2015 Midnight UTC = 2015-01-01T00:00:00Z)
// Custom Epoch (January 1, 2015 Midnight UTC = 2015-01-01T00:00:00Z) .
customEpoch int64 = 1420070400000
)
var maxNodeID int64
var maxSequence int64
var (
maxNodeID int64
maxSequence int64
mu sync.Mutex // single mutex protects lastTimestamp and sequence
nodeID int64
lastTimestamp int64 = 0
sequence int64
)
var nodeID int64
var lastTimestamp int64 = 0
var sequence int64
const two = 2
func init() {
maxNodeID = int64(math.Pow(2, nodeIDBits) - 1)
maxSequence = int64(math.Pow(2, sequenceBits) - 1)
maxNodeID = int64(math.Pow(two, nodeIDBits) - 1)
maxSequence = int64(math.Pow(two, sequenceBits) - 1)
nodeID = generateNodeID()
}
func generateNodeID() int64 {
var nodeID int64
if interfaces, err := net.Interfaces(); err == nil {
h := fnv.New32a()
for _, i := range interfaces {
h.Write(i.HardwareAddr)
}
nodeID = int64(h.Sum32())
} else {
panic("interfaces not available")
}
nodeID = nodeID & maxNodeID
return nodeID
}
var timestampMutex sync.Mutex
var sequenceMutex sync.Mutex
// Next returns the next logical snowflake
// Next generates and returns the next unique snowflake ID.
// It is thread-safe and guarantees uniqueness within this node.
// If the sequence is exhausted within a millisecond, it waits for the next millisecond.
func Next() int64 {
timestampMutex.Lock()
currentTimestamp := ts()
timestampMutex.Unlock()
mu.Lock()
defer mu.Unlock()
currentTimestamp := ts()
sequenceMutex.Lock()
if currentTimestamp == lastTimestamp {
sequence = (sequence + 1) & maxSequence
if sequence == 0 {
@@ -64,19 +73,17 @@ func Next() int64 {
currentTimestamp = waitNextMillis(currentTimestamp)
}
} else {
// New millisecond, reset sequence
sequence = 0
}
sequenceMutex.Unlock()
lastTimestamp = currentTimestamp
// id := currentTimestamp << (totalBits - epochBits)
// id |= (nodeID << (totalBits - epochBits - nodeIDBits))
// id |= sequence
// Construct the snowflake ID
var id int64 = currentTimestamp << (nodeIDBits + sequenceBits)
id |= (nodeID << sequenceBits)
id |= sequence
fmt.Printf("%b\n", id)
return id
}
@@ -88,5 +95,6 @@ func waitNextMillis(currentTimestamp int64) int64 {
for currentTimestamp == lastTimestamp {
currentTimestamp = ts()
}
return currentTimestamp
}

View File

@@ -9,7 +9,6 @@ func TestNext(t *testing.T) {
fmt.Printf("node id: %b\n", generateNodeID())
fmt.Printf("timestamp: %b\n", ts())
fmt.Printf("full token: %b\n", Next())
// t.Fail()
}
func BenchmarkNext(b *testing.B) {

View File

@@ -10,7 +10,7 @@ import (
type Arvelie string
func (a *Arvelie) isValid() bool {
func (a *Arvelie) IsValid() bool {
if a != nil {
return strings.EqualFold(string(*a), string(FromDate(ToDate(*a))))
}
@@ -29,7 +29,7 @@ func ToDate(a Arvelie) time.Time {
mon = (int(m[0]) - 65)
}
doty := (math.Floor(float64(mon)*14) + math.Floor(float64(d)) - 1)
doty := (math.Floor(float64(mon)*14) + float64(d) - 1)
yr, _ := strconv.Atoi(fmt.Sprintf("20%s", y))
return time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(doty))
}
@@ -42,17 +42,15 @@ func FromDate(date time.Time) Arvelie {
if doty == 365 || doty == 366 {
m = "+"
} else {
m = strings.ToUpper(string([]byte{byte(97 + math.Floor(float64(doty/14)))}))
m = strings.ToUpper(string([]byte{byte(97 + math.Floor(float64(doty)/14))}))
}
var d string
switch doty {
case 365:
d = fmt.Sprintf("%02d", 1)
break
case 366:
d = fmt.Sprintf("%02d", 2)
break
default:
d = fmt.Sprintf("%02d", (doty % 14))
}

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/jchenry/x/time/arvelie"
"git.sdf.org/jchenry/x/time/arvelie"
)
func TestFromDate(t *testing.T) {

5
time/format.go Normal file
View File

@@ -0,0 +1,5 @@
package time
import "time"
const ISO8601 = time.RFC3339

View File

@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/jchenry/x/time/neralie"
"git.sdf.org/jchenry/x/time/neralie"
)
func TestFromTime(t *testing.T) {

6
version.sh Executable file
View File

@@ -0,0 +1,6 @@
latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "$(VERSION_TAG_PREFIX)0.0.0"); \
major=$(echo $latest_tag | sed -E 's/$(VERSION_TAG_PREFIX)([0-9]+)\.[0-9]+\.[0-9]+/\1/'); \
minor=$(echo $latest_tag | sed -E 's/$(VERSION_TAG_PREFIX)[0-9]+\.([0-9]+)\.[0-9]+/\1/'); \
patch=$(echo $latest_tag | sed -E 's/$(VERSION_TAG_PREFIX)[0-9]+\.[0-9]+\.([0-9]+)/\1/'); \
next_patch=$(($patch + 1)); \
echo "$(VERSION_TAG_PREFIX)$major.$minor.$next_patch"