package skiprules import ( "errors" "log/slog" "os" "sync" "time" ) // Option configures a File. type Option func(*File) // WithLogger makes the file log its reloads and problems to logger instead // of slog.Default(). func WithLogger(logger *slog.Logger) Option { return func(f *File) { f.log = logger } } // File is a rules file that is read again when it changes (see Rules). // Create it with Open. It is safe for concurrent use. type File struct { path string log *slog.Logger mu sync.Mutex // rules are the last good rules, or nil while the file is missing. rules *Rules // modTime and size are the file's when it was last read, good or not, so // a bad file isn't read again until it changes. modTime time.Time size int64 // problem is the missing or unreadable file last logged, so it is // logged once rather than on every call; "" once the file is found. problem string } // Open reads the rules file at path. Unlike a reload, a missing or invalid // file is an error. func Open(path string, opts ...Option) (*File, error) { file := &File{ path: path, } for _, option := range opts { option(file) } info, err := os.Stat(path) if err != nil { return nil, err } rules, err := Load(path) if err != nil { return nil, err } file.rules = rules file.modTime = info.ModTime() file.size = info.Size() return file, nil } // logger returns the logger the file logs to, tagged with its component. func (f *File) logger() *slog.Logger { log := f.log if log == nil { log = slog.Default() } return log.With("component", "skiprules") } // Path returns the path of the rules file. func (f *File) Path() string { return f.path } // Rules returns the current rules, first reading the file again if its // modification time or size has changed since it was last read. // // If the file can't be read or parsed, the error is logged and the last good // rules are kept. If it has been deleted, a warning is logged and the result // is nil (no rules) until it is back. A file that is missing or can't be // checked is logged once, not on every call; an invalid one once per change. func (f *File) Rules() *Rules { f.mu.Lock() defer f.mu.Unlock() info, err := os.Stat(f.path) if errors.Is(err, os.ErrNotExist) { f.rules = nil f.modTime = time.Time{} f.size = 0 if f.newProblem("missing") { f.logger().Warn( "skip rules file missing, skipping nothing", "file", f.path, ) } return nil } if err != nil { if f.newProblem(err.Error()) { f.logger().Error( "skip rules file unreadable, keeping the last good rules", "file", f.path, "error", err, ) } return f.rules } f.problem = "" if info.ModTime().Equal(f.modTime) && info.Size() == f.size { return f.rules } f.modTime = info.ModTime() f.size = info.Size() rules, err := Load(f.path) if err != nil { f.logger().Error( "skip rules file invalid, keeping the last good rules", "file", f.path, "error", err, ) return f.rules } f.rules = rules filters, active := rules.Filters() f.logger().Info( "skip rules reloaded", "file", f.path, "filters", filters, "active_filters", active, ) return f.rules } // newProblem reports whether problem isn't the one last logged, and // remembers it. Call it with f.mu held. func (f *File) newProblem(problem string) bool { if problem == f.problem { return false } f.problem = problem return true }