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