diff --git a/cmd/logs.go b/cmd/logs.go index d690625de90d8a0f7ec42d366f5d414c21dbebf0..bb0aaf9d7b8c2cbcf1da8823c2848002f6d2e252 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -1,11 +1,14 @@ package cmd import ( + "bufio" "encoding/json" "fmt" + "io" "os" "path/filepath" "slices" + "strings" "time" "github.com/charmbracelet/crush/internal/config" @@ -14,20 +17,30 @@ import ( "github.com/spf13/cobra" ) -func init() { - rootCmd.AddCommand(logsCmd) -} - var logsCmd = &cobra.Command{ Use: "logs", Short: "View crush logs", - Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`, + Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`, RunE: func(cmd *cobra.Command, args []string) error { cwd, err := cmd.Flags().GetString("cwd") if err != nil { return fmt.Errorf("failed to get current working directory: %v", err) } + + follow, err := cmd.Flags().GetBool("follow") + if err != nil { + return fmt.Errorf("failed to get follow flag: %v", err) + } + + tailLines, err := cmd.Flags().GetInt("tail") + if err != nil { + return fmt.Errorf("failed to get tail flag: %v", err) + } + log.SetLevel(log.DebugLevel) + // Configure log to output to stdout instead of stderr + log.SetOutput(os.Stdout) + cfg, err := config.Load(cwd, false) if err != nil { return fmt.Errorf("failed to load configuration: %v", err) @@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{ log.Warn("Looks like you are not in a crush project. No logs found.") return nil } - t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger}) - if err != nil { - return fmt.Errorf("failed to tail log file: %v", err) - } - // Print the text of each received line - for line := range t.Lines { - var data map[string]any - if err := json.Unmarshal([]byte(line.Text), &data); err != nil { - continue + if follow { + // Follow mode - tail the file continuously + t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger}) + if err != nil { + return fmt.Errorf("failed to tail log file: %v", err) } - msg := data["msg"] - level := data["level"] - otherData := []any{} - keys := []string{} - for k := range data { - keys = append(keys, k) - } - slices.Sort(keys) - for _, k := range keys { - switch k { - case "msg", "level", "time": - continue - case "source": - source, ok := data[k].(map[string]any) - if !ok { - continue - } - sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64))) - otherData = append(otherData, "source", sourceFile) - default: - otherData = append(otherData, k, data[k]) - } + // Print the text of each received line + for line := range t.Lines { + printLogLine(line.Text) } - log.SetTimeFunction(func(_ time.Time) time.Time { - // parse the timestamp from the log line if available - t, err := time.Parse(time.RFC3339, data["time"].(string)) + } else if tailLines > 0 { + // Tail mode - show last N lines + lines, err := readLastNLines(logsFile, tailLines) + if err != nil { + return fmt.Errorf("failed to read last %d lines: %v", tailLines, err) + } + for _, line := range lines { + printLogLine(line) + } + } else { + // Oneshot mode - read the entire file once + file, err := os.Open(logsFile) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + defer file.Close() + + reader := bufio.NewReader(file) + for { + line, err := reader.ReadString('\n') if err != nil { - return time.Now() // fallback to current time if parsing fails + if err == io.EOF && line != "" { + // Handle last line without newline + printLogLine(line) + } + break } - return t - }) - switch level { - case "INFO": - log.Info(msg, otherData...) - case "DEBUG": - log.Debug(msg, otherData...) - case "ERROR": - log.Error(msg, otherData...) - case "WARN": - log.Warn(msg, otherData...) - default: - log.Info(msg, otherData...) + // Remove trailing newline + line = strings.TrimSuffix(line, "\n") + printLogLine(line) } } + return nil }, } + +func init() { + logsCmd.Flags().BoolP("follow", "f", false, "Follow log output") + logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines") + rootCmd.AddCommand(logsCmd) +} + +// readLastNLines reads the last N lines from a file using a simple circular buffer approach +func readLastNLines(filename string, n int) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + // Use a circular buffer to keep only the last N lines + lines := make([]string, n) + count := 0 + index := 0 + + reader := bufio.NewReader(file) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF && line != "" { + // Handle last line without newline + line = strings.TrimSuffix(line, "\n") + lines[index] = line + count++ + index = (index + 1) % n + } + break + } + // Remove trailing newline + line = strings.TrimSuffix(line, "\n") + lines[index] = line + count++ + index = (index + 1) % n + } + + // Extract the last N lines in correct order + if count <= n { + // We have fewer lines than requested, return them all + return lines[:count], nil + } + + // We have more lines than requested, extract from circular buffer + result := make([]string, n) + for i := range n { + result[i] = lines[(index+i)%n] + } + return result, nil +} + +func printLogLine(lineText string) { + var data map[string]any + if err := json.Unmarshal([]byte(lineText), &data); err != nil { + return + } + msg := data["msg"] + level := data["level"] + otherData := []any{} + keys := []string{} + for k := range data { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + switch k { + case "msg", "level", "time": + continue + case "source": + source, ok := data[k].(map[string]any) + if !ok { + continue + } + sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64))) + otherData = append(otherData, "source", sourceFile) + + default: + otherData = append(otherData, k, data[k]) + } + } + log.SetTimeFunction(func(_ time.Time) time.Time { + // parse the timestamp from the log line if available + t, err := time.Parse(time.RFC3339, data["time"].(string)) + if err != nil { + return time.Now() // fallback to current time if parsing fails + } + return t + }) + switch level { + case "INFO": + log.Info(msg, otherData...) + case "DEBUG": + log.Debug(msg, otherData...) + case "ERROR": + log.Error(msg, otherData...) + case "WARN": + log.Warn(msg, otherData...) + default: + log.Info(msg, otherData...) + } +}