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 tailLines, err := cmd.Flags().GetInt("tail")
36 if err != nil {
37 return fmt.Errorf("failed to get tail flag: %v", err)
38 }
39
40 log.SetLevel(log.DebugLevel)
41 // Configure log to output to stdout instead of stderr
42 log.SetOutput(os.Stdout)
43
44 cfg, err := config.Load(cwd, false)
45 if err != nil {
46 return fmt.Errorf("failed to load configuration: %v", err)
47 }
48 logsFile := filepath.Join(cfg.WorkingDir(), cfg.Options.DataDirectory, "logs", "crush.log")
49 _, err = os.Stat(logsFile)
50 if os.IsNotExist(err) {
51 log.Warn("Looks like you are not in a crush project. No logs found.")
52 return nil
53 }
54
55 if follow {
56 // Follow mode - tail the file continuously
57 t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
58 if err != nil {
59 return fmt.Errorf("failed to tail log file: %v", err)
60 }
61
62 // Print the text of each received line
63 for line := range t.Lines {
64 printLogLine(line.Text)
65 }
66 } else if tailLines > 0 {
67 // Tail mode - show last N lines
68 lines, err := readLastNLines(logsFile, tailLines)
69 if err != nil {
70 return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
71 }
72 for _, line := range lines {
73 printLogLine(line)
74 }
75 } else {
76 // Oneshot mode - read the entire file once
77 file, err := os.Open(logsFile)
78 if err != nil {
79 return fmt.Errorf("failed to open log file: %v", err)
80 }
81 defer file.Close()
82
83 reader := bufio.NewReader(file)
84 for {
85 line, err := reader.ReadString('\n')
86 if err != nil {
87 if err == io.EOF && line != "" {
88 // Handle last line without newline
89 printLogLine(line)
90 }
91 break
92 }
93 // Remove trailing newline
94 line = strings.TrimSuffix(line, "\n")
95 printLogLine(line)
96 }
97 }
98
99 return nil
100 },
101}
102
103func init() {
104 logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
105 logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
106 rootCmd.AddCommand(logsCmd)
107}
108
109// readLastNLines reads the last N lines from a file using a simple circular buffer approach
110func readLastNLines(filename string, n int) ([]string, error) {
111 file, err := os.Open(filename)
112 if err != nil {
113 return nil, err
114 }
115 defer file.Close()
116
117 // Use a circular buffer to keep only the last N lines
118 lines := make([]string, n)
119 count := 0
120 index := 0
121
122 reader := bufio.NewReader(file)
123 for {
124 line, err := reader.ReadString('\n')
125 if err != nil {
126 if err == io.EOF && line != "" {
127 // Handle last line without newline
128 line = strings.TrimSuffix(line, "\n")
129 lines[index] = line
130 count++
131 index = (index + 1) % n
132 }
133 break
134 }
135 // Remove trailing newline
136 line = strings.TrimSuffix(line, "\n")
137 lines[index] = line
138 count++
139 index = (index + 1) % n
140 }
141
142 // Extract the last N lines in correct order
143 if count <= n {
144 // We have fewer lines than requested, return them all
145 return lines[:count], nil
146 }
147
148 // We have more lines than requested, extract from circular buffer
149 result := make([]string, n)
150 for i := range n {
151 result[i] = lines[(index+i)%n]
152 }
153 return result, nil
154}
155
156func printLogLine(lineText string) {
157 var data map[string]any
158 if err := json.Unmarshal([]byte(lineText), &data); err != nil {
159 return
160 }
161 msg := data["msg"]
162 level := data["level"]
163 otherData := []any{}
164 keys := []string{}
165 for k := range data {
166 keys = append(keys, k)
167 }
168 slices.Sort(keys)
169 for _, k := range keys {
170 switch k {
171 case "msg", "level", "time":
172 continue
173 case "source":
174 source, ok := data[k].(map[string]any)
175 if !ok {
176 continue
177 }
178 sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
179 otherData = append(otherData, "source", sourceFile)
180
181 default:
182 otherData = append(otherData, k, data[k])
183 }
184 }
185 log.SetTimeFunction(func(_ time.Time) time.Time {
186 // parse the timestamp from the log line if available
187 t, err := time.Parse(time.RFC3339, data["time"].(string))
188 if err != nil {
189 return time.Now() // fallback to current time if parsing fails
190 }
191 return t
192 })
193 switch level {
194 case "INFO":
195 log.Info(msg, otherData...)
196 case "DEBUG":
197 log.Debug(msg, otherData...)
198 case "ERROR":
199 log.Error(msg, otherData...)
200 case "WARN":
201 log.Warn(msg, otherData...)
202 default:
203 log.Info(msg, otherData...)
204 }
205}