first commit
This commit is contained in:
commit
c145062ff0
61
scanner.go
Normal file
61
scanner.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
KeyValueDelimiterChars = []byte{':', '='}
|
||||||
|
|
||||||
|
ErrNoValidDelimiter = fmt.Errorf("no valid delimiter found (use %s)", KeyValueDelimiterCharsToString())
|
||||||
|
)
|
||||||
|
|
||||||
|
func KeyValueDelimiterCharsToString() string {
|
||||||
|
var builder strings.Builder
|
||||||
|
for i, char := range KeyValueDelimiterChars {
|
||||||
|
builder.WriteString(fmt.Sprintf("'%c'", char))
|
||||||
|
if i < len(KeyValueDelimiterChars)-1 {
|
||||||
|
builder.WriteString(" or ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func LineIsComment(line string) bool {
|
||||||
|
return strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scanner struct {
|
||||||
|
lineNumber int
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns errors without any key/value.
|
||||||
|
// does not clone the strings it extracted.
|
||||||
|
func (s *Scanner) ReadNextLine() (key, value string, err error) {
|
||||||
|
for s.scanner.Scan() {
|
||||||
|
s.lineNumber++
|
||||||
|
line := strings.TrimSpace(s.scanner.Text())
|
||||||
|
if len(line) == 0 || LineIsComment(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
splitIndex := strings.IndexAny(line, string(KeyValueDelimiterChars))
|
||||||
|
if splitIndex == -1 {
|
||||||
|
return "", "", fmt.Errorf("line %d: %w", s.lineNumber, ErrNoValidDelimiter)
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(line[:splitIndex])
|
||||||
|
value = strings.TrimSpace(line[splitIndex+1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return "", "", io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScanner(r io.Reader) *Scanner {
|
||||||
|
return &Scanner{
|
||||||
|
lineNumber: 0,
|
||||||
|
scanner: bufio.NewScanner(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
80
scanner_test.go
Normal file
80
scanner_test.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
KeyValuePairs = map[string]string{
|
||||||
|
"A": "a",
|
||||||
|
"B": "b",
|
||||||
|
"C": "c",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func RandomKeyValueDelimiter() byte {
|
||||||
|
return KeyValueDelimiterChars[rand.Intn(len(KeyValueDelimiterChars))]
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteMapToWriter(w io.Writer, m map[string]string) error {
|
||||||
|
writer := bufio.NewWriter(w)
|
||||||
|
comments := []string{"// this is a random comment", "# this is another random comment"}
|
||||||
|
randomComment := func() string { return comments[rand.Intn(len(comments))] }
|
||||||
|
var position int
|
||||||
|
for k, v := range m {
|
||||||
|
switch rand.Float64() >= .5 {
|
||||||
|
case true:
|
||||||
|
fmt.Fprintf(writer, "%s\n", randomComment())
|
||||||
|
}
|
||||||
|
fmt.Fprintf(writer, "%s%c%s", k, RandomKeyValueDelimiter(), v)
|
||||||
|
if position < len(m)-1 {
|
||||||
|
writer.WriteByte('\n')
|
||||||
|
}
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerParsesFilesProperly(t *testing.T) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
err := WriteMapToWriter(&buffer, KeyValuePairs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing map to buffer: %v\n", err)
|
||||||
|
}
|
||||||
|
scanner := NewScanner(&buffer)
|
||||||
|
got := make(map[string]string)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
key, value, err := scanner.ReadNextLine()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
t.Fatalf("error reading next line from scanner: %v\n", err)
|
||||||
|
}
|
||||||
|
got[key] = value
|
||||||
|
}
|
||||||
|
if want := KeyValuePairs; !maps.Equal(want, got) {
|
||||||
|
t.Fatalf("error: wanted '%+v'; got '%+v'\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerHandlesInvalidLinesProperly(t *testing.T) {
|
||||||
|
buffer := bytes.NewBufferString("validKey-validValue")
|
||||||
|
scanner := NewScanner(buffer)
|
||||||
|
key, value, err := scanner.ReadNextLine()
|
||||||
|
if !errors.Is(err, ErrNoValidDelimiter) {
|
||||||
|
t.Fatalf("error: wanted %v; got %v\n", ErrNoValidDelimiter, err)
|
||||||
|
} else if want, got := "", key; want != got {
|
||||||
|
t.Fatalf("error: wanted key '%s'; got '%s'\n", want, got)
|
||||||
|
} else if want, got := "", value; want != got {
|
||||||
|
t.Fatalf("error: wanted value '%s'; got '%s'\n", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
settings.go
Normal file
163
settings.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
MaintenanceRoutinePace = time.Second
|
||||||
|
WarnIfFileNotFound = false
|
||||||
|
)
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
mutex sync.RWMutex
|
||||||
|
contents map[string]string
|
||||||
|
filename string
|
||||||
|
modtime time.Time
|
||||||
|
LogUpdates atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) GetFilename() string {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
return s.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) SetFilename(filename string) (updated bool) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
if filename == s.filename {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.LogUpdates.Load() {
|
||||||
|
log.Printf("Settings::SetFilename changing from '%s' to '%s'\n", s.filename, filename)
|
||||||
|
}
|
||||||
|
s.filename = strings.Clone(filename)
|
||||||
|
s.modtime = time.Time{}
|
||||||
|
err := s.lockedUpdate()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound {
|
||||||
|
log.Printf("Settings::SetFilename %s: error from lockedUpdate: %v\n", filepath.Base(s.filename), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) lockedSetKeyValue(key, value string) bool {
|
||||||
|
logUpdates := s.LogUpdates.Load()
|
||||||
|
baseFilename := filepath.Base(s.filename)
|
||||||
|
if stored, found := s.contents[key]; found && stored == value {
|
||||||
|
return false
|
||||||
|
} else if found && logUpdates {
|
||||||
|
log.Printf("Settings::lockedKeyValue %s: updating key '%s' from value '%s' to '%s'\n", baseFilename, key, stored, value)
|
||||||
|
} else if !found && logUpdates {
|
||||||
|
log.Printf("Settings::lockedKeyValue %s: storing key '%s' with value '%s'\n", baseFilename, key, value)
|
||||||
|
}
|
||||||
|
s.contents[strings.Clone(key)] = strings.Clone(value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) SetKeyValue(key, value string) (updated bool) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
return s.lockedSetKeyValue(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) lockedContainsKey(key string) bool {
|
||||||
|
_, found := s.contents[key]
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) ContainsKey(key string) bool {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
return s.lockedContainsKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) lockedGetKeyValue(key string) string {
|
||||||
|
return s.contents[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) GetKeyValue(key string) string {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
return s.lockedGetKeyValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) lockedUpdate() error {
|
||||||
|
stat, err := os.Stat(s.filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error getting os.FileInfo for %s: %w", s.filename, err)
|
||||||
|
} else if !stat.ModTime().After(s.modtime) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(s.filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading %s: %w", s.filename, err)
|
||||||
|
}
|
||||||
|
scanner := NewScanner(bytes.NewReader(contents))
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
key, value, err := scanner.ReadNextLine()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, io.EOF) {
|
||||||
|
log.Printf("error from scanner.ReadNextLine: %v\n", err)
|
||||||
|
} else {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.lockedSetKeyValue(key, value)
|
||||||
|
}
|
||||||
|
s.modtime = stat.ModTime()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) Update() error {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
return s.lockedUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Settings) maintenanceRoutine(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(MaintenanceRoutinePace):
|
||||||
|
err := s.Update()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) || WarnIfFileNotFound {
|
||||||
|
log.Printf("Settings::maintenanceRoutine %s error from Update: %v\n", filepath.Base(s.GetFilename()), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettings(filename string, logUpdates bool) *Settings {
|
||||||
|
return NewSettingsWithContext(context.Background(), filename, logUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsWithContext(ctx context.Context, filename string, logUpdates bool) *Settings {
|
||||||
|
settings := &Settings{
|
||||||
|
contents: make(map[string]string),
|
||||||
|
filename: strings.Clone(filename),
|
||||||
|
}
|
||||||
|
settings.LogUpdates.Store(logUpdates)
|
||||||
|
if err := settings.lockedUpdate(); err != nil {
|
||||||
|
log.Printf("NewSettingsWithContext %s error from initial update: %v\n", filepath.Base(filename), err)
|
||||||
|
}
|
||||||
|
go settings.maintenanceRoutine(ctx)
|
||||||
|
return settings
|
||||||
|
}
|
||||||
127
settings_test.go
Normal file
127
settings_test.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TheTestFilename = "settings_test.settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
AdditionalKeyValuePairs = map[string]string{
|
||||||
|
"D": "d",
|
||||||
|
"E": "e",
|
||||||
|
"F": "f",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettings(t *testing.T) {
|
||||||
|
err := os.Remove(TheTestFilename)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("error cleaning up %s from previous run: %v\n", TheTestFilename, err)
|
||||||
|
}
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
err = WriteMapToWriter(&buffer, KeyValuePairs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing test data to *bytes.Buffer: %v\n", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(TheTestFilename, buffer.Bytes(), 0664)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing %s: %v\n", TheTestFilename, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(TheTestFilename); err != nil {
|
||||||
|
t.Fatalf("error cleaning up %s from this run: %v\n", TheTestFilename, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
settings := NewSettingsWithContext(t.Context(), TheTestFilename, false)
|
||||||
|
if want, got := TheTestFilename, settings.GetFilename(); want != got {
|
||||||
|
t.Fatalf("error: wanted '%s'; got '%s'\n", want, got)
|
||||||
|
} else if got := settings.modtime; got.Equal(time.Time{}) {
|
||||||
|
t.Fatalf("error: modtime == (time.Time{}) - not updated when initialized")
|
||||||
|
} else {
|
||||||
|
for k, v := range KeyValuePairs {
|
||||||
|
if !settings.ContainsKey(k) {
|
||||||
|
t.Fatalf("error: key '%s' not found\n", k)
|
||||||
|
} else if want, got := v, settings.GetKeyValue(k); want != got {
|
||||||
|
t.Fatalf("error: wanted '%s' for key '%s'; got '%s'\n", want, k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// change the filename
|
||||||
|
TheNewTestFilename := fmt.Sprintf("New%s", TheTestFilename)
|
||||||
|
buffer.Reset()
|
||||||
|
err = WriteMapToWriter(&buffer, AdditionalKeyValuePairs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing new map to *bytes.Buffer: %v\n", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(TheNewTestFilename, buffer.Bytes(), 0664)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing new test file %s: %v\n", TheNewTestFilename, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(TheNewTestFilename); err != nil {
|
||||||
|
t.Fatalf("error deleting %s: %v", TheNewTestFilename, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if updated := settings.SetFilename(TheNewTestFilename); !updated {
|
||||||
|
t.Fatalf("error: SetFilename() indicated the filename was not updated...\n")
|
||||||
|
} else if updated := settings.SetFilename(settings.GetFilename()); updated {
|
||||||
|
t.Fatalf("error: setting filename to itself still resulted in updated=true\n")
|
||||||
|
}
|
||||||
|
for k, v := range AdditionalKeyValuePairs {
|
||||||
|
if !settings.ContainsKey(k) {
|
||||||
|
t.Fatalf("error: key '%s' not found\n", k)
|
||||||
|
} else if want, got := v, settings.GetKeyValue(k); want != got {
|
||||||
|
t.Fatalf("error: wanted '%s' for key '%s'; got '%s'\n", want, k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update the file, wait, then try again
|
||||||
|
newKeyValuePairs := make(map[string]string)
|
||||||
|
maps.Copy(newKeyValuePairs, KeyValuePairs)
|
||||||
|
maps.Copy(newKeyValuePairs, AdditionalKeyValuePairs)
|
||||||
|
newKeyValuePairs["G"] = "g"
|
||||||
|
buffer.Reset()
|
||||||
|
err = WriteMapToWriter(&buffer, newKeyValuePairs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing new key value pairs to *bytes.Buffer: %v\n", err)
|
||||||
|
}
|
||||||
|
err = os.WriteFile(TheNewTestFilename, buffer.Bytes(), 0664)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error writing updates to %s: %v\n", TheNewTestFilename, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-t.Context().Done():
|
||||||
|
t.Fatalf("error: timed out before finishing wait")
|
||||||
|
case <-time.After(time.Duration(float64(MaintenanceRoutinePace.Nanoseconds()) * 1.1)):
|
||||||
|
}
|
||||||
|
for k, v := range newKeyValuePairs {
|
||||||
|
if !settings.ContainsKey(k) {
|
||||||
|
t.Fatalf("error: key '%s' not found in settings\n", k)
|
||||||
|
} else if want, got := v, settings.GetKeyValue(k); want != got {
|
||||||
|
t.Fatalf("error: wanted '%s' for key '%s'; got '%s'\n", want, k, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.Reset()
|
||||||
|
defaultLogWriter := log.Writer()
|
||||||
|
log.SetOutput(&buffer)
|
||||||
|
// set settings to a fake filename
|
||||||
|
WarnIfFileNotFound = true
|
||||||
|
if updated := settings.SetFilename("fakefilename.txt"); !updated {
|
||||||
|
t.Fatalf("failed to update filename to fake filename...\n")
|
||||||
|
}
|
||||||
|
log.SetOutput(defaultLogWriter)
|
||||||
|
WarnIfFileNotFound = false
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user