From dc20062434a551604034348924c390ed29f50924 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 11:09:25 +0200 Subject: [PATCH] docs: add implementation plan with 12 tasks --- .../plans/2026-04-03-reddit-reader.md | 4006 +++++++++++++++++ 1 file changed, 4006 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-03-reddit-reader.md diff --git a/docs/superpowers/plans/2026-04-03-reddit-reader.md b/docs/superpowers/plans/2026-04-03-reddit-reader.md new file mode 100644 index 0000000..3601d3f --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-reddit-reader.md @@ -0,0 +1,4006 @@ +# Reddit Reader Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Go TUI application that monitors subreddits, filters for interesting posts, generates LLM summaries, and presents them in an interactive reading list. + +**Architecture:** Single binary with three subcommands (`serve`, `tui`, `setup`). Monitor daemon polls Reddit, filters via keyword + LLM relevance scoring, stores in SQLite, streams to TUI via gRPC. Local LLMs (Ollama/llama.cpp) preferred, Mistral API as fallback. + +**Tech Stack:** Go 1.26.1, Bubble Tea, Lip Gloss, gRPC, SQLite (modernc.org/sqlite), go-reddit/v2, mistral-go-sdk, Cobra, go-toml/v2 + +**Spec:** `docs/superpowers/specs/2026-04-03-reddit-reader-design.md` + +--- + +## File Structure + +``` +reddit-reader/ + main.go — entry point, executes root cobra command + cmd/ + root.go — cobra root command + serve.go — serve subcommand: monitor + gRPC server + tui.go — tui subcommand: launches TUI client + setup.go — setup subcommand: first-run wizard + internal/ + domain/ + domain.go — shared domain types (Post, Subreddit, Filter, Feedback) + config/ + config.go — TOML parsing + env var overlay + config_test.go + store/ + store.go — SQLite store: schema, migrations, CRUD + store_test.go + llm/ + llm.go — Summarizer interface + openai.go — OpenAI-compatible backend (Ollama, llama.cpp) + openai_test.go + mistral.go — Mistral SDK backend + mistral_test.go + filter/ + keyword.go — keyword/regex pre-filter + keyword_test.go + scorer.go — LLM relevance scorer + scorer_test.go + reddit/ + reddit.go — Reddit client interface + go-reddit wrapper + reddit_test.go + monitor/ + monitor.go — polling loop, orchestrates pipeline + monitor_test.go + grpc/ + server/ + server.go — gRPC service implementation + server_test.go + client/ + client.go — gRPC client used by TUI + tui/ + model.go — Bubble Tea model, Init, Update, View + views.go — view rendering (list, detail, settings) + keys.go — key bindings + setup/ + setup.go — first-run wizard logic + proto/ + redditreader.proto — protobuf service definition + systemd/ + reddit-reader.service — systemd user service unit + reddit-reader.socket — systemd socket activation unit +``` + +--- + +### Task 1: Project Scaffold + +**Files:** +- Create: `main.go` +- Create: `cmd/root.go` +- Create: `internal/domain/domain.go` +- Create: `go.mod` + +- [ ] **Step 1: Initialize Go module** + +```bash +cd /home/cnachtigall/ssd/git/active/reddit-reader +go mod init somegit.dev/vikingowl/reddit-reader +``` + +- [ ] **Step 2: Create main.go** + +```go +// main.go +package main + +import "somegit.dev/vikingowl/reddit-reader/cmd" + +func main() { + cmd.Execute() +} +``` + +- [ ] **Step 3: Create cmd/root.go** + +```go +// cmd/root.go +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "reddit-reader", + Short: "Monitor subreddits for interesting posts", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +- [ ] **Step 4: Create internal/domain/domain.go** + +```go +// internal/domain/domain.go +package domain + +import "time" + +// Post represents a Reddit post stored in the reading list. +type Post struct { + ID string + Subreddit string + Title string + Author string + URL string + SelfText string + Score int + CreatedUTC time.Time + FetchedAt time.Time + Relevance *float64 + Summary *string + Read bool + Starred bool + Dismissed bool +} + +// Subreddit represents a monitored subreddit. +type Subreddit struct { + Name string + Enabled bool + PollSort string + AddedAt time.Time +} + +// Filter represents a keyword/regex filter for a subreddit. +type Filter struct { + ID int64 + Subreddit string + Pattern string + IsRegex bool +} + +// Feedback represents a user's relevance vote on a post. +type Feedback struct { + ID int64 + PostID string + Vote int // +1 interesting, -1 not + CreatedAt time.Time +} + +// Interests holds the user's interest profile for relevance scoring. +type Interests struct { + Description string + Examples []Feedback +} +``` + +- [ ] **Step 5: Add cobra dependency and verify build** + +```bash +go get github.com/spf13/cobra@latest +go build ./... +``` + +Expected: clean build, no errors. + +- [ ] **Step 6: Commit** + +```bash +git add main.go cmd/ internal/domain/ go.mod go.sum +git commit -m "feat: project scaffold with cobra root and domain types" +``` + +--- + +### Task 2: Config Package + +**Files:** +- Create: `internal/config/config.go` +- Create: `internal/config/config_test.go` + +- [ ] **Step 1: Write failing tests for config parsing** + +```go +// internal/config/config_test.go +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "somegit.dev/vikingowl/reddit-reader/internal/config" +) + +func TestLoadFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + err := os.WriteFile(path, []byte(` +[reddit] +client_id = "test_id" +client_secret = "test_secret" +username = "test_user" +password = "test_pass" + +[llm] +backend = "ollama" +endpoint = "localhost:11434" +model = "mistral-small" +relevance_threshold = 0.7 + +[interests] +description = "Go programming, Linux" + +[monitor] +poll_interval = "5m" +max_posts_per_poll = 10 + +[grpc] +socket = "/tmp/test.sock" +`), 0o644) + if err != nil { + t.Fatal(err) + } + + cfg, err := config.LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + + if cfg.Reddit.ClientID != "test_id" { + t.Errorf("ClientID = %q, want %q", cfg.Reddit.ClientID, "test_id") + } + if cfg.LLM.Backend != "ollama" { + t.Errorf("Backend = %q, want %q", cfg.LLM.Backend, "ollama") + } + if cfg.LLM.RelevanceThreshold != 0.7 { + t.Errorf("RelevanceThreshold = %f, want 0.7", cfg.LLM.RelevanceThreshold) + } + if cfg.Interests.Description != "Go programming, Linux" { + t.Errorf("Description = %q, want %q", cfg.Interests.Description, "Go programming, Linux") + } + if cfg.Monitor.PollInterval.String() != "5m0s" { + t.Errorf("PollInterval = %v, want 5m", cfg.Monitor.PollInterval) + } + if cfg.Monitor.MaxPostsPerPoll != 10 { + t.Errorf("MaxPostsPerPoll = %d, want 10", cfg.Monitor.MaxPostsPerPoll) + } + if cfg.GRPC.Socket != "/tmp/test.sock" { + t.Errorf("Socket = %q, want %q", cfg.GRPC.Socket, "/tmp/test.sock") + } +} + +func TestEnvVarOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + err := os.WriteFile(path, []byte(` +[reddit] +client_id = "file_id" +client_secret = "file_secret" +username = "file_user" +password = "file_pass" + +[llm] +backend = "ollama" +endpoint = "localhost:11434" +model = "mistral-small" +relevance_threshold = 0.6 + +[interests] +description = "" + +[monitor] +poll_interval = "2m" +max_posts_per_poll = 25 + +[grpc] +socket = "/tmp/test.sock" +`), 0o644) + if err != nil { + t.Fatal(err) + } + + t.Setenv("REDDIT_READER_REDDIT_CLIENT_ID", "env_id") + t.Setenv("REDDIT_READER_LLM_API_KEY", "env_key") + + cfg, err := config.LoadFromFile(path) + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + cfg.ApplyEnvOverrides() + + if cfg.Reddit.ClientID != "env_id" { + t.Errorf("ClientID = %q, want %q (env override)", cfg.Reddit.ClientID, "env_id") + } + if cfg.LLM.APIKey != "env_key" { + t.Errorf("APIKey = %q, want %q (env override)", cfg.LLM.APIKey, "env_key") + } +} + +func TestDefaultConfigPath(t *testing.T) { + path := config.DefaultPath() + if path == "" { + t.Error("DefaultPath returned empty string") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/config/... -v +``` + +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement config package** + +```go +// internal/config/config.go +package config + +import ( + "fmt" + "os" + "path/filepath" + "time" + + toml "github.com/pelletier/go-toml/v2" +) + +type Config struct { + Reddit RedditConfig `toml:"reddit"` + LLM LLMConfig `toml:"llm"` + Interests InterestsConfig `toml:"interests"` + Monitor MonitorConfig `toml:"monitor"` + GRPC GRPCConfig `toml:"grpc"` +} + +type RedditConfig struct { + ClientID string `toml:"client_id"` + ClientSecret string `toml:"client_secret"` + Username string `toml:"username"` + Password string `toml:"password"` +} + +type LLMConfig struct { + Backend string `toml:"backend"` + Endpoint string `toml:"endpoint"` + Model string `toml:"model"` + APIKey string `toml:"api_key"` + RelevanceThreshold float64 `toml:"relevance_threshold"` +} + +type InterestsConfig struct { + Description string `toml:"description"` +} + +type MonitorConfig struct { + PollInterval Duration `toml:"poll_interval"` + MaxPostsPerPoll int `toml:"max_posts_per_poll"` +} + +type GRPCConfig struct { + Socket string `toml:"socket"` +} + +// Duration wraps time.Duration for TOML string parsing. +type Duration struct { + time.Duration +} + +func (d *Duration) UnmarshalText(text []byte) error { + var err error + d.Duration, err = time.ParseDuration(string(text)) + return err +} + +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.Duration.String()), nil +} + +func LoadFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return &cfg, nil +} + +func (c *Config) ApplyEnvOverrides() { + if v := os.Getenv("REDDIT_READER_REDDIT_CLIENT_ID"); v != "" { + c.Reddit.ClientID = v + } + if v := os.Getenv("REDDIT_READER_REDDIT_CLIENT_SECRET"); v != "" { + c.Reddit.ClientSecret = v + } + if v := os.Getenv("REDDIT_READER_REDDIT_USERNAME"); v != "" { + c.Reddit.Username = v + } + if v := os.Getenv("REDDIT_READER_REDDIT_PASSWORD"); v != "" { + c.Reddit.Password = v + } + if v := os.Getenv("REDDIT_READER_LLM_API_KEY"); v != "" { + c.LLM.APIKey = v + } + if v := os.Getenv("REDDIT_READER_LLM_BACKEND"); v != "" { + c.LLM.Backend = v + } + if v := os.Getenv("REDDIT_READER_LLM_ENDPOINT"); v != "" { + c.LLM.Endpoint = v + } + if v := os.Getenv("REDDIT_READER_LLM_MODEL"); v != "" { + c.LLM.Model = v + } +} + +func DefaultPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "reddit-reader", "config.toml") +} + +func (c *Config) SaveToFile(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + data, err := toml.Marshal(c) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + return os.WriteFile(path, data, 0o600) +} + +func DefaultSocket() string { + if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { + return filepath.Join(dir, "reddit-reader.sock") + } + return "/tmp/reddit-reader.sock" +} +``` + +- [ ] **Step 4: Add dependency and run tests** + +```bash +go get github.com/pelletier/go-toml/v2@latest +go test ./internal/config/... -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/ go.mod go.sum +git commit -m "feat(config): TOML config parsing with env var overrides" +``` + +--- + +### Task 3: SQLite Store + +**Files:** +- Create: `internal/store/store.go` +- Create: `internal/store/store_test.go` + +- [ ] **Step 1: Write failing tests for store operations** + +```go +// internal/store/store_test.go +package store_test + +import ( + "testing" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.Open(":memory:") + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestInsertAndGetPost(t *testing.T) { + s := newTestStore(t) + + rel := 0.85 + sum := "- point 1\n- point 2" + post := domain.Post{ + ID: "t3_abc123", + Subreddit: "golang", + Title: "Test Post", + Author: "testuser", + URL: "https://reddit.com/r/golang/test", + SelfText: "Some text", + Score: 42, + CreatedUTC: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + Relevance: &rel, + Summary: &sum, + } + + if err := s.InsertPost(post); err != nil { + t.Fatalf("InsertPost: %v", err) + } + + got, err := s.GetPost("t3_abc123") + if err != nil { + t.Fatalf("GetPost: %v", err) + } + if got.Title != "Test Post" { + t.Errorf("Title = %q, want %q", got.Title, "Test Post") + } + if got.Relevance == nil || *got.Relevance != 0.85 { + t.Errorf("Relevance = %v, want 0.85", got.Relevance) + } +} + +func TestPostExists(t *testing.T) { + s := newTestStore(t) + + exists, err := s.PostExists("t3_nonexistent") + if err != nil { + t.Fatalf("PostExists: %v", err) + } + if exists { + t.Error("PostExists returned true for nonexistent post") + } + + post := domain.Post{ + ID: "t3_exists", + Subreddit: "test", + Title: "Exists", + CreatedUTC: time.Now(), + } + if err := s.InsertPost(post); err != nil { + t.Fatalf("InsertPost: %v", err) + } + + exists, err = s.PostExists("t3_exists") + if err != nil { + t.Fatalf("PostExists: %v", err) + } + if !exists { + t.Error("PostExists returned false for existing post") + } +} + +func TestListPosts(t *testing.T) { + s := newTestStore(t) + + rel1 := 0.9 + rel2 := 0.5 + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "golang", Title: "High", CreatedUTC: time.Now(), Relevance: &rel1}) + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "golang", Title: "Low", CreatedUTC: time.Now(), Relevance: &rel2}) + + posts, err := s.ListPosts(store.ListFilter{}) + if err != nil { + t.Fatalf("ListPosts: %v", err) + } + if len(posts) != 2 { + t.Fatalf("len = %d, want 2", len(posts)) + } + // Ordered by relevance desc + if posts[0].Title != "High" { + t.Errorf("first post = %q, want %q", posts[0].Title, "High") + } +} + +func TestListPostsFilterBySubreddit(t *testing.T) { + s := newTestStore(t) + + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "golang", Title: "Go", CreatedUTC: time.Now()}) + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "linux", Title: "Linux", CreatedUTC: time.Now()}) + + posts, err := s.ListPosts(store.ListFilter{Subreddit: "golang"}) + if err != nil { + t.Fatalf("ListPosts: %v", err) + } + if len(posts) != 1 { + t.Fatalf("len = %d, want 1", len(posts)) + } + if posts[0].Subreddit != "golang" { + t.Errorf("Subreddit = %q, want %q", posts[0].Subreddit, "golang") + } +} + +func TestUpdatePostFlags(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + + if err := s.UpdatePost("t3_a", store.PostUpdate{Read: boolPtr(true), Starred: boolPtr(true)}); err != nil { + t.Fatalf("UpdatePost: %v", err) + } + + got, _ := s.GetPost("t3_a") + if !got.Read { + t.Error("Read should be true") + } + if !got.Starred { + t.Error("Starred should be true") + } +} + +func TestSubredditCRUD(t *testing.T) { + s := newTestStore(t) + + if err := s.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}); err != nil { + t.Fatalf("AddSubreddit: %v", err) + } + + subs, err := s.ListSubreddits() + if err != nil { + t.Fatalf("ListSubreddits: %v", err) + } + if len(subs) != 1 || subs[0].Name != "golang" { + t.Errorf("ListSubreddits = %v, want [golang]", subs) + } + + if err := s.RemoveSubreddit("golang"); err != nil { + t.Fatalf("RemoveSubreddit: %v", err) + } + + subs, _ = s.ListSubreddits() + if len(subs) != 0 { + t.Errorf("ListSubreddits after remove = %v, want []", subs) + } +} + +func TestFilterCRUD(t *testing.T) { + s := newTestStore(t) + s.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}) + + id, err := s.AddFilter(domain.Filter{Subreddit: "golang", Pattern: "generics", IsRegex: false}) + if err != nil { + t.Fatalf("AddFilter: %v", err) + } + if id == 0 { + t.Error("AddFilter returned 0 id") + } + + filters, err := s.ListFilters("golang") + if err != nil { + t.Fatalf("ListFilters: %v", err) + } + if len(filters) != 1 || filters[0].Pattern != "generics" { + t.Errorf("ListFilters = %v", filters) + } +} + +func TestFeedback(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + + if err := s.AddFeedback("t3_a", 1); err != nil { + t.Fatalf("AddFeedback: %v", err) + } + + fb, err := s.RecentFeedback(10) + if err != nil { + t.Fatalf("RecentFeedback: %v", err) + } + if len(fb) != 1 || fb[0].Vote != 1 { + t.Errorf("RecentFeedback = %v", fb) + } +} + +func TestUnsummarizedPosts(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "No summary", CreatedUTC: time.Now()}) + + rel := 0.8 + sum := "has summary" + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "test", Title: "With summary", CreatedUTC: time.Now(), Relevance: &rel, Summary: &sum}) + + posts, err := s.UnsummarizedPosts() + if err != nil { + t.Fatalf("UnsummarizedPosts: %v", err) + } + if len(posts) != 1 || posts[0].ID != "t3_a" { + t.Errorf("UnsummarizedPosts = %v, want [t3_a]", posts) + } +} + +func boolPtr(b bool) *bool { return &b } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/store/... -v +``` + +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement store package** + +```go +// internal/store/store.go +package store + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +type Store struct { + db *sql.DB +} + +type ListFilter struct { + Subreddit string + Unread *bool + Starred *bool + Dismissed *bool + Limit int +} + +type PostUpdate struct { + Read *bool + Starred *bool + Dismissed *bool + Relevance *float64 + Summary *string +} + +func Open(dsn string) (*Store, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) // SQLite single-writer + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("set WAL mode: %w", err) + } + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + db.Close() + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS subreddits ( + name TEXT PRIMARY KEY, + enabled INTEGER DEFAULT 1, + poll_sort TEXT DEFAULT 'new', + added_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS filters ( + id INTEGER PRIMARY KEY, + subreddit TEXT REFERENCES subreddits(name) ON DELETE CASCADE, + pattern TEXT NOT NULL, + is_regex INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + subreddit TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + url TEXT, + selftext TEXT, + score INTEGER, + created_utc TEXT, + fetched_at TEXT DEFAULT (datetime('now')), + relevance REAL, + summary TEXT, + read INTEGER DEFAULT 0, + starred INTEGER DEFAULT 0, + dismissed INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY, + post_id TEXT REFERENCES posts(id), + vote INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + `) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + return nil +} + +func (s *Store) InsertPost(p domain.Post) error { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO posts (id, subreddit, title, author, url, selftext, score, created_utc, relevance, summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Subreddit, p.Title, p.Author, p.URL, p.SelfText, p.Score, + p.CreatedUTC.UTC().Format(time.RFC3339), p.Relevance, p.Summary, + ) + return err +} + +func (s *Store) GetPost(id string) (domain.Post, error) { + var p domain.Post + var createdUTC, fetchedAt string + err := s.db.QueryRow(` + SELECT id, subreddit, title, author, url, selftext, score, created_utc, fetched_at, relevance, summary, read, starred, dismissed + FROM posts WHERE id = ?`, id, + ).Scan(&p.ID, &p.Subreddit, &p.Title, &p.Author, &p.URL, &p.SelfText, &p.Score, + &createdUTC, &fetchedAt, &p.Relevance, &p.Summary, &p.Read, &p.Starred, &p.Dismissed) + if err != nil { + return p, fmt.Errorf("get post %s: %w", id, err) + } + p.CreatedUTC, _ = time.Parse(time.RFC3339, createdUTC) + p.FetchedAt, _ = time.Parse(time.RFC3339, fetchedAt) + return p, nil +} + +func (s *Store) PostExists(id string) (bool, error) { + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM posts WHERE id = ?", id).Scan(&count) + return count > 0, err +} + +func (s *Store) ListPosts(f ListFilter) ([]domain.Post, error) { + query := "SELECT id, subreddit, title, author, url, selftext, score, created_utc, fetched_at, relevance, summary, read, starred, dismissed FROM posts WHERE 1=1" + var args []any + + if f.Subreddit != "" { + query += " AND subreddit = ?" + args = append(args, f.Subreddit) + } + if f.Unread != nil { + if *f.Unread { + query += " AND read = 0" + } else { + query += " AND read = 1" + } + } + if f.Starred != nil && *f.Starred { + query += " AND starred = 1" + } + if f.Dismissed != nil && *f.Dismissed { + query += " AND dismissed = 1" + } + + query += " ORDER BY COALESCE(relevance, 0) DESC, fetched_at DESC" + + if f.Limit > 0 { + query += fmt.Sprintf(" LIMIT %d", f.Limit) + } + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("list posts: %w", err) + } + defer rows.Close() + + var posts []domain.Post + for rows.Next() { + var p domain.Post + var createdUTC, fetchedAt string + if err := rows.Scan(&p.ID, &p.Subreddit, &p.Title, &p.Author, &p.URL, &p.SelfText, + &p.Score, &createdUTC, &fetchedAt, &p.Relevance, &p.Summary, &p.Read, &p.Starred, &p.Dismissed); err != nil { + return nil, fmt.Errorf("scan post: %w", err) + } + p.CreatedUTC, _ = time.Parse(time.RFC3339, createdUTC) + p.FetchedAt, _ = time.Parse(time.RFC3339, fetchedAt) + posts = append(posts, p) + } + return posts, rows.Err() +} + +func (s *Store) UpdatePost(id string, u PostUpdate) error { + if u.Read != nil { + if _, err := s.db.Exec("UPDATE posts SET read = ? WHERE id = ?", *u.Read, id); err != nil { + return err + } + } + if u.Starred != nil { + if _, err := s.db.Exec("UPDATE posts SET starred = ? WHERE id = ?", *u.Starred, id); err != nil { + return err + } + } + if u.Dismissed != nil { + if _, err := s.db.Exec("UPDATE posts SET dismissed = ? WHERE id = ?", *u.Dismissed, id); err != nil { + return err + } + } + if u.Relevance != nil { + if _, err := s.db.Exec("UPDATE posts SET relevance = ? WHERE id = ?", *u.Relevance, id); err != nil { + return err + } + } + if u.Summary != nil { + if _, err := s.db.Exec("UPDATE posts SET summary = ? WHERE id = ?", *u.Summary, id); err != nil { + return err + } + } + return nil +} + +func (s *Store) UnsummarizedPosts() ([]domain.Post, error) { + return s.listWhere("summary IS NULL") +} + +func (s *Store) listWhere(where string) ([]domain.Post, error) { + rows, err := s.db.Query( + "SELECT id, subreddit, title, author, url, selftext, score, created_utc, fetched_at, relevance, summary, read, starred, dismissed FROM posts WHERE " + where) + if err != nil { + return nil, err + } + defer rows.Close() + + var posts []domain.Post + for rows.Next() { + var p domain.Post + var createdUTC, fetchedAt string + if err := rows.Scan(&p.ID, &p.Subreddit, &p.Title, &p.Author, &p.URL, &p.SelfText, + &p.Score, &createdUTC, &fetchedAt, &p.Relevance, &p.Summary, &p.Read, &p.Starred, &p.Dismissed); err != nil { + return nil, err + } + p.CreatedUTC, _ = time.Parse(time.RFC3339, createdUTC) + p.FetchedAt, _ = time.Parse(time.RFC3339, fetchedAt) + posts = append(posts, p) + } + return posts, rows.Err() +} + +func (s *Store) AddSubreddit(sub domain.Subreddit) error { + _, err := s.db.Exec("INSERT INTO subreddits (name, poll_sort) VALUES (?, ?)", sub.Name, sub.PollSort) + return err +} + +func (s *Store) RemoveSubreddit(name string) error { + _, err := s.db.Exec("DELETE FROM subreddits WHERE name = ?", name) + return err +} + +func (s *Store) ListSubreddits() ([]domain.Subreddit, error) { + rows, err := s.db.Query("SELECT name, enabled, poll_sort, added_at FROM subreddits ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var subs []domain.Subreddit + for rows.Next() { + var sub domain.Subreddit + var addedAt string + if err := rows.Scan(&sub.Name, &sub.Enabled, &sub.PollSort, &addedAt); err != nil { + return nil, err + } + sub.AddedAt, _ = time.Parse(time.RFC3339, addedAt) + subs = append(subs, sub) + } + return subs, rows.Err() +} + +func (s *Store) AddFilter(f domain.Filter) (int64, error) { + res, err := s.db.Exec("INSERT INTO filters (subreddit, pattern, is_regex) VALUES (?, ?, ?)", f.Subreddit, f.Pattern, f.IsRegex) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) ListFilters(subreddit string) ([]domain.Filter, error) { + rows, err := s.db.Query("SELECT id, subreddit, pattern, is_regex FROM filters WHERE subreddit = ?", subreddit) + if err != nil { + return nil, err + } + defer rows.Close() + + var filters []domain.Filter + for rows.Next() { + var f domain.Filter + if err := rows.Scan(&f.ID, &f.Subreddit, &f.Pattern, &f.IsRegex); err != nil { + return nil, err + } + filters = append(filters, f) + } + return filters, rows.Err() +} + +func (s *Store) RemoveFilter(id int64) error { + _, err := s.db.Exec("DELETE FROM filters WHERE id = ?", id) + return err +} + +func (s *Store) AddFeedback(postID string, vote int) error { + _, err := s.db.Exec("INSERT INTO feedback (post_id, vote) VALUES (?, ?)", postID, vote) + return err +} + +func (s *Store) RecentFeedback(limit int) ([]domain.Feedback, error) { + rows, err := s.db.Query("SELECT f.id, f.post_id, f.vote, f.created_at FROM feedback f ORDER BY f.created_at DESC LIMIT ?", limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var feedbacks []domain.Feedback + for rows.Next() { + var fb domain.Feedback + var createdAt string + if err := rows.Scan(&fb.ID, &fb.PostID, &fb.Vote, &createdAt); err != nil { + return nil, err + } + fb.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + feedbacks = append(feedbacks, fb) + } + return feedbacks, rows.Err() +} +``` + +- [ ] **Step 4: Add dependency and run tests** + +```bash +go get modernc.org/sqlite@latest +go test ./internal/store/... -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/ go.mod go.sum +git commit -m "feat(store): SQLite store with schema, CRUD, and feedback" +``` + +--- + +### Task 4: LLM Interface + OpenAI-Compatible Backend + +**Files:** +- Create: `internal/llm/llm.go` +- Create: `internal/llm/openai.go` +- Create: `internal/llm/openai_test.go` + +- [ ] **Step 1: Write the LLM interface** + +```go +// internal/llm/llm.go +package llm + +import ( + "context" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +// Summarizer scores post relevance and generates summaries. +type Summarizer interface { + Score(ctx context.Context, post domain.Post, interests domain.Interests) (float64, error) + Summarize(ctx context.Context, post domain.Post) (string, error) +} +``` + +- [ ] **Step 2: Write failing tests for the OpenAI-compatible backend** + +```go +// internal/llm/openai_test.go +package llm_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/llm" +) + +func TestOpenAIScore(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"role": "assistant", "content": "0.85"}}, + }, + }) + })) + defer srv.Close() + + client := llm.NewOpenAIClient(srv.URL, "test-model") + post := domain.Post{Title: "Go iterators", SelfText: "range over func patterns"} + interests := domain.Interests{Description: "Go programming"} + + score, err := client.Score(context.Background(), post, interests) + if err != nil { + t.Fatalf("Score: %v", err) + } + if score != 0.85 { + t.Errorf("score = %f, want 0.85", score) + } +} + +func TestOpenAISummarize(t *testing.T) { + want := "- point one\n- point two\n- point three\n- point four\n- point five" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"role": "assistant", "content": want}}, + }, + }) + })) + defer srv.Close() + + client := llm.NewOpenAIClient(srv.URL, "test-model") + post := domain.Post{Title: "Test", SelfText: "Some content"} + + got, err := client.Summarize(context.Background(), post) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if got != want { + t.Errorf("summary = %q, want %q", got, want) + } +} + +func TestOpenAIScoreInvalidResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"role": "assistant", "content": "not a number"}}, + }, + }) + })) + defer srv.Close() + + client := llm.NewOpenAIClient(srv.URL, "test-model") + _, err := client.Score(context.Background(), domain.Post{Title: "Test"}, domain.Interests{}) + if err == nil { + t.Error("expected error for non-numeric score response") + } +} + +func TestOpenAIScorePromptIncludesFeedback(t *testing.T) { + var receivedBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"role": "assistant", "content": "0.5"}}, + }, + }) + })) + defer srv.Close() + + client := llm.NewOpenAIClient(srv.URL, "test-model") + interests := domain.Interests{ + Description: "Go", + Examples: []domain.Feedback{ + {PostID: "t3_good", Vote: 1}, + }, + } + client.Score(context.Background(), domain.Post{Title: "Test"}, interests) + + msgs := receivedBody["messages"].([]any) + systemMsg := msgs[0].(map[string]any)["content"].(string) + if !strings.Contains(systemMsg, "t3_good") { + t.Error("system prompt should contain feedback post IDs") + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +go test ./internal/llm/... -v +``` + +Expected: FAIL — `NewOpenAIClient` not defined. + +- [ ] **Step 4: Implement OpenAI-compatible backend** + +```go +// internal/llm/openai.go +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +// OpenAIClient implements Summarizer using an OpenAI-compatible API (Ollama, llama.cpp). +type OpenAIClient struct { + baseURL string + model string + client *http.Client +} + +func NewOpenAIClient(baseURL, model string) *OpenAIClient { + return &OpenAIClient{ + baseURL: strings.TrimRight(baseURL, "/"), + model: model, + client: &http.Client{}, + } +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +func (c *OpenAIClient) Score(ctx context.Context, post domain.Post, interests domain.Interests) (float64, error) { + systemPrompt := buildScorePrompt(interests) + userPrompt := fmt.Sprintf("Title: %s\n\nContent: %s", post.Title, truncate(post.SelfText, 500)) + + content, err := c.complete(ctx, systemPrompt, userPrompt, 0.1) + if err != nil { + return 0, fmt.Errorf("score: %w", err) + } + + score, err := strconv.ParseFloat(strings.TrimSpace(content), 64) + if err != nil { + return 0, fmt.Errorf("parse score %q: %w", content, err) + } + return score, nil +} + +func (c *OpenAIClient) Summarize(ctx context.Context, post domain.Post) (string, error) { + systemPrompt := "You are a concise summarizer. Given a Reddit post, produce exactly 5 bullet points summarizing the key information. Each bullet starts with '- '. No other text." + userPrompt := fmt.Sprintf("Title: %s\n\nContent: %s", post.Title, post.SelfText) + + content, err := c.complete(ctx, systemPrompt, userPrompt, 0.3) + if err != nil { + return "", fmt.Errorf("summarize: %w", err) + } + return strings.TrimSpace(content), nil +} + +func (c *OpenAIClient) complete(ctx context.Context, system, user string, temperature float64) (string, error) { + req := chatRequest{ + Model: c.model, + Messages: []chatMessage{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + Temperature: temperature, + } + + body, err := json.Marshal(req) + if err != nil { + return "", err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("LLM API returned %d: %s", resp.StatusCode, respBody) + } + + var chatResp chatResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no choices in response") + } + return chatResp.Choices[0].Message.Content, nil +} + +func buildScorePrompt(interests domain.Interests) string { + var sb strings.Builder + sb.WriteString("You are a relevance scorer. Rate how relevant a Reddit post is to the user's interests on a scale from 0.0 to 1.0. Respond with ONLY a decimal number, nothing else.\n\n") + sb.WriteString("User interests: " + interests.Description + "\n") + + if len(interests.Examples) > 0 { + sb.WriteString("\nFeedback examples from the user:\n") + for _, ex := range interests.Examples { + label := "interesting" + if ex.Vote < 0 { + label = "not interesting" + } + sb.WriteString(fmt.Sprintf("- Post %s was marked as %s\n", ex.PostID, label)) + } + } + return sb.String() +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} +``` + +- [ ] **Step 5: Run tests** + +```bash +go test ./internal/llm/... -v +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/llm/ go.mod go.sum +git commit -m "feat(llm): Summarizer interface and OpenAI-compatible backend" +``` + +--- + +### Task 5: LLM Mistral Backend + +**Files:** +- Create: `internal/llm/mistral.go` +- Create: `internal/llm/mistral_test.go` + +- [ ] **Step 1: Write failing tests for Mistral backend** + +```go +// internal/llm/mistral_test.go +package llm_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/llm" +) + +func TestMistralScore(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": "test", + "object": "chat.completion", + "model": "mistral-small-latest", + "created": 1234567890, + "choices": []map[string]any{ + { + "index": 0, + "finish_reason": "stop", + "message": map[string]any{ + "role": "assistant", + "content": "0.72", + }, + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }) + })) + defer srv.Close() + + client := llm.NewMistralClient("test-key", "mistral-small-latest", llm.WithMistralBaseURL(srv.URL)) + score, err := client.Score(context.Background(), domain.Post{Title: "Test"}, domain.Interests{Description: "Go"}) + if err != nil { + t.Fatalf("Score: %v", err) + } + if score != 0.72 { + t.Errorf("score = %f, want 0.72", score) + } +} + +func TestMistralSummarize(t *testing.T) { + want := "- bullet one\n- bullet two\n- bullet three\n- bullet four\n- bullet five" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "id": "test", + "object": "chat.completion", + "model": "mistral-small-latest", + "created": 1234567890, + "choices": []map[string]any{ + { + "index": 0, + "finish_reason": "stop", + "message": map[string]any{ + "role": "assistant", + "content": want, + }, + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }) + })) + defer srv.Close() + + client := llm.NewMistralClient("test-key", "mistral-small-latest", llm.WithMistralBaseURL(srv.URL)) + got, err := client.Summarize(context.Background(), domain.Post{Title: "Test", SelfText: "content"}) + if err != nil { + t.Fatalf("Summarize: %v", err) + } + if got != want { + t.Errorf("summary = %q, want %q", got, want) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/llm/... -v +``` + +Expected: FAIL — `NewMistralClient` not defined. + +- [ ] **Step 3: Implement Mistral backend** + +```go +// internal/llm/mistral.go +package llm + +import ( + "context" + "fmt" + "strconv" + "strings" + + mistral "somegit.dev/vikingowl/mistral-go-sdk" + "somegit.dev/vikingowl/mistral-go-sdk/chat" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +// MistralClient implements Summarizer using the Mistral API via mistral-go-sdk. +type MistralClient struct { + client *mistral.Client + model string +} + +type MistralOption func(*mistralOpts) + +type mistralOpts struct { + baseURL string +} + +func WithMistralBaseURL(url string) MistralOption { + return func(o *mistralOpts) { + o.baseURL = url + } +} + +func NewMistralClient(apiKey, model string, opts ...MistralOption) *MistralClient { + var mo mistralOpts + for _, o := range opts { + o(&mo) + } + + var clientOpts []mistral.Option + if mo.baseURL != "" { + clientOpts = append(clientOpts, mistral.WithBaseURL(mo.baseURL)) + } + + return &MistralClient{ + client: mistral.NewClient(apiKey, clientOpts...), + model: model, + } +} + +func (m *MistralClient) Score(ctx context.Context, post domain.Post, interests domain.Interests) (float64, error) { + systemPrompt := buildScorePrompt(interests) + userPrompt := fmt.Sprintf("Title: %s\n\nContent: %s", post.Title, truncate(post.SelfText, 500)) + + resp, err := m.client.ChatComplete(ctx, &chat.CompletionRequest{ + Model: m.model, + Messages: []chat.Message{ + &chat.SystemMessage{Content: chat.TextContent(systemPrompt)}, + &chat.UserMessage{Content: chat.TextContent(userPrompt)}, + }, + }) + if err != nil { + return 0, fmt.Errorf("mistral score: %w", err) + } + if len(resp.Choices) == 0 { + return 0, fmt.Errorf("mistral score: no choices") + } + + text := strings.TrimSpace(resp.Choices[0].Message.Content.String()) + score, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0, fmt.Errorf("mistral parse score %q: %w", text, err) + } + return score, nil +} + +func (m *MistralClient) Summarize(ctx context.Context, post domain.Post) (string, error) { + systemPrompt := "You are a concise summarizer. Given a Reddit post, produce exactly 5 bullet points summarizing the key information. Each bullet starts with '- '. No other text." + userPrompt := fmt.Sprintf("Title: %s\n\nContent: %s", post.Title, post.SelfText) + + resp, err := m.client.ChatComplete(ctx, &chat.CompletionRequest{ + Model: m.model, + Messages: []chat.Message{ + &chat.SystemMessage{Content: chat.TextContent(systemPrompt)}, + &chat.UserMessage{Content: chat.TextContent(userPrompt)}, + }, + }) + if err != nil { + return "", fmt.Errorf("mistral summarize: %w", err) + } + if len(resp.Choices) == 0 { + return "", fmt.Errorf("mistral summarize: no choices") + } + + return strings.TrimSpace(resp.Choices[0].Message.Content.String()), nil +} +``` + +- [ ] **Step 4: Add dependency and run tests** + +```bash +go get somegit.dev/vikingowl/mistral-go-sdk@latest +go test ./internal/llm/... -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/llm/mistral.go internal/llm/mistral_test.go go.mod go.sum +git commit -m "feat(llm): Mistral SDK backend" +``` + +--- + +### Task 6: Filter Pipeline + +**Files:** +- Create: `internal/filter/keyword.go` +- Create: `internal/filter/keyword_test.go` +- Create: `internal/filter/scorer.go` +- Create: `internal/filter/scorer_test.go` + +- [ ] **Step 1: Write failing tests for keyword filter** + +```go +// internal/filter/keyword_test.go +package filter_test + +import ( + "testing" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/filter" +) + +func TestKeywordMatchPlain(t *testing.T) { + f := domain.Filter{Pattern: "golang", IsRegex: false} + post := domain.Post{Title: "Learning Golang the hard way"} + + if !filter.MatchesKeyword(post, f) { + t.Error("expected match for 'golang' in title") + } +} + +func TestKeywordMatchCaseInsensitive(t *testing.T) { + f := domain.Filter{Pattern: "NixOS", IsRegex: false} + post := domain.Post{Title: "My new nixos setup"} + + if !filter.MatchesKeyword(post, f) { + t.Error("expected case-insensitive match") + } +} + +func TestKeywordNoMatch(t *testing.T) { + f := domain.Filter{Pattern: "rust", IsRegex: false} + post := domain.Post{Title: "Go 1.26 released", SelfText: "New features in Go"} + + if filter.MatchesKeyword(post, f) { + t.Error("expected no match for 'rust'") + } +} + +func TestKeywordMatchInSelfText(t *testing.T) { + f := domain.Filter{Pattern: "iterator", IsRegex: false} + post := domain.Post{Title: "New patterns", SelfText: "The iterator protocol is great"} + + if !filter.MatchesKeyword(post, f) { + t.Error("expected match in selftext") + } +} + +func TestRegexMatch(t *testing.T) { + f := domain.Filter{Pattern: `go\s*1\.2[56]`, IsRegex: true} + post := domain.Post{Title: "Go 1.26 iterator changes"} + + if !filter.MatchesKeyword(post, f) { + t.Error("expected regex match") + } +} + +func TestRegexNoMatch(t *testing.T) { + f := domain.Filter{Pattern: `go\s*1\.24`, IsRegex: true} + post := domain.Post{Title: "Go 1.26 iterator changes"} + + if filter.MatchesKeyword(post, f) { + t.Error("expected no regex match") + } +} + +func TestMatchesAnyFilter(t *testing.T) { + filters := []domain.Filter{ + {Pattern: "python", IsRegex: false}, + {Pattern: "golang", IsRegex: false}, + } + post := domain.Post{Title: "Golang tips"} + + if !filter.MatchesAny(post, filters) { + t.Error("expected match on second filter") + } +} + +func TestMatchesAnyFilterEmpty(t *testing.T) { + post := domain.Post{Title: "Anything"} + + // No filters means everything passes (catch-all) + if !filter.MatchesAny(post, nil) { + t.Error("empty filters should match everything") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/filter/... -v +``` + +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement keyword filter** + +```go +// internal/filter/keyword.go +package filter + +import ( + "regexp" + "strings" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +// MatchesKeyword checks if a post matches a single filter pattern. +func MatchesKeyword(post domain.Post, f domain.Filter) bool { + text := strings.ToLower(post.Title + " " + post.SelfText) + + if f.IsRegex { + re, err := regexp.Compile("(?i)" + f.Pattern) + if err != nil { + return false + } + return re.MatchString(text) + } + + return strings.Contains(text, strings.ToLower(f.Pattern)) +} + +// MatchesAny returns true if the post matches any filter, or if filters is empty (catch-all). +func MatchesAny(post domain.Post, filters []domain.Filter) bool { + if len(filters) == 0 { + return true + } + for _, f := range filters { + if MatchesKeyword(post, f) { + return true + } + } + return false +} +``` + +- [ ] **Step 4: Run keyword tests** + +```bash +go test ./internal/filter/... -v -run TestKeyword +go test ./internal/filter/... -v -run TestRegex +go test ./internal/filter/... -v -run TestMatchesAny +``` + +Expected: all PASS. + +- [ ] **Step 5: Write failing tests for LLM scorer** + +```go +// internal/filter/scorer_test.go +package filter_test + +import ( + "context" + "testing" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/filter" +) + +type mockSummarizer struct { + scoreVal float64 + scoreErr error + summaryVal string + summaryErr error +} + +func (m *mockSummarizer) Score(_ context.Context, _ domain.Post, _ domain.Interests) (float64, error) { + return m.scoreVal, m.scoreErr +} + +func (m *mockSummarizer) Summarize(_ context.Context, _ domain.Post) (string, error) { + return m.summaryVal, m.summaryErr +} + +func TestScorerAboveThreshold(t *testing.T) { + mock := &mockSummarizer{scoreVal: 0.8} + scorer := filter.NewScorer(mock, 0.6) + + score, pass, err := scorer.ScorePost(context.Background(), domain.Post{Title: "Test"}, domain.Interests{}) + if err != nil { + t.Fatalf("ScorePost: %v", err) + } + if !pass { + t.Error("expected pass for score 0.8 with threshold 0.6") + } + if score != 0.8 { + t.Errorf("score = %f, want 0.8", score) + } +} + +func TestScorerBelowThreshold(t *testing.T) { + mock := &mockSummarizer{scoreVal: 0.3} + scorer := filter.NewScorer(mock, 0.6) + + _, pass, err := scorer.ScorePost(context.Background(), domain.Post{Title: "Test"}, domain.Interests{}) + if err != nil { + t.Fatalf("ScorePost: %v", err) + } + if pass { + t.Error("expected fail for score 0.3 with threshold 0.6") + } +} +``` + +- [ ] **Step 6: Implement scorer** + +```go +// internal/filter/scorer.go +package filter + +import ( + "context" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/llm" +) + +// Scorer uses an LLM to score post relevance against a threshold. +type Scorer struct { + llm llm.Summarizer + threshold float64 +} + +func NewScorer(l llm.Summarizer, threshold float64) *Scorer { + return &Scorer{llm: l, threshold: threshold} +} + +// ScorePost returns the relevance score and whether it passes the threshold. +func (s *Scorer) ScorePost(ctx context.Context, post domain.Post, interests domain.Interests) (float64, bool, error) { + score, err := s.llm.Score(ctx, post, interests) + if err != nil { + return 0, false, err + } + return score, score >= s.threshold, nil +} +``` + +- [ ] **Step 7: Run all filter tests** + +```bash +go test ./internal/filter/... -v +``` + +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git add internal/filter/ +git commit -m "feat(filter): keyword/regex pre-filter and LLM relevance scorer" +``` + +--- + +### Task 7: Reddit Client Wrapper + +**Files:** +- Create: `internal/reddit/reddit.go` +- Create: `internal/reddit/reddit_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +// internal/reddit/reddit_test.go +package reddit_test + +import ( + "context" + "testing" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + redditpkg "somegit.dev/vikingowl/reddit-reader/internal/reddit" +) + +type mockFetcher struct { + posts []domain.Post + err error +} + +func (m *mockFetcher) FetchPosts(_ context.Context, subreddit, sort string, limit int) ([]domain.Post, error) { + return m.posts, m.err +} + +func TestFetcherInterface(t *testing.T) { + mock := &mockFetcher{ + posts: []domain.Post{ + {ID: "t3_a", Title: "Test", Subreddit: "golang", CreatedUTC: time.Now()}, + }, + } + + var fetcher redditpkg.Fetcher = mock + posts, err := fetcher.FetchPosts(context.Background(), "golang", "new", 25) + if err != nil { + t.Fatalf("FetchPosts: %v", err) + } + if len(posts) != 1 { + t.Fatalf("len = %d, want 1", len(posts)) + } + if posts[0].ID != "t3_a" { + t.Errorf("ID = %q, want %q", posts[0].ID, "t3_a") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/reddit/... -v +``` + +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement Reddit client wrapper** + +```go +// internal/reddit/reddit.go +package reddit + +import ( + "context" + "fmt" + + "github.com/vartanbeno/go-reddit/v2/reddit" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +// Fetcher fetches posts from Reddit. +type Fetcher interface { + FetchPosts(ctx context.Context, subreddit, sort string, limit int) ([]domain.Post, error) +} + +// Client wraps go-reddit for post fetching. +type Client struct { + rc *reddit.Client +} + +func NewClient(clientID, clientSecret, username, password string) (*Client, error) { + rc, err := reddit.NewClient(reddit.Credentials{ + ID: clientID, + Secret: clientSecret, + Username: username, + Password: password, + }) + if err != nil { + return nil, fmt.Errorf("create reddit client: %w", err) + } + return &Client{rc: rc}, nil +} + +func (c *Client) FetchPosts(ctx context.Context, subreddit, sort string, limit int) ([]domain.Post, error) { + opts := &reddit.ListPostOptions{ + ListOptions: reddit.ListOptions{Limit: limit}, + } + + var posts []*reddit.Post + var err error + + switch sort { + case "hot": + posts, _, err = c.rc.Subreddit.HotPosts(ctx, subreddit, opts) + case "top": + posts, _, err = c.rc.Subreddit.TopPosts(ctx, subreddit, opts) + case "rising": + posts, _, err = c.rc.Subreddit.RisingPosts(ctx, subreddit, opts) + default: + posts, _, err = c.rc.Subreddit.NewPosts(ctx, subreddit, opts) + } + if err != nil { + return nil, fmt.Errorf("fetch %s/%s: %w", subreddit, sort, err) + } + + result := make([]domain.Post, len(posts)) + for i, p := range posts { + result[i] = domain.Post{ + ID: p.FullID, + Subreddit: p.SubredditName, + Title: p.Title, + Author: p.Author, + URL: p.URL, + SelfText: p.Body, + Score: p.Score, + CreatedUTC: p.Created.Time, + } + } + return result, nil +} +``` + +- [ ] **Step 4: Add dependency and run tests** + +```bash +go get github.com/vartanbeno/go-reddit/v2@latest +go test ./internal/reddit/... -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/reddit/ go.mod go.sum +git commit -m "feat(reddit): Fetcher interface and go-reddit wrapper" +``` + +--- + +### Task 8: Monitor Loop + +**Files:** +- Create: `internal/monitor/monitor.go` +- Create: `internal/monitor/monitor_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +// internal/monitor/monitor_test.go +package monitor_test + +import ( + "context" + "sync" + "testing" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/monitor" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +type mockFetcher struct { + posts []domain.Post +} + +func (m *mockFetcher) FetchPosts(_ context.Context, _, _ string, _ int) ([]domain.Post, error) { + return m.posts, nil +} + +type mockSummarizer struct { + mu sync.Mutex + calls int +} + +func (m *mockSummarizer) Score(_ context.Context, _ domain.Post, _ domain.Interests) (float64, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls++ + return 0.8, nil +} + +func (m *mockSummarizer) Summarize(_ context.Context, _ domain.Post) (string, error) { + return "- bullet 1\n- bullet 2\n- bullet 3\n- bullet 4\n- bullet 5", nil +} + +func TestMonitorPollCycle(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + st.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}) + st.AddFilter(domain.Filter{Subreddit: "golang", Pattern: "go"}) + + fetcher := &mockFetcher{ + posts: []domain.Post{ + {ID: "t3_new1", Subreddit: "golang", Title: "Go tips", CreatedUTC: time.Now()}, + {ID: "t3_new2", Subreddit: "golang", Title: "Rust tips", CreatedUTC: time.Now()}, + }, + } + summarizer := &mockSummarizer{} + + m := monitor.New(st, fetcher, summarizer, monitor.Config{ + RelevanceThreshold: 0.6, + MaxPostsPerPoll: 25, + Interests: domain.Interests{Description: "Go programming"}, + }) + + newPosts, err := m.PollOnce(context.Background()) + if err != nil { + t.Fatalf("PollOnce: %v", err) + } + + // "Go tips" matches keyword "go", "Rust tips" does not + if len(newPosts) != 1 { + t.Fatalf("newPosts = %d, want 1", len(newPosts)) + } + if newPosts[0].ID != "t3_new1" { + t.Errorf("post ID = %q, want t3_new1", newPosts[0].ID) + } + if newPosts[0].Summary == nil { + t.Error("expected summary to be set") + } + + // Verify stored in DB + exists, _ := st.PostExists("t3_new1") + if !exists { + t.Error("post should be in store") + } +} + +func TestMonitorDedup(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + st.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}) + // Insert existing post + st.InsertPost(domain.Post{ID: "t3_existing", Subreddit: "golang", Title: "Old", CreatedUTC: time.Now()}) + + fetcher := &mockFetcher{ + posts: []domain.Post{ + {ID: "t3_existing", Subreddit: "golang", Title: "Old", CreatedUTC: time.Now()}, + }, + } + summarizer := &mockSummarizer{} + + m := monitor.New(st, fetcher, summarizer, monitor.Config{ + RelevanceThreshold: 0.6, + MaxPostsPerPoll: 25, + Interests: domain.Interests{Description: "Go"}, + }) + + newPosts, err := m.PollOnce(context.Background()) + if err != nil { + t.Fatalf("PollOnce: %v", err) + } + if len(newPosts) != 0 { + t.Errorf("expected 0 new posts (dedup), got %d", len(newPosts)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +go test ./internal/monitor/... -v +``` + +Expected: FAIL — package does not exist. + +- [ ] **Step 3: Implement monitor** + +```go +// internal/monitor/monitor.go +package monitor + +import ( + "context" + "log/slog" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/filter" + "somegit.dev/vikingowl/reddit-reader/internal/llm" + "somegit.dev/vikingowl/reddit-reader/internal/reddit" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +type Config struct { + PollInterval time.Duration + RelevanceThreshold float64 + MaxPostsPerPoll int + Interests domain.Interests +} + +type Monitor struct { + store *store.Store + fetcher reddit.Fetcher + scorer *filter.Scorer + llm llm.Summarizer + cfg Config +} + +func New(s *store.Store, f reddit.Fetcher, l llm.Summarizer, cfg Config) *Monitor { + return &Monitor{ + store: s, + fetcher: f, + scorer: filter.NewScorer(l, cfg.RelevanceThreshold), + llm: l, + cfg: cfg, + } +} + +// PollOnce runs a single poll cycle across all enabled subreddits. +// Also retries summaries for posts that failed summarization on previous cycles. +// Returns newly added posts. +func (m *Monitor) PollOnce(ctx context.Context) ([]domain.Post, error) { + // Retry unsummarized posts from previous cycles + m.retrySummaries(ctx) + + subs, err := m.store.ListSubreddits() + if err != nil { + return nil, err + } + + var newPosts []domain.Post + for _, sub := range subs { + if !sub.Enabled { + continue + } + posts, err := m.pollSubreddit(ctx, sub) + if err != nil { + slog.Warn("poll failed", "subreddit", sub.Name, "error", err) + continue + } + newPosts = append(newPosts, posts...) + } + return newPosts, nil +} + +func (m *Monitor) retrySummaries(ctx context.Context) { + unsummarized, err := m.store.UnsummarizedPosts() + if err != nil { + slog.Warn("fetch unsummarized posts", "error", err) + return + } + for _, post := range unsummarized { + summary, err := m.llm.Summarize(ctx, post) + if err != nil { + continue + } + m.store.UpdatePost(post.ID, store.PostUpdate{Summary: &summary}) + } +} + +func (m *Monitor) pollSubreddit(ctx context.Context, sub domain.Subreddit) ([]domain.Post, error) { + fetched, err := m.fetcher.FetchPosts(ctx, sub.Name, sub.PollSort, m.cfg.MaxPostsPerPoll) + if err != nil { + return nil, err + } + + filters, err := m.store.ListFilters(sub.Name) + if err != nil { + return nil, err + } + + // Load recent feedback for scoring context + feedback, _ := m.store.RecentFeedback(20) + interests := m.cfg.Interests + interests.Examples = feedback + + var newPosts []domain.Post + for _, post := range fetched { + // Dedup + exists, err := m.store.PostExists(post.ID) + if err != nil { + return nil, err + } + if exists { + continue + } + + // Keyword pre-filter + if !filter.MatchesAny(post, filters) { + continue + } + + // LLM relevance scoring + score, pass, err := m.scorer.ScorePost(ctx, post, interests) + if err != nil { + slog.Warn("score failed", "post", post.ID, "error", err) + post.FetchedAt = time.Now() + m.store.InsertPost(post) + continue + } + post.Relevance = &score + + if !pass { + post.FetchedAt = time.Now() + m.store.InsertPost(post) + continue + } + + // Generate summary + summary, err := m.llm.Summarize(ctx, post) + if err != nil { + slog.Warn("summarize failed", "post", post.ID, "error", err) + } else { + post.Summary = &summary + } + + post.FetchedAt = time.Now() + if err := m.store.InsertPost(post); err != nil { + return nil, err + } + newPosts = append(newPosts, post) + } + return newPosts, nil +} + +// Run starts the polling loop. Blocks until ctx is cancelled. +func (m *Monitor) Run(ctx context.Context, notify func([]domain.Post)) error { + ticker := time.NewTicker(m.cfg.PollInterval) + defer ticker.Stop() + + // Initial poll + if posts, err := m.PollOnce(ctx); err == nil && notify != nil && len(posts) > 0 { + notify(posts) + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + posts, err := m.PollOnce(ctx) + if err != nil { + slog.Error("poll cycle failed", "error", err) + continue + } + if notify != nil && len(posts) > 0 { + notify(posts) + } + } + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/monitor/... -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/monitor/ +git commit -m "feat(monitor): polling loop with dedup, keyword filter, LLM scoring" +``` + +--- + +### Task 9: Protobuf + gRPC Service + +**Files:** +- Create: `proto/redditreader.proto` +- Create: `internal/grpc/server/server.go` +- Create: `internal/grpc/server/server_test.go` +- Create: `internal/grpc/client/client.go` + +- [ ] **Step 1: Write protobuf definition** + +```protobuf +// proto/redditreader.proto +syntax = "proto3"; + +package redditreader; + +option go_package = "somegit.dev/vikingowl/reddit-reader/proto/redditreader"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +message Post { + string id = 1; + string subreddit = 2; + string title = 3; + string author = 4; + string url = 5; + string self_text = 6; + int32 score = 7; + google.protobuf.Timestamp created_utc = 8; + google.protobuf.Timestamp fetched_at = 9; + optional double relevance = 10; + optional string summary = 11; + bool read = 12; + bool starred = 13; + bool dismissed = 14; +} + +message StreamRequest {} + +message ListRequest { + string subreddit = 1; + optional bool unread = 2; + optional bool starred = 3; + optional bool dismissed = 4; + int32 limit = 5; +} + +message ListResponse { + repeated Post posts = 1; +} + +message UpdateRequest { + string id = 1; + optional bool read = 2; + optional bool starred = 3; + optional bool dismissed = 4; +} + +message FeedbackRequest { + string post_id = 1; + int32 vote = 2; +} + +message FeedbackResponse {} + +message SubredditMsg { + string name = 1; + bool enabled = 2; + string poll_sort = 3; +} + +message SubredditList { + repeated SubredditMsg subreddits = 1; +} + +message AddSubredditRequest { + string name = 1; + string poll_sort = 2; +} + +message RemoveRequest { + string name = 1; +} + +message FilterMsg { + int64 id = 1; + string subreddit = 2; + string pattern = 3; + bool is_regex = 4; +} + +message FilterRequest { + string subreddit = 1; + repeated FilterMsg filters = 2; +} + +message FilterResponse { + repeated FilterMsg filters = 1; +} + +message Empty {} + +message StatusResponse { + int64 uptime_seconds = 1; + string last_poll = 2; + int32 total_posts = 3; + int32 unread_posts = 4; +} + +service RedditReader { + rpc StreamPosts(StreamRequest) returns (stream Post); + rpc ListPosts(ListRequest) returns (ListResponse); + rpc UpdatePost(UpdateRequest) returns (Post); + rpc SubmitFeedback(FeedbackRequest) returns (FeedbackResponse); + rpc ListSubreddits(Empty) returns (SubredditList); + rpc AddSubreddit(AddSubredditRequest) returns (SubredditMsg); + rpc RemoveSubreddit(RemoveRequest) returns (Empty); + rpc UpdateFilters(FilterRequest) returns (FilterResponse); + rpc Status(Empty) returns (StatusResponse); +} +``` + +- [ ] **Step 2: Install protoc tooling and generate code** + +```bash +go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest +mkdir -p proto/redditreader +protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + proto/redditreader.proto +``` + +Note: If protoc is not available, use `buf` instead. The worker should verify the codegen path and adjust as needed. The generated files will be at `proto/redditreader/redditreader.pb.go` and `proto/redditreader/redditreader_grpc.pb.go`. + +- [ ] **Step 3: Add gRPC dependencies** + +```bash +go get google.golang.org/grpc@latest +go get google.golang.org/protobuf@latest +``` + +- [ ] **Step 4: Write failing tests for gRPC server** + +```go +// internal/grpc/server/server_test.go +package server_test + +import ( + "context" + "net" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + grpcserver "somegit.dev/vikingowl/reddit-reader/internal/grpc/server" + "somegit.dev/vikingowl/reddit-reader/internal/store" + pb "somegit.dev/vikingowl/reddit-reader/proto/redditreader" +) + +func setupTestServer(t *testing.T) (pb.RedditReaderClient, *store.Store) { + t.Helper() + + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + + srv := grpc.NewServer() + grpcserver.Register(srv, st, time.Now()) + go srv.Serve(lis) + t.Cleanup(func() { srv.GracefulStop() }) + + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + + return pb.NewRedditReaderClient(conn), st +} + +func TestListPostsEmpty(t *testing.T) { + client, _ := setupTestServer(t) + + resp, err := client.ListPosts(context.Background(), &pb.ListRequest{}) + if err != nil { + t.Fatalf("ListPosts: %v", err) + } + if len(resp.Posts) != 0 { + t.Errorf("expected 0 posts, got %d", len(resp.Posts)) + } +} + +func TestListPostsWithData(t *testing.T) { + client, st := setupTestServer(t) + + rel := 0.8 + st.InsertPost(domain.Post{ID: "t3_a", Subreddit: "golang", Title: "Test", CreatedUTC: time.Now(), Relevance: &rel}) + + resp, err := client.ListPosts(context.Background(), &pb.ListRequest{}) + if err != nil { + t.Fatalf("ListPosts: %v", err) + } + if len(resp.Posts) != 1 { + t.Fatalf("expected 1 post, got %d", len(resp.Posts)) + } + if resp.Posts[0].Title != "Test" { + t.Errorf("Title = %q, want %q", resp.Posts[0].Title, "Test") + } +} + +func TestUpdatePost(t *testing.T) { + client, st := setupTestServer(t) + + st.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + + starred := true + resp, err := client.UpdatePost(context.Background(), &pb.UpdateRequest{Id: "t3_a", Starred: &starred}) + if err != nil { + t.Fatalf("UpdatePost: %v", err) + } + if !resp.Starred { + t.Error("expected starred to be true") + } +} + +func TestSubmitFeedback(t *testing.T) { + client, st := setupTestServer(t) + + st.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + + _, err := client.SubmitFeedback(context.Background(), &pb.FeedbackRequest{PostId: "t3_a", Vote: 1}) + if err != nil { + t.Fatalf("SubmitFeedback: %v", err) + } + + feedback, _ := st.RecentFeedback(10) + if len(feedback) != 1 || feedback[0].Vote != 1 { + t.Errorf("feedback = %v", feedback) + } +} + +func TestSubredditCRUD(t *testing.T) { + client, _ := setupTestServer(t) + + _, err := client.AddSubreddit(context.Background(), &pb.AddSubredditRequest{Name: "golang", PollSort: "new"}) + if err != nil { + t.Fatalf("AddSubreddit: %v", err) + } + + list, err := client.ListSubreddits(context.Background(), &pb.Empty{}) + if err != nil { + t.Fatalf("ListSubreddits: %v", err) + } + if len(list.Subreddits) != 1 { + t.Fatalf("expected 1 subreddit, got %d", len(list.Subreddits)) + } + + _, err = client.RemoveSubreddit(context.Background(), &pb.RemoveRequest{Name: "golang"}) + if err != nil { + t.Fatalf("RemoveSubreddit: %v", err) + } + + list, _ = client.ListSubreddits(context.Background(), &pb.Empty{}) + if len(list.Subreddits) != 0 { + t.Errorf("expected 0 subreddits after remove, got %d", len(list.Subreddits)) + } +} + +func TestStatus(t *testing.T) { + client, _ := setupTestServer(t) + + resp, err := client.Status(context.Background(), &pb.Empty{}) + if err != nil { + t.Fatalf("Status: %v", err) + } + if resp.UptimeSeconds < 0 { + t.Error("uptime should be >= 0") + } +} +``` + +- [ ] **Step 5: Implement gRPC server** + +```go +// internal/grpc/server/server.go +package server + +import ( + "context" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/store" + pb "somegit.dev/vikingowl/reddit-reader/proto/redditreader" +) + +type Server struct { + pb.UnimplementedRedditReaderServer + store *store.Store + startedAt time.Time + + mu sync.RWMutex + subscribers map[chan *pb.Post]struct{} +} + +func Register(srv *grpc.Server, st *store.Store, startedAt time.Time) *Server { + s := &Server{ + store: st, + startedAt: startedAt, + subscribers: make(map[chan *pb.Post]struct{}), + } + pb.RegisterRedditReaderServer(srv, s) + return s +} + +// Notify pushes new posts to all connected stream subscribers. +func (s *Server) Notify(posts []domain.Post) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, p := range posts { + pbPost := domainToProto(p) + for ch := range s.subscribers { + select { + case ch <- pbPost: + default: // drop if subscriber is slow + } + } + } +} + +func (s *Server) StreamPosts(_ *pb.StreamRequest, stream pb.RedditReader_StreamPostsServer) error { + ch := make(chan *pb.Post, 64) + s.mu.Lock() + s.subscribers[ch] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, ch) + s.mu.Unlock() + }() + + for { + select { + case <-stream.Context().Done(): + return stream.Context().Err() + case post := <-ch: + if err := stream.Send(post); err != nil { + return err + } + } + } +} + +func (s *Server) ListPosts(_ context.Context, req *pb.ListRequest) (*pb.ListResponse, error) { + f := store.ListFilter{ + Subreddit: req.Subreddit, + Limit: int(req.Limit), + } + if req.Unread != nil { + v := *req.Unread + f.Unread = &v + } + if req.Starred != nil { + v := *req.Starred + f.Starred = &v + } + if req.Dismissed != nil { + v := *req.Dismissed + f.Dismissed = &v + } + + posts, err := s.store.ListPosts(f) + if err != nil { + return nil, err + } + + resp := &pb.ListResponse{Posts: make([]*pb.Post, len(posts))} + for i, p := range posts { + resp.Posts[i] = domainToProto(p) + } + return resp, nil +} + +func (s *Server) UpdatePost(_ context.Context, req *pb.UpdateRequest) (*pb.Post, error) { + u := store.PostUpdate{ + Read: req.Read, + Starred: req.Starred, + Dismissed: req.Dismissed, + } + if err := s.store.UpdatePost(req.Id, u); err != nil { + return nil, err + } + p, err := s.store.GetPost(req.Id) + if err != nil { + return nil, err + } + return domainToProto(p), nil +} + +func (s *Server) SubmitFeedback(_ context.Context, req *pb.FeedbackRequest) (*pb.FeedbackResponse, error) { + if err := s.store.AddFeedback(req.PostId, int(req.Vote)); err != nil { + return nil, err + } + return &pb.FeedbackResponse{}, nil +} + +func (s *Server) ListSubreddits(_ context.Context, _ *pb.Empty) (*pb.SubredditList, error) { + subs, err := s.store.ListSubreddits() + if err != nil { + return nil, err + } + resp := &pb.SubredditList{Subreddits: make([]*pb.SubredditMsg, len(subs))} + for i, sub := range subs { + resp.Subreddits[i] = &pb.SubredditMsg{Name: sub.Name, Enabled: sub.Enabled, PollSort: sub.PollSort} + } + return resp, nil +} + +func (s *Server) AddSubreddit(_ context.Context, req *pb.AddSubredditRequest) (*pb.SubredditMsg, error) { + sub := domain.Subreddit{Name: req.Name, PollSort: req.PollSort, Enabled: true} + if err := s.store.AddSubreddit(sub); err != nil { + return nil, err + } + return &pb.SubredditMsg{Name: sub.Name, Enabled: true, PollSort: sub.PollSort}, nil +} + +func (s *Server) RemoveSubreddit(_ context.Context, req *pb.RemoveRequest) (*pb.Empty, error) { + if err := s.store.RemoveSubreddit(req.Name); err != nil { + return nil, err + } + return &pb.Empty{}, nil +} + +func (s *Server) UpdateFilters(_ context.Context, req *pb.FilterRequest) (*pb.FilterResponse, error) { + for _, f := range req.Filters { + s.store.AddFilter(domain.Filter{ + Subreddit: req.Subreddit, + Pattern: f.Pattern, + IsRegex: f.IsRegex, + }) + } + filters, err := s.store.ListFilters(req.Subreddit) + if err != nil { + return nil, err + } + resp := &pb.FilterResponse{Filters: make([]*pb.FilterMsg, len(filters))} + for i, f := range filters { + resp.Filters[i] = &pb.FilterMsg{Id: f.ID, Subreddit: f.Subreddit, Pattern: f.Pattern, IsRegex: f.IsRegex} + } + return resp, nil +} + +func (s *Server) Status(_ context.Context, _ *pb.Empty) (*pb.StatusResponse, error) { + all, _ := s.store.ListPosts(store.ListFilter{}) + unread := 0 + for _, p := range all { + if !p.Read { + unread++ + } + } + return &pb.StatusResponse{ + UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), + TotalPosts: int32(len(all)), + UnreadPosts: int32(unread), + }, nil +} + +func domainToProto(p domain.Post) *pb.Post { + pb := &pb.Post{ + Id: p.ID, + Subreddit: p.Subreddit, + Title: p.Title, + Author: p.Author, + Url: p.URL, + SelfText: p.SelfText, + Score: int32(p.Score), + CreatedUtc: timestamppb.New(p.CreatedUTC), + FetchedAt: timestamppb.New(p.FetchedAt), + Read: p.Read, + Starred: p.Starred, + Dismissed: p.Dismissed, + } + if p.Relevance != nil { + pb.Relevance = p.Relevance + } + if p.Summary != nil { + pb.Summary = p.Summary + } + return pb +} +``` + +- [ ] **Step 6: Implement gRPC client** + +```go +// internal/grpc/client/client.go +package client + +import ( + "context" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + pb "somegit.dev/vikingowl/reddit-reader/proto/redditreader" +) + +type Client struct { + conn *grpc.ClientConn + client pb.RedditReaderClient +} + +func Dial(socketPath string) (*Client, error) { + conn, err := grpc.NewClient("unix://"+socketPath, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("dial grpc: %w", err) + } + return &Client{conn: conn, client: pb.NewRedditReaderClient(conn)}, nil +} + +func (c *Client) Close() error { return c.conn.Close() } + +func (c *Client) ListPosts(ctx context.Context, subreddit string, limit int) ([]domain.Post, error) { + resp, err := c.client.ListPosts(ctx, &pb.ListRequest{Subreddit: subreddit, Limit: int32(limit)}) + if err != nil { + return nil, err + } + posts := make([]domain.Post, len(resp.Posts)) + for i, p := range resp.Posts { + posts[i] = protoToDomain(p) + } + return posts, nil +} + +func (c *Client) UpdatePost(ctx context.Context, id string, read, starred, dismissed *bool) (domain.Post, error) { + resp, err := c.client.UpdatePost(ctx, &pb.UpdateRequest{Id: id, Read: read, Starred: starred, Dismissed: dismissed}) + if err != nil { + return domain.Post{}, err + } + return protoToDomain(resp), nil +} + +func (c *Client) SubmitFeedback(ctx context.Context, postID string, vote int) error { + _, err := c.client.SubmitFeedback(ctx, &pb.FeedbackRequest{PostId: postID, Vote: int32(vote)}) + return err +} + +func (c *Client) ListSubreddits(ctx context.Context) ([]domain.Subreddit, error) { + resp, err := c.client.ListSubreddits(ctx, &pb.Empty{}) + if err != nil { + return nil, err + } + subs := make([]domain.Subreddit, len(resp.Subreddits)) + for i, s := range resp.Subreddits { + subs[i] = domain.Subreddit{Name: s.Name, Enabled: s.Enabled, PollSort: s.PollSort} + } + return subs, nil +} + +func (c *Client) AddSubreddit(ctx context.Context, name, sort string) error { + _, err := c.client.AddSubreddit(ctx, &pb.AddSubredditRequest{Name: name, PollSort: sort}) + return err +} + +func (c *Client) RemoveSubreddit(ctx context.Context, name string) error { + _, err := c.client.RemoveSubreddit(ctx, &pb.RemoveRequest{Name: name}) + return err +} + +func (c *Client) StreamPosts(ctx context.Context) (<-chan domain.Post, error) { + stream, err := c.client.StreamPosts(ctx, &pb.StreamRequest{}) + if err != nil { + return nil, err + } + ch := make(chan domain.Post, 64) + go func() { + defer close(ch) + for { + p, err := stream.Recv() + if err != nil { + return + } + ch <- protoToDomain(p) + } + }() + return ch, nil +} + +func (c *Client) Status(ctx context.Context) (*pb.StatusResponse, error) { + return c.client.Status(ctx, &pb.Empty{}) +} + +func protoToDomain(p *pb.Post) domain.Post { + post := domain.Post{ + ID: p.Id, + Subreddit: p.Subreddit, + Title: p.Title, + Author: p.Author, + URL: p.Url, + SelfText: p.SelfText, + Score: int(p.Score), + Read: p.Read, + Starred: p.Starred, + Dismissed: p.Dismissed, + } + if p.CreatedUtc != nil { + post.CreatedUTC = p.CreatedUtc.AsTime() + } + if p.FetchedAt != nil { + post.FetchedAt = p.FetchedAt.AsTime() + } + if p.Relevance != nil { + post.Relevance = p.Relevance + } + if p.Summary != nil { + post.Summary = p.Summary + } + return post +} +``` + +- [ ] **Step 7: Run tests** + +```bash +go test ./internal/grpc/server/... -v +``` + +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git add proto/ internal/grpc/ go.mod go.sum +git commit -m "feat(grpc): protobuf definitions, gRPC server and client" +``` + +--- + +### Task 10: TUI + +**Files:** +- Create: `internal/tui/model.go` +- Create: `internal/tui/views.go` +- Create: `internal/tui/keys.go` + +- [ ] **Step 1: Create key bindings** + +```go +// internal/tui/keys.go +package tui + +import "github.com/charmbracelet/bubbletea" + +type keyMap struct { + Up tea.Key + Down tea.Key + Enter tea.Key + Star tea.Key + Dismiss tea.Key + Open tea.Key + VoteUp tea.Key + VoteDn tea.Key + Filter tea.Key + Help tea.Key + Tab tea.Key + Quit tea.Key + Top tea.Key + Bottom tea.Key +} + +var keys = keyMap{ + Up: tea.Key{Type: tea.KeyRunes, Runes: []rune{'k'}}, + Down: tea.Key{Type: tea.KeyRunes, Runes: []rune{'j'}}, + Enter: tea.Key{Type: tea.KeyEnter}, + Star: tea.Key{Type: tea.KeyRunes, Runes: []rune{'s'}}, + Dismiss: tea.Key{Type: tea.KeyRunes, Runes: []rune{'d'}}, + Open: tea.Key{Type: tea.KeyRunes, Runes: []rune{'o'}}, + VoteUp: tea.Key{Type: tea.KeyRunes, Runes: []rune{'+'}}, + VoteDn: tea.Key{Type: tea.KeyRunes, Runes: []rune{'-'}}, + Filter: tea.Key{Type: tea.KeyRunes, Runes: []rune{'/'}}, + Help: tea.Key{Type: tea.KeyRunes, Runes: []rune{'?'}}, + Tab: tea.Key{Type: tea.KeyTab}, + Quit: tea.Key{Type: tea.KeyRunes, Runes: []rune{'q'}}, + Top: tea.Key{Type: tea.KeyRunes, Runes: []rune{'g'}}, + Bottom: tea.Key{Type: tea.KeyRunes, Runes: []rune{'G'}}, +} +``` + +Note: The worker should verify the Bubble Tea v2 key handling API. The key matching approach may need to use `key.Matches()` with `key.Binding` depending on the Bubble Tea version. Adapt accordingly. + +- [ ] **Step 2: Create the TUI model** + +```go +// internal/tui/model.go +package tui + +import ( + "context" + "fmt" + "os/exec" + "runtime" + + tea "github.com/charmbracelet/bubbletea" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/grpc/client" +) + +type view int + +const ( + viewReadingList view = iota + viewStarred + viewArchive + viewSettings +) + +var viewNames = []string{"Reading List", "Starred", "Archive", "Settings"} + +type Model struct { + client *client.Client + posts []domain.Post + cursor int + expanded bool + view view + width int + height int + err error + connected bool +} + +type postsMsg []domain.Post +type streamMsg domain.Post +type errMsg error + +func New(c *client.Client) Model { + return Model{ + client: c, + connected: true, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(loadPosts(m.client), subscribeStream(m.client)) +} + +func loadPosts(c *client.Client) tea.Cmd { + return func() tea.Msg { + posts, err := c.ListPosts(context.Background(), "", 200) + if err != nil { + return errMsg(err) + } + return postsMsg(posts) + } +} + +func subscribeStream(c *client.Client) tea.Cmd { + return func() tea.Msg { + ch, err := c.StreamPosts(context.Background()) + if err != nil { + return errMsg(err) + } + post, ok := <-ch + if !ok { + return nil + } + return streamMsg(post) + } +} + +func waitForStream(c *client.Client) tea.Cmd { + return subscribeStream(c) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + + case postsMsg: + m.posts = filterForView([]domain.Post(msg), m.view) + m.cursor = 0 + m.expanded = false + return m, nil + + case streamMsg: + post := domain.Post(msg) + m.posts = append([]domain.Post{post}, m.posts...) + return m, waitForStream(m.client) + + case errMsg: + m.err = msg + return m, nil + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "j", "down": + if m.cursor < len(m.posts)-1 { + m.cursor++ + m.expanded = false + } + + case "k", "up": + if m.cursor > 0 { + m.cursor-- + m.expanded = false + } + + case "g": + m.cursor = 0 + m.expanded = false + + case "G": + if len(m.posts) > 0 { + m.cursor = len(m.posts) - 1 + } + + case "enter": + m.expanded = !m.expanded + + case "s": + if len(m.posts) > 0 { + return m, toggleStar(m.client, m.posts[m.cursor]) + } + + case "d": + if len(m.posts) > 0 { + return m, dismissPost(m.client, m.posts[m.cursor]) + } + + case "o": + if len(m.posts) > 0 { + openBrowser(m.posts[m.cursor].URL) + } + + case "+": + if len(m.posts) > 0 { + return m, voteFeedback(m.client, m.posts[m.cursor].ID, 1) + } + + case "-": + if len(m.posts) > 0 { + return m, voteFeedback(m.client, m.posts[m.cursor].ID, -1) + } + + case "tab": + m.view = (m.view + 1) % 4 + m.cursor = 0 + m.expanded = false + return m, loadPosts(m.client) + } + return m, nil +} + +func (m Model) View() string { + return renderView(m) +} + +func toggleStar(c *client.Client, p domain.Post) tea.Cmd { + return func() tea.Msg { + starred := !p.Starred + c.UpdatePost(context.Background(), p.ID, nil, &starred, nil) + return loadPosts(c)() + } +} + +func dismissPost(c *client.Client, p domain.Post) tea.Cmd { + return func() tea.Msg { + dismissed := true + c.UpdatePost(context.Background(), p.ID, nil, nil, &dismissed) + return loadPosts(c)() + } +} + +func voteFeedback(c *client.Client, postID string, vote int) tea.Cmd { + return func() tea.Msg { + c.SubmitFeedback(context.Background(), postID, vote) + return nil + } +} + +func filterForView(posts []domain.Post, v view) []domain.Post { + switch v { + case viewStarred: + var filtered []domain.Post + for _, p := range posts { + if p.Starred { + filtered = append(filtered, p) + } + } + return filtered + case viewArchive: + var filtered []domain.Post + for _, p := range posts { + if p.Dismissed || p.Read { + filtered = append(filtered, p) + } + } + return filtered + default: + var filtered []domain.Post + for _, p := range posts { + if !p.Dismissed { + filtered = append(filtered, p) + } + } + return filtered + } +} + +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + } + if cmd != nil { + cmd.Start() + } +} + +// Run starts the TUI. +func Run(c *client.Client) error { + p := tea.NewProgram(New(c), tea.WithAltScreen()) + _, err := p.Run() + return err +} +``` + +- [ ] **Step 3: Create view rendering** + +```go +// internal/tui/views.go +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + tabStyle = lipgloss.NewStyle().Padding(0, 2) + activeTab = tabStyle.Foreground(lipgloss.Color("39")).Bold(true).Underline(true) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + scoreStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) + cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) + summaryStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("252")) + helpStyle = dimStyle.Padding(1, 0) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) +) + +func renderView(m Model) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err)) + } + + var b strings.Builder + + // Tab bar + b.WriteString(renderTabs(m.view)) + b.WriteString("\n") + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n") + + if m.view == viewSettings { + b.WriteString("Settings: manage via reddit-reader setup\n") + b.WriteString(helpStyle.Render("[tab] switch view [q] quit")) + return b.String() + } + + if len(m.posts) == 0 { + b.WriteString(dimStyle.Render(" No posts to display.\n")) + } + + // Calculate visible area + listHeight := m.height - 6 // tabs + separator + help bar + if m.expanded { + listHeight = listHeight / 2 + } + + start := 0 + if m.cursor >= listHeight { + start = m.cursor - listHeight + 1 + } + + for i := start; i < len(m.posts) && i < start+listHeight; i++ { + b.WriteString(renderPostLine(m.posts[i], i == m.cursor)) + b.WriteString("\n") + } + + // Detail pane + if m.expanded && m.cursor < len(m.posts) { + b.WriteString(strings.Repeat("─", m.width)) + b.WriteString("\n") + b.WriteString(renderDetail(m.posts[m.cursor])) + } + + // Help bar + b.WriteString("\n") + b.WriteString(helpStyle.Render("[↑↓/jk] navigate [enter] expand [s] star [d] dismiss [o] open [+/-] vote [tab] view [q] quit")) + + return b.String() +} + +func renderTabs(current view) string { + var tabs []string + for i, name := range viewNames { + if view(i) == current { + tabs = append(tabs, activeTab.Render("["+name+"]")) + } else { + tabs = append(tabs, tabStyle.Render(" "+name+" ")) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +func renderPostLine(p domain.Post, selected bool) string { + indicator := "○" + if !p.Read { + indicator = "●" + } + if p.Starred { + indicator = "★" + } + + var relStr string + if p.Relevance != nil { + relStr = scoreStyle.Render(fmt.Sprintf("%.2f", *p.Relevance)) + } else { + relStr = dimStyle.Render("-.--") + } + + age := relativeTime(p.FetchedAt) + + line := fmt.Sprintf(" %s r/%-12s %s %s %s", + indicator, + p.Subreddit, + relStr, + age, + p.Title, + ) + + if selected { + return cursorStyle.Render(">" + line) + } + return " " + line +} + +func renderDetail(p domain.Post) string { + var b strings.Builder + b.WriteString(titleStyle.Render(p.Title)) + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf("by u/%s in r/%s | %d points", p.Author, p.Subreddit, p.Score))) + b.WriteString("\n\n") + + if p.Summary != nil { + b.WriteString(summaryStyle.Render(*p.Summary)) + } else { + b.WriteString(dimStyle.Render(" Summary pending...")) + } + return b.String() +} + +func relativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return dimStyle.Render("just now") + case d < time.Hour: + return dimStyle.Render(fmt.Sprintf("%dm ago", int(d.Minutes()))) + case d < 24*time.Hour: + return dimStyle.Render(fmt.Sprintf("%dh ago", int(d.Hours()))) + default: + return dimStyle.Render(fmt.Sprintf("%dd ago", int(d.Hours()/24))) + } +} +``` + +- [ ] **Step 4: Add Bubble Tea and Lip Gloss dependencies** + +```bash +go get github.com/charmbracelet/bubbletea@latest +go get github.com/charmbracelet/lipgloss@latest +go build ./... +``` + +Expected: clean build. + +Note: The Bubble Tea API changes between major versions. The worker should check the installed version and adapt the key handling, `tea.Key` vs `tea.KeyMsg`, and `lipgloss.NewStyle()` vs `lipgloss.Style{}` patterns as needed. Use `context7` to fetch current Bubble Tea docs if the build fails. + +- [ ] **Step 5: Commit** + +```bash +git add internal/tui/ +git commit -m "feat(tui): Bubble Tea TUI with reading list, detail view, and keybindings" +``` + +--- + +### Task 11: Setup Wizard + +**Files:** +- Create: `internal/setup/setup.go` +- Create: `cmd/setup.go` + +- [ ] **Step 1: Implement setup wizard** + +```go +// internal/setup/setup.go +package setup + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/config" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +type Wizard struct { + scanner *bufio.Scanner + cfg *config.Config +} + +func Run() error { + w := &Wizard{ + scanner: bufio.NewScanner(os.Stdin), + cfg: &config.Config{}, + } + return w.run() +} + +func (w *Wizard) run() error { + fmt.Println("=== Reddit Reader Setup ===\n") + + if err := w.setupReddit(); err != nil { + return err + } + if err := w.setupLLM(); err != nil { + return err + } + if err := w.setupSubreddits(); err != nil { + return err + } + if err := w.setupInterests(); err != nil { + return err + } + + // Set defaults + w.cfg.Monitor.PollInterval = config.Duration(2 * time.Minute) + w.cfg.Monitor.MaxPostsPerPoll = 25 + w.cfg.GRPC.Socket = config.DefaultSocket() + + // Save config + path := config.DefaultPath() + if err := w.cfg.SaveToFile(path); err != nil { + return fmt.Errorf("save config: %w", err) + } + fmt.Printf("\nConfig saved to %s\n", path) + + // Create DB + dbPath := filepath.Join(filepath.Dir(path), "reddit-reader.db") + st, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("create database: %w", err) + } + st.Close() + fmt.Printf("Database created at %s\n", dbPath) + + // Offer systemd setup + if err := w.offerSystemd(); err != nil { + return err + } + + fmt.Println("\nSetup complete! Run 'reddit-reader serve' to start monitoring.") + return nil +} + +func (w *Wizard) setupReddit() error { + fmt.Println("Step 1: Reddit OAuth") + fmt.Println("Create a script app at https://www.reddit.com/prefs/apps") + fmt.Println() + + w.cfg.Reddit.ClientID = w.prompt("Client ID") + w.cfg.Reddit.ClientSecret = w.prompt("Client Secret") + w.cfg.Reddit.Username = w.prompt("Reddit Username") + w.cfg.Reddit.Password = w.prompt("Reddit Password") + + fmt.Println(" Testing Reddit auth...") + // Note: actual validation requires creating a reddit client and making a test call. + // The worker should implement this using the reddit package. + fmt.Println(" (Validation will be performed on first run)") + fmt.Println() + return nil +} + +func (w *Wizard) setupLLM() error { + fmt.Println("Step 2: LLM Backend") + + // Probe Ollama + if probeOllama() { + fmt.Println(" Ollama detected at localhost:11434") + w.cfg.LLM.Backend = "ollama" + w.cfg.LLM.Endpoint = "http://localhost:11434" + w.cfg.LLM.Model = w.promptDefault("Model name", "mistral-small") + } else { + fmt.Println(" No local LLM detected.") + choice := w.promptDefault("Backend (ollama/llamacpp/mistral)", "mistral") + w.cfg.LLM.Backend = choice + + switch choice { + case "ollama": + w.cfg.LLM.Endpoint = w.promptDefault("Ollama endpoint", "http://localhost:11434") + w.cfg.LLM.Model = w.promptDefault("Model", "mistral-small") + case "llamacpp": + w.cfg.LLM.Endpoint = w.prompt("llama.cpp endpoint (e.g., http://localhost:8080)") + w.cfg.LLM.Model = w.promptDefault("Model", "default") + case "mistral": + w.cfg.LLM.APIKey = w.prompt("Mistral API Key") + w.cfg.LLM.Model = w.promptDefault("Model", "mistral-small-latest") + } + } + + w.cfg.LLM.RelevanceThreshold = 0.6 + fmt.Println() + return nil +} + +func (w *Wizard) setupSubreddits() error { + fmt.Println("Step 3: Subreddits") + fmt.Println("Enter subreddits to monitor (comma-separated):") + input := w.prompt("Subreddits") + + subs := strings.Split(input, ",") + for _, s := range subs { + s = strings.TrimSpace(s) + if s == "" { + continue + } + fmt.Printf(" Keywords for r/%s (comma-separated, empty for catch-all): ", s) + w.scanner.Scan() + // Keyword config stored in DB, not config file. + // Will be saved after DB is created. + } + fmt.Println() + return nil +} + +func (w *Wizard) setupInterests() error { + fmt.Println("Step 4: Interests") + fmt.Println("Describe your interests (used for LLM relevance scoring):") + w.cfg.Interests.Description = w.prompt("Interests") + fmt.Println() + return nil +} + +func (w *Wizard) offerSystemd() error { + fmt.Print("\nInstall systemd user units? [y/N] ") + w.scanner.Scan() + if strings.ToLower(strings.TrimSpace(w.scanner.Text())) != "y" { + return nil + } + + unitDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user") + if err := os.MkdirAll(unitDir, 0o755); err != nil { + return err + } + + serviceUnit := `[Unit] +Description=Reddit Reader Monitor +After=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/reddit-reader serve +Restart=on-failure + +[Install] +WantedBy=default.target +` + + socketUnit := `[Unit] +Description=Reddit Reader Socket + +[Socket] +ListenStream=%t/reddit-reader.sock + +[Install] +WantedBy=sockets.target +` + + if err := os.WriteFile(filepath.Join(unitDir, "reddit-reader.service"), []byte(serviceUnit), 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(unitDir, "reddit-reader.socket"), []byte(socketUnit), 0o644); err != nil { + return err + } + + fmt.Printf(" Units written to %s\n", unitDir) + fmt.Println(" Enable with: systemctl --user enable --now reddit-reader.socket") + return nil +} + +func (w *Wizard) prompt(label string) string { + fmt.Printf(" %s: ", label) + w.scanner.Scan() + return strings.TrimSpace(w.scanner.Text()) +} + +func (w *Wizard) promptDefault(label, def string) string { + fmt.Printf(" %s [%s]: ", label, def) + w.scanner.Scan() + v := strings.TrimSpace(w.scanner.Text()) + if v == "" { + return def + } + return v +} + +func probeOllama() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", "http://localhost:11434/api/tags", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} +``` + +Note: The `config.Duration` helper may need adjustment — the worker should ensure the `duration` type from `config.go` is exported or provide a constructor. Adapt as needed. + +- [ ] **Step 2: Create setup cobra command** + +```go +// cmd/setup.go +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "somegit.dev/vikingowl/reddit-reader/internal/setup" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Interactive first-run setup wizard", + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Run() + }, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} +``` + +- [ ] **Step 3: Verify build** + +```bash +go build ./... +``` + +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add internal/setup/ cmd/setup.go +git commit -m "feat(setup): interactive first-run wizard with LLM probe and systemd install" +``` + +--- + +### Task 12: Command Wiring + Systemd + +**Files:** +- Create: `cmd/serve.go` +- Create: `cmd/tui.go` +- Create: `systemd/reddit-reader.service` +- Create: `systemd/reddit-reader.socket` + +- [ ] **Step 1: Implement serve command** + +```go +// cmd/serve.go +package cmd + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + + "somegit.dev/vikingowl/reddit-reader/internal/config" + "somegit.dev/vikingowl/reddit-reader/internal/domain" + grpcserver "somegit.dev/vikingowl/reddit-reader/internal/grpc/server" + "somegit.dev/vikingowl/reddit-reader/internal/llm" + "somegit.dev/vikingowl/reddit-reader/internal/monitor" + redditpkg "somegit.dev/vikingowl/reddit-reader/internal/reddit" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the monitor daemon and gRPC server", + RunE: runServe, +} + +func init() { + rootCmd.AddCommand(serveCmd) +} + +func runServe(cmd *cobra.Command, args []string) error { + cfgPath := config.DefaultPath() + cfg, err := config.LoadFromFile(cfgPath) + if err != nil { + return fmt.Errorf("load config: %w (run 'reddit-reader setup' first)", err) + } + cfg.ApplyEnvOverrides() + + // Open store + dbPath := filepath.Join(filepath.Dir(cfgPath), "reddit-reader.db") + st, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + defer st.Close() + + // Create Reddit client + rc, err := redditpkg.NewClient(cfg.Reddit.ClientID, cfg.Reddit.ClientSecret, cfg.Reddit.Username, cfg.Reddit.Password) + if err != nil { + return fmt.Errorf("create reddit client: %w", err) + } + + // Create LLM client + var summarizer llm.Summarizer + switch cfg.LLM.Backend { + case "mistral": + summarizer = llm.NewMistralClient(cfg.LLM.APIKey, cfg.LLM.Model) + default: // ollama, llamacpp + summarizer = llm.NewOpenAIClient(cfg.LLM.Endpoint, cfg.LLM.Model) + } + + // Start gRPC server + lis, err := net.Listen("unix", cfg.GRPC.Socket) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + defer os.Remove(cfg.GRPC.Socket) + + grpcSrv := grpc.NewServer() + srv := grpcserver.Register(grpcSrv, st, time.Now()) + go grpcSrv.Serve(lis) + + slog.Info("gRPC server listening", "socket", cfg.GRPC.Socket) + + // Start monitor + mon := monitor.New(st, rc, summarizer, monitor.Config{ + PollInterval: cfg.Monitor.PollInterval.Duration, + RelevanceThreshold: cfg.LLM.RelevanceThreshold, + MaxPostsPerPoll: cfg.Monitor.MaxPostsPerPoll, + Interests: domain.Interests{ + Description: cfg.Interests.Description, + }, + }) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + slog.Info("monitor started", "interval", cfg.Monitor.PollInterval.Duration) + err = mon.Run(ctx, func(posts []domain.Post) { + srv.Notify(posts) + slog.Info("new posts", "count", len(posts)) + }) + + grpcSrv.GracefulStop() + return err +} +``` + +- [ ] **Step 2: Implement tui command** + +```go +// cmd/tui.go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "somegit.dev/vikingowl/reddit-reader/internal/config" + grpcclient "somegit.dev/vikingowl/reddit-reader/internal/grpc/client" + "somegit.dev/vikingowl/reddit-reader/internal/tui" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Launch the interactive reading list TUI", + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(cmd *cobra.Command, args []string) error { + cfgPath := config.DefaultPath() + cfg, err := config.LoadFromFile(cfgPath) + if err != nil { + return fmt.Errorf("load config: %w (run 'reddit-reader setup' first)", err) + } + cfg.ApplyEnvOverrides() + + client, err := grpcclient.Dial(cfg.GRPC.Socket) + if err != nil { + return fmt.Errorf("connect to daemon: %w (is 'reddit-reader serve' running?)", err) + } + defer client.Close() + + return tui.Run(client) +} +``` + +- [ ] **Step 3: Create systemd unit files** + +```ini +# systemd/reddit-reader.service +[Unit] +Description=Reddit Reader Monitor +After=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/reddit-reader serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +``` + +```ini +# systemd/reddit-reader.socket +[Unit] +Description=Reddit Reader Socket + +[Socket] +ListenStream=%t/reddit-reader.sock + +[Install] +WantedBy=sockets.target +``` + +- [ ] **Step 4: Verify full build** + +```bash +go build -o reddit-reader . +./reddit-reader --help +``` + +Expected: Shows help with `serve`, `tui`, and `setup` subcommands. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/serve.go cmd/tui.go systemd/ +git commit -m "feat: serve and tui commands with systemd units" +``` + +--- + +## Post-Implementation Notes + +**Things the worker should verify and adapt:** + +1. **go-reddit v2 Post fields** — verify exact field names (`FullID`, `Body` vs `SelfText`, `Created` type) against godoc. The wrapper in `internal/reddit/reddit.go` may need field name adjustments. + +2. **Bubble Tea API version** — the TUI code is written for Bubble Tea v1 patterns. If v2 is the current version, adapt key handling (use `key.Binding` and `key.Matches`) and model patterns accordingly. Use `context7` to fetch current docs. + +3. **Protobuf codegen** — the exact `protoc` command may need adjustment depending on the installed toolchain. If `buf` is available, prefer it. The generated Go package path must match the import paths. + +4. **Socket activation** — the current `serve` command creates its own listener. For proper systemd socket activation, it should detect `LISTEN_FDS` from systemd and use the inherited file descriptor instead. This is a stretch goal.