// Package config handles loading and parsing the p4lf configuration file.
//
// The config file uses a simple KEY=VALUE format (one per line).
// Lines beginning with '#' are comments. Blank lines are ignored.
// The file is watched for modification time changes; callers should
// reload by calling Load again when they detect a change.
package config
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// durationRe matches values like 60s, 3m, 1h.
var durationRe = regexp.MustCompile(`^(\d+)([hms])$`)
// sizeRe matches values like 500M, 3G, 1T, or plain integers (bytes).
var sizeRe = regexp.MustCompile(`^(\d+)([KMGT]?)$`)
// percentRe matches values like 3%.
var percentRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)%$`)
// SpaceSpec represents a MinLogSpace value — either a percentage or an absolute size in bytes.
type SpaceSpec struct {
IsPercent bool
Percent float64
Bytes int64
}
func (s SpaceSpec) String() string {
if s.IsPercent {
return fmt.Sprintf("%.1f%%", s.Percent)
}
return fmt.Sprintf("%d bytes", s.Bytes)
}
// Config holds all p4lf configuration values.
type Config struct {
// P4LogFile is the path to the Perforce server log to tail.
// Default: $P4LOG (from environment); required if not set.
P4LogFile string
// LogTailDelay is how often to flush a log chunk. Default: 60s.
LogTailDelay time.Duration
// LogChunksDir is where compressed chunk files are written.
// Default: $LOGS/logchunks.
LogChunksDir string
// MaxLogChunks is the maximum number of un-consumed chunk files
// allowed before pausing capture. 0 = unlimited. Default: 0.
MaxLogChunks int
// MinLogSpace is the minimum free space required in LogChunksDir
// before pausing capture. Zero value means no check. Default: none.
MinLogSpace SpaceSpec
// MaxLogSize is the maximum size of p4lf's own log before it is
// rotated. 0 = no rotation. Default: 100M.
MaxLogSize int64
// MaxRotationRecoverySize is the maximum size of a freshly rotated-in
// P4LOG before we give up reading from the beginning and warn instead.
// 0 = always read from beginning. Default: 500M.
MaxRotationRecoverySize int64
// StateFile is the path for the persistent state file (inode + offset).
// Default: $LOGS/p4lf.state.
StateFile string
// ReadFromStart controls behaviour on first run (no state file).
// true = read P4LOG from byte 0; false = start from EOF. Default: true.
ReadFromStart bool
// Debug: 0 = off, 1 = on, 2 = pedantic. Default: 0.
Debug int
}
// Defaults returns a Config populated with default values.
// Paths that depend on $LOGS or $P4LOG are resolved at this point.
func Defaults() Config {
logsDir := os.Getenv("LOGS")
if logsDir == "" {
logsDir = "/tmp"
}
return Config{
P4LogFile: os.Getenv("P4LOG"),
LogTailDelay: 60 * time.Second,
LogChunksDir: logsDir + "/logchunks",
MaxLogChunks: 0,
MaxLogSize: 100 * 1024 * 1024,
MaxRotationRecoverySize: 500 * 1024 * 1024,
StateFile: logsDir + "/p4lf.state",
ReadFromStart: true,
Debug: 0,
}
}
// Load reads the config file at path, starting from defaults, and returns
// the populated Config.
func Load(path string) (Config, error) {
cfg := Defaults()
f, err := os.Open(path)
if err != nil {
return cfg, fmt.Errorf("opening config file %q: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
return cfg, fmt.Errorf("config line %d: no '=' found: %q", lineNum, line)
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
// Strip inline comments.
if idx := strings.Index(val, " #"); idx >= 0 {
val = strings.TrimSpace(val[:idx])
}
if err := applyKey(&cfg, key, val, lineNum); err != nil {
return cfg, err
}
}
if err := scanner.Err(); err != nil {
return cfg, fmt.Errorf("reading config file: %w", err)
}
if cfg.P4LogFile == "" {
return cfg, fmt.Errorf("P4LogFile is required (set in config or via $P4LOG environment variable)")
}
return cfg, nil
}
func applyKey(cfg *Config, key, val string, line int) error {
switch key {
case "P4LogFile":
cfg.P4LogFile = val
case "LogTailDelay":
d, err := parseDuration(val)
if err != nil {
return fmt.Errorf("config line %d: LogTailDelay: %w", line, err)
}
cfg.LogTailDelay = d
case "LogChunksDir":
cfg.LogChunksDir = val
case "MaxLogChunks":
n, err := strconv.Atoi(val)
if err != nil || n < 0 {
return fmt.Errorf("config line %d: MaxLogChunks must be a non-negative integer", line)
}
cfg.MaxLogChunks = n
case "MinLogSpace":
sp, err := parseSpaceSpec(val)
if err != nil {
return fmt.Errorf("config line %d: MinLogSpace: %w", line, err)
}
cfg.MinLogSpace = sp
case "MaxLogSize":
sz, err := parseSize(val)
if err != nil {
return fmt.Errorf("config line %d: MaxLogSize: %w", line, err)
}
cfg.MaxLogSize = sz
case "MaxRotationRecoverySize":
sz, err := parseSize(val)
if err != nil {
return fmt.Errorf("config line %d: MaxRotationRecoverySize: %w", line, err)
}
cfg.MaxRotationRecoverySize = sz
case "StateFile":
cfg.StateFile = val
case "ReadFromStart":
b, err := parseBool(val)
if err != nil {
return fmt.Errorf("config line %d: ReadFromStart: %w", line, err)
}
cfg.ReadFromStart = b
case "Debug":
n, err := strconv.Atoi(val)
if err != nil || n < 0 {
return fmt.Errorf("config line %d: Debug must be a non-negative integer", line)
}
cfg.Debug = n
default:
return fmt.Errorf("config line %d: unknown key %q", line, key)
}
return nil
}
func parseDuration(s string) (time.Duration, error) {
m := durationRe.FindStringSubmatch(s)
if m == nil {
// Also accept plain seconds integer.
if n, err := strconv.Atoi(s); err == nil {
return time.Duration(n) * time.Second, nil
}
return 0, fmt.Errorf("invalid duration %q (expected e.g. 60s, 3m, 1h)", s)
}
n, _ := strconv.Atoi(m[1])
switch m[2] {
case "s":
return time.Duration(n) * time.Second, nil
case "m":
return time.Duration(n) * time.Minute, nil
case "h":
return time.Duration(n) * time.Hour, nil
}
return 0, fmt.Errorf("invalid duration unit in %q", s)
}
func parseSize(s string) (int64, error) {
if s == "0" || s == "None" || s == "none" {
return 0, nil
}
m := sizeRe.FindStringSubmatch(strings.ToUpper(s))
if m == nil {
return 0, fmt.Errorf("invalid size %q (expected e.g. 500M, 3G, 1T or plain bytes)", s)
}
n, _ := strconv.ParseInt(m[1], 10, 64)
switch m[2] {
case "K":
return n * 1024, nil
case "M":
return n * 1024 * 1024, nil
case "G":
return n * 1024 * 1024 * 1024, nil
case "T":
return n * 1024 * 1024 * 1024 * 1024, nil
default:
return n, nil
}
}
func parseSpaceSpec(s string) (SpaceSpec, error) {
if s == "0" || strings.EqualFold(s, "none") || s == "" {
return SpaceSpec{}, nil
}
if m := percentRe.FindStringSubmatch(s); m != nil {
pct, _ := strconv.ParseFloat(m[1], 64)
return SpaceSpec{IsPercent: true, Percent: pct}, nil
}
sz, err := parseSize(s)
if err != nil {
return SpaceSpec{}, err
}
return SpaceSpec{Bytes: sz}, nil
}
func parseBool(s string) (bool, error) {
switch strings.ToLower(s) {
case "true", "yes", "1", "on":
return true, nil
case "false", "no", "0", "off":
return false, nil
}
return false, fmt.Errorf("invalid boolean %q (expected true/false/yes/no/1/0)", s)
}
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 32818 | C. Thomas Tyler |
Initial implementation of p4lf in Go. Adds: - Go module (github.com/rcowham/p4lf) with fsnotify dependency - internal/config: KEY=VALUE config parser with all settings - internal/tailer: file reader with inode-based rotation detection and state file checkpoint/resume (inode + byte offset, JSON, atomic write) - internal/chunker: gzip chunk writer with MaxLogChunks/MinLogSpace guards - internal/logger: rotating log writer with gzip of rotated files - cmd/p4lf/main.go: service main loop, SIGHUP/SIGTERM/SIGINT handling, config hot-reload on modtime change - Makefile: build/test/install/release targets for Linux amd64/arm64, macOS arm64/amd64; version injected via ldflags - p4lf.cfg.example: fully documented example config - p4lf.service: systemd unit file (User=perforce, Restart=on-failure) - ai/session_log_2026-06-25.md: design session log - ai/session_log_2026-06-25-2.md: implementation session log - .p4ignore: added bin/ and dist/ |