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}