1package cmd
2
3import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "time"
13
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/log/v2"
16 "github.com/nxadm/tail"
17 "github.com/spf13/cobra"
18)
19
20var logsCmd = &cobra.Command{
21 Use: "logs",
22 Short: "View crush logs",
23 Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
24 RunE: func(cmd *cobra.Command, args []string) error {
25 cwd, err := cmd.Flags().GetString("cwd")
26 if err != nil {
27 return fmt.Errorf("failed to get current working directory: %v", err)
28 }
29
30 follow, err := cmd.Flags().GetBool("follow")
31 if err != nil {
32 return fmt.Errorf("failed to get follow flag: %v", err)
33 }
34
35 log.SetLevel(log.DebugLevel)
36 // Configure log to output to stdout instead of stderr
37 log.SetOutput(os.Stdout)
38
39 cfg, err := config.Load(cwd, false)
40 if err != nil {
41 return fmt.Errorf("failed to load configuration: %v", err)
42 }
43 logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
44 _, err = os.Stat(logsFile)
45 if os.IsNotExist(err) {
46 log.Warn("Looks like you are not in a crush project. No logs found.")
47 return nil
48 }
49
50 if follow {
51 // Follow mode - tail the file continuously
52 t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
53 if err != nil {
54 return fmt.Errorf("failed to tail log file: %v", err)
55 }
56
57 // Print the text of each received line
58 for line := range t.Lines {
59 printLogLine(line.Text)
60 }
61 } else {
62 // Oneshot mode - read the entire file once
63 file, err := os.Open(logsFile)
64 if err != nil {
65 return fmt.Errorf("failed to open log file: %v", err)
66 }
67 defer file.Close()
68
69 reader := bufio.NewReader(file)
70 for {
71 line, err := reader.ReadString('\n')
72 if err != nil {
73 if err == io.EOF && line != "" {
74 // Handle last line without newline
75 printLogLine(line)
76 }
77 break
78 }
79 // Remove trailing newline
80 line = strings.TrimSuffix(line, "\n")
81 printLogLine(line)
82 }
83 }
84
85 return nil
86 },
87}
88
89func init() {
90 logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
91 rootCmd.AddCommand(logsCmd)
92}
93
94func printLogLine(lineText string) {
95 var data map[string]any
96 if err := json.Unmarshal([]byte(lineText), &data); err != nil {
97 return
98 }
99 msg := data["msg"]
100 level := data["level"]
101 otherData := []any{}
102 keys := []string{}
103 for k := range data {
104 keys = append(keys, k)
105 }
106 slices.Sort(keys)
107 for _, k := range keys {
108 switch k {
109 case "msg", "level", "time":
110 continue
111 case "source":
112 source, ok := data[k].(map[string]any)
113 if !ok {
114 continue
115 }
116 sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
117 otherData = append(otherData, "source", sourceFile)
118
119 default:
120 otherData = append(otherData, k, data[k])
121 }
122 }
123 log.SetTimeFunction(func(_ time.Time) time.Time {
124 // parse the timestamp from the log line if available
125 t, err := time.Parse(time.RFC3339, data["time"].(string))
126 if err != nil {
127 return time.Now() // fallback to current time if parsing fails
128 }
129 return t
130 })
131 switch level {
132 case "INFO":
133 log.Info(msg, otherData...)
134 case "DEBUG":
135 log.Debug(msg, otherData...)
136 case "ERROR":
137 log.Error(msg, otherData...)
138 case "WARN":
139 log.Warn(msg, otherData...)
140 default:
141 log.Info(msg, otherData...)
142 }
143}