package command import ( "encoding/json" "fmt" "os" "path/filepath" "task/internal/ticket" ) const ( historyFileName = "history.json" // MaxHistory is the maximum number of undo (and redo) entries kept. MaxHistory = 20 ) type persistedFile struct { Undo []persistedCmd `json:"undo"` Redo []persistedCmd `json:"redo"` } type persistedCmd struct { Type string `json:"type"` Ticket *ticket.Ticket `json:"ticket,omitempty"` Snapshot *ticket.Ticket `json:"snapshot,omitempty"` Before *ticket.Ticket `json:"before,omitempty"` After *ticket.Ticket `json:"after,omitempty"` TimeEntry *ticket.TimeEntry `json:"timeEntry,omitempty"` TimeBefore *ticket.TimeEntry `json:"timeBefore,omitempty"` TimeAfter *ticket.TimeEntry `json:"timeAfter,omitempty"` } const ( typeCreate = "create" typeDelete = "delete" typeUpdate = "update" typeAddTime = "add_time" typeRemoveTime = "remove_time" typeUpdateTime = "update_time" ) // HistoryPath returns DataDir/history.json. func HistoryPath(dataDir string) string { return filepath.Join(dataDir, historyFileName) } func encodeCmd(cmd Command) (persistedCmd, error) { switch c := cmd.(type) { case *CreateTicket: t := c.Ticket if c.created.ID != "" { t = c.created } cp := t return persistedCmd{Type: typeCreate, Ticket: &cp}, nil case *DeleteTicket: cp := c.Snapshot return persistedCmd{Type: typeDelete, Snapshot: &cp}, nil case *UpdateTicket: b, a := c.Before, c.After return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil case *AddTimeEntry: e := c.Entry if c.created.ID != "" { e = c.created } cp := e return persistedCmd{Type: typeAddTime, TimeEntry: &cp}, nil case *RemoveTimeEntry: cp := c.Snapshot return persistedCmd{Type: typeRemoveTime, TimeEntry: &cp}, nil case *UpdateTimeEntry: b, a := c.Before, c.After return persistedCmd{Type: typeUpdateTime, TimeBefore: &b, TimeAfter: &a}, nil default: return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd) } } func decodeCmd(rec persistedCmd, store ticket.Store, timeStore ticket.TimeStore) (Command, error) { switch rec.Type { case typeCreate: if rec.Ticket == nil { return nil, fmt.Errorf("create record missing ticket") } return &CreateTicket{Store: store, Ticket: *rec.Ticket, created: *rec.Ticket}, nil case typeDelete: if rec.Snapshot == nil { return nil, fmt.Errorf("delete record missing snapshot") } return &DeleteTicket{Store: store, Snapshot: *rec.Snapshot}, nil case typeUpdate: if rec.Before == nil || rec.After == nil { return nil, fmt.Errorf("update record missing before/after") } return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil case typeAddTime: if rec.TimeEntry == nil { return nil, fmt.Errorf("add_time record missing entry") } if timeStore == nil { return nil, fmt.Errorf("add_time: time store not configured") } return &AddTimeEntry{Store: timeStore, Entry: *rec.TimeEntry, created: *rec.TimeEntry}, nil case typeRemoveTime: if rec.TimeEntry == nil { return nil, fmt.Errorf("remove_time record missing entry") } if timeStore == nil { return nil, fmt.Errorf("remove_time: time store not configured") } return &RemoveTimeEntry{Store: timeStore, Snapshot: *rec.TimeEntry}, nil case typeUpdateTime: if rec.TimeBefore == nil || rec.TimeAfter == nil { return nil, fmt.Errorf("update_time record missing before/after") } if timeStore == nil { return nil, fmt.Errorf("update_time: time store not configured") } return &UpdateTimeEntry{Store: timeStore, Before: *rec.TimeBefore, After: *rec.TimeAfter}, nil default: return nil, fmt.Errorf("unknown command type %q", rec.Type) } } func encodeStack(cmds []Command) ([]persistedCmd, error) { out := make([]persistedCmd, 0, len(cmds)) for _, cmd := range cmds { rec, err := encodeCmd(cmd) if err != nil { return nil, err } out = append(out, rec) } return out, nil } func decodeStack(recs []persistedCmd, store ticket.Store, timeStore ticket.TimeStore) ([]Command, error) { out := make([]Command, 0, len(recs)) for _, rec := range recs { cmd, err := decodeCmd(rec, store, timeStore) if err != nil { return nil, err } out = append(out, cmd) } return out, nil } func trimStack(cmds []Command, max int) []Command { if max <= 0 || len(cmds) <= max { return cmds } return cmds[len(cmds)-max:] } func (h *History) persistLocked() error { if h.path == "" || h.store == nil { return nil } undo, err := encodeStack(h.undoS) if err != nil { return err } redo, err := encodeStack(h.redoS) if err != nil { return err } data, err := json.MarshalIndent(persistedFile{Undo: undo, Redo: redo}, "", " ") if err != nil { return fmt.Errorf("marshal history: %w", err) } data = append(data, '\n') if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil { return fmt.Errorf("create history dir: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(h.path), "history-*.tmp") if err != nil { return fmt.Errorf("create temp history: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("write temp history: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp history: %w", err) } if err := os.Rename(tmpName, h.path); err != nil { return fmt.Errorf("rename history: %w", err) } return nil } func loadHistoryFile(path string, store ticket.Store, timeStore ticket.TimeStore) (undo, redo []Command, err error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil, nil } return nil, nil, fmt.Errorf("read history: %w", err) } if len(data) == 0 { return nil, nil, nil } var file persistedFile if err := json.Unmarshal(data, &file); err != nil { return nil, nil, fmt.Errorf("parse history: %w", err) } undo, err = decodeStack(file.Undo, store, timeStore) if err != nil { return nil, nil, err } redo, err = decodeStack(file.Redo, store, timeStore) if err != nil { return nil, nil, err } return trimStack(undo, MaxHistory), trimStack(redo, MaxHistory), nil }