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}