diff --git a/internal/repl/history.go b/internal/repl/history.go index 35f815c..c66ec80 100644 --- a/internal/repl/history.go +++ b/internal/repl/history.go @@ -19,6 +19,7 @@ type history struct { path string loadCount int logger *slog.Logger + disabled bool } func newHistory(historyPath string, logger *slog.Logger) (*history, []prompt.HistoryCommand) { @@ -37,7 +38,7 @@ func (h *history) loadHistory() []prompt.HistoryCommand { file, err := os.Open(h.path) if err != nil { if !os.IsNotExist(err) { - h.logger.Warn("could not open history file", "path", h.path, "error", err) + h.disableHistory("failed load histrory form path, err, hisotry is disabled", err) } return []prompt.HistoryCommand{} } @@ -49,7 +50,7 @@ func (h *history) loadHistory() []prompt.HistoryCommand { entries, err := loadHistory(file, maxHistoryLines, h.logger) if err != nil { - h.logger.Error("failed to load history", "error", err) + h.disableHistory("failed load histrory form path, err, hisotry is disabled", err) return []prompt.HistoryCommand{} } return entries @@ -82,18 +83,25 @@ func loadHistory(r io.Reader, maxHistoryLines int, logger *slog.Logger) ([]promp } func (h *history) saveHistory(entries []prompt.HistoryCommand) { + if h.disabled { + return + } + if len(entries) <= h.loadCount { return } newCommands := entries[h.loadCount:] - if len(newCommands) == 0 { + + historyDir := filepath.Dir(h.path) + if err := os.MkdirAll(historyDir, 0o700); err != nil { + h.disableHistory("failed save history to path, err, history is disabled", err) return } f, err := os.OpenFile(h.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { - h.logger.Error("failed to open history file for writing", "error", err) + h.disableHistory("failed save history to path, err, history is disabled", err) return } defer func() { @@ -114,12 +122,17 @@ func (h *history) saveHistory(entries []prompt.HistoryCommand) { } if err := w.Flush(); err != nil { - h.logger.Error("failed to flush history file", "error", err) + h.disableHistory("failed save history to path, err, history is disabled", err) return } h.logger.Debug("history saved", "new_entries", len(newCommands)) } +func (h *history) disableHistory(message string, err error) { + h.logger.Error(message, "path", h.path, "err", err) + h.disabled = true +} + func getHistoryFilePath() string { homeDir, err := os.UserHomeDir() if err != nil { diff --git a/internal/repl/history_test.go b/internal/repl/history_test.go index 3d8914b..770efe2 100644 --- a/internal/repl/history_test.go +++ b/internal/repl/history_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "os" + "path/filepath" "strings" "testing" @@ -96,6 +97,42 @@ func TestHistorySaveHistory_EntriesShorterThanLoadCount(t *testing.T) { assert.Equal(t, "", string(data)) } +func TestHistorySaveHistory_CreatesMissingParentDirectoryAndFile(t *testing.T) { + baseDir, err := os.MkdirTemp("", "history_parent_test") + assert.NoError(t, err) + defer func() { + err := os.RemoveAll(baseDir) + assert.NoError(t, err) + }() + + historyPath := filepath.Join(baseDir, "non_exist_folder", "user_provided_name.jsonl") + h := history{path: historyPath, loadCount: 0, logger: testLogger()} + entries := []prompt.HistoryCommand{{Command: "select 1"}} + + h.saveHistory(entries) + + _, err = os.Stat(filepath.Dir(historyPath)) + assert.NoError(t, err) + + data, err := os.ReadFile(historyPath) + assert.NoError(t, err) + assert.NotEmpty(t, strings.TrimSpace(string(data))) +} + +func TestHistorySaveHistory_DisablesHistoryOnInvalidPath(t *testing.T) { + h := history{path: "\x00", loadCount: 0, logger: testLogger()} + entries := []prompt.HistoryCommand{{Command: "select 1"}} + + h.saveHistory(entries) + + assert.True(t, h.disabled) + + // Second call should no-op because history is disabled. + assert.NotPanics(t, func() { + h.saveHistory(entries) + }) +} + func TestLoadHistory(t *testing.T) { r := strings.NewReader(strings.Join([]string{ `{"command":"query1","timestamp":"2026-04-04T10:00:00Z"}`,