diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e4923f2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,281 @@ +# AGENTS.md + +## Project Overview + +Backer is a Go-based HTTP/HTTPS backup server that creates `tar.gz` archives on-the-fly from configured directories. It serves a single endpoint that clients (e.g., `curl`) can query to download a timestamped backup archive. Licensed under MIT. + +## Workflow Rules + +- **Do not apply changes without explicit user consent.** When asked to analyze, review, or suggest improvements, present your proposal first and wait for confirmation before editing any files. Always ask "Should I implement this?" or "Should I apply these changes?" before writing code. +- **Keep the codebase simple, small, maintainable, safe, and human-readable.** Favor minimal, clear solutions over clever or abstract ones. Do not introduce patterns, layers, or indirection unless they directly solve a concrete problem. Every line of code must earn its place. +- **When stuck, ask a clarification question, or propose a short plan, or both.** Do not guess intent or make assumptions — ask. +- **Always make a TODO plan before acting. And try to follow this plan.** +- **If you notice defects, bugs, or issues, always report it.** +- **Track new features, known issues, bugs, and other defects in `TODO.org`.** Update it as you implement features, fix issues, fix bugs, or discover new ones — check off completed items, add new findings. +- **Update `AGENTS.md` as the project evolves.** Keep it comprehensive but dense. +- **Don't use `context` package unless absolutely necessary.** Try to find another solution first. +- **Create unit tests if possible/necessary** to verify changes that are not covered by unit tests. + +## Build & Test Commands + +```bash +make build # Build binary (version from git or "dev") +make build VERSION=0.1.0 # Build with specific version tag +make test # Run all tests (go test ./...) +make clean # Remove built binary +make # Clean + build +make upgrade # Update dependencies (go get -u, mod tidy, vendor) +make help # Show all targets +``` + +Version is injected at build time via ldflags: `-X main.version=$(VERSION)`. + +## Linting + +```bash +golangci-lint run # Run linter (configured via .golangci.yml) +``` + +The project uses golangci-lint v2 with a strict set of linters. See `.golangci.yml` for the full configuration. + +## Code Style & Conventions + +- **Language**: Go 1.25 +- **Formatting**: `gofmt` + `goimports` (enforced by linter) +- **Imports**: Grouped imports, single `import` block required (`grouper` linter) +- **Declaration order**: Type, const, var, func (`decorder` linter) +- **Named returns**: Avoid named return values, especially in `defer` blocks (`nonamedreturns` linter) +- **Naming**: Follow Go initialisms (HTTP, TLS, IP, etc.) as defined in `staticcheck` config +- **File footer**: Each source file ends with `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */` + +### Godoc Comments + +All exported types, functions, and methods must have godoc comments (`godoclint`, `godot`). Comments must end with a period. + +### Error Handling + +- **Error messages must be capitalized** (e.g., `"Failed to read config"`, not `"failed to read config"`). The `ST1005` linter rule is disabled to allow this. +- Use `fmt.Errorf` with `%w` for error wrapping. +- Use `%v` for errors in format strings (not `%s`). +- Past tense style: `"Failed to..."`, `"Unable to..."`. +- Always handle errors explicitly; never discard with `_` except in tests where intentional. + +### Logging Conventions + +- Use `backer/internal/log` package (wrapper around Go's `slog`). +- Log levels: `error`, `warn`, `info`, `debug`. +- Past tense with context: `"Skipping %s: %v", filename, err`. +- Appropriate severity: Warn for recoverable issues, Error for problems, Fatal for startup failures. + +### Config Validation Pattern + +For each config option: set default first, then immediately validate range/required value: +```go +if C.Port == 0 { + C.Port = 8086 + log.Warnf("Config option port is not set, fallback to %d", C.Port) +} + +if C.Port < 1 || C.Port > 65535 { + return fmt.Errorf("Port must be between 1 and 65535, got %d", C.Port) +} +``` + +### Function Naming + +```go +// Exported (public): VerbNoun pattern +NewServer() +LoadConfig() +CreateTarGzStream() +GetFilesFromDirectories() + +// Unexported (private): verbNoun or isNoun pattern +writeWithContext() +copyWithContext() +isPipeClosedError() +defaultConfigPath() +``` + +### JSON Tags + +Struct fields use PascalCase, json tags use snake_case: +```go +BackupTimeout int `json:"backup_timeout"` +CompressionLevel int `json:"compression_level"` +``` + +### Intentional Linter Suppressions + +| Rule | Reason | +|------|--------| +| ST1005 | Disable — allows capitalized error messages (e.g., `"Failed to..."`) | +| QF1001 | De Morgan's law disabled for auth condition clarity | +| revive (error strings) | Same reason as ST1005 | + +## Project Structure + +``` +cmd/ + backer/ + main.go # CLI entrypoint, flag parsing, TLS setup, server start + +internal/ + backer/ + types.go # Config struct definition + globals.go # Global C Config variable, compiled excludePatterns + loadConfig.go # HJSON/JSON config parsing with validation + loadConfig_test.go # Config validation tests + newServer.go # HTTP server creation, auth handler, context helpers + newServer_test.go # Server, auth, and context helper tests + utils.go # CreateTarGzStream (tar.gz streaming via io.Pipe) + utils_test.go # Tar.gz stream tests + getFilesFromDirectories.go # Directory walking, exclusion filtering (isExcluded) + getFilesFromDirectories_test.go # File traversal and exclude pattern tests + log/ + main.go # Logging wrapper around stdlib slog + main_test.go # Log package tests + +data/ + config_example.json # Annotated example config (HJSON with comments) + +contrib/ + backer # OpenRC init script (Alpine/Gentoo) + backer.service # systemd unit file + backer.freebsd # FreeBSD rc.d script + +test_data/ + test_config.json # Test config fixture + example.crt / example.key # TLS test certificates + test1/ # File/directory fixtures for tests + +Makefile # Build targets +TODO.org # Project backlog (Org mode) +README.md # User documentation +LICENSE # MIT License +llms.txt # LLM-friendly project reference +``` + +## Configuration + +Config is JSON (HJSON with comments supported). Default path depends on OS: +- Linux/macOS: `/etc/backer.json` +- FreeBSD/NetBSD/OpenBSD/DragonFly: `/usr/local/etc/backer.json` +- Override with `-c /path/to/config.json` + +### Config Options + +| Option | Default | Description | +|--------|---------|-------------| +| address | `0.0.0.0` | Listen address | +| port | `8086` | Listen port (1-65535) | +| cert | — | TLS certificate file path | +| key | — | TLS key file path | +| nohttps | `false` | Disable HTTPS (for dev/lab) | +| location | `/archive` | API endpoint path | +| user | — | Basic auth username (required) | +| password | — | Basic auth password (required) | +| log | stderr | Log output file path | +| loglevel | `info` | Log verbosity: error/warn/info/debug | +| directories | — | Directories to backup (required, validated for existence) | +| backup_timeout | `60` | Stream timeout in minutes (1-1440) | +| compression_level | `9` | Gzip level (1-9) | +| exclude_patterns | `[]` | Regex patterns to exclude from backup | + +## Architecture & Key Patterns + +### Global Config + +Uses a global `C Config` variable (`internal/backer/globals.go`). This is intentional — config is loaded once at startup and never modified concurrently. + +### HJSON Config Parsing + +Requires double conversion (hjson → map → json → struct): +```go +hjson.Unmarshal(buf, &tmp) // parse HJSON into map[string]any +json.Marshal(tmp) // re-encode as strict JSON +json.Unmarshal(buf, &C) // decode into Config struct +``` + +### Streaming Archive Creation + +No temp files on disk. Uses `io.Pipe` with a goroutine to stream tar.gz on-the-fly: +- `CreateTarGzStream()` returns an `io.ReadCloser` +- Goroutine writes tar entries → gzip writer → pipe writer +- HTTP handler copies from pipe reader directly to HTTP response + +### Context-Aware I/O + +Two helper functions in `newServer.go` handle context cancellation during streaming: +- `writeWithContext(ctx, fn)` — checks context before calling fn +- `copyWithContext(ctx, dst, src)` — buffered copy (32KB) with cancellation checks + +### Special File Type Handling + +- **Regular files**: streamed into tar archive with content +- **Directories**: included as tar directory entries +- **Symlinks**: stored as symlink entries (target path preserved) +- **Device files** (char/block): included with correct major/minor +- **Named pipes**: included (header only, no data) +- **Sockets**: skipped (not archivable) + +### Pipe Error Recovery + +`isPipeClosedError()` checks for `"io: read/write on closed pipe"` errors to avoid noisy logging when a client disconnects mid-stream. + +### Auth Handler + +HTTP Basic Auth. Both username AND password must match exactly. Returns `401 Unauthorized` with `WWW-Authenticate` header on failure. The condition is intentionally written without De Morgan's law simplification for clarity (linter suppression `//nolint:staticcheck`). + +### Client IP Detection + +Checks `X-Forwarded-For` header first (for proxied requests), then falls back to `RemoteAddr`. + +### Server Configuration + +- `ReadHeaderTimeout`: 5 seconds +- `WriteTimeout`: configurable via `backup_timeout` (converted to `time.Duration`) +- TLS minimum version: TLS 1.3 (when HTTPS enabled) + +## Dependencies + +- `github.com/hjson/hjson-go` v3.3.0 — HJSON config parsing + +## Testing + +```bash +go test ./... # Run all tests +go test -v -run TestName ./... # Run specific test +make test # Via Makefile +``` + +Tests live alongside source files as `*_test.go` (package name, not `_test` suffix). Coverage is approximately 69% on `internal/backer` and ~100% on `internal/log`. + +### Test Conventions + +- Reset global config before tests: `C = Config{}` +- Reset exclude patterns: `excludePatterns = nil` +- Use `t.TempDir()` for temporary files +- Helper `writeTempConfig(t, content)` creates temp config files for validation tests +- Symlink tests skip gracefully with `t.Skip()` when not supported +- Context cancellation tests verify correct behavior + +### Test Data + +- `test_data/test_config.json` — valid test config (user: JohnnyGoode, password: SharpShooter) +- `test_data/test1/foo/` — test directory with files, subdirs, empty dirs +- `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension) +- `test_data/example.crt` / `example.key` — TLS test certificates + +## Service Deployment + +Init scripts provided in `contrib/`: +- **systemd** (`backer.service`): `ExecStart=/usr/local/sbin/backer`, restart on failure +- **OpenRC** (`backer`): uses `supervise-daemon`, creates log/pid dirs in `start_pre` +- **FreeBSD rc.d** (`backer.freebsd`): daemonizes via `/usr/sbin/daemon` + +## TODO / Roadmap + +See `TODO.org` for current backlog: +- Consider alternative compression algorithms (bz2, xz, lzo, lz4) +- Configurable filename prefix for `Content-Disposition` header diff --git a/cmd/backer/main.go b/cmd/backer/main.go index 86a46c3..671b17b 100644 --- a/cmd/backer/main.go +++ b/cmd/backer/main.go @@ -10,8 +10,6 @@ import ( "backer/internal/backer" "backer/internal/log" - - "github.com/pkg/errors" ) var ( @@ -39,14 +37,14 @@ func main() { server, err := backer.NewServer(cfgPath) if err != nil { - log.Fatalf("Failed to create server: %v", errors.WithStack(err)) + log.Fatalf("Failed to create server: %v", err) } log.Infof("Starting server at %s", server.Addr) if backer.C.NoHTTPS { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Failed to start server: %v", errors.WithStack(err)) + log.Fatalf("Failed to start server: %v", err) } } else { if _, err := os.Stat(backer.C.Cert); err != nil { @@ -58,7 +56,7 @@ func main() { } if err := server.ListenAndServeTLS(backer.C.Cert, backer.C.Key); err != nil && err != http.ErrServerClosed { - log.Fatalf("Failed to start server: %v", errors.WithStack(err)) + log.Fatalf("Failed to start server: %v", err) } } } diff --git a/go.mod b/go.mod index 19948ed..4686d0c 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,4 @@ module backer go 1.25 -require ( - github.com/hjson/hjson-go v3.3.0+incompatible - github.com/pkg/errors v0.9.1 -) +require github.com/hjson/hjson-go v3.3.0+incompatible diff --git a/go.sum b/go.sum index 42c8663..66c2912 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,2 @@ github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI= github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/internal/backer/getFilesFromDirectories.go b/internal/backer/getFilesFromDirectories.go index d78beb8..d0ab6cd 100644 --- a/internal/backer/getFilesFromDirectories.go +++ b/internal/backer/getFilesFromDirectories.go @@ -9,9 +9,12 @@ import ( ) // isExcluded checks if path matches any of the configured exclude patterns. +// Paths are normalized to forward slashes for cross-platform regex matching. func isExcluded(path string) bool { + normalized := filepath.ToSlash(path) + for _, re := range excludePatterns { - if re.MatchString(path) { + if re.MatchString(normalized) { return true } } diff --git a/internal/backer/getFilesFromDirectories_test.go b/internal/backer/getFilesFromDirectories_test.go index a5d139d..c9a52a0 100644 --- a/internal/backer/getFilesFromDirectories_test.go +++ b/internal/backer/getFilesFromDirectories_test.go @@ -15,6 +15,9 @@ func normalizePath(p string) string { // TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error. func TestGetFilesFromDirectories(t *testing.T) { + // Ensure empty_dir exists for the test. + os.MkdirAll("../../test_data/test1/foo/empty_dir", 0755) + var ( input = Config{ Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"}, diff --git a/internal/backer/newServer.go b/internal/backer/newServer.go index 449accf..04d0af5 100644 --- a/internal/backer/newServer.go +++ b/internal/backer/newServer.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "fmt" "io" + "net" "net/http" "strings" "time" @@ -107,47 +108,22 @@ func NewServer(configPath string) (*http.Server, error) { }, nil } -// writeWithContext executes fn in a goroutine and returns its result. -// If the context is canceled before fn completes, ctx.Err() is returned. -// fn should perform I/O that will unblock when the underlying pipe is closed -// on context cancellation (e.g., writes to a pipe writer). Panics in fn are -// recovered and returned as errors. +// writeWithContext checks if context is canceled before executing fn. +// Returns ctx.Err() if context is already canceled, otherwise returns fn(). func writeWithContext(ctx context.Context, fn func() error) error { - done := make(chan error, 1) - - go func() { - defer func() { - if r := recover(); r != nil { - done <- fmt.Errorf("panic: %v", r) - } - }() - - select { - case <-ctx.Done(): - done <- ctx.Err() - default: - done <- fn() - } - }() - select { case <-ctx.Done(): return ctx.Err() - case err := <-done: - return err + default: } + + return fn() } // copyWithContext copies data from src to dst using a 32KB buffer, respecting // context cancellation. If the context is canceled, the copy stops and returns -// ctx.Err(). Panics are recovered and returned as errors. -func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err error) { //nolint:nonamedreturns - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic: %v", r) - } - }() - +// ctx.Err(). +func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error { buf := make([]byte, 32*1024) for { @@ -178,15 +154,15 @@ func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err err // It checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr. func getClientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - return strings.Split(xff, ",")[0] + return strings.TrimSpace(strings.Split(xff, ",")[0]) } - ip := r.RemoteAddr - if colon := strings.LastIndex(ip, ":"); colon != -1 { - return ip[:colon] + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr } - return ip + return host } /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/internal/backer/newServer_test.go b/internal/backer/newServer_test.go index 152df50..d901135 100644 --- a/internal/backer/newServer_test.go +++ b/internal/backer/newServer_test.go @@ -338,37 +338,4 @@ func TestCopyWithContextCancellation(t *testing.T) { } } -func TestWriteWithContextPanic(t *testing.T) { - ctx := context.Background() - err := writeWithContext(ctx, func() error { - panic("test panic") - }) - if err == nil { - t.Error("Expected error from panic, got nil") - } - if !strings.Contains(err.Error(), "panic") { - t.Errorf("Expected panic error, got %v", err) - } -} - -func TestCopyWithContextPanic(t *testing.T) { - ctx := context.Background() - // Use a reader that panics on Read - panicReader := &panicReader{} - dst := &strings.Builder{} - err := copyWithContext(ctx, dst, panicReader) - if err == nil { - t.Error("Expected error from panic, got nil") - } - if !strings.Contains(err.Error(), "panic") { - t.Errorf("Expected panic error, got %v", err) - } -} - -type panicReader struct{} - -func (pr *panicReader) Read(p []byte) (n int, err error) { - panic("read panic") -} - /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/internal/backer/utils.go b/internal/backer/utils.go index 28ea288..1ebdc07 100644 --- a/internal/backer/utils.go +++ b/internal/backer/utils.go @@ -156,7 +156,6 @@ func CreateTarGzStream(ctx context.Context, filepaths []string) io.ReadCloser { continue } } - } }() diff --git a/internal/log/main.go b/internal/log/main.go index 602b33e..33ff2e0 100644 --- a/internal/log/main.go +++ b/internal/log/main.go @@ -82,9 +82,9 @@ func Init(level, filename string) error { return err } -// Error passes message directly to [slog.Error()]. -func Error(message string) { - slog.Error(message) +// Error logs args at error level using [slog.Error()]. +func Error(args ...any) { + slog.Error(fmt.Sprint(args...)) } // Errorf logs message on error log level and allows to supply arguments in printf() style. @@ -92,9 +92,9 @@ func Errorf(format string, a ...any) { slog.Error(fmt.Sprintf(format, a...)) } -// Warn passes message directly to [slog.Warn()]. -func Warn(message string) { - slog.Warn(message) +// Warn logs args at warn level using [slog.Warn()]. +func Warn(args ...any) { + slog.Warn(fmt.Sprint(args...)) } // Warnf logs message on warn log level and allows to supply arguments in printf() style. @@ -102,9 +102,9 @@ func Warnf(format string, a ...any) { slog.Warn(fmt.Sprintf(format, a...)) } -// Info passes message directly to [slog.Info()]. -func Info(message string) { - slog.Info(message) +// Info logs args at info level using [slog.Info()]. +func Info(args ...any) { + slog.Info(fmt.Sprint(args...)) } // Infof logs message on info log level and allows to supply arguments in printf() style. @@ -112,9 +112,9 @@ func Infof(format string, a ...any) { slog.Info(fmt.Sprintf(format, a...)) } -// Debug passes message directly to [slog.Debug()]. -func Debug(message string) { - slog.Debug(message) +// Debug logs args at debug level using [slog.Debug()]. +func Debug(args ...any) { + slog.Debug(fmt.Sprint(args...)) } // Debugf logs message on debug log level and allows to supply arguments in printf() style. @@ -122,9 +122,9 @@ func Debugf(format string, a ...any) { slog.Debug(fmt.Sprintf(format, a...)) } -// Fatal passes message directly to [slog.Error()] and quits with status code 1. -func Fatal(message string) { - slog.Error(message) +// Fatal logs args at error level and quits with status code 1. +func Fatal(args ...any) { + slog.Error(fmt.Sprint(args...)) Close() os.Exit(1) } @@ -139,6 +139,10 @@ func Fatalf(format string, a ...any) { // Close closes log file descriptor. func Close() { + if Log == nil || Log == os.Stderr { + return + } + if err := Log.Close(); err != nil { msg := fmt.Sprintln("Failed to close log file:", err) slog.Error(msg) diff --git a/internal/log/main_test.go b/internal/log/main_test.go new file mode 100644 index 0000000..bcdbcb0 --- /dev/null +++ b/internal/log/main_test.go @@ -0,0 +1,357 @@ +package log + +import ( + "errors" + "os" + "strings" + "testing" +) + +// logFile holds the path to the current test log file for reading. +var logFilePath string + +// initTestLog sets up a temp log file and returns cleanup function. +func initTestLog(t *testing.T, level string) func() { + t.Helper() + + tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log") + if err != nil { + t.Fatal(err) + } + + logFilePath = tmpFile.Name() + tmpFile.Close() + + if err := Init(level, logFilePath); err != nil { + t.Fatal(err) + } + + return func() { + Close() + logFilePath = "" + } +} + +// getLogContent reads the log file and returns its contents. +func getLogContent(t *testing.T) string { + t.Helper() + + if logFilePath == "" { + t.Fatal("logFilePath not set") + } + + data, err := os.ReadFile(logFilePath) + if err != nil { + t.Fatal(err) + } + + return string(data) +} + +// TestInitDefaultLevel tests Init with empty level defaults to info. +func TestInitDefaultLevel(t *testing.T) { + cleanup := initTestLog(t, "") + defer cleanup() + + Info("info message") + Debug("debug message") + + content := getLogContent(t) + + if !strings.Contains(content, "info message") { + t.Error("Expected info message in log") + } + + if strings.Contains(content, "debug message") { + t.Error("Debug message should not appear at info level") + } +} + +// TestInitErrorLevel tests Init with error level. +func TestInitErrorLevel(t *testing.T) { + cleanup := initTestLog(t, "error") + defer cleanup() + + Error("error msg") + Warn("warn msg") + Info("info msg") + + content := getLogContent(t) + + if !strings.Contains(content, "error msg") { + t.Error("Expected error message in log") + } + + if strings.Contains(content, "warn msg") { + t.Error("Warn message should not appear at error level") + } + + if strings.Contains(content, "info msg") { + t.Error("Info message should not appear at error level") + } +} + +// TestInitWarnLevel tests Init with warn level. +func TestInitWarnLevel(t *testing.T) { + cleanup := initTestLog(t, "warn") + defer cleanup() + + Error("error msg") + Warn("warn msg") + Info("info msg") + + content := getLogContent(t) + + if !strings.Contains(content, "error msg") { + t.Error("Expected error message in log") + } + + if !strings.Contains(content, "warn msg") { + t.Error("Expected warn message in log") + } + + if strings.Contains(content, "info msg") { + t.Error("Info message should not appear at warn level") + } +} + +// TestInitDebugLevel tests Init with debug level shows everything. +func TestInitDebugLevel(t *testing.T) { + cleanup := initTestLog(t, "debug") + defer cleanup() + + Error("error msg") + Warn("warn msg") + Info("info msg") + Debug("debug msg") + + content := getLogContent(t) + + if !strings.Contains(content, "error msg") { + t.Error("Expected error message in log") + } + + if !strings.Contains(content, "warn msg") { + t.Error("Expected warn message in log") + } + + if !strings.Contains(content, "info msg") { + t.Error("Expected info message in log") + } + + if !strings.Contains(content, "debug msg") { + t.Error("Expected debug message in log") + } +} + +// TestInitStderr tests Init with empty filename uses stderr. +func TestInitStderr(t *testing.T) { + err := Init("info", "") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if Log != os.Stderr { + t.Error("Expected Log to be os.Stderr") + } +} + +// TestInitInvalidPath tests Init with invalid file path returns error. +func TestInitInvalidPath(t *testing.T) { + err := Init("info", "/nonexistent/path/to/log.log") + if err == nil { + t.Error("Expected error for invalid path, got nil") + } +} + +// TestErrorWithString tests Error with a string argument. +func TestErrorWithString(t *testing.T) { + cleanup := initTestLog(t, "error") + defer cleanup() + + Error("test error message") + + content := getLogContent(t) + + if !strings.Contains(content, "test error message") { + t.Error("Expected error message in log") + } +} + +// TestErrorWithError tests Error with an error argument. +func TestErrorWithError(t *testing.T) { + cleanup := initTestLog(t, "error") + defer cleanup() + + err := errors.New("something went wrong") + Error(err) + + content := getLogContent(t) + + if !strings.Contains(content, "something went wrong") { + t.Error("Expected error message in log") + } +} + +// TestErrorWithMultipleArgs tests Error with multiple arguments. +func TestErrorWithMultipleArgs(t *testing.T) { + cleanup := initTestLog(t, "error") + defer cleanup() + + err := errors.New("disk full") + Error("Failed to write: ", err) + + content := getLogContent(t) + + if !strings.Contains(content, "Failed to write:") { + t.Error("Expected prefix in log") + } + + if !strings.Contains(content, "disk full") { + t.Error("Expected error message in log") + } +} + +// TestWarnWithString tests Warn with a string argument. +func TestWarnWithString(t *testing.T) { + cleanup := initTestLog(t, "warn") + defer cleanup() + + Warn("test warning") + + content := getLogContent(t) + + if !strings.Contains(content, "test warning") { + t.Error("Expected warning message in log") + } +} + +// TestInfoWithString tests Info with a string argument. +func TestInfoWithString(t *testing.T) { + cleanup := initTestLog(t, "info") + defer cleanup() + + Info("test info") + + content := getLogContent(t) + + if !strings.Contains(content, "test info") { + t.Error("Expected info message in log") + } +} + +// TestDebugWithString tests Debug with a string argument. +func TestDebugWithString(t *testing.T) { + cleanup := initTestLog(t, "debug") + defer cleanup() + + Debug("test debug") + + content := getLogContent(t) + + if !strings.Contains(content, "test debug") { + t.Error("Expected debug message in log") + } +} + +// TestErrorf tests Errorf with format string. +func TestErrorf(t *testing.T) { + cleanup := initTestLog(t, "error") + defer cleanup() + + Errorf("Failed to read %s: %v", "config.json", errors.New("not found")) + + content := getLogContent(t) + + if !strings.Contains(content, "Failed to read config.json: not found") { + t.Errorf("Expected formatted error in log, got: %s", content) + } +} + +// TestWarnf tests Warnf with format string. +func TestWarnf(t *testing.T) { + cleanup := initTestLog(t, "warn") + defer cleanup() + + Warnf("Skipping %s: %d files", "dir", 5) + + content := getLogContent(t) + + if !strings.Contains(content, "Skipping dir: 5 files") { + t.Errorf("Expected formatted warning in log, got: %s", content) + } +} + +// TestInfof tests Infof with format string. +func TestInfof(t *testing.T) { + cleanup := initTestLog(t, "info") + defer cleanup() + + Infof("Server started on %s:%d", "0.0.0.0", 8086) + + content := getLogContent(t) + + if !strings.Contains(content, "Server started on 0.0.0.0:8086") { + t.Errorf("Expected formatted info in log, got: %s", content) + } +} + +// TestDebugf tests Debugf with format string. +func TestDebugf(t *testing.T) { + cleanup := initTestLog(t, "debug") + defer cleanup() + + Debugf("Processing file %s (%d bytes)", "test.txt", 1024) + + content := getLogContent(t) + + if !strings.Contains(content, "Processing file test.txt (1024 bytes)") { + t.Errorf("Expected formatted debug in log, got: %s", content) + } +} + +// TestClose tests that Close closes the log file. +func TestClose(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log") + if err != nil { + t.Fatal(err) + } + + logFilePath = tmpFile.Name() + tmpFile.Close() + + if err := Init("info", logFilePath); err != nil { + t.Fatal(err) + } + + Close() + + // Verify file is closed by trying to write (should fail). + // But we can't easily test this without reopening. + // Just verify no panic occurred. + logFilePath = "" +} + +// TestCloseNil tests that Close doesn't panic when Log is nil. +func TestCloseNil(t *testing.T) { + Log = nil + + // Should not panic. + Close() +} + +// TestCloseStderr tests that Close doesn't close stderr. +func TestCloseStderr(t *testing.T) { + Log = os.Stderr + + // Should not panic and should not close stderr. + Close() + + // Verify stderr is still usable. + _, err := os.Stderr.WriteString("") + if err != nil { + t.Error("stderr should still be usable after Close()") + } +} + +/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */