config.go #1

  • //
  • p4lf/
  • dev/
  • internal/
  • config/
  • config.go
  • View
  • Commits
  • Open Download .zip Download (7 KB)
// 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/