diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..d4c2f70 --- /dev/null +++ b/RULES.md @@ -0,0 +1,43 @@ +# GOLIB Rules + +1. All changes should be documented +Documentation is done in a few ways: + - docstrings + - README.md + - doc.go + - wiki + +The README for each module should be laid out as follows: +- Title and description +- Feature list (DO NOT USE EMOTICONS) +- Installation (go get) +- Quick Start (brief example of setting up and using) +- Documentation links to the wiki +- Additional information (e.g. supported databases if package has database features) +- License +- Contributing +- Related projects (if relevant) + +Docstrings and doc.go should conform to godoc standards. +Any Config structs with environment variables should have their docstrings match the format +`// ENV ENV_NAME: Description (required ) (default: )` +where the required and default fields are only present if relevant to that variable + +The wiki is located at ~/projects/golib-wiki and should be laid out as follows: +- Title and description with version number (i will manually handle commits and versioning) +- Installation +- Key Concepts and features +- Quick start +- Configuration (explicity prefer using ConfigFromEnv for packages that support it) +- Detailed sections on how to use all the features +- Integration (many of the packages in this repo are designed to work in tandem. any close integration with other packages should be mentioned here) +- Best practices +- Troubleshooting +- See also (links to other related or imported packages from this repo) +- Links (GoDoc api link, source code, issue tracker) + +2. All features should have tests. +Any changes to existing features or additional features implemented should have tests created and/or updated + +3. NEVER COMMIT +I will handle and manage all version control diff --git a/hlog/config.go b/hlog/config.go new file mode 100644 index 0000000..e1ac0e9 --- /dev/null +++ b/hlog/config.go @@ -0,0 +1,55 @@ +package hlog + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +// Config holds the configuration settings for the logger. +// It can be populated from environment variables using ConfigFromEnv +// or created programmatically. +type Config struct { + LogLevel Level // ENV LOG_LEVEL: Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info) + LogOutput string // ENV LOG_OUTPUT: Output destination for logs - console, file, or both (default: console) + LogDir string // ENV LOG_DIR: Directory path for log files (required when LOG_OUTPUT is "file" or "both") + LogFileName string // ENV LOG_FILE_NAME: Name of the log file (required when LOG_OUTPUT is "file" or "both") + LogAppend bool // ENV LOG_APPEND: Append to existing log file or overwrite (default: true) +} + +// ConfigFromEnv loads logger configuration from environment variables. +// +// Environment variables: +// - LOG_LEVEL: Log level (trace, debug, info, warn, error, fatal, panic) - default: info +// - LOG_OUTPUT: Output destination (console, file, both) - default: console +// - LOG_DIR: Directory for log files (required when LOG_OUTPUT is "file" or "both") +// +// Returns an error if: +// - LOG_LEVEL contains an invalid value +// - LOG_OUTPUT contains an invalid value +// - LogDir or LogFileName is not set and file logging is enabled +func ConfigFromEnv() (*Config, error) { + logLevel, err := LogLevel(env.String("LOG_LEVEL", "info")) + if err != nil { + return nil, errors.Wrap(err, "LogLevel") + } + logOutput := env.String("LOG_OUTPUT", "console") + if logOutput != "both" && logOutput != "console" && logOutput != "file" { + return nil, errors.Errorf("Invalid LOG_OUTPUT: %s", logOutput) + } + cfg := &Config{ + LogLevel: logLevel, + LogOutput: logOutput, + LogDir: env.String("LOG_DIR", ""), + LogFileName: env.String("LOG_FILE_NAME", ""), + LogAppend: env.Bool("LOG_APPEND", true), + } + if cfg.LogOutput != "console" { + if cfg.LogDir == "" { + return nil, errors.New("LOG_DIR not set but file logging enabled") + } + if cfg.LogFileName == "" { + return nil, errors.New("LOG_FILE_NAME not set but file logging enabled") + } + } + return cfg, nil +} diff --git a/hlog/config_test.go b/hlog/config_test.go new file mode 100644 index 0000000..346d118 --- /dev/null +++ b/hlog/config_test.go @@ -0,0 +1,181 @@ +package hlog + +import ( + "os" + "testing" + + "github.com/rs/zerolog" +) + +func TestConfigFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + want *Config + wantErr bool + errMsg string + }{ + { + name: "default values", + envVars: map[string]string{}, + want: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "console", + LogDir: "", + LogFileName: "", + LogAppend: true, + }, + wantErr: false, + }, + { + name: "custom values", + envVars: map[string]string{ + "LOG_LEVEL": "debug", + "LOG_OUTPUT": "both", + "LOG_DIR": "/var/log/myapp", + "LOG_FILE_NAME": "application.log", + "LOG_APPEND": "false", + }, + want: &Config{ + LogLevel: zerolog.DebugLevel, + LogOutput: "both", + LogDir: "/var/log/myapp", + LogFileName: "application.log", + LogAppend: false, + }, + wantErr: false, + }, + { + name: "file output mode", + envVars: map[string]string{ + "LOG_LEVEL": "warn", + "LOG_OUTPUT": "file", + "LOG_DIR": "/tmp/logs", + "LOG_FILE_NAME": "test.log", + "LOG_APPEND": "true", + }, + want: &Config{ + LogLevel: zerolog.WarnLevel, + LogOutput: "file", + LogDir: "/tmp/logs", + LogFileName: "test.log", + LogAppend: true, + }, + wantErr: false, + }, + { + name: "invalid log level", + envVars: map[string]string{ + "LOG_LEVEL": "invalid", + "LOG_OUTPUT": "console", + }, + want: nil, + wantErr: true, + errMsg: "LogLevel", + }, + { + name: "invalid log output", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "LOG_OUTPUT": "invalid", + }, + want: nil, + wantErr: true, + errMsg: "Invalid LOG_OUTPUT", + }, + { + name: "trace log level with defaults", + envVars: map[string]string{ + "LOG_LEVEL": "trace", + "LOG_OUTPUT": "console", + }, + want: &Config{ + LogLevel: zerolog.TraceLevel, + LogOutput: "console", + LogDir: "", + LogFileName: "", + LogAppend: true, + }, + wantErr: false, + }, + { + name: "file output without LOG_DIR", + envVars: map[string]string{ + "LOG_OUTPUT": "file", + "LOG_FILE_NAME": "test.log", + }, + want: nil, + wantErr: true, + errMsg: "LOG_DIR not set", + }, + { + name: "file output without LOG_FILE_NAME", + envVars: map[string]string{ + "LOG_OUTPUT": "file", + "LOG_DIR": "/tmp", + }, + want: nil, + wantErr: true, + errMsg: "LOG_FILE_NAME not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear all environment variables first + os.Unsetenv("LOG_LEVEL") + os.Unsetenv("LOG_OUTPUT") + os.Unsetenv("LOG_DIR") + os.Unsetenv("LOG_FILE_NAME") + os.Unsetenv("LOG_APPEND") + + // Set test environment variables (only set if value provided) + for k, v := range tt.envVars { + os.Setenv(k, v) + } + + // Cleanup after test + defer func() { + os.Unsetenv("LOG_LEVEL") + os.Unsetenv("LOG_OUTPUT") + os.Unsetenv("LOG_DIR") + os.Unsetenv("LOG_FILE_NAME") + os.Unsetenv("LOG_APPEND") + }() + + got, err := ConfigFromEnv() + + if tt.wantErr { + if err == nil { + t.Errorf("ConfigFromEnv() expected error but got nil") + return + } + if tt.errMsg != "" && err.Error() == "" { + t.Errorf("ConfigFromEnv() error = %v, should contain %v", err, tt.errMsg) + } + return + } + + if err != nil { + t.Errorf("ConfigFromEnv() unexpected error = %v", err) + return + } + + if got.LogLevel != tt.want.LogLevel { + t.Errorf("ConfigFromEnv() LogLevel = %v, want %v", got.LogLevel, tt.want.LogLevel) + } + if got.LogOutput != tt.want.LogOutput { + t.Errorf("ConfigFromEnv() LogOutput = %v, want %v", got.LogOutput, tt.want.LogOutput) + } + if got.LogDir != tt.want.LogDir { + t.Errorf("ConfigFromEnv() LogDir = %v, want %v", got.LogDir, tt.want.LogDir) + } + if got.LogFileName != tt.want.LogFileName { + t.Errorf("ConfigFromEnv() LogFileName = %v, want %v", got.LogFileName, tt.want.LogFileName) + } + if got.LogAppend != tt.want.LogAppend { + t.Errorf("ConfigFromEnv() LogAppend = %v, want %v", got.LogAppend, tt.want.LogAppend) + } + }) + } +} diff --git a/hlog/doc.go b/hlog/doc.go new file mode 100644 index 0000000..06820e3 --- /dev/null +++ b/hlog/doc.go @@ -0,0 +1,64 @@ +// Package hlog provides a structured logging solution built on top of zerolog. +// +// hlog supports multiple output modes (console, file, or both), configurable +// log levels, and automatic log file management. It is designed to be simple +// to configure via environment variables while remaining flexible for +// programmatic configuration. +// +// # Basic Usage +// +// Create a logger with environment-based configuration: +// +// cfg, err := hlog.ConfigFromEnv() +// if err != nil { +// log.Fatal(err) +// } +// +// logger, err := hlog.NewLogger(cfg, os.Stdout) +// if err != nil { +// log.Fatal(err) +// } +// defer logger.CloseLogFile() +// +// logger.Info().Msg("Application started") +// +// # Configuration +// +// hlog can be configured via environment variables: +// +// LOG_LEVEL=info # trace, debug, info, warn, error, fatal, panic (default: info) +// LOG_OUTPUT=console # console, file, or both (default: console) +// LOG_DIR=/var/log/app # Required when LOG_OUTPUT is "file" or "both" +// +// Or programmatically: +// +// cfg := &hlog.Config{ +// LogLevel: hlog.InfoLevel, +// LogOutput: "both", +// LogDir: "/var/log/myapp", +// } +// +// # Log Levels +// +// hlog supports the following log levels (from most to least verbose): +// - trace: Very detailed debugging information +// - debug: Detailed debugging information +// - info: General informational messages +// - warn: Warning messages for potentially harmful situations +// - error: Error messages for error events +// - fatal: Fatal messages that will exit the application +// - panic: Panic messages that will panic the application +// +// # Output Modes +// +// - console: Logs to the provided io.Writer (typically os.Stdout or os.Stderr) +// - file: Logs to a file named "server.log" in the configured directory +// - both: Logs to both console and file simultaneously +// +// # File Management +// +// When using file output, hlog creates a file named "server.log" in the +// specified directory. The file is opened in append mode, so logs persist +// across application restarts. Remember to call CloseLogFile() when shutting +// down your application to ensure all logs are flushed. +package hlog diff --git a/hlog/go.mod b/hlog/go.mod index 57594bd..784a8f7 100644 --- a/hlog/go.mod +++ b/hlog/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + git.haelnorr.com/h/golib/env v0.9.1 github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect golang.org/x/sys v0.12.0 // indirect diff --git a/hlog/go.sum b/hlog/go.sum index 1f7edd4..3639901 100644 --- a/hlog/go.sum +++ b/hlog/go.sum @@ -1,3 +1,5 @@ +git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY= +git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= diff --git a/hlog/levels.go b/hlog/levels.go index 52bfcfe..562fdbc 100644 --- a/hlog/levels.go +++ b/hlog/levels.go @@ -5,11 +5,21 @@ import ( "github.com/rs/zerolog" ) +// Level is an alias for zerolog.Level, representing the severity of a log message. type Level = zerolog.Level -// Takes a log level as string and converts it to a Level interface. -// If the string is not a valid input it will return InfoLevel -// Valid levels: trace, debug, info, warn, error, fatal, panic +// LogLevel converts a string to a Level value. +// +// Valid level strings (case-sensitive): +// - "trace": Most verbose, for very detailed debugging +// - "debug": Detailed debugging information +// - "info": General informational messages +// - "warn": Warning messages for potentially harmful situations +// - "error": Error messages for error events +// - "fatal": Fatal messages that will exit the application +// - "panic": Panic messages that will panic the application +// +// Returns an error if the provided string is not a valid log level. func LogLevel(level string) (Level, error) { levels := map[string]zerolog.Level{ "trace": zerolog.TraceLevel, diff --git a/hlog/levels_test.go b/hlog/levels_test.go new file mode 100644 index 0000000..05875a3 --- /dev/null +++ b/hlog/levels_test.go @@ -0,0 +1,155 @@ +package hlog + +import ( + "testing" + + "github.com/rs/zerolog" +) + +func TestLogLevel(t *testing.T) { + tests := []struct { + name string + level string + want Level + wantErr bool + }{ + { + name: "trace level", + level: "trace", + want: zerolog.TraceLevel, + wantErr: false, + }, + { + name: "debug level", + level: "debug", + want: zerolog.DebugLevel, + wantErr: false, + }, + { + name: "info level", + level: "info", + want: zerolog.InfoLevel, + wantErr: false, + }, + { + name: "warn level", + level: "warn", + want: zerolog.WarnLevel, + wantErr: false, + }, + { + name: "error level", + level: "error", + want: zerolog.ErrorLevel, + wantErr: false, + }, + { + name: "fatal level", + level: "fatal", + want: zerolog.FatalLevel, + wantErr: false, + }, + { + name: "panic level", + level: "panic", + want: zerolog.PanicLevel, + wantErr: false, + }, + { + name: "invalid level", + level: "invalid", + want: 0, + wantErr: true, + }, + { + name: "empty string", + level: "", + want: 0, + wantErr: true, + }, + { + name: "uppercase level (should fail - case sensitive)", + level: "INFO", + want: 0, + wantErr: true, + }, + { + name: "mixed case level (should fail - case sensitive)", + level: "Info", + want: 0, + wantErr: true, + }, + { + name: "numeric string", + level: "123", + want: 0, + wantErr: true, + }, + { + name: "whitespace", + level: " ", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := LogLevel(tt.level) + + if tt.wantErr { + if err == nil { + t.Errorf("LogLevel() expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("LogLevel() unexpected error = %v", err) + return + } + + if got != tt.want { + t.Errorf("LogLevel() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLogLevel_AllValidLevels(t *testing.T) { + // Ensure all valid levels are tested + validLevels := map[string]Level{ + "trace": zerolog.TraceLevel, + "debug": zerolog.DebugLevel, + "info": zerolog.InfoLevel, + "warn": zerolog.WarnLevel, + "error": zerolog.ErrorLevel, + "fatal": zerolog.FatalLevel, + "panic": zerolog.PanicLevel, + } + + for levelStr, expectedLevel := range validLevels { + t.Run("valid_"+levelStr, func(t *testing.T) { + got, err := LogLevel(levelStr) + if err != nil { + t.Errorf("LogLevel(%s) unexpected error = %v", levelStr, err) + return + } + if got != expectedLevel { + t.Errorf("LogLevel(%s) = %v, want %v", levelStr, got, expectedLevel) + } + }) + } +} + +func TestLogLevel_ErrorMessage(t *testing.T) { + _, err := LogLevel("invalid") + if err == nil { + t.Fatal("LogLevel() expected error but got nil") + } + + expectedMsg := "Invalid log level specified." + if err.Error() != expectedMsg { + t.Errorf("LogLevel() error message = %v, want %v", err.Error(), expectedMsg) + } +} diff --git a/hlog/logfile.go b/hlog/logfile.go index 2cbc7a1..48ef655 100644 --- a/hlog/logfile.go +++ b/hlog/logfile.go @@ -7,17 +7,45 @@ import ( "github.com/pkg/errors" ) -// Returns a pointer to a new log file with the specified path. -// Remember to call file.Close() when finished writing to the log file -func NewLogFile(path string) (*os.File, error) { - logPath := filepath.Join(path, "server.log") - file, err := os.OpenFile( - logPath, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, - 0663, - ) +// newLogFile creates or opens the log file based on the configuration. +// The file is created in the specified directory with the configured filename. +// File permissions are set to 0663 (rw-rw--w-). +// +// If append is true, the file is opened in append mode and new logs are added +// to the end. If append is false, the file is truncated on open, overwriting +// any existing content. +// +// Returns an error if the file cannot be opened or created. +func newLogFile(dir, filename string, append bool) (*os.File, error) { + logPath := filepath.Join(dir, filename) + + flags := os.O_CREATE | os.O_WRONLY + if append { + flags |= os.O_APPEND + } else { + flags |= os.O_TRUNC + } + + file, err := os.OpenFile(logPath, flags, 0663) if err != nil { return nil, errors.Wrap(err, "os.OpenFile") } return file, nil } + +// CloseLogFile closes the underlying log file if one is open. +// This should be called when shutting down the application to ensure +// all buffered logs are flushed to disk. +// +// If no log file is open, this is a no-op and returns nil. +// Returns an error if the file cannot be closed. +func (l *Logger) CloseLogFile() error { + if l.logFile == nil { + return nil + } + err := l.logFile.Close() + if err != nil { + return err + } + return nil +} diff --git a/hlog/logfile_test.go b/hlog/logfile_test.go new file mode 100644 index 0000000..9526d8c --- /dev/null +++ b/hlog/logfile_test.go @@ -0,0 +1,242 @@ +package hlog + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewLogFile(t *testing.T) { + tests := []struct { + name string + dir string + filename string + append bool + preCreate string // content to pre-create in file + write string // content to write during test + wantErr bool + }{ + { + name: "create new file in append mode", + dir: t.TempDir(), + filename: "test.log", + append: true, + write: "test content", + wantErr: false, + }, + { + name: "create new file in overwrite mode", + dir: t.TempDir(), + filename: "test.log", + append: false, + write: "test content", + wantErr: false, + }, + { + name: "append to existing file", + dir: t.TempDir(), + filename: "existing.log", + append: true, + preCreate: "existing content\n", + write: "new content\n", + wantErr: false, + }, + { + name: "overwrite existing file", + dir: t.TempDir(), + filename: "existing.log", + append: false, + preCreate: "old content\n", + write: "new content\n", + wantErr: false, + }, + { + name: "invalid directory", + dir: "/nonexistent/invalid/path", + filename: "test.log", + append: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logPath := filepath.Join(tt.dir, tt.filename) + + // Pre-create file if needed + if tt.preCreate != "" { + err := os.WriteFile(logPath, []byte(tt.preCreate), 0663) + if err != nil { + t.Fatalf("Failed to create pre-existing file: %v", err) + } + } + + // Create log file + file, err := newLogFile(tt.dir, tt.filename, tt.append) + + if tt.wantErr { + if err == nil { + t.Errorf("newLogFile() expected error but got nil") + if file != nil { + file.Close() + } + } + return + } + + if err != nil { + t.Errorf("newLogFile() unexpected error = %v", err) + return + } + + if file == nil { + t.Errorf("newLogFile() returned nil file") + return + } + defer file.Close() + + // Write test content + if tt.write != "" { + _, err = file.WriteString(tt.write) + if err != nil { + t.Errorf("Failed to write to file: %v", err) + return + } + file.Sync() + } + + // Verify file contents + file.Close() + content, err := os.ReadFile(logPath) + if err != nil { + t.Errorf("Failed to read file: %v", err) + return + } + + contentStr := string(content) + + if tt.append && tt.preCreate != "" { + // In append mode, both old and new content should exist + if !strings.Contains(contentStr, tt.preCreate) { + t.Errorf("Append mode: file missing pre-existing content. Got: %s", contentStr) + } + if !strings.Contains(contentStr, tt.write) { + t.Errorf("Append mode: file missing new content. Got: %s", contentStr) + } + } else if !tt.append && tt.preCreate != "" { + // In overwrite mode, only new content should exist + if strings.Contains(contentStr, tt.preCreate) { + t.Errorf("Overwrite mode: file still contains old content. Got: %s", contentStr) + } + if !strings.Contains(contentStr, tt.write) { + t.Errorf("Overwrite mode: file missing new content. Got: %s", contentStr) + } + } else { + // New file, should only have new content + if !strings.Contains(contentStr, tt.write) { + t.Errorf("New file: missing expected content. Got: %s", contentStr) + } + } + }) + } +} + +func TestNewLogFile_Permissions(t *testing.T) { + tempDir := t.TempDir() + filename := "permissions_test.log" + + file, err := newLogFile(tempDir, filename, true) + if err != nil { + t.Fatalf("newLogFile() error = %v", err) + } + file.Close() + + logPath := filepath.Join(tempDir, filename) + _, err = os.Stat(logPath) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + + // Note: Actual file permissions may differ from requested permissions + // due to umask settings, so we just verify the file was created + // The OS will apply umask to the requested 0663 permissions +} + +func TestNewLogFile_MultipleAppends(t *testing.T) { + tempDir := t.TempDir() + filename := "multiple_appends.log" + + messages := []string{ + "first message\n", + "second message\n", + "third message\n", + } + + // Write messages sequentially + for _, msg := range messages { + file, err := newLogFile(tempDir, filename, true) + if err != nil { + t.Fatalf("newLogFile() error = %v", err) + } + + _, err = file.WriteString(msg) + if err != nil { + t.Fatalf("WriteString() error = %v", err) + } + + file.Close() + } + + // Verify all messages are present + logPath := filepath.Join(tempDir, filename) + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + contentStr := string(content) + for _, msg := range messages { + if !strings.Contains(contentStr, msg) { + t.Errorf("File missing message: %s. Got: %s", msg, contentStr) + } + } +} + +func TestNewLogFile_OverwriteClears(t *testing.T) { + tempDir := t.TempDir() + filename := "overwrite_clear.log" + + // Create file with initial content + initialContent := "this should be removed\n" + file1, err := newLogFile(tempDir, filename, true) + if err != nil { + t.Fatalf("newLogFile() error = %v", err) + } + file1.WriteString(initialContent) + file1.Close() + + // Open in overwrite mode + newContent := "new content only\n" + file2, err := newLogFile(tempDir, filename, false) + if err != nil { + t.Fatalf("newLogFile() error = %v", err) + } + file2.WriteString(newContent) + file2.Close() + + // Verify only new content exists + logPath := filepath.Join(tempDir, filename) + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + contentStr := string(content) + if strings.Contains(contentStr, initialContent) { + t.Errorf("File still contains initial content after overwrite. Got: %s", contentStr) + } + if !strings.Contains(contentStr, newContent) { + t.Errorf("File missing new content. Got: %s", contentStr) + } +} diff --git a/hlog/logger.go b/hlog/logger.go index 03fc5e2..96ab830 100644 --- a/hlog/logger.go +++ b/hlog/logger.go @@ -9,17 +9,42 @@ import ( "github.com/rs/zerolog/pkgerrors" ) -type Logger = zerolog.Logger +// Logger wraps a zerolog.Logger and manages an optional log file. +// It embeds *zerolog.Logger, so all zerolog methods are available directly. +type Logger struct { + *zerolog.Logger + logFile *os.File +} -// Get a pointer to a new zerolog.Logger with the specified level and output -// Can provide a file, writer or both. Must provide at least one of the two +// NewLogger creates a new Logger instance based on the provided configuration. +// +// The logger output depends on cfg.LogOutput: +// - "console": Logs to the provided io.Writer w +// - "file": Logs to a file in cfg.LogDir (w can be nil) +// - "both": Logs to both the io.Writer and a file +// +// When file logging is enabled, cfg.LogDir must be set to a valid directory path. +// The log file will be named "server.log" and placed in that directory. +// +// The logger is configured with: +// - Unix timestamp format +// - Error stack trace marshaling +// - Log level from cfg.LogLevel +// +// Returns an error if: +// - cfg is nil +// - w is nil when cfg.LogOutput is not "file" +// - cfg.LogDir is empty when file logging is enabled +// - cfg.LogFileName is empty when file logging is enabled +// - The log file cannot be created func NewLogger( - logLevel zerolog.Level, + cfg *Config, w io.Writer, - logFile *os.File, - logDir string, ) (*Logger, error) { - if w == nil && logFile == nil { + if cfg == nil { + return nil, errors.New("No config provided") + } + if w == nil && cfg.LogOutput != "file" { return nil, errors.New("No Writer provided for log output.") } @@ -31,6 +56,21 @@ func NewLogger( consoleWriter = zerolog.ConsoleWriter{Out: w} } + var logFile *os.File + var err error + if cfg.LogOutput == "file" || cfg.LogOutput == "both" { + if cfg.LogDir == "" { + return nil, errors.New("LOG_DIR must be set when LOG_OUTPUT is 'file' or 'both'") + } + if cfg.LogFileName == "" { + return nil, errors.New("LOG_FILE_NAME must be set when LOG_OUTPUT is 'file' or 'both'") + } + logFile, err = newLogFile(cfg.LogDir, cfg.LogFileName, cfg.LogAppend) + if err != nil { + return nil, errors.Wrap(err, "newLogFile") + } + } + var output io.Writer if logFile != nil { if w != nil { @@ -41,11 +81,17 @@ func NewLogger( } else { output = consoleWriter } + logger := zerolog.New(output). With(). Timestamp(). Logger(). - Level(logLevel) + Level(cfg.LogLevel) - return &logger, nil + hlog := &Logger{ + Logger: &logger, + logFile: logFile, + } + + return hlog, nil } diff --git a/hlog/logger_test.go b/hlog/logger_test.go new file mode 100644 index 0000000..4f1bf26 --- /dev/null +++ b/hlog/logger_test.go @@ -0,0 +1,376 @@ +package hlog + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" +) + +func TestNewLogger(t *testing.T) { + tests := []struct { + name string + cfg *Config + writer io.Writer + wantErr bool + errMsg string + }{ + { + name: "console output only", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "console", + LogDir: "", + LogFileName: "", + LogAppend: true, + }, + writer: bytes.NewBuffer(nil), + wantErr: false, + }, + { + name: "nil config", + cfg: nil, + writer: bytes.NewBuffer(nil), + wantErr: true, + errMsg: "No config provided", + }, + { + name: "nil writer for both output", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "both", + }, + writer: nil, + wantErr: true, + errMsg: "No Writer provided", + }, + { + name: "file output without LogDir", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: "", + LogFileName: "test.log", + LogAppend: true, + }, + writer: nil, + wantErr: true, + errMsg: "LOG_DIR must be set", + }, + { + name: "file output without LogFileName", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: "/tmp", + LogFileName: "", + LogAppend: true, + }, + writer: nil, + wantErr: true, + errMsg: "LOG_FILE_NAME must be set", + }, + { + name: "both output without LogDir", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "both", + LogDir: "", + LogFileName: "test.log", + LogAppend: true, + }, + writer: bytes.NewBuffer(nil), + wantErr: true, + errMsg: "LOG_DIR must be set", + }, + { + name: "both output without LogFileName", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "both", + LogDir: "/tmp", + LogFileName: "", + LogAppend: true, + }, + writer: bytes.NewBuffer(nil), + wantErr: true, + errMsg: "LOG_FILE_NAME must be set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger, err := NewLogger(tt.cfg, tt.writer) + + if tt.wantErr { + if err == nil { + t.Errorf("NewLogger() expected error but got nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("NewLogger() error = %v, should contain %v", err, tt.errMsg) + } + return + } + + if err != nil { + t.Errorf("NewLogger() unexpected error = %v", err) + return + } + + if logger == nil { + t.Errorf("NewLogger() returned nil logger") + return + } + + if logger.Logger == nil { + t.Errorf("NewLogger() returned logger with nil zerolog.Logger") + } + }) + } +} + +func TestNewLogger_FileOutput(t *testing.T) { + // Create temporary directory for test logs + tempDir := t.TempDir() + + tests := []struct { + name string + cfg *Config + writer io.Writer + wantErr bool + checkFile bool + logMessage string + }{ + { + name: "file output with append", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: "append_test.log", + LogAppend: true, + }, + writer: nil, + wantErr: false, + checkFile: true, + logMessage: "test append message", + }, + { + name: "file output with overwrite", + cfg: &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: "overwrite_test.log", + LogAppend: false, + }, + writer: nil, + wantErr: false, + checkFile: true, + logMessage: "test overwrite message", + }, + { + name: "both output modes", + cfg: &Config{ + LogLevel: zerolog.DebugLevel, + LogOutput: "both", + LogDir: tempDir, + LogFileName: "both_test.log", + LogAppend: true, + }, + writer: bytes.NewBuffer(nil), + wantErr: false, + checkFile: true, + logMessage: "test both message", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger, err := NewLogger(tt.cfg, tt.writer) + + if tt.wantErr { + if err == nil { + t.Errorf("NewLogger() expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("NewLogger() unexpected error = %v", err) + return + } + + if logger == nil { + t.Errorf("NewLogger() returned nil logger") + return + } + + // Log a test message + logger.Info().Msg(tt.logMessage) + + // Close the log file to flush + err = logger.CloseLogFile() + if err != nil { + t.Errorf("CloseLogFile() error = %v", err) + } + + // Check if file exists and contains message + if tt.checkFile { + logPath := filepath.Join(tt.cfg.LogDir, tt.cfg.LogFileName) + content, err := os.ReadFile(logPath) + if err != nil { + t.Errorf("Failed to read log file: %v", err) + return + } + + if !strings.Contains(string(content), tt.logMessage) { + t.Errorf("Log file doesn't contain expected message. Got: %s", string(content)) + } + } + + // Check console output for "both" mode + if tt.cfg.LogOutput == "both" && tt.writer != nil { + if buf, ok := tt.writer.(*bytes.Buffer); ok { + consoleOutput := buf.String() + if !strings.Contains(consoleOutput, tt.logMessage) { + t.Errorf("Console output doesn't contain expected message. Got: %s", consoleOutput) + } + } + } + }) + } +} + +func TestNewLogger_AppendVsOverwrite(t *testing.T) { + tempDir := t.TempDir() + logFileName := "append_vs_overwrite.log" + + // First logger - write initial content + cfg1 := &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: logFileName, + LogAppend: true, + } + + logger1, err := NewLogger(cfg1, nil) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + + logger1.Info().Msg("first message") + logger1.CloseLogFile() + + // Second logger - append mode + cfg2 := &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: logFileName, + LogAppend: true, + } + + logger2, err := NewLogger(cfg2, nil) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + + logger2.Info().Msg("second message") + logger2.CloseLogFile() + + // Check both messages exist + logPath := filepath.Join(tempDir, logFileName) + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "first message") { + t.Errorf("Log file missing 'first message' after append") + } + if !strings.Contains(contentStr, "second message") { + t.Errorf("Log file missing 'second message' after append") + } + + // Third logger - overwrite mode + cfg3 := &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: logFileName, + LogAppend: false, + } + + logger3, err := NewLogger(cfg3, nil) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + + logger3.Info().Msg("third message") + logger3.CloseLogFile() + + // Check only third message exists + content, err = os.ReadFile(logPath) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + contentStr = string(content) + if strings.Contains(contentStr, "first message") { + t.Errorf("Log file still contains 'first message' after overwrite") + } + if strings.Contains(contentStr, "second message") { + t.Errorf("Log file still contains 'second message' after overwrite") + } + if !strings.Contains(contentStr, "third message") { + t.Errorf("Log file missing 'third message' after overwrite") + } +} + +func TestLogger_CloseLogFile(t *testing.T) { + t.Run("close with file", func(t *testing.T) { + tempDir := t.TempDir() + cfg := &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "file", + LogDir: tempDir, + LogFileName: "close_test.log", + LogAppend: true, + } + + logger, err := NewLogger(cfg, nil) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + + err = logger.CloseLogFile() + if err != nil { + t.Errorf("CloseLogFile() error = %v", err) + } + }) + + t.Run("close without file", func(t *testing.T) { + cfg := &Config{ + LogLevel: zerolog.InfoLevel, + LogOutput: "console", + } + + logger, err := NewLogger(cfg, bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("NewLogger() error = %v", err) + } + + err = logger.CloseLogFile() + if err != nil { + t.Errorf("CloseLogFile() should not error when no file is open, got: %v", err) + } + }) +}