package main import ( "bufio" "fmt" "os" "strings" "syscall" ) // LineStore gives locked read-modify-write access to a flat text file with // one record per line. Lines starting with '#' and blank lines are ignored. // parse/format convert between a line and a record of type T. type LineStore[T any] struct { path string file *os.File header string parse func(line string) (T, error) format func(v T) string } func openLineStore[T any](path, header string, parse func(string) (T, error), format func(T) string) (*LineStore[T], error) { if err := os.MkdirAll(dirOf(path), 0755); err != nil { return nil, fmt.Errorf("cannot create directory for %s: %w", path, err) } f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644) if err != nil { return nil, fmt.Errorf("cannot open %s: %w", path, err) } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { f.Close() return nil, fmt.Errorf("cannot lock %s: %w", path, err) } return &LineStore[T]{path: path, file: f, header: header, parse: parse, format: format}, nil } func (s *LineStore[T]) Close() error { syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN) return s.file.Close() } func (s *LineStore[T]) Load() ([]T, error) { if _, err := s.file.Seek(0, 0); err != nil { return nil, err } var items []T sc := bufio.NewScanner(s.file) lineNo := 0 for sc.Scan() { lineNo++ line := strings.TrimRight(sc.Text(), "\r\n") if line == "" || strings.HasPrefix(line, "#") { continue } v, err := s.parse(line) if err != nil { return nil, fmt.Errorf("%s:%d: %w", s.path, lineNo, err) } items = append(items, v) } if err := sc.Err(); err != nil { return nil, err } return items, nil } func (s *LineStore[T]) Save(items []T) error { var b strings.Builder b.WriteString(s.header) for _, v := range items { b.WriteString(s.format(v)) b.WriteString("\n") } if err := s.file.Truncate(0); err != nil { return err } if _, err := s.file.Seek(0, 0); err != nil { return err } if _, err := s.file.WriteString(b.String()); err != nil { return err } return s.file.Sync() } func dirOf(path string) string { i := strings.LastIndexByte(path, '/') if i <= 0 { return "." } return path[:i] }