Author SHA1 Message Date
wmdillon 0e6b66bcbd adding .gitignore 2026-08-17 19:34:22 -04:00
wmdillon b4f8eab489 fixing tests 2026-08-17 19:33:06 -04:00
wmdillon 3d14359832 working on valuemonitor 2026-08-17 09:49:50 -04:00
6 changed files with 431 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
cover.out
+26
View File
@@ -0,0 +1,26 @@
package settings
import (
"errors"
"os"
"testing"
)
const (
TheTestFilename = "settings_test.settings"
)
func TestMain(m *testing.M) {
exitCode := func() int {
if err := os.Remove(TheTestFilename); err != nil && !errors.Is(err, os.ErrNotExist) {
panic("error cleaning up " + TheTestFilename + " from previous run: " + err.Error())
} else if err := os.WriteFile(TheTestFilename, nil, 0664); err != nil {
panic("error creating empty " + TheTestFilename + ": " + err.Error())
}
defer os.Remove(TheTestFilename)
TheSettings = NewSettings(TheTestFilename, false)
return m.Run()
}()
os.Exit(exitCode)
}
+65 -3
View File
@@ -1,8 +1,10 @@
package settings package settings
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -25,12 +27,68 @@ var (
type Settings struct { type Settings struct {
mutex sync.RWMutex mutex sync.RWMutex
context context.Context
contents map[string]string contents map[string]string
monitorKeys map[string]chan<- string
filename string filename string
modtime time.Time modtime time.Time
LogUpdates atomic.Bool LogUpdates atomic.Bool
} }
func insertOrTimeout(ctx context.Context, c chan<- string, s string) error {
select {
case <-ctx.Done():
return ctx.Err()
case c <- s:
return nil
}
}
func (s *Settings) RegisterKeyToMonitor(key string, updateOutputChannel chan<- string) error {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.monitorKeys == nil {
s.monitorKeys = make(map[string]chan<- string, 1)
}
if _, found := s.monitorKeys[key]; found {
return fmt.Errorf("key %q already in monitorKeys map", key)
}
if err := insertOrTimeout(s.context, updateOutputChannel, s.contents[key]); err != nil {
return fmt.Errorf("error inserting: %w", err)
}
s.monitorKeys[key] = updateOutputChannel
return nil
}
func (s *Settings) Dump() ([]byte, error) {
var buffer bytes.Buffer
if err := s.DumpToWriter(&buffer); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func (s *Settings) DumpToWriter(w io.Writer) error {
writer := bufio.NewWriter(w)
s.mutex.RLock()
defer s.mutex.RUnlock()
var index int
for k, v := range s.contents {
writer.WriteString(fmt.Sprintf("%s=%s", k, v))
if index < len(s.contents)-1 {
writer.WriteByte('\n')
}
index++
}
return writer.Flush()
}
func (s *Settings) DumpToJson() ([]byte, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
return json.Marshal(s.contents)
}
func (s *Settings) GetFilename() string { func (s *Settings) GetFilename() string {
s.mutex.RLock() s.mutex.RLock()
defer s.mutex.RUnlock() defer s.mutex.RUnlock()
@@ -68,6 +126,9 @@ func (s *Settings) lockedSetKeyValue(key, value string) bool {
log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value) log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value)
} }
s.contents[strings.Clone(key)] = strings.Clone(value) s.contents[strings.Clone(key)] = strings.Clone(value)
if updateChannel, found := s.monitorKeys[key]; found {
insertOrTimeout(s.context, updateChannel, value)
}
return true return true
} }
@@ -132,10 +193,10 @@ func (s *Settings) Update() error {
return s.lockedUpdate() return s.lockedUpdate()
} }
func (s *Settings) maintenanceRoutine(ctx context.Context) { func (s *Settings) maintenanceRoutine() {
for { for {
select { select {
case <-ctx.Done(): case <-s.context.Done():
return return
case <-time.After(MaintenanceRoutinePace): case <-time.After(MaintenanceRoutinePace):
err := s.Update() err := s.Update()
@@ -154,6 +215,7 @@ func NewSettings(filename string, logUpdates bool) *Settings {
func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings { func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings {
settings := &Settings{ settings := &Settings{
context: ctx,
contents: make(map[string]string), contents: make(map[string]string),
filename: strings.Clone(filename), filename: strings.Clone(filename),
} }
@@ -161,7 +223,7 @@ func NewSettingsWithContext(ctx context.Context, filename string, logUpdates boo
if err := settings.lockedUpdate(); err != nil && (!errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound) { if err := settings.lockedUpdate(); err != nil && (!errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound) {
log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err) log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err)
} }
go settings.maintenanceRoutine(ctx) go settings.maintenanceRoutine()
return settings return settings
} }
+161 -6
View File
@@ -2,8 +2,11 @@ package settings
import ( import (
"bytes" "bytes"
"crypto/rand"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"maps" "maps"
"os" "os"
@@ -12,10 +15,6 @@ import (
"time" "time"
) )
const (
TheTestFilename = "settings_test.settings"
)
var ( var (
AdditionalKeyValuePairs = map[string]string{ AdditionalKeyValuePairs = map[string]string{
"D": "d", "D": "d",
@@ -43,8 +42,11 @@ func TestSettings(t *testing.T) {
t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err) t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err)
} }
}() }()
settings := NewSettingsWithContext(t.Context(), TheTestFilename, false) settings := TheSettings
if want, got := TheTestFilename, settings.GetFilename(); want != got { err = settings.Update()
if err != nil {
t.Fatalf("error updating settings: %v\n", err)
} else if want, got := TheTestFilename, settings.GetFilename(); want != got {
t.Fatalf("error: wanted '%s'; got '%s'\n", want, got) t.Fatalf("error: wanted '%s'; got '%s'\n", want, got)
} else if got := settings.modtime; got.Equal(time.Time{}) { } else if got := settings.modtime; got.Equal(time.Time{}) {
t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized") t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized")
@@ -119,9 +121,162 @@ func TestSettings(t *testing.T) {
if updated := settings.SetFilename("fakefilename.txt"); !updated { if updated := settings.SetFilename("fakefilename.txt"); !updated {
t.Fatalf("failed to update filename to fake filename...\n") t.Fatalf("failed to update filename to fake filename...\n")
} }
defer settings.SetFilename(TheTestFilename)
log.SetOutput(defaultLogWriter) log.SetOutput(defaultLogWriter)
WarnIfFileNotFound = false WarnIfFileNotFound = false
if !strings.Contains(buffer.String(), "no such file or directory") { if !strings.Contains(buffer.String(), "no such file or directory") {
t.Fatalf("error: wanted error indicating 'no such file or directory'; got %v\n", err) t.Fatalf("error: wanted error indicating 'no such file or directory'; got %v\n", err)
} }
} }
func TestDump(t *testing.T) {
dump, err := TheSettings.Dump()
if err != nil {
t.Fatalf("error dumping settings: %v\n", err)
}
scanner := NewScanner(bytes.NewReader(dump))
var found int
loop:
for {
key, value, err := scanner.ReadNextLine()
if err != nil {
if !errors.Is(err, io.EOF) {
t.Fatalf("error reading next line: %v\n", err)
}
break loop
}
if want, got := TheSettings.GetKeyValue(key), value; want != got {
t.Fatalf("error: wanted key %q value %q; got %q\n", key, want, got)
}
found++
}
if want, got := len(TheSettings.contents), found; want != got {
t.Fatalf("error: wanted %d entries; got %d\n", want, got)
}
}
func TestDumpJson(t *testing.T) {
marshaled, err := TheSettings.DumpToJson()
if err != nil {
t.Fatalf("error dumping to json: %v\n", err)
}
var unmarshaled map[string]string
err = json.Unmarshal(marshaled, &unmarshaled)
if err != nil {
t.Fatalf("error unmarshaling json dump: %v\n", err)
}
if want, got := TheSettings.contents, unmarshaled; !maps.Equal(want, got) {
t.Fatalf("error: wanted %v; got %v\n", want, got)
}
}
func TestSetKeyValue(t *testing.T) {
key, value := rand.Text(), rand.Text()
if !TheSettings.SetKeyValue(key, value) {
t.Fatalf("SetKeyValue didn't update?\n")
}
if want, got := value, TheSettings.GetKeyValue(key); want != got {
t.Fatalf("error: wanted %q; got %q\n", want, got)
}
}
func TestParseBool(t *testing.T) {
TheTestItems := map[string]bool{
"yes": true,
"on": true,
"true": true,
"True": true,
"TRUE": true,
"no": false,
"off": false,
"false": false,
}
for boolString, want := range TheTestItems {
got, err := ParseBool(boolString)
if err != nil {
t.Fatalf("error: %v\n", err)
}
if want != got {
t.Fatalf("error: wanted %s=%v; got %v\n", boolString, want, got)
}
}
TheBadTestItems := []string{
"blue",
"cat",
"https://google.com",
}
for _, boolString := range TheBadTestItems {
got, err := ParseBool(boolString)
if err == nil {
t.Fatalf("error: successfully parsed %s as bool %v...\n", boolString, got)
}
}
}
func TestGetStringOrDefault(t *testing.T) {
dump, err := TheSettings.Dump()
if err != nil {
t.Fatalf("error getting dump: %v\n", err)
}
scanner := NewScanner(bytes.NewReader(dump))
loop:
for {
key, value, err := scanner.ReadNextLine()
if err != nil {
if !errors.Is(err, io.EOF) {
t.Fatalf("error reading from dump: %v\n", err)
}
break loop
}
if want, got := value, TheSettings.GetStringOrDefault(key, "SomeInvalidDefault"); want != got {
t.Fatalf("error: wanted key %q=%q; got %q\n", key, want, got)
}
}
badKey := "some new key"
badValue := "some invalid value"
if want, got := badValue, TheSettings.GetStringOrDefault(badKey, badValue); want != got {
t.Fatalf("error: wanted key %q=%q; got %q\n", badKey, want, got)
}
}
func TestGetBoolOrDefault(t *testing.T) {
key, value := "some bool value", "on"
TheSettings.SetKeyValue(key, value)
if want, got := true, TheSettings.GetBoolOrDefault(key, false); want != got {
t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got)
}
value = "shark"
TheSettings.SetKeyValue(key, value)
if want, got := false, TheSettings.GetBoolOrDefault(key, false); want != got {
t.Fatalf("error: wanted %q=%v; got %v\n", key, want, got)
}
}
func TestGetDurationOrDefault(t *testing.T) {
key, value := "some duration value", "10s"
TheSettings.SetKeyValue(key, value)
if want, got := time.Second*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got {
t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got)
}
value = "loud"
TheSettings.SetKeyValue(key, value)
if want, got := time.Minute*10, TheSettings.GetDurationOrDefault(key, time.Minute*10); want != got {
t.Fatalf("error: wanted %q=%s; got %s\n", key, want, got)
}
}
func TestGetIntOrDefault(t *testing.T) {
key, value := "some int value", "10"
TheSettings.SetKeyValue(key, value)
if want, got := 10, TheSettings.GetIntOrDefault(key, 200); want != got {
t.Fatalf("error: wanted %q=%d; got %d\n", key, want, got)
}
value = "cat"
TheSettings.SetKeyValue(key, value)
if want, got := 200, TheSettings.GetIntOrDefault(key, 200); want != got {
t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got)
}
if want, got := 200, TheSettings.GetIntOrDefault("some fake bad key", 200); want != got {
t.Fatalf("error: wanted default value %d for bad key; got %d\n", want, got)
}
}
+81
View File
@@ -0,0 +1,81 @@
package settings
import (
"errors"
"fmt"
"log"
"sync"
)
type ValueMonitorParseFunc[T any] func(string) (T, error)
type ValueMonitor[T any] struct {
mutex sync.RWMutex
parent *Settings
updates <-chan string
key string
parserFunc ValueMonitorParseFunc[T]
value T
}
func (v *ValueMonitor[T]) GetValue() T {
v.mutex.RLock()
defer v.mutex.RUnlock()
return v.value
}
func (v *ValueMonitor[T]) maintenanceThread() {
ctx := v.parent.context
for {
select {
case <-ctx.Done():
return
case s, ok := <-v.updates:
if !ok {
return
}
t, err := v.parserFunc(s)
if err != nil {
log.Printf("ValueMonitor error parsing key %q value %q from channel: %v\n", v.key, s, err)
continue
}
func() {
v.mutex.RLock()
defer v.mutex.RUnlock()
v.value = t
}()
}
}
}
func NewValueMonitor[T any](settings *Settings, key string, parserFunc ValueMonitorParseFunc[T]) (*ValueMonitor[T], error) {
monitorChannel := make(chan string, 1)
err := settings.RegisterKeyToMonitor(key, monitorChannel)
if err != nil {
close(monitorChannel)
return nil, fmt.Errorf("error registering ValueMonitor %w", err)
}
initialString, ok := <-monitorChannel
if !ok {
close(monitorChannel)
return nil, errors.New("channel closed before reading initial value")
}
parsed, err := parserFunc(initialString)
if err != nil {
close(monitorChannel)
return nil, fmt.Errorf("error parsing initial value from %q: %v", initialString, err)
}
go func() {
<-settings.context.Done()
close(monitorChannel)
}()
results := &ValueMonitor[T]{
parent: settings,
updates: monitorChannel,
key: key,
parserFunc: parserFunc,
value: parsed,
}
go results.maintenanceThread()
return results, nil
}
+92
View File
@@ -0,0 +1,92 @@
package settings
import (
"context"
"fmt"
"os"
"testing"
"time"
)
func TestStringValueMonitor(t *testing.T) {
key, value := "my key to monitor", "first value"
monitor, err := NewValueMonitor(TheSettings, key, func(s string) (string, error) { return value, nil })
if err != nil {
t.Fatalf("error opening new value monitor: %v\n", err)
}
if want, got := value, monitor.GetValue(); want != got {
t.Fatalf("error: wanted %q; got %q\n", want, got)
}
value = "second value"
TheSettings.SetKeyValue(key, value)
loop:
for range 5 {
select {
case <-t.Context().Done():
t.Fatalf("error: %v\n", t.Context().Err())
case <-time.After(time.Millisecond * 50):
if want, got := value, monitor.GetValue(); want == got {
break loop
}
}
}
if want, got := value, monitor.GetValue(); want != got {
t.Fatalf("error: wanted %q; got %q\n", want, got)
}
value = "third value"
f, err := os.OpenFile(TheSettings.GetFilename(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0664)
if err != nil {
t.Fatalf("error opening file writer to append settings: %v\n", err)
}
_, err = fmt.Fprintf(f, "\n%s=%s\n", key, value)
if err != nil {
t.Fatalf("error appending to file: %v\n", err)
}
err = f.Close()
if err != nil {
t.Fatalf("error closing %s: %v\n", TheSettings.GetFilename(), err)
}
err = TheSettings.Update()
if err != nil {
t.Fatalf("error updating the settings: %v\n", err)
}
for range 5 {
select {
case <-t.Context().Done():
t.Fatalf("error: %v\n", t.Context().Err())
case <-time.After(time.Millisecond * 50):
if want, got := value, monitor.GetValue(); want != got {
t.Fatalf("error: wanted %q; got %q\n", want, got)
}
}
}
if want, got := value, monitor.GetValue(); want != got {
t.Fatalf("error: wanted %q; got %q\n", want, got)
}
}
func TestInsertOrTimeout(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
channel := make(chan string, 1)
defer close(channel)
err := insertOrTimeout(ctx, channel, "first string")
if err != nil {
t.Fatalf("error: %v\n", err)
}
select {
case <-ctx.Done():
t.Fatalf("error: %v\n", ctx.Err())
case _, ok := <-channel:
if !ok {
t.Fatalf("error: channel closed earlier than expected...\n")
}
}
cancel()
<-ctx.Done()
err = insertOrTimeout(ctx, channel, "second string")
if err == nil {
t.Fatalf("error: inserted after ctx.Done\n")
}
}