@@ -1,11 +1,14 @@
11package cmd
22
33import (
4+ "bufio"
45 "encoding/json"
56 "fmt"
7+ "io"
68 "os"
79 "path/filepath"
810 "slices"
11+ "strings"
912 "time"
1013
1114 "github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
1417 "github.com/spf13/cobra"
1518)
1619
17−func init() {
18− rootCmd.AddCommand(logsCmd)
19−}
20−
2120var logsCmd = &cobra.Command{
2221 Use: "logs",
2322 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 purposes.`,
23+ Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
2524 RunE: func(cmd *cobra.Command, args []string) error {
2625 cwd, err := cmd.Flags().GetString("cwd")
2726 if err != nil {
2827 return fmt.Errorf("failed to get current working directory: %v", err)
2928 }
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+
3040 log.SetLevel(log.DebugLevel)
41+ // Configure log to output to stdout instead of stderr
42+ log.SetOutput(os.Stdout)
43+
3144 cfg, err := config.Load(cwd, false)
3245 if err != nil {
3346 return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
3851 log.Warn("Looks like you are not in a crush project. No logs found.")
3952 return nil
4053 }
41− t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
42− if err != nil {
43− return fmt.Errorf("failed to tail log file: %v", err)
44− }
4554
46− // Print the text of each received line
47− for line := range t.Lines {
48− var data map[string]any
49− if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
50− continue
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)
5160 }
52− msg := data["msg"]
53− level := data["level"]
54− otherData := []any{}
55− keys := []string{}
56− for k := range data {
57− keys = append(keys, k)
58− }
59− slices.Sort(keys)
60− for _, k := range keys {
61− switch k {
62− case "msg", "level", "time":
63− continue
64− case "source":
65− source, ok := data[k].(map[string]any)
66− if !ok {
67− continue
68− }
69− sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
70− otherData = append(otherData, "source", sourceFile)
7161
72− default:
73− otherData = append(otherData, k, data[k])
74− }
62+ // Print the text of each received line
63+ for line := range t.Lines {
64+ printLogLine(line.Text)
7565 }
76− log.SetTimeFunction(func(_ time.Time) time.Time {
77− // parse the timestamp from the log line if available
78− t, err := time.Parse(time.RFC3339, data["time"].(string))
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')
7986 if err != nil {
80− return time.Now() // fallback to current time if parsing fails
87+ if err == io.EOF && line != "" {
88+ // Handle last line without newline
89+ printLogLine(line)
90+ }
91+ break
8192 }
82− return t
83− })
84− switch level {
85− case "INFO":
86− log.Info(msg, otherData...)
87− case "DEBUG":
88− log.Debug(msg, otherData...)
89− case "ERROR":
90− log.Error(msg, otherData...)
91− case "WARN":
92− log.Warn(msg, otherData...)
93− default:
94− log.Info(msg, otherData...)
93+ // Remove trailing newline
94+ line = strings.TrimSuffix(line, "\n")
95+ printLogLine(line)
9596 }
9697 }
98+
9799 return nil
98100 },
99101}
102+
103+func 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
110+func 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+
156+func 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+}