1package cmd
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "time"
12
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/log/v2"
15 "github.com/nxadm/tail"
16 "github.com/spf13/cobra"
17)
18
19const defaultTailLines = 1000
20
21var logsCmd = &cobra.Command{
22 Use: "logs",
23 Short: "View crush logs",
24 Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
25 RunE: func(cmd *cobra.Command, args []string) error {
26 cwd, err := cmd.Flags().GetString("cwd")
27 if err != nil {
28 return fmt.Errorf("failed to get current working directory: %v", err)
29 }
30
31 dataDir, err := cmd.Flags().GetString("data-dir")
32 if err != nil {
33 return fmt.Errorf("failed to get data directory: %v", err)
34 }
35
36 follow, err := cmd.Flags().GetBool("follow")
37 if err != nil {
38 return fmt.Errorf("failed to get follow flag: %v", err)
39 }
40
41 tailLines, err := cmd.Flags().GetInt("tail")
42 if err != nil {
43 return fmt.Errorf("failed to get tail flag: %v", err)
44 }
45
46 log.SetLevel(log.DebugLevel)
47 log.SetOutput(os.Stdout)
48
49 cfg, err := config.Load(cwd, dataDir, false)
50 if err != nil {
51 return fmt.Errorf("failed to load configuration: %v", err)
52 }
53 logsFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
54 _, err = os.Stat(logsFile)
55 if os.IsNotExist(err) {
56 log.Warn("Looks like you are not in a crush project. No logs found.")
57 return nil
58 }
59
60 if follow {
61 return followLogs(cmd.Context(), logsFile, tailLines)
62 }
63
64 return showLogs(logsFile, tailLines)
65 },
66}
67
68func init() {
69 logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
70 logsCmd.Flags().IntP("tail", "t", defaultTailLines, "Show only the last N lines default: 1000 for performance")
71}
72
73func followLogs(ctx context.Context, logsFile string, tailLines int) error {
74 t, err := tail.TailFile(logsFile, tail.Config{
75 Follow: false,
76 ReOpen: false,
77 Logger: tail.DiscardingLogger,
78 })
79 if err != nil {
80 return fmt.Errorf("failed to tail log file: %v", err)
81 }
82
83 var lines []string
84 for line := range t.Lines {
85 if line.Err != nil {
86 continue
87 }
88 lines = append(lines, line.Text)
89 if len(lines) > tailLines {
90 lines = lines[len(lines)-tailLines:]
91 }
92 }
93 t.Stop()
94
95 for _, line := range lines {
96 printLogLine(line)
97 }
98
99 if len(lines) == tailLines {
100 fmt.Fprintf(os.Stderr, "\nShowing last %d lines. Full logs available at: %s\n", tailLines, logsFile)
101 fmt.Fprintf(os.Stderr, "Following new log entries...\n\n")
102 }
103
104 t, err = tail.TailFile(logsFile, tail.Config{
105 Follow: true,
106 ReOpen: true,
107 Logger: tail.DiscardingLogger,
108 Location: &tail.SeekInfo{Offset: 0, Whence: io.SeekEnd},
109 })
110 if err != nil {
111 return fmt.Errorf("failed to tail log file: %v", err)
112 }
113 defer t.Stop()
114
115 for {
116 select {
117 case line := <-t.Lines:
118 if line.Err != nil {
119 continue
120 }
121 printLogLine(line.Text)
122 case <-ctx.Done():
123 return nil
124 }
125 }
126}
127
128func showLogs(logsFile string, tailLines int) error {
129 t, err := tail.TailFile(logsFile, tail.Config{
130 Follow: false,
131 ReOpen: false,
132 Logger: tail.DiscardingLogger,
133 MaxLineSize: 0,
134 })
135 if err != nil {
136 return fmt.Errorf("failed to tail log file: %v", err)
137 }
138 defer t.Stop()
139
140 var lines []string
141 for line := range t.Lines {
142 if line.Err != nil {
143 continue
144 }
145 lines = append(lines, line.Text)
146 if len(lines) > tailLines {
147 lines = lines[len(lines)-tailLines:]
148 }
149 }
150
151 for _, line := range lines {
152 printLogLine(line)
153 }
154
155 if len(lines) == tailLines {
156 fmt.Fprintf(os.Stderr, "\nShowing last %d lines. Full logs available at: %s\n", tailLines, logsFile)
157 }
158
159 return nil
160}
161
162func printLogLine(lineText string) {
163 var data map[string]any
164 if err := json.Unmarshal([]byte(lineText), &data); err != nil {
165 return
166 }
167 msg := data["msg"]
168 level := data["level"]
169 otherData := []any{}
170 keys := []string{}
171 for k := range data {
172 keys = append(keys, k)
173 }
174 slices.Sort(keys)
175 for _, k := range keys {
176 switch k {
177 case "msg", "level", "time":
178 continue
179 case "source":
180 source, ok := data[k].(map[string]any)
181 if !ok {
182 continue
183 }
184 sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
185 otherData = append(otherData, "source", sourceFile)
186
187 default:
188 otherData = append(otherData, k, data[k])
189 }
190 }
191 log.SetTimeFunction(func(_ time.Time) time.Time {
192 // parse the timestamp from the log line if available
193 t, err := time.Parse(time.RFC3339, data["time"].(string))
194 if err != nil {
195 return time.Now() // fallback to current time if parsing fails
196 }
197 return t
198 })
199 switch level {
200 case "INFO":
201 log.Info(msg, otherData...)
202 case "DEBUG":
203 log.Debug(msg, otherData...)
204 case "ERROR":
205 log.Error(msg, otherData...)
206 case "WARN":
207 log.Warn(msg, otherData...)
208 default:
209 log.Info(msg, otherData...)
210 }
211}