feat(history): distinct project/description/tag values from tock (sqlite + tock-last fallback)
d713c8fc9f628b2e2990468e5732b4a430b0fdb3
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 22:17
parent aae4bf89
2 files changed
internal/history/history.go +158 −0
@@ -0,0 +1,158 @@
+// Package history reads distinct project/description/tag values from the user's
+// tock data for autocomplete. Read-only; tock remains the source of truth.
+package history
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// History holds recency-ordered distinct values for autocomplete.
+type History struct {
+ Projects, Descriptions, Tags []string
+}
+
+type runner interface {
+ run(name string, args ...string) ([]byte, error)
+}
+
+type execRunner struct{}
+
+func (execRunner) run(name string, args ...string) ([]byte, error) {
+ return exec.Command(name, args...).Output()
+}
+
+// Load reads history using the real environment. Never errors: it degrades to
+// the tock-last fallback and finally to an empty History.
+func Load() History {
+ home, err := os.UserHomeDir()
+ cfgPath := ""
+ if err == nil {
+ cfgPath = filepath.Join(home, ".config", "tock", "tock.yaml")
+ }
+ return loadWith(execRunner{}, cfgPath)
+}
+
+const (
+ qProjects = "SELECT project FROM activities WHERE project<>'' GROUP BY project ORDER BY MAX(start_time) DESC;"
+ qDescriptions = "SELECT description FROM activities WHERE description<>'' GROUP BY description ORDER BY MAX(start_time) DESC;"
+ qTags = "SELECT je.value FROM activities, json_each(activities.tags) je WHERE activities.tags IS NOT NULL AND activities.tags NOT IN ('','[]') GROUP BY je.value ORDER BY MAX(activities.start_time) DESC;"
+)
+
+func loadWith(r runner, cfgPath string) History {
+ backend, dbPath := "", ""
+ if cfgPath != "" {
+ if b, err := os.ReadFile(cfgPath); err == nil {
+ backend, dbPath = parseTockConfig(b)
+ }
+ }
+ dbPath = expandHome(dbPath)
+ if backend == "sqlite" && dbPath != "" && fileExists(dbPath) {
+ if h, ok := loadSqlite(r, dbPath); ok {
+ return h
+ }
+ }
+ return loadTockLast(r)
+}
+
+func loadSqlite(r runner, dbPath string) (History, bool) {
+ proj, err1 := r.run("sqlite3", dbPath, qProjects)
+ desc, err2 := r.run("sqlite3", dbPath, qDescriptions)
+ tags, err3 := r.run("sqlite3", dbPath, qTags)
+ if err1 != nil || err2 != nil || err3 != nil {
+ return History{}, false
+ }
+ return History{
+ Projects: parseLines(proj),
+ Descriptions: parseLines(desc),
+ Tags: parseLines(tags),
+ }, true
+}
+
+type lastEntry struct {
+ Project string `json:"project"`
+ Description string `json:"description"`
+ Tags []string `json:"tags"`
+}
+
+func loadTockLast(r runner) History {
+ out, err := r.run("tock", "last", "-n", "500", "--json")
+ if err != nil {
+ return History{}
+ }
+ var raw []lastEntry
+ if json.Unmarshal(out, &raw) != nil {
+ return History{}
+ }
+ return dedupeEntries(raw)
+}
+
+// parseTockConfig scans for `backend:` and the `path:` under the `sqlite:` block.
+func parseTockConfig(b []byte) (string, string) {
+ var backend, sqlitePath string
+ inSqlite := false
+ for _, line := range strings.Split(string(b), "\n") {
+ trimmed := strings.TrimSpace(line)
+ indented := line != trimmed && strings.HasPrefix(line, " ")
+ if !indented {
+ inSqlite = strings.HasPrefix(trimmed, "sqlite:")
+ if strings.HasPrefix(trimmed, "backend:") {
+ backend = strings.TrimSpace(strings.TrimPrefix(trimmed, "backend:"))
+ }
+ continue
+ }
+ if inSqlite && strings.HasPrefix(trimmed, "path:") {
+ sqlitePath = strings.TrimSpace(strings.TrimPrefix(trimmed, "path:"))
+ }
+ }
+ return backend, sqlitePath
+}
+
+func parseLines(b []byte) []string {
+ var out []string
+ for _, line := range strings.Split(string(b), "\n") {
+ if s := strings.TrimSpace(line); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func dedupeEntries(raw []lastEntry) History {
+ var h History
+ sp, sd, st := map[string]bool{}, map[string]bool{}, map[string]bool{}
+ for _, e := range raw {
+ if e.Project != "" && !sp[e.Project] {
+ sp[e.Project] = true
+ h.Projects = append(h.Projects, e.Project)
+ }
+ if e.Description != "" && !sd[e.Description] {
+ sd[e.Description] = true
+ h.Descriptions = append(h.Descriptions, e.Description)
+ }
+ for _, tg := range e.Tags {
+ if tg != "" && !st[tg] {
+ st[tg] = true
+ h.Tags = append(h.Tags, tg)
+ }
+ }
+ }
+ return h
+}
+
+func expandHome(p string) string {
+ if strings.HasPrefix(p, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, p[2:])
+ }
+ }
+ return p
+}
+
+func fileExists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
internal/history/history_test.go +124 −0
@@ -0,0 +1,124 @@
+package history
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+)
+
+var errBoom = errors.New("boom")
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestParseTockConfig(t *testing.T) {
+ cfg := []byte("backend: sqlite\nsqlite:\n path: /Users/x/.tock.db\nlanguage: eng\n")
+ b, p := parseTockConfig(cfg)
+ if b != "sqlite" || p != "/Users/x/.tock.db" {
+ t.Fatalf("got backend=%q path=%q", b, p)
+ }
+ b2, _ := parseTockConfig([]byte("backend: file\n"))
+ if b2 != "file" {
+ t.Errorf("backend=%q want file", b2)
+ }
+}
+
+func TestParseLines(t *testing.T) {
+ got := parseLines([]byte("ARCHER\n Development \n\nDoctor\n"))
+ want := []string{"ARCHER", "Development", "Doctor"}
+ if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
+ t.Fatalf("got %v want %v", got, want)
+ }
+}
+
+func TestDedupeEntries(t *testing.T) {
+ raw := []lastEntry{
+ {Project: "ARCHER", Description: "a", Tags: []string{"x", "y"}},
+ {Project: "ARCHER", Description: "b", Tags: []string{"x"}},
+ {Project: "", Description: "", Tags: nil},
+ }
+ h := dedupeEntries(raw)
+ if len(h.Projects) != 1 || h.Projects[0] != "ARCHER" {
+ t.Errorf("projects=%v", h.Projects)
+ }
+ if len(h.Descriptions) != 2 {
+ t.Errorf("descriptions=%v", h.Descriptions)
+ }
+ if len(h.Tags) != 2 || h.Tags[0] != "x" || h.Tags[1] != "y" {
+ t.Errorf("tags=%v", h.Tags)
+ }
+}
+
+// fakeRunner routes by the command name (sqlite3 vs tock) and a query substring.
+type fakeRunner struct {
+ sqlite map[string][]byte
+ last []byte
+ sqlErr error
+ tockErr error
+}
+
+func (f fakeRunner) run(name string, args ...string) ([]byte, error) {
+ if name == "sqlite3" {
+ if f.sqlErr != nil {
+ return nil, f.sqlErr
+ }
+ q := args[len(args)-1]
+ for sub, out := range f.sqlite {
+ if strings.Contains(q, sub) {
+ return out, nil
+ }
+ }
+ return nil, nil
+ }
+ // tock last --json
+ if f.tockErr != nil {
+ return nil, f.tockErr
+ }
+ return f.last, nil
+}
+
+func TestLoadWithSqlite(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := dir + "/tock.yaml"
+ writeFile(t, cfgPath, "backend: sqlite\nsqlite:\n path: "+dir+"/x.db\n")
+ writeFile(t, dir+"/x.db", "") // must exist for the DB-present check
+ r := fakeRunner{sqlite: map[string][]byte{
+ "FROM activities WHERE project": []byte("ARCHER\nDoctor\n"),
+ "FROM activities WHERE description": []byte("standup\nemails\n"),
+ "json_each": []byte("admin\nmeeting\n"),
+ }}
+ h := loadWith(r, cfgPath)
+ if len(h.Projects) != 2 || h.Projects[0] != "ARCHER" {
+ t.Errorf("projects=%v", h.Projects)
+ }
+ if len(h.Descriptions) != 2 || len(h.Tags) != 2 {
+ t.Errorf("desc=%v tags=%v", h.Descriptions, h.Tags)
+ }
+}
+
+func TestLoadWithFallbackToTockLast(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := dir + "/tock.yaml"
+ writeFile(t, cfgPath, "backend: file\n")
+ r := fakeRunner{last: []byte(`[{"project":"ARCHER","description":"a","tags":["x"]},{"project":"ARCHER","description":"b"}]`)}
+ h := loadWith(r, cfgPath)
+ if len(h.Projects) != 1 || len(h.Descriptions) != 2 || len(h.Tags) != 1 {
+ t.Errorf("fallback wrong: %+v", h)
+ }
+}
+
+func TestLoadWithTotalFailureIsEmpty(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := dir + "/tock.yaml"
+ writeFile(t, cfgPath, "backend: file\n")
+ r := fakeRunner{tockErr: errBoom}
+ h := loadWith(r, cfgPath)
+ if len(h.Projects) != 0 || len(h.Descriptions) != 0 || len(h.Tags) != 0 {
+ t.Errorf("expected empty, got %+v", h)
+ }
+}