@@ -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...)
+ }
+}