▍ humdrum codex / ticktock v0.0.2

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)
+	}
+}