Skip to content

Commit 9001993

Browse files
committed
drop options pattern and -1 cursor sentinel from history
1 parent 902ed8c commit 9001993

5 files changed

Lines changed: 106 additions & 124 deletions

File tree

pkg/history/history.go

Lines changed: 69 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
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 (
@@ -9,105 +11,58 @@ import (
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 {
8948
h.addInMemory(message)
9049
h.current = len(h.Messages)
9150
return h.append(message)
9251
}
9352

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.
9455
func (h *History) Previous() string {
9556
if len(h.Messages) == 0 {
9657
return ""
9758
}
98-
switch {
99-
case h.current == -1:
100-
h.current = len(h.Messages) - 1
101-
case h.current > 0:
102-
h.current--
103-
}
59+
h.current = max(h.current-1, 0)
10460
return h.Messages[h.current]
10561
}
10662

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.
10765
func (h *History) Next() string {
108-
if len(h.Messages) == 0 {
109-
return ""
110-
}
11166
if h.current >= len(h.Messages)-1 {
11267
h.current = len(h.Messages)
11368
return ""
@@ -116,8 +71,13 @@ func (h *History) Next() string {
11671
return h.Messages[h.current]
11772
}
11873

119-
// LatestMatch returns the most recent history entry that extends the provided
120-
// prefix, or an empty string when none does.
74+
// SetCurrent positions the cursor at index i.
75+
func (h *History) SetCurrent(i int) {
76+
h.current = i
77+
}
78+
79+
// LatestMatch returns the most recent entry that strictly extends prefix, or
80+
// an empty string when none does.
12181
func (h *History) LatestMatch(prefix string) string {
12282
for _, msg := range slices.Backward(h.Messages) {
12383
if strings.HasPrefix(msg, prefix) && len(msg) > len(prefix) {
@@ -127,10 +87,9 @@ func (h *History) LatestMatch(prefix string) string {
12787
return ""
12888
}
12989

130-
// FindPrevContains searches backward through history for a message containing query.
131-
// from is an exclusive upper bound index. Pass len(Messages) to start from the most recent.
132-
// Returns the matched message, its index, and whether a match was found.
133-
// An empty query matches any entry.
90+
// FindPrevContains searches backward from index from-1 for an entry containing
91+
// query (case-insensitive). An empty query matches any entry. Pass
92+
// len(Messages) to start from the most recent entry.
13493
func (h *History) FindPrevContains(query string, from int) (msg string, idx int, ok bool) {
13594
query = strings.ToLower(query)
13695
for i := min(from-1, len(h.Messages)-1); i >= 0; i-- {
@@ -141,10 +100,9 @@ func (h *History) FindPrevContains(query string, from int) (msg string, idx int,
141100
return "", -1, false
142101
}
143102

144-
// FindNextContains searches forward through history for a message containing query.
145-
// from is an exclusive lower bound index. Pass -1 to start from the oldest.
146-
// Returns the matched message, its index, and whether a match was found.
147-
// An empty query matches any entry.
103+
// FindNextContains searches forward from index from+1 for an entry containing
104+
// query (case-insensitive). An empty query matches any entry. Pass -1 to
105+
// start from the oldest entry.
148106
func (h *History) FindNextContains(query string, from int) (msg string, idx int, ok bool) {
149107
query = strings.ToLower(query)
150108
for i := max(from+1, 0); i < len(h.Messages); i++ {
@@ -155,10 +113,6 @@ func (h *History) FindNextContains(query string, from int) (msg string, idx int,
155113
return "", -1, false
156114
}
157115

158-
func (h *History) SetCurrent(i int) {
159-
h.current = i
160-
}
161-
162116
// addInMemory removes any prior occurrence of message and appends it as the
163117
// most recent entry.
164118
func (h *History) addInMemory(message string) {
@@ -168,35 +122,35 @@ func (h *History) addInMemory(message string) {
168122
h.Messages = append(h.Messages, message)
169123
}
170124

125+
// append writes message to the persistent history file as one JSON-encoded
126+
// line.
171127
func (h *History) append(message string) error {
172128
if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil {
173129
return err
174130
}
175-
176-
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
131+
encoded, err := json.Marshal(message)
177132
if err != nil {
178133
return err
179134
}
180-
defer f.Close()
181135

182-
encoded, err := json.Marshal(message)
136+
f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
183137
if err != nil {
184138
return err
185139
}
140+
defer f.Close()
186141

187142
_, err = f.Write(append(encoded, '\n'))
188143
return err
189144
}
190145

146+
// load reads the persistent history file and populates Messages, deduplicating
147+
// entries while keeping the latest occurrence of each.
191148
func (h *History) load() error {
192149
data, err := os.ReadFile(h.path)
193150
if err != nil {
194151
return err
195152
}
196153

197-
// The file is append-only with one JSON-encoded string per line.
198-
// Replaying each entry through addInMemory naturally deduplicates,
199-
// keeping the latest occurrence of each message.
200154
for line := range bytes.Lines(data) {
201155
line = bytes.TrimRight(line, "\n")
202156
if len(line) == 0 {
@@ -210,3 +164,31 @@ func (h *History) load() error {
210164
}
211165
return nil
212166
}
167+
168+
// migrateOldHistory imports messages from the legacy history.json file (if it
169+
// exists) into the new line-oriented format and removes the old file.
170+
func (h *History) migrateOldHistory(baseDir string) error {
171+
oldPath := filepath.Join(baseDir, ".cagent", "history.json")
172+
173+
data, err := os.ReadFile(oldPath)
174+
if os.IsNotExist(err) {
175+
return nil
176+
}
177+
if err != nil {
178+
return err
179+
}
180+
181+
var old struct {
182+
Messages []string `json:"messages"`
183+
}
184+
if err := json.Unmarshal(data, &old); err != nil {
185+
return err
186+
}
187+
188+
for _, msg := range old.Messages {
189+
if err := h.append(msg); err != nil {
190+
return err
191+
}
192+
}
193+
return os.Remove(oldPath)
194+
}

0 commit comments

Comments
 (0)