1package cmd
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/spf13/cobra"
13
14 "github.com/bnema/dumber/internal/cli/styles"
15 "github.com/bnema/dumber/internal/infrastructure/config"
16)
17
18type crashReportSummary struct {
19 SessionID string `json:"session_id"`
20 GeneratedAt string `json:"generated_at"`
21 Classification struct {
22 Class string `json:"class"`
23 Reason string `json:"reason"`
24 } `json:"classification"`
25}
26
27var crashesCmd = &cobra.Command{
28 Use: "crashes",
29 Short: "List unexpected-close crash reports",
30 Long: `List crash reports generated after unexpected browser closes.
31
32Use subcommands to inspect full details or print an issue template.
33
34Examples:
35 dumber crashes
36 dumber crashes show latest
37 dumber crashes issue latest`,
38 RunE: runCrashes,
39}
40
41var crashesShowCmd = &cobra.Command{
42 Use: "show <report|latest>",
43 Short: "Show a crash report",
44 Args: cobra.ExactArgs(1),
45 RunE: runCrashesShow,
46}
47
48var crashesIssueCmd = &cobra.Command{
49 Use: "issue <report|latest>",
50 Short: "Print GitHub-ready issue body from report",
51 Args: cobra.ExactArgs(1),
52 RunE: runCrashesIssue,
53}
54
55func init() {
56 rootCmd.AddCommand(crashesCmd)
57 crashesCmd.AddCommand(crashesShowCmd)
58 crashesCmd.AddCommand(crashesIssueCmd)
59}
60
61func runCrashes(_ *cobra.Command, _ []string) error {
62 app := GetApp()
63 if app == nil {
64 return fmt.Errorf("app not initialized")
65 }
66
67 crashDir := getCrashReportsDir(app.Config)
68 reports, err := loadCrashReports(crashDir)
69 if err != nil {
70 return err
71 }
72 if len(reports) == 0 {
73 fmt.Println(app.Theme.Subtle.Render("No crash reports found."))
74 return nil
75 }
76
77 fmt.Println(app.Theme.Title.Render("Crash reports (newest first):"))
78 fmt.Println()
79 for _, item := range reports {
80 generated := item.GeneratedAt
81 if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil {
82 generated = ts.Format("2006-01-02 15:04:05")
83 }
84 shortID := shortCrashID(item.Path)
85 line := fmt.Sprintf(" %s %s %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class)
86 fmt.Println(line)
87 }
88
89 fmt.Println()
90 fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details"))
91 return nil
92}
93
94func runCrashesShow(_ *cobra.Command, args []string) error {
95 app := GetApp()
96 if app == nil {
97 return fmt.Errorf("app not initialized")
98 }
99 renderer := styles.NewCrashesCLIRenderer(app.Theme)
100
101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
102 if err != nil {
103 _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104 return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
105 }
106
107 body, err := os.ReadFile(report.MarkdownPath)
108 if err != nil {
109 _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110 return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
111 }
112 fmt.Print(string(body))
113 return nil
114}
115
116func runCrashesIssue(_ *cobra.Command, args []string) error {
117 app := GetApp()
118 if app == nil {
119 return fmt.Errorf("app not initialized")
120 }
121 renderer := styles.NewCrashesCLIRenderer(app.Theme)
122
123 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
124 if err != nil {
125 _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126 return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
127 }
128
129 body, err := os.ReadFile(report.MarkdownPath)
130 if err != nil {
131 _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132 return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
133 }
134 all := string(body)
135 start := strings.Index(all, "## GitHub Issue Template")
136 if start < 0 {
137 fmt.Print(all)
138 return nil
139 }
140 fmt.Print(strings.TrimSpace(all[start:]) + "\n")
141 return nil
142}
143
144type crashReportFile struct {
145 Path string
146 MarkdownPath string
147 crashReportSummary
148}
149
150func getCrashReportsDir(cfg *config.Config) string {
151 logDir := ""
152 if cfg != nil {
153 logDir = cfg.Logging.LogDir
154 }
155 if logDir == "" {
156 resolved, err := config.GetLogDir()
157 if err == nil {
158 logDir = resolved
159 }
160 }
161 if logDir == "" {
162 stateDir := os.Getenv("XDG_STATE_HOME")
163 if stateDir == "" {
164 home, _ := os.UserHomeDir()
165 stateDir = filepath.Join(home, ".local", "state")
166 }
167 logDir = filepath.Join(stateDir, "dumber", "logs")
168 }
169 return filepath.Join(logDir, "crashes")
170}
171
172func loadCrashReports(crashDir string) ([]crashReportFile, error) {
173 entries, err := os.ReadDir(crashDir)
174 if err != nil {
175 if os.IsNotExist(err) {
176 return nil, nil
177 }
178 return nil, err
179 }
180
181 reports := make([]crashReportFile, 0, len(entries))
182 for _, entry := range entries {
183 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") {
184 continue
185 }
186 path := filepath.Join(crashDir, entry.Name())
187 raw, readErr := os.ReadFile(path)
188 if readErr != nil {
189 continue
190 }
191 var parsed crashReportSummary
192 if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil {
193 continue
194 }
195 reports = append(reports, crashReportFile{
196 Path: path,
197 MarkdownPath: strings.TrimSuffix(path, ".json") + ".md",
198 crashReportSummary: parsed,
199 })
200 }
201
202 sort.Slice(reports, func(i, j int) bool {
203 left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt)
204 right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt)
205 if errLeft == nil && errRight == nil {
206 return left.After(right)
207 }
208 return reports[i].Path > reports[j].Path
209 })
210
211 return reports, nil
212}
213
214func resolveCrashReport(crashDir, query string) (*crashReportFile, error) {
215 reports, err := loadCrashReports(crashDir)
216 if err != nil {
217 return nil, err
218 }
219 if len(reports) == 0 {
220 return nil, fmt.Errorf("no crash reports found")
221 }
222 if strings.EqualFold(query, "latest") {
223 return &reports[0], nil
224 }
225
226 query = strings.TrimSpace(strings.ToLower(query))
227 for i := range reports {
228 id := strings.ToLower(shortCrashID(reports[i].Path))
229 if id == query {
230 return &reports[i], nil
231 }
232 }
233
234 matches := make([]int, 0, len(reports))
235 for i := range reports {
236 full := strings.ToLower(filepath.Base(reports[i].Path))
237 if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) {
238 matches = append(matches, i)
239 }
240 }
241
242 if len(matches) == 1 {
243 return &reports[matches[0]], nil
244 }
245 if len(matches) > 1 {
246 ids := make([]string, 0, len(matches))
247 for _, idx := range matches {
248 ids = append(ids, shortCrashID(reports[idx].Path))
249 }
250 return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", "))
251 }
252
253 return nil, fmt.Errorf("no crash report matching '%s' found", query)
254}
255
256func shortCrashID(path string) string {
257 base := filepath.Base(path)
258 base = strings.TrimSuffix(base, ".crash.json")
259 base = strings.TrimPrefix(base, "session_")
260 return base
261}