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