• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / fya / 26439453922

26 May 2026 07:47AM UTC coverage: 84.766% (-0.2%) from 84.917%
26439453922

push

github

umputun
refactor: simplify internal wrapper plumbing

- remove redundant prompt/session adapters
- return transcript paths directly
- drop unused PTY helpers

39 of 47 new or added lines in 7 files covered. (82.98%)

2 existing lines in 1 file now uncovered.

1213 of 1431 relevant lines covered (84.77%)

12.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.12
/app/transcript/path.go
1
// Package transcript discovers and tails Claude Code JSONL transcript files.
2
package transcript
3

4
import (
5
        "bufio"
6
        "bytes"
7
        "encoding/json"
8
        "errors"
9
        "fmt"
10
        "io"
11
        "io/fs"
12
        "os"
13
        "path/filepath"
14
        "sort"
15
        "strings"
16
        "time"
17
        "unicode"
18
)
19

20
// ErrNoTranscript is returned by Catalog.Select when no candidate transcript
21
// matches the requested since/prompt filter. Callers may retry until a fresh
22
// transcript appears (Claude can take a moment to flush the new file).
23
var ErrNoTranscript = errors.New("no matching transcript found")
24

25
// Catalog enumerates Claude Code transcript files for a given project working
26
// dir. Select is the only cross-package entry point.
27
type Catalog struct {
28
        root string
29
}
30

31
// candidate is one transcript JSONL file with its modification metadata.
32
type candidate struct {
33
        path    string
34
        modTime time.Time
35
}
36

37
// NewCatalog returns a Catalog rooted at the given Claude config dir. An empty
38
// root falls back to ~/.claude (or the OS-specific user home).
39
func NewCatalog(root string) *Catalog {
5✔
40
        if root == "" {
5✔
41
                home, _ := os.UserHomeDir()
×
42
                root = filepath.Join(home, ".claude")
×
43
        }
×
44
        return &Catalog{root: root}
5✔
45
}
46

47
// projectDir returns the encoded project directory under root/projects for the
48
// supplied working directory. The cwd is resolved to an absolute path first.
49
func (c *Catalog) projectDir(cwd string) (string, error) {
9✔
50
        abs, err := filepath.Abs(cwd)
9✔
51
        if err != nil {
9✔
52
                return "", fmt.Errorf("resolve cwd: %w", err)
×
53
        }
×
54
        return filepath.Join(c.root, "projects", encodeProjectPath(abs)), nil
9✔
55
}
56

57
// candidates lists all .jsonl transcripts in the project directory for cwd,
58
// sorted by modification time newest-first. A missing project directory (Claude
59
// has not yet created it for this cwd) is returned as an empty list with no
60
// error so selectors can retry until the first transcript appears.
61
func (c *Catalog) candidates(cwd string) ([]candidate, error) {
5✔
62
        dir, err := c.projectDir(cwd)
5✔
63
        if err != nil {
5✔
64
                return nil, err
×
65
        }
×
66
        entries, err := os.ReadDir(dir)
5✔
67
        if err != nil {
6✔
68
                if errors.Is(err, fs.ErrNotExist) {
2✔
69
                        return nil, nil
1✔
70
                }
1✔
71
                return nil, fmt.Errorf("read transcript dir: %w", err)
×
72
        }
73
        candidates := make([]candidate, 0, len(entries))
4✔
74
        for _, entry := range entries {
9✔
75
                if entry.IsDir() || filepath.Ext(entry.Name()) != ".jsonl" {
5✔
76
                        continue
×
77
                }
78
                info, err := entry.Info()
5✔
79
                if err != nil {
5✔
80
                        return nil, fmt.Errorf("stat transcript: %w", err)
×
81
                }
×
82
                candidates = append(candidates, candidate{
5✔
83
                        path:    filepath.Join(dir, entry.Name()),
5✔
84
                        modTime: info.ModTime(),
5✔
85
                })
5✔
86
        }
87
        sort.Slice(candidates, func(i, j int) bool {
5✔
88
                return candidates[i].modTime.After(candidates[j].modTime)
1✔
89
        })
1✔
90
        return candidates, nil
4✔
91
}
92

93
// Select returns the most recent transcript in cwd that was modified at or after
94
// `since` and (when prompt is non-empty) contains the prompt text. Returns
95
// ErrNoTranscript when no candidate satisfies the filter; callers can retry as
96
// Claude may not have flushed the new transcript yet.
97
//
98
// Prompt matching checks two forms because the transcript stores the prompt as
99
// a JSON-encoded string: a raw search ("hello world") and a JSON-string-encoded
100
// search ("hello \"world\"") so prompts with quotes, backslashes, or newlines
101
// still match.
102
func (c *Catalog) Select(cwd string, since time.Time, prompt string) (string, error) {
5✔
103
        candidates, err := c.candidates(cwd)
5✔
104
        if err != nil {
5✔
NEW
105
                return "", err
×
106
        }
×
107
        forms := c.promptForms(prompt)
5✔
108
        for _, candidate := range candidates {
9✔
109
                if candidate.modTime.Before(since) {
5✔
110
                        continue
1✔
111
                }
112
                if prompt == "" {
3✔
NEW
113
                        return candidate.path, nil
×
114
                }
×
115
                matches, err := c.fileContainsAny(candidate.path, forms)
3✔
116
                if err != nil {
3✔
NEW
117
                        return "", fmt.Errorf("scan transcript %s: %w", candidate.path, err)
×
118
                }
×
119
                if matches {
5✔
120
                        return candidate.path, nil
2✔
121
                }
2✔
122
        }
123
        return "", ErrNoTranscript
3✔
124
}
125

126
// promptForms returns the raw prompt plus its JSON-string-encoded form (minus
127
// the surrounding quotes) so transcripts written with JSON escaping still match.
128
// Duplicates (when the prompt has no characters needing escaping) are removed.
129
func (*Catalog) promptForms(prompt string) []string {
5✔
130
        if prompt == "" {
5✔
131
                return nil
×
132
        }
×
133
        forms := []string{prompt}
5✔
134
        encoded, err := json.Marshal(prompt)
5✔
135
        if err == nil && len(encoded) >= 2 {
10✔
136
                body := string(encoded[1 : len(encoded)-1]) // strip surrounding quotes
5✔
137
                if body != prompt {
6✔
138
                        forms = append(forms, body)
1✔
139
                }
1✔
140
        }
141
        return forms
5✔
142
}
143

144
// encodeProjectPath returns the Claude Code project directory encoding of path:
145
// every rune that is not a letter or digit becomes '-'. Matches Claude Code's
146
// internal encoding so cwd "/Users/x/repo" → "-Users-x-repo".
147
func encodeProjectPath(path string) string {
10✔
148
        var b strings.Builder
10✔
149
        for _, r := range path {
535✔
150
                if unicode.IsLetter(r) || unicode.IsDigit(r) {
1,019✔
151
                        b.WriteRune(r)
494✔
152
                        continue
494✔
153
                }
154
                b.WriteByte('-')
31✔
155
        }
156
        return b.String()
10✔
157
}
158

159
// fileContainsAny streams path looking for any of the needles and returns true
160
// on the first match. The overlap window between chunk reads is sized to the
161
// longest needle so a match split across a read boundary is not missed. Read
162
// errors are surfaced rather than silently treated as "no match" — permission
163
// errors or partial reads otherwise hide real problems.
164
func (*Catalog) fileContainsAny(path string, needles []string) (bool, error) {
3✔
165
        if len(needles) == 0 {
3✔
166
                return false, nil
×
167
        }
×
168
        f, err := os.Open(path) //nolint:gosec // path is built by Catalog from a controlled ProjectDir.
3✔
169
        if err != nil {
3✔
170
                return false, fmt.Errorf("open transcript: %w", err)
×
171
        }
×
172
        defer f.Close()
3✔
173
        needleBytes := make([][]byte, len(needles))
3✔
174
        maxLen := 0
3✔
175
        for i, n := range needles {
7✔
176
                needleBytes[i] = []byte(n)
4✔
177
                if len(needleBytes[i]) > maxLen {
8✔
178
                        maxLen = len(needleBytes[i])
4✔
179
                }
4✔
180
        }
181
        overlap := maxLen
3✔
182
        reader := bufio.NewReaderSize(f, 64*1024)
3✔
183
        tail := make([]byte, 0, overlap)
3✔
184
        buf := make([]byte, 32*1024)
3✔
185
        for {
7✔
186
                n, readErr := reader.Read(buf)
4✔
187
                if n > 0 {
7✔
188
                        scan := append(tail, buf[:n]...) //nolint:gocritic // overlap window across chunks.
3✔
189
                        for _, needle := range needleBytes {
7✔
190
                                if bytes.Contains(scan, needle) {
6✔
191
                                        return true, nil
2✔
192
                                }
2✔
193
                        }
194
                        if len(scan) > overlap {
2✔
195
                                tail = append(tail[:0], scan[len(scan)-overlap:]...)
1✔
196
                        } else {
1✔
UNCOV
197
                                tail = append(tail[:0], scan...)
×
UNCOV
198
                        }
×
199
                }
200
                if errors.Is(readErr, io.EOF) {
3✔
201
                        return false, nil
1✔
202
                }
1✔
203
                if readErr != nil {
1✔
204
                        return false, fmt.Errorf("read transcript: %w", readErr)
×
205
                }
×
206
        }
207
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc