mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
374 lines
11 KiB
Go
Executable File
374 lines
11 KiB
Go
Executable File
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
|
|
}
|