1package logger
2
3import (
4 "fmt"
5 "io"
6 "log/slog"
7 "os"
8 "strings"
9 "time"
10
11 "github.com/bnema/archup/internal/system"
12)
13
14// Logger wraps slog.Logger and adds dry-run mode support
15type Logger struct {
16 slog *slog.Logger
17 logPath string
18 dryRun bool
19 logFile *os.File
20}
21
22// syncWriter wraps an io.Writer to sync after every write
23type syncWriter struct {
24 w interface {
25 io.Writer
26 Sync() error
27 }
28}
29
30func (sw *syncWriter) Write(p []byte) (n int, err error) {
31 n, err = sw.w.Write(p)
32 if err != nil {
33 return n, err
34 }
35 // Sync to disk immediately to ensure logs are written even on crash
36 if syncErr := sw.w.Sync(); syncErr != nil {
37 // Log sync error but don't fail the write
38 fmt.Fprintf(os.Stderr, "Warning: failed to sync log file: %v\n", syncErr)
39 }
40 return n, err
41}
42
43// archiveOldLog archives the existing log file if it exists and is not empty
44// Creates a timestamped backup like: /var/log/archup-install.log.2025-10-26_203045
45func archiveOldLog(logPath string) error {
46 // Check if log file exists
47 info, err := os.Stat(logPath)
48 if err != nil {
49 if os.IsNotExist(err) {
50 // No log file to archive
51 return nil
52 }
53 return fmt.Errorf("failed to stat log file: %w", err)
54 }
55
56 // If log file is empty, just truncate it
57 if info.Size() == 0 {
58 return nil
59 }
60
61 // Create archive filename with timestamp
62 timestamp := time.Now().Format("2006-01-02_150405")
63 archivePath := fmt.Sprintf("%s.%s", logPath, timestamp)
64
65 // Rename old log to archive
66 if err := os.Rename(logPath, archivePath); err != nil {
67 return fmt.Errorf("failed to rename log to archive: %w", err)
68 }
69
70 return nil
71}
72
73// New creates a new Logger with optional file logging and dry-run mode
74func New(logPath string, dryRun bool) (*Logger, error) {
75 // Archive old log if it exists and is not empty
76 if err := archiveOldLog(logPath); err != nil {
77 // Non-fatal error - just warn and continue
78 fmt.Fprintf(os.Stderr, "Warning: failed to archive old log: %v\n", err)
79 }
80
81 // Open log file for writing with sync flag for immediate writes
82 logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_SYNC, 0644)
83 if err != nil {
84 return nil, fmt.Errorf("failed to open log file: %w", err)
85 }
86
87 // Wrap log file with sync writer to ensure immediate disk writes
88 syncLogFile := &syncWriter{w: logFile}
89
90 // Only write to log file (not stdout) to avoid interfering with TUI
91 // If you need stdout logging for debugging, add it back temporarily
92 handler := slog.NewTextHandler(syncLogFile, &slog.HandlerOptions{
93 Level: slog.LevelInfo,
94 })
95
96 logger := &Logger{
97 slog: slog.New(handler),
98 logPath: logPath,
99 logFile: logFile,
100 dryRun: dryRun,
101 }
102
103 // Write initial log entry to verify logging works
104 logger.Info("Logger initialized", "log_path", logPath, "dry_run", dryRun)
105
106 return logger, nil
107}
108
109// NewFromSlog creates a Logger from an existing slog.Logger
110// Note: This does not create a log file - the provided slog.Logger should handle output
111func NewFromSlog(slogLogger *slog.Logger, logPath string, dryRun bool) *Logger {
112 return &Logger{
113 slog: slogLogger,
114 logPath: logPath,
115 logFile: nil, // No log file for this constructor
116 dryRun: dryRun,
117 }
118}
119
120// Info logs an informational message
121func (l *Logger) Info(msg string, args ...any) {
122 l.slog.Info(msg, args...)
123}
124
125// Warn logs a warning message
126func (l *Logger) Warn(msg string, args ...any) {
127 l.slog.Warn(msg, args...)
128}
129
130// Error logs an error message
131func (l *Logger) Error(msg string, args ...any) {
132 l.slog.Error(msg, args...)
133}
134
135// Debug logs a debug message
136func (l *Logger) Debug(msg string, args ...any) {
137 l.slog.Debug(msg, args...)
138}
139
140// IsDryRun returns true if in dry-run mode
141func (l *Logger) IsDryRun() bool {
142 return l.dryRun
143}
144
145// LogPath returns the log file path
146func (l *Logger) LogPath() string {
147 return l.logPath
148}
149
150// ExecCommand executes a command, or logs it in dry-run mode
151func (l *Logger) ExecCommand(command string, args ...string) system.CommandResult {
152 if l.dryRun {
153 l.slog.Info("DRY-RUN", "command", command, "args", args)
154 return system.CommandResult{
155 ExitCode: 0,
156 Output: fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
157 Error: nil,
158 }
159 }
160
161 return system.Run(system.RunConfig{
162 Command: command,
163 Args: args,
164 StreamToLog: true,
165 LogPath: l.logPath,
166 })
167}
168
169// ExecSimple executes a command without logging to file
170func (l *Logger) ExecSimple(command string, args ...string) system.CommandResult {
171 if l.dryRun {
172 l.slog.Info("DRY-RUN", "command", command, "args", args)
173 return system.CommandResult{
174 ExitCode: 0,
175 Output: fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
176 Error: nil,
177 }
178 }
179
180 return system.RunSimple(command, args...)
181}
182
183// ExecWithConfig executes a command with custom RunConfig
184func (l *Logger) ExecWithConfig(cfg system.RunConfig) system.CommandResult {
185 if l.dryRun {
186 l.slog.Info("DRY-RUN", "command", cfg.Command, "args", cfg.Args)
187 return system.CommandResult{
188 ExitCode: 0,
189 Output: fmt.Sprintf("DRY-RUN: %s %s", cfg.Command, strings.Join(cfg.Args, " ")),
190 Error: nil,
191 }
192 }
193
194 // Set log path if not already set
195 if cfg.LogPath == "" {
196 cfg.LogPath = l.logPath
197 cfg.StreamToLog = true
198 }
199
200 return system.Run(cfg)
201}
202
203// Slog returns the underlying slog.Logger for advanced usage
204func (l *Logger) Slog() *slog.Logger {
205 return l.slog
206}
207
208// Close closes the log file (call this when done)
209func (l *Logger) Close() error {
210 if l.logFile != nil {
211 return l.logFile.Close()
212 }
213 return nil
214}