From c145062ff0adeb36d7f0c1996c37c03cd0b331b4 Mon Sep 17 00:00:00 2001 From: William Dillon Date: Tue, 25 Nov 2025 21:10:31 -0500 Subject: [PATCH] first commit --- go.mod | 3 + scanner.go | 61 ++++++++++++++++++ scanner_test.go | 80 +++++++++++++++++++++++ settings.go | 163 +++++++++++++++++++++++++++++++++++++++++++++++ settings_test.go | 127 ++++++++++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 go.mod create mode 100644 scanner.go create mode 100644 scanner_test.go create mode 100644 settings.go create mode 100644 settings_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c454435 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.wmdillon.com/wmdillon/settings + +go 1.25.3 diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..66cb2ab --- /dev/null +++ b/scanner.go @@ -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), + } +} diff --git a/scanner_test.go b/scanner_test.go new file mode 100644 index 0000000..1adb6e2 --- /dev/null +++ b/scanner_test.go @@ -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) + } +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..849b71c --- /dev/null +++ b/settings.go @@ -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 +} diff --git a/settings_test.go b/settings_test.go new file mode 100644 index 0000000..0247226 --- /dev/null +++ b/settings_test.go @@ -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) + } +}