package logger import ( "fmt" "log" "os" "path/filepath" "strings" "github.com/chenasraf/sofmani/platform" "github.com/davecgh/go-spew/spew" "github.com/mattn/go-runewidth" "golang.org/x/term" ) // Highlight markers (using unlikely byte sequences) const ( highlightStart = "\x00HS\x00" highlightEnd = "\x00HE\x00" ) // ANSI color codes const ( ansiHighlight = "\033[1;96m" // Bright cyan for highlights ansiBlueBold = "\033[1;34m" ansiYellowBold = "\033[1;33m" ansiRedBold = "\033[1;31m" ansiGreenBold = "\033[1;32m" ansiReset = "\033[0m" ) // Highlight marks text to be displayed in white/bold in console output. // Use this for installer names, types, and other important identifiers. func Highlight(text string) string { return highlightStart + text + highlightEnd } // H is a shorthand alias for Highlight. func H(text string) string { return Highlight(text) } // Logger provides logging functionality with support for file and console output. type Logger struct { fileLogger *log.Logger // fileLogger is the logger for writing to the log file. consoleOut *log.Logger // consoleOut is the logger for writing to the console. logFile *os.File // logFile is the opened log file. logFilePath string // logFilePath is the path to the log file. debug bool // debug indicates whether debug logging is enabled. } var logger *Logger // logger is the global logger instance. var customLogFile *string // customLogFile holds the custom log file path if set. // GetLogDir returns the appropriate log directory based on the operating system. func GetLogDir() string { var logDir string switch platform.GetPlatform() { case platform.PlatformLinux: stateDir := os.Getenv("XDG_STATE_HOME") if stateDir == "" { home, err := os.UserHomeDir() if err != nil { fmt.Printf("Could not get user home directory: %v\n", err) panic(err) } stateDir = filepath.Join(home, ".local", "state") } logDir = filepath.Join(stateDir, "sofmani") case platform.PlatformMacos: home, err := os.UserHomeDir() if err != nil { fmt.Printf("Could not get user home directory: %v\n", err) panic(err) } logDir = filepath.Join(home, "Library", "Logs", "sofmani") case platform.PlatformWindows: appData := os.Getenv("APPDATA") logDir = filepath.Join(appData, "sofmani", "Logs") } return logDir } // GetDefaultLogFile returns the default log file path. func GetDefaultLogFile() string { return filepath.Join(GetLogDir(), "sofmani.log") } // GetLogFile returns the current log file path (custom or default). func GetLogFile() string { if customLogFile != nil { return *customLogFile } return GetDefaultLogFile() } // SetLogFile sets a custom log file path. func SetLogFile(path string) { customLogFile = &path } const maxLogSize = 10 * 1024 * 1024 // 10MB // InitLogger initializes the global logger with the specified debug mode. // It creates the log directory and file if they don't exist. // If the log file exceeds maxLogSize, it will be truncated. func InitLogger(debug bool) *Logger { filePath := GetLogFile() logDir := filepath.Dir(filePath) if _, err := os.Stat(logDir); os.IsNotExist(err) { err := os.MkdirAll(logDir, 0755) if err != nil { fmt.Printf("Could not create log directory: %v\n", err) os.Exit(1) } } // Truncate log file if it exceeds maxLogSize if info, err := os.Stat(filePath); err == nil && info.Size() > maxLogSize { if err := os.Truncate(filePath, 0); err != nil { fmt.Printf("Could not truncate log file: %v\n", err) } } logFile, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Printf("Could not create log file: %v\n", err) os.Exit(1) } // Create file and console loggers fileLogger := log.New(logFile, "", log.LstdFlags) consoleOut := log.New(os.Stdout, "", log.LstdFlags) // Initialize the logger logger = &Logger{ fileLogger: fileLogger, consoleOut: consoleOut, logFile: logFile, logFilePath: filePath, debug: debug, } return logger } // stripHighlightMarkers removes highlight markers from text (for file output). func stripHighlightMarkers(text string) string { text = strings.ReplaceAll(text, highlightStart, "") text = strings.ReplaceAll(text, highlightEnd, "") return text } // processHighlights converts highlight markers to ANSI codes for console output. func processHighlights(text string, baseColorSeq string) string { // Replace start marker with highlight color text = strings.ReplaceAll(text, highlightStart, ansiHighlight) // Replace end marker with reset + base color (to restore the log level color) text = strings.ReplaceAll(text, highlightEnd, ansiReset+baseColorSeq) return text } // log is an internal helper function for logging messages with a specific level and color. func (l *Logger) log(level string, colorSeq string, format string, args ...any) { message := fmt.Sprintf("[%s] %s", level, fmt.Sprintf(format, args...)) // Write to file (strip all highlight markers - file should have no colors) fileMessage := stripHighlightMarkers(message) l.fileLogger.Println(fileMessage) if level == "DEBUG" && !l.debug { return } // Write to console with color if colorSeq != "" { consoleMessage := processHighlights(message, colorSeq) // Wrap entire message in base color and reset at end l.consoleOut.Println(colorSeq + consoleMessage + ansiReset) } else { // No base color - just convert highlights to white consoleMessage := processHighlights(message, "") l.consoleOut.Println(consoleMessage) } } // Info logs an informational message. func Info(format string, args ...any) { logger.log(" INFO", ansiBlueBold, format, args...) } // Warn logs a warning message. func Warn(format string, args ...any) { logger.log(" WARN", ansiYellowBold, format, args...) } // Error logs an error message. func Error(format string, args ...any) { logger.log("ERROR", ansiRedBold, format, args...) } // Debug logs a debug message. Only printed if debug mode is enabled. func Debug(format string, args ...any) { logger.log("DEBUG", ansiGreenBold, format, args...) } // Spew logs a detailed representation of a value using spew.Dump. // This is typically used for debugging complex data structures. func Spew(v any) { // Print/debug the passed value (works like spew.Dump) spewDump := spew.Sdump(v) Debug("%s", spewDump) } // CloseLogger closes the log file. func CloseLogger() { if logger != nil && logger.logFile != nil { err := logger.logFile.Close() if err != nil { fmt.Printf("Could not close log file: %v\n", err) } } } // Box-drawing characters for category headers const ( boxTopLeft = "┌" boxTopRight = "┐" boxBottomLeft = "└" boxBottomRight = "┘" boxHorizontal = "─" boxVertical = "│" boxLeftT = "├" boxRightT = "┤" boxDefaultWidth = 60 ) // getBoxWidth returns the width to use for category boxes. // It uses the terminal width if it's narrower than the default, otherwise uses the default. func getBoxWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { return boxDefaultWidth } if width < boxDefaultWidth { return width } return boxDefaultWidth } // CategoryDisplayMode controls how category headers are rendered. type CategoryDisplayMode string const ( // CategoryDisplayBorder renders categories with a full border and spacing. CategoryDisplayBorder CategoryDisplayMode = "border" // CategoryDisplayBorderCompact renders categories with a border but no spacing. CategoryDisplayBorderCompact CategoryDisplayMode = "border-compact" // CategoryDisplayMinimal renders categories without a border or spacing. CategoryDisplayMinimal CategoryDisplayMode = "minimal" ) // Category logs a category header with a decorative border. // If desc is provided, it will be displayed below the category name with auto-wrapping. // The displayMode controls the visual style: "border" (default), "border-compact", or "minimal". func Category(name string, desc *string, displayMode CategoryDisplayMode) { switch displayMode { case CategoryDisplayMinimal: categoryMinimal(name, desc) case CategoryDisplayBorderCompact: categoryBorder(name, desc, false) default: categoryBorder(name, desc, true) } } // categoryBorder renders a category with box-drawing borders. // If spaced is true, empty lines are added before and after. func categoryBorder(name string, desc *string, spaced bool) { boxWidth := getBoxWidth() innerWidth := boxWidth - 4 // Account for "│ " and " │" // Build the border lines horizontalLine := strings.Repeat(boxHorizontal, boxWidth-2) topBorder := boxTopLeft + horizontalLine + boxTopRight bottomBorder := boxBottomLeft + horizontalLine + boxBottomRight separator := boxLeftT + horizontalLine + boxRightT // Log the header if spaced { Info("") } Info("%s", topBorder) Info("%s", formatBoxLine(name, innerWidth)) // Log description if provided if desc != nil && len(*desc) > 0 { Info("%s", separator) for _, line := range wrapText(*desc, innerWidth) { Info("%s", formatBoxLine(line, innerWidth)) } } Info("%s", bottomBorder) if spaced { Info("") } } // categoryMinimal renders a category as plain text without borders or spacing. func categoryMinimal(name string, desc *string) { Info("%s", name) if desc != nil && len(*desc) > 0 { boxWidth := getBoxWidth() for _, line := range wrapText(*desc, boxWidth) { Info("%s", line) } } } // formatBoxLine formats a line of text to fit within the box. func formatBoxLine(text string, innerWidth int) string { // Truncate if too long (using display width) text = runewidth.Truncate(text, innerWidth, "") // Pad to fill the width (using display width) displayWidth := runewidth.StringWidth(text) padding := strings.Repeat(" ", innerWidth-displayWidth) return boxVertical + " " + text + padding + " " + boxVertical } // wrapText wraps text to fit within maxWidth, respecting existing newlines. // Uses display width to handle Unicode characters (including emojis) correctly. func wrapText(text string, maxWidth int) []string { var result []string // Split by existing newlines first to respect user formatting for paragraph := range strings.SplitSeq(text, "\n") { if len(paragraph) == 0 { result = append(result, "") continue } // Wrap each paragraph words := strings.Fields(paragraph) if len(words) == 0 { result = append(result, "") continue } var currentLine string var currentWidth int for _, word := range words { wordWidth := runewidth.StringWidth(word) switch { case currentLine == "": currentLine = word currentWidth = wordWidth case currentWidth+1+wordWidth <= maxWidth: currentLine += " " + word currentWidth += 1 + wordWidth default: result = append(result, currentLine) currentLine = word currentWidth = wordWidth } } if currentLine != "" { result = append(result, currentLine) } } return result }