Skip to content

Commit d19bd13

Browse files
feat: local file reader with live tailing and rotation handling (#7)
Implements FileSource that reads log files with: - Initial reading from start or last N lines - Live tailing via fsnotify - Log rotation detection (truncation + file replacement) - Glob pattern and multi-file support - Streaming reads for large file efficiency Fixes #6
1 parent 341496b commit d19bd13

5 files changed

Lines changed: 673 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.24.2
55
require (
66
github.com/charmbracelet/bubbletea v1.3.10
77
github.com/charmbracelet/lipgloss v1.1.0
8+
github.com/fsnotify/fsnotify v1.9.0
89
)
910

1011
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
2020
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
2121
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2222
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
23+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
24+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
2325
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
2426
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
2527
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

internal/source/file.go

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
package source
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"sync"
11+
"time"
12+
13+
"github.com/fsnotify/fsnotify"
14+
)
15+
16+
// FileConfig holds configuration for a file source.
17+
type FileConfig struct {
18+
// Patterns is a list of file paths or glob patterns.
19+
Patterns []string
20+
// TailLines is the number of lines to read from the end on startup.
21+
// If 0, read from the beginning. If negative, read from the beginning.
22+
TailLines int
23+
}
24+
25+
// FileSource reads log lines from one or more files with live tailing
26+
// and log rotation support.
27+
type FileSource struct {
28+
config FileConfig
29+
lines chan LogEntry
30+
errs chan error
31+
cancel context.CancelFunc
32+
wg sync.WaitGroup
33+
stopped chan struct{}
34+
}
35+
36+
// NewFileSource creates a new file source from the given config.
37+
func NewFileSource(cfg FileConfig) *FileSource {
38+
return &FileSource{
39+
config: cfg,
40+
lines: make(chan LogEntry, 256),
41+
errs: make(chan error, 32),
42+
stopped: make(chan struct{}),
43+
}
44+
}
45+
46+
func (fs *FileSource) Lines() <-chan LogEntry { return fs.lines }
47+
func (fs *FileSource) Errors() <-chan error { return fs.errs }
48+
49+
// Start resolves glob patterns and begins tailing all matched files.
50+
func (fs *FileSource) Start(ctx context.Context) error {
51+
ctx, fs.cancel = context.WithCancel(ctx)
52+
53+
paths, err := fs.resolvePatterns()
54+
if err != nil {
55+
return fmt.Errorf("resolving file patterns: %w", err)
56+
}
57+
if len(paths) == 0 {
58+
return fmt.Errorf("no files matched patterns: %v", fs.config.Patterns)
59+
}
60+
61+
watcher, err := fsnotify.NewWatcher()
62+
if err != nil {
63+
return fmt.Errorf("creating watcher: %w", err)
64+
}
65+
66+
// Watch directories containing the files (for rotation detection).
67+
dirs := map[string]struct{}{}
68+
for _, p := range paths {
69+
d := filepath.Dir(p)
70+
dirs[d] = struct{}{}
71+
}
72+
for d := range dirs {
73+
if err := watcher.Add(d); err != nil {
74+
fs.sendError(fmt.Errorf("watching directory %s: %w", d, err))
75+
}
76+
}
77+
78+
// Start a tailer goroutine per file.
79+
for _, p := range paths {
80+
fs.wg.Add(1)
81+
go fs.tailFile(ctx, watcher, p)
82+
}
83+
84+
// Wait for all tailers then clean up.
85+
go func() {
86+
fs.wg.Wait()
87+
watcher.Close()
88+
close(fs.lines)
89+
close(fs.errs)
90+
close(fs.stopped)
91+
}()
92+
93+
return nil
94+
}
95+
96+
// Stop cancels tailing and waits for goroutines to finish.
97+
func (fs *FileSource) Stop() error {
98+
if fs.cancel != nil {
99+
fs.cancel()
100+
}
101+
<-fs.stopped
102+
return nil
103+
}
104+
105+
// resolvePatterns expands glob patterns into unique absolute file paths.
106+
func (fs *FileSource) resolvePatterns() ([]string, error) {
107+
seen := map[string]struct{}{}
108+
var result []string
109+
110+
for _, pattern := range fs.config.Patterns {
111+
matches, err := filepath.Glob(pattern)
112+
if err != nil {
113+
return nil, fmt.Errorf("invalid glob %q: %w", pattern, err)
114+
}
115+
if len(matches) == 0 {
116+
// Treat as literal path.
117+
abs, err := filepath.Abs(pattern)
118+
if err != nil {
119+
return nil, err
120+
}
121+
if _, err := os.Stat(abs); err != nil {
122+
return nil, fmt.Errorf("file not found: %s", abs)
123+
}
124+
if _, ok := seen[abs]; !ok {
125+
seen[abs] = struct{}{}
126+
result = append(result, abs)
127+
}
128+
continue
129+
}
130+
for _, m := range matches {
131+
abs, err := filepath.Abs(m)
132+
if err != nil {
133+
return nil, err
134+
}
135+
info, err := os.Stat(abs)
136+
if err != nil || info.IsDir() {
137+
continue
138+
}
139+
if _, ok := seen[abs]; !ok {
140+
seen[abs] = struct{}{}
141+
result = append(result, abs)
142+
}
143+
}
144+
}
145+
return result, nil
146+
}
147+
148+
// tailFile reads initial lines then tails a single file, handling rotation.
149+
func (fs *FileSource) tailFile(ctx context.Context, watcher *fsnotify.Watcher, path string) {
150+
defer fs.wg.Done()
151+
152+
f, err := os.Open(path)
153+
if err != nil {
154+
fs.sendError(fmt.Errorf("opening %s: %w", path, err))
155+
return
156+
}
157+
defer f.Close()
158+
159+
// Read initial lines.
160+
if fs.config.TailLines > 0 {
161+
if err := fs.seekToLastN(f, fs.config.TailLines); err != nil {
162+
fs.sendError(fmt.Errorf("seeking in %s: %w", path, err))
163+
}
164+
}
165+
166+
offset, err := fs.readLines(f, path)
167+
if err != nil {
168+
fs.sendError(fmt.Errorf("initial read of %s: %w", path, err))
169+
return
170+
}
171+
172+
// Record inode for rotation detection.
173+
lastStat, _ := f.Stat()
174+
lastSize := offset
175+
176+
// Poll ticker as fallback for missed events.
177+
ticker := time.NewTicker(1 * time.Second)
178+
defer ticker.Stop()
179+
180+
for {
181+
select {
182+
case <-ctx.Done():
183+
return
184+
185+
case event, ok := <-watcher.Events:
186+
if !ok {
187+
return
188+
}
189+
abs, _ := filepath.Abs(event.Name)
190+
if abs != path {
191+
continue
192+
}
193+
194+
if event.Has(fsnotify.Write) {
195+
offset, lastSize, err = fs.handleWrite(f, path, offset, lastSize)
196+
if err != nil {
197+
fs.sendError(err)
198+
}
199+
}
200+
201+
if event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove) {
202+
// File was rotated — reopen.
203+
newF, newOffset, reopened := fs.tryReopen(path, lastStat)
204+
if reopened {
205+
f.Close()
206+
f = newF
207+
offset = newOffset
208+
lastStat, _ = f.Stat()
209+
lastSize = newOffset
210+
}
211+
}
212+
213+
case _, ok := <-watcher.Errors:
214+
if !ok {
215+
return
216+
}
217+
218+
case <-ticker.C:
219+
// Check for truncation or new data.
220+
stat, err := os.Stat(path)
221+
if err != nil {
222+
// File gone — try to reopen (rotation).
223+
newF, newOffset, reopened := fs.tryReopen(path, lastStat)
224+
if reopened {
225+
f.Close()
226+
f = newF
227+
offset = newOffset
228+
lastStat, _ = f.Stat()
229+
lastSize = newOffset
230+
}
231+
continue
232+
}
233+
234+
if stat.Size() < lastSize {
235+
// Truncated — reread from start.
236+
f.Close()
237+
f2, err := os.Open(path)
238+
if err != nil {
239+
fs.sendError(fmt.Errorf("reopening truncated %s: %w", path, err))
240+
continue
241+
}
242+
f = f2
243+
offset = 0
244+
lastStat, _ = f.Stat()
245+
}
246+
247+
newOff, err := fs.readLines(f, path)
248+
if err != nil {
249+
fs.sendError(err)
250+
continue
251+
}
252+
if newOff > 0 {
253+
offset = newOff
254+
}
255+
lastSize = offset
256+
}
257+
}
258+
}
259+
260+
// handleWrite reads new data after a write event, handling truncation.
261+
func (fs *FileSource) handleWrite(f *os.File, path string, offset, lastSize int64) (int64, int64, error) {
262+
stat, err := os.Stat(path)
263+
if err != nil {
264+
return offset, lastSize, fmt.Errorf("stat %s: %w", path, err)
265+
}
266+
if stat.Size() < lastSize {
267+
// Truncated.
268+
if _, err := f.Seek(0, io.SeekStart); err != nil {
269+
return 0, 0, fmt.Errorf("seek after truncation %s: %w", path, err)
270+
}
271+
offset = 0
272+
}
273+
newOff, err := fs.readLines(f, path)
274+
if err != nil {
275+
return offset, lastSize, err
276+
}
277+
if newOff > 0 {
278+
offset = newOff
279+
}
280+
return offset, stat.Size(), nil
281+
}
282+
283+
// tryReopen attempts to reopen a file after rotation. Returns the new file,
284+
// offset after initial read, and whether reopening succeeded.
285+
func (fs *FileSource) tryReopen(path string, lastStat os.FileInfo) (*os.File, int64, bool) {
286+
// Wait briefly for the new file to appear.
287+
for i := 0; i < 5; i++ {
288+
f, err := os.Open(path)
289+
if err != nil {
290+
time.Sleep(100 * time.Millisecond)
291+
continue
292+
}
293+
// Check if it's actually a new file (different inode or smaller).
294+
newStat, _ := f.Stat()
295+
if lastStat != nil && os.SameFile(lastStat, newStat) {
296+
f.Close()
297+
time.Sleep(100 * time.Millisecond)
298+
continue
299+
}
300+
off, _ := fs.readLines(f, path)
301+
return f, off, true
302+
}
303+
return nil, 0, false
304+
}
305+
306+
// readLines reads available lines from the current position, sends them,
307+
// and returns the new offset.
308+
func (fs *FileSource) readLines(f *os.File, path string) (int64, error) {
309+
scanner := bufio.NewScanner(f)
310+
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
311+
for scanner.Scan() {
312+
fs.lines <- LogEntry{
313+
Line: scanner.Text(),
314+
Source: path,
315+
}
316+
}
317+
if err := scanner.Err(); err != nil {
318+
return 0, fmt.Errorf("reading %s: %w", path, err)
319+
}
320+
off, _ := f.Seek(0, io.SeekCurrent)
321+
return off, nil
322+
}
323+
324+
// seekToLastN positions the file to read approximately the last n lines.
325+
// It works by scanning backwards from the end.
326+
func (fs *FileSource) seekToLastN(f *os.File, n int) error {
327+
stat, err := f.Stat()
328+
if err != nil {
329+
return err
330+
}
331+
size := stat.Size()
332+
if size == 0 {
333+
return nil
334+
}
335+
336+
// Read chunks from the end to find newlines.
337+
const chunkSize = 8192
338+
newlines := 0
339+
offset := size
340+
341+
for offset > 0 && newlines <= n {
342+
readSize := int64(chunkSize)
343+
if readSize > offset {
344+
readSize = offset
345+
}
346+
offset -= readSize
347+
348+
buf := make([]byte, readSize)
349+
if _, err := f.ReadAt(buf, offset); err != nil && err != io.EOF {
350+
return err
351+
}
352+
for i := len(buf) - 1; i >= 0; i-- {
353+
if buf[i] == '\n' {
354+
newlines++
355+
if newlines > n {
356+
offset += int64(i) + 1
357+
break
358+
}
359+
}
360+
}
361+
}
362+
363+
if _, err := f.Seek(offset, io.SeekStart); err != nil {
364+
return err
365+
}
366+
return nil
367+
}
368+
369+
func (fs *FileSource) sendError(err error) {
370+
select {
371+
case fs.errs <- err:
372+
default:
373+
}
374+
}

0 commit comments

Comments
 (0)