diff --git a/internal/exercises/templates/35_basic_key_value_store/key_value_store.go b/internal/exercises/templates/35_basic_key_value_store/key_value_store.go index 18eaf71..0a6f854 100644 --- a/internal/exercises/templates/35_basic_key_value_store/key_value_store.go +++ b/internal/exercises/templates/35_basic_key_value_store/key_value_store.go @@ -1,16 +1,15 @@ package basic_key_value_store import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" "sync" ) -// TODO: -// - Implement a basic persistent key-value store: -// - In-memory map protected with RWMutex. -// - Load: read key=value pairs from a file if present. -// - Save: write all pairs to a file. -// - Set/Get/Delete operations; Get/Delete error on missing keys. - +// KeyValueStore implements a simple thread-safe persistent key-value store. type KeyValueStore struct { data map[string]string filepath string @@ -25,31 +24,119 @@ func (e *StoreError) Error() string { return e.Message } +// Sentinel error for missing keys. +var ErrKeyNotFound = &StoreError{Message: "key not found"} + +// NewKeyValueStore initializes a new key-value store at the given filepath. func NewKeyValueStore(filepath string) *KeyValueStore { - // TODO: initialize the store - return &KeyValueStore{} + return &KeyValueStore{ + data: make(map[string]string), + filepath: filepath, + } } +// Load replaces the current store with the contents of the file. func (s *KeyValueStore) Load() error { - // TODO: load key/value pairs from file if present + file, err := os.Open(s.filepath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0), 1024*1024) // allow long lines up to 1MB + tmp := make(map[string]string) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + k := strings.TrimSpace(parts[0]) + v := strings.TrimSpace(parts[1]) + tmp[k] = v + } + } + if err := scanner.Err(); err != nil { + return err + } + + s.mu.Lock() + s.data = tmp + s.mu.Unlock() return nil } +// Save writes the store atomically to disk. func (s *KeyValueStore) Save() error { - // TODO: write pairs to file - return nil + // Snapshot under read lock + s.mu.RLock() + snapshot := make(map[string]string, len(s.data)) + for k, v := range s.data { + snapshot[k] = v + } + s.mu.RUnlock() + + dir := filepath.Dir(s.filepath) + tmp, err := os.CreateTemp(dir, ".kvtmp-*") + if err != nil { + return err + } + + writer := bufio.NewWriter(tmp) + for k, v := range snapshot { + if _, err := fmt.Fprintf(writer, "%s=%s\n", k, v); err != nil { + tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + } + if err := writer.Flush(); err != nil { + tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + return os.Rename(tmp.Name(), s.filepath) } +// Set updates or inserts a key. func (s *KeyValueStore) Set(key, value string) { - // TODO: set key to value + s.mu.Lock() + defer s.mu.Unlock() + s.data[key] = value } +// Get retrieves a value or ErrKeyNotFound. func (s *KeyValueStore) Get(key string) (string, *StoreError) { - // TODO: get value or return error when missing - return "", nil + s.mu.RLock() + defer s.mu.RUnlock() + val, ok := s.data[key] + if !ok { + return "", ErrKeyNotFound + } + return val, nil } +// Delete removes a key or returns ErrKeyNotFound. func (s *KeyValueStore) Delete(key string) *StoreError { - // TODO: delete key or return error when missing + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.data[key]; !ok { + return ErrKeyNotFound + } + delete(s.data, key) return nil } diff --git a/internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go b/internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go index 004ecd4..50a8ac5 100644 --- a/internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go +++ b/internal/exercises/templates/35_basic_key_value_store/key_value_store_test.go @@ -1,98 +1,76 @@ package basic_key_value_store import ( - "os" + "errors" + "path/filepath" "testing" ) -func TestNewKeyValueStore(t *testing.T) { - filepath := "test_kv_store.txt" - s := NewKeyValueStore(filepath) - if s == nil { - t.Errorf("Expected a new KeyValueStore, got nil") - } - if s.filepath != filepath { - t.Errorf("Expected filepath %s, got %s", filepath, s.filepath) - } - if s.data == nil { - t.Errorf("Expected data map to be initialized, got nil") - } -} - -func TestSetAndGet(t *testing.T) { - filepath := "test_set_get.txt" - defer os.Remove(filepath) +func TestLoadAndSave(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "test_store.db") - s := NewKeyValueStore(filepath) - s.Set("name", "Alice") - s.Set("age", "30") + store := NewKeyValueStore(filename) + store.Set("city", "New York") + store.Set("country", "USA") - val, err := s.Get("name") - if err != nil || val != "Alice" { - t.Errorf("Expected 'Alice', got %q, error: %v", val, err) + if err := store.Save(); err != nil { + t.Fatalf("unexpected save error: %v", err) } - val, err = s.Get("age") - if err != nil || val != "30" { - t.Errorf("Expected '30', got %q, error: %v", val, err) + // load into a new store + other := NewKeyValueStore(filename) + if err := other.Load(); err != nil { + t.Fatalf("unexpected load error: %v", err) } - _, err = s.Get("nonexistent") - if err == nil { - t.Errorf("Expected error for non-existent key, got nil") + if v, err := other.Get("city"); v != "New York" || err != nil { + t.Errorf("expected New York, got %q, err=%v", v, err) + } + if v, err := other.Get("country"); v != "USA" || err != nil { + t.Errorf("expected USA, got %q, err=%v", v, err) } } -func TestDelete(t *testing.T) { - filepath := "test_delete.txt" - defer os.Remove(filepath) +func TestMissingKey(t *testing.T) { + dir := t.TempDir() + filename := filepath.Join(dir, "test_store_missing.db") - s := NewKeyValueStore(filepath) - s.Set("key1", "value1") - - err := s.Delete("key1") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } + store := NewKeyValueStore(filename) + store.Set("name", "Alice") - _, err = s.Get("key1") - if err == nil { - t.Errorf("Expected error for deleted key, got nil") - } - - err = s.Delete("nonexistent") - if err == nil { - t.Errorf("Expected error for deleting non-existent key, got nil") + _, err := store.Get("notthere") + if !errors.Is(err, ErrKeyNotFound) { + t.Errorf("expected ErrKeyNotFound, got %v", err) } } -func TestLoadAndSave(t *testing.T) { - filepath := "test_load_save.txt" - defer os.Remove(filepath) +func TestDelete(t *testing.T) { + t.Parallel() + dir := t.TempDir() + filename := filepath.Join(dir, "test_store.db") - // Create a store and save it - s1 := NewKeyValueStore(filepath) - s1.Set("city", "New York") - s1.Set("country", "USA") - err := s1.Save() - if err != nil { - t.Fatalf("Failed to save store: %v", err) - } + store := NewKeyValueStore(filename) + store.Set("k", "v") - // Load into a new store - s2 := NewKeyValueStore(filepath) - err = s2.Load() - if err != nil { - t.Fatalf("Failed to load store: %v", err) + if err := store.Delete("k"); err != nil { + t.Fatalf("unexpected delete error: %v", err) } - - val, err := s2.Get("city") - if err != nil || val != "New York" { - t.Errorf("Expected 'New York', got %q, error: %v", val, err) + if _, err := store.Get("k"); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("expected ErrKeyNotFound after delete, got %v", err) + } + if err := store.Delete("k"); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("expected ErrKeyNotFound on second delete, got %v", err) } +} + +func TestLoadMissingFileIsNoop(t *testing.T) { + t.Parallel() + dir := t.TempDir() + filename := filepath.Join(dir, "does_not_exist.db") - val, err = s2.Get("country") - if err != nil || val != "USA" { - t.Errorf("Expected 'USA', got %q, error: %v", val, err) + store := NewKeyValueStore(filename) + if err := store.Load(); err != nil { + t.Fatalf("expected nil error on missing file, got %v", err) } }