Skip to content

Commit 6a124b2

Browse files
authored
Merge pull request #2567 from dgageot/board/simplifying-history-package-code-ffaadbd2
Simplify the history package
2 parents 236cda4 + b272a60 commit 6a124b2

5 files changed

Lines changed: 154 additions & 191 deletions

File tree

pkg/history/history.go

Lines changed: 87 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,263 +1,196 @@
1+
// Package history persists the user's command/message history in an
2+
// append-only file and provides cursor-based navigation and search over it.
13
package history
24

35
import (
6+
"bytes"
47
"encoding/json"
58
"os"
69
"path/filepath"
710
"slices"
8-
"strconv"
911
"strings"
1012
)
1113

14+
// History is the in-memory view of a persistent message history. The cursor
15+
// (used by [History.Previous] and [History.Next]) stays in [0, len(Messages)],
16+
// where len(Messages) means "past the most recent entry".
1217
type History struct {
13-
Messages []string `json:"messages"`
18+
Messages []string
1419

1520
path string
1621
current int
1722
}
1823

19-
type options struct {
20-
homeDir string
21-
}
22-
23-
type Opt func(*options)
24-
25-
func WithBaseDir(dir string) Opt {
26-
return func(o *options) {
27-
o.homeDir = dir
28-
}
29-
}
30-
31-
func New(opts ...Opt) (*History, error) {
32-
o := &options{}
33-
for _, opt := range opts {
34-
opt(o)
35-
}
36-
37-
homeDir := o.homeDir
38-
if homeDir == "" {
24+
// New loads the history stored under baseDir/.cagent/history. If baseDir is
25+
// empty, the user's home directory is used.
26+
func New(baseDir string) (*History, error) {
27+
if baseDir == "" {
3928
var err error
40-
if homeDir, err = os.UserHomeDir(); err != nil {
29+
if baseDir, err = os.UserHomeDir(); err != nil {
4130
return nil, err
4231
}
4332
}
4433

45-
h := &History{
46-
path: filepath.Join(homeDir, ".cagent", "history"),
47-
current: -1,
48-
}
49-
50-
if err := h.migrateOldHistory(homeDir); err != nil {
34+
h := &History{path: filepath.Join(baseDir, ".cagent", "history")}
35+
if err := h.migrateOldHistory(baseDir); err != nil {
5136
return nil, err
5237
}
53-
5438
if err := h.load(); err != nil && !os.IsNotExist(err) {
5539
return nil, err
5640
}
57-
41+
h.current = len(h.Messages)
5842
return h, nil
5943
}
6044

61-
func (h *History) migrateOldHistory(homeDir string) error {
62-
oldPath := filepath.Join(homeDir, ".cagent", "history.json")
63-
64-
data, err := os.ReadFile(oldPath)
65-
if os.IsNotExist(err) {
66-
return nil
67-
}
68-
if err != nil {
69-
return err
70-
}
71-
72-
var old struct {
73-
Messages []string `json:"messages"`
74-
}
75-
if err := json.Unmarshal(data, &old); err != nil {
76-
return err
77-
}
78-
79-
for _, msg := range old.Messages {
80-
if err := h.append(msg); err != nil {
81-
return err
82-
}
83-
}
84-
85-
return os.Remove(oldPath)
86-
}
87-
45+
// Add records a new message. Any prior occurrence of the same message is
46+
// removed and the new one becomes the most recent entry.
8847
func (h *History) Add(message string) error {
89-
// Update in-memory list: remove duplicate and append to end
90-
h.Messages = slices.DeleteFunc(h.Messages, func(m string) bool {
91-
return m == message
92-
})
93-
h.Messages = append(h.Messages, message)
48+
h.addInMemory(message)
9449
h.current = len(h.Messages)
95-
9650
return h.append(message)
9751
}
9852

53+
// Previous moves the cursor one step toward older entries and returns the
54+
// entry under it. At the oldest entry, the cursor stays put.
9955
func (h *History) Previous() string {
10056
if len(h.Messages) == 0 {
10157
return ""
10258
}
103-
104-
// If we're at -1 (initial state), start from the end
105-
if h.current == -1 {
106-
h.current = len(h.Messages) - 1
107-
return h.Messages[h.current]
108-
}
109-
110-
// If we're at the beginning, stay there
111-
if h.current <= 0 {
112-
return h.Messages[0]
113-
}
114-
115-
h.current--
59+
h.current = max(h.current-1, 0)
11660
return h.Messages[h.current]
11761
}
11862

63+
// Next moves the cursor one step toward newer entries and returns the entry
64+
// under it. Past the most recent entry, returns an empty string.
11965
func (h *History) Next() string {
120-
if len(h.Messages) == 0 {
121-
return ""
122-
}
123-
12466
if h.current >= len(h.Messages)-1 {
12567
h.current = len(h.Messages)
12668
return ""
12769
}
128-
12970
h.current++
13071
return h.Messages[h.current]
13172
}
13273

133-
// LatestMatch returns the most recent history entry that extends the provided
134-
// prefix, or the latest message when no prefix is supplied.
74+
// SetCurrent positions the cursor at index i, clamped to [0, len(Messages)].
75+
// Keeping the cursor in this range guarantees that subsequent Previous and
76+
// Next calls never index out of bounds.
77+
func (h *History) SetCurrent(i int) {
78+
h.current = max(0, min(i, len(h.Messages)))
79+
}
80+
81+
// LatestMatch returns the most recent entry that strictly extends prefix, or
82+
// an empty string when none does.
13583
func (h *History) LatestMatch(prefix string) string {
136-
for i := len(h.Messages) - 1; i >= 0; i-- {
137-
msg := h.Messages[i]
84+
for _, msg := range slices.Backward(h.Messages) {
13885
if strings.HasPrefix(msg, prefix) && len(msg) > len(prefix) {
13986
return msg
14087
}
14188
}
14289
return ""
14390
}
14491

145-
// FindPrevContains searches backward through history for a message containing query.
146-
// from is an exclusive upper bound index. Pass len(Messages) to start from the most recent.
147-
// Returns the matched message, its index, and whether a match was found.
148-
// An empty query matches any entry.
92+
// FindPrevContains searches backward from index from-1 for an entry containing
93+
// query (case-insensitive). An empty query matches any entry. Pass
94+
// len(Messages) to start from the most recent entry.
14995
func (h *History) FindPrevContains(query string, from int) (msg string, idx int, ok bool) {
150-
if len(h.Messages) == 0 {
151-
return "", -1, false
152-
}
153-
154-
start := min(from-1, len(h.Messages)-1)
155-
15696
query = strings.ToLower(query)
157-
for i := start; i >= 0; i-- {
97+
for i := min(from-1, len(h.Messages)-1); i >= 0; i-- {
15898
if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) {
15999
return h.Messages[i], i, true
160100
}
161101
}
162-
163102
return "", -1, false
164103
}
165104

166-
// FindNextContains searches forward through history for a message containing query.
167-
// from is an exclusive lower bound index. Pass -1 to start from the oldest.
168-
// Returns the matched message, its index, and whether a match was found.
105+
// FindNextContains searches forward from index from+1 for an entry containing
106+
// query (case-insensitive). An empty query matches any entry. Pass -1 to
107+
// start from the oldest entry.
169108
func (h *History) FindNextContains(query string, from int) (msg string, idx int, ok bool) {
170-
if len(h.Messages) == 0 {
171-
return "", -1, false
172-
}
173-
174-
start := max(from+1, 0)
175-
176109
query = strings.ToLower(query)
177-
for i := start; i < len(h.Messages); i++ {
110+
for i := max(from+1, 0); i < len(h.Messages); i++ {
178111
if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) {
179112
return h.Messages[i], i, true
180113
}
181114
}
182-
183115
return "", -1, false
184116
}
185117

186-
func (h *History) SetCurrent(i int) {
187-
h.current = i
118+
// addInMemory removes any prior occurrence of message and appends it as the
119+
// most recent entry.
120+
func (h *History) addInMemory(message string) {
121+
h.Messages = slices.DeleteFunc(h.Messages, func(m string) bool {
122+
return m == message
123+
})
124+
h.Messages = append(h.Messages, message)
188125
}
189126

127+
// append writes message to the persistent history file as one JSON-encoded
128+
// line.
190129
func (h *History) append(message string) error {
191130
if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil {
192131
return err
193132
}
194-
195-
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
133+
encoded, err := json.Marshal(message)
196134
if err != nil {
197135
return err
198136
}
199-
defer f.Close()
200137

201-
encoded, err := json.Marshal(message)
138+
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
202139
if err != nil {
203140
return err
204141
}
142+
defer f.Close()
205143

206144
_, err = f.Write(append(encoded, '\n'))
207145
return err
208146
}
209147

148+
// load reads the persistent history file and populates Messages, deduplicating
149+
// entries while keeping the latest occurrence of each.
210150
func (h *History) load() error {
211151
data, err := os.ReadFile(h.path)
212152
if err != nil {
213153
return err
214154
}
215155

216-
// Count lines to pre-size the slice.
217-
n := 0
218-
for _, b := range data {
219-
if b == '\n' {
220-
n++
221-
}
222-
}
223-
224-
// Parse all lines. Each line is a JSON-encoded string (e.g. "hello").
225-
// strconv.Unquote handles the same escape sequences as JSON and is
226-
// much faster than json.Unmarshal for quoted strings.
227-
all := make([]string, 0, n)
228-
s := string(data)
229-
for s != "" {
230-
i := strings.IndexByte(s, '\n')
231-
var line string
232-
if i < 0 {
233-
line = s
234-
s = ""
235-
} else {
236-
line = s[:i]
237-
s = s[i+1:]
238-
}
239-
if line == "" {
156+
for line := range bytes.Lines(data) {
157+
line = bytes.TrimSuffix(line, []byte("\n"))
158+
if len(line) == 0 {
240159
continue
241160
}
242-
243-
message, err := strconv.Unquote(line)
244-
if err != nil {
161+
var msg string
162+
if err := json.Unmarshal(line, &msg); err != nil {
245163
continue
246164
}
247-
all = append(all, message)
165+
h.addInMemory(msg)
248166
}
167+
return nil
168+
}
249169

250-
// Deduplicate keeping the latest occurrence of each message.
251-
seen := make(map[string]struct{}, len(all))
252-
h.Messages = make([]string, 0, len(all))
253-
for i := len(all) - 1; i >= 0; i-- {
254-
if _, dup := seen[all[i]]; dup {
255-
continue
256-
}
257-
seen[all[i]] = struct{}{}
258-
h.Messages = append(h.Messages, all[i])
170+
// migrateOldHistory imports messages from the legacy history.json file (if it
171+
// exists) into the new line-oriented format and removes the old file.
172+
func (h *History) migrateOldHistory(baseDir string) error {
173+
oldPath := filepath.Join(baseDir, ".cagent", "history.json")
174+
175+
data, err := os.ReadFile(oldPath)
176+
if os.IsNotExist(err) {
177+
return nil
178+
}
179+
if err != nil {
180+
return err
259181
}
260-
slices.Reverse(h.Messages)
261182

262-
return nil
183+
var old struct {
184+
Messages []string `json:"messages"`
185+
}
186+
if err := json.Unmarshal(data, &old); err != nil {
187+
return err
188+
}
189+
190+
for _, msg := range old.Messages {
191+
if err := h.append(msg); err != nil {
192+
return err
193+
}
194+
}
195+
return os.Remove(oldPath)
263196
}

0 commit comments

Comments
 (0)