diff --git a/cmd/context_helpers.go b/cmd/context_helpers.go new file mode 100644 index 0000000..b2aa07b --- /dev/null +++ b/cmd/context_helpers.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/chris-regnier/diaryctl/internal/context" + "github.com/chris-regnier/diaryctl/internal/entry" +) + +// buildContentProviders creates ContentProviders from config names, skipping unknown. +func buildContentProviders(names []string) []context.ContentProvider { + var providers []context.ContentProvider + for _, name := range names { + p := context.LookupContentProvider(name) + if p != nil { + providers = append(providers, p) + } + } + return providers +} + +// buildContextResolvers creates ContextResolvers from config names, skipping unknown. +func buildContextResolvers(names []string) []context.ContextResolver { + var resolvers []context.ContextResolver + for _, name := range names { + r := context.LookupContextResolver(name) + if r != nil { + resolvers = append(resolvers, r) + } + } + return resolvers +} + +// resolveContexts is a convenience function that builds resolvers from config, +// loads manual contexts, calls ResolveActiveContexts, and prints warnings. +// Returns the resolved context refs. +func resolveContexts() []entry.ContextRef { + resolvers := buildContextResolvers(appConfig.ContextResolvers) + manual, err := context.LoadManualContexts(appConfig.DataDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load manual contexts: %v\n", err) + } + refs, warnings := context.ResolveActiveContexts(resolvers, manual, store) + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", w) + } + return refs +} diff --git a/cmd/context_helpers_test.go b/cmd/context_helpers_test.go new file mode 100644 index 0000000..1f5d188 --- /dev/null +++ b/cmd/context_helpers_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "testing" +) + +func TestBuildContentProviders(t *testing.T) { + // Known providers + providers := buildContentProviders([]string{"datetime", "git"}) + if len(providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(providers)) + } + if providers[0].Name() != "datetime" { + t.Errorf("expected datetime, got %s", providers[0].Name()) + } + if providers[1].Name() != "git" { + t.Errorf("expected git, got %s", providers[1].Name()) + } +} + +func TestBuildContentProvidersSkipsUnknown(t *testing.T) { + providers := buildContentProviders([]string{"datetime", "nonexistent"}) + if len(providers) != 1 { + t.Fatalf("expected 1 provider, got %d", len(providers)) + } +} + +func TestBuildContentProvidersEmpty(t *testing.T) { + providers := buildContentProviders(nil) + if len(providers) != 0 { + t.Fatalf("expected 0 providers, got %d", len(providers)) + } +} + +func TestBuildContextResolvers(t *testing.T) { + resolvers := buildContextResolvers([]string{"git"}) + if len(resolvers) != 1 { + t.Fatalf("expected 1 resolver, got %d", len(resolvers)) + } + if resolvers[0].Name() != "git" { + t.Errorf("expected git, got %s", resolvers[0].Name()) + } +} + +func TestBuildContextResolversSkipsUnknown(t *testing.T) { + resolvers := buildContextResolvers([]string{"nonexistent"}) + if len(resolvers) != 0 { + t.Fatalf("expected 0 resolvers, got %d", len(resolvers)) + } +} diff --git a/cmd/create.go b/cmd/create.go index 5c523a5..e9ddf23 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -7,6 +7,7 @@ import ( "strings" "time" + ctxpkg "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/editor" "github.com/chris-regnier/diaryctl/internal/entry" tmpl "github.com/chris-regnier/diaryctl/internal/template" @@ -87,11 +88,15 @@ Use --no-template to skip the default template.`, } } + // Compose content from providers and template + providers := buildContentProviders(appConfig.ContextProviders) + editorContent := ctxpkg.ComposeContent(providers, templateContent) + // Open editor editorCmd := editor.ResolveEditor(appConfig.Editor) var err error var changed bool - content, changed, err = editor.Edit(editorCmd, templateContent) + content, changed, err = editor.Edit(editorCmd, editorContent) if err != nil { fmt.Fprintln(os.Stderr, "Editor error:", err) os.Exit(3) @@ -115,6 +120,9 @@ Use --no-template to skip the default template.`, os.Exit(2) } + // Resolve active contexts + contextRefs := resolveContexts() + now := time.Now().UTC() e := entry.Entry{ ID: id, @@ -122,6 +130,7 @@ Use --no-template to skip the default template.`, CreatedAt: now, UpdatedAt: now, Templates: templateRefs, + Contexts: contextRefs, } if err := store.Create(e); err != nil { diff --git a/cmd/create_test.go b/cmd/create_test.go index 4188ab7..7ec5563 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" tmpl "github.com/chris-regnier/diaryctl/internal/template" @@ -191,3 +192,50 @@ func TestCreateDefaultTemplateMisconfigured(t *testing.T) { t.Errorf("expected ErrNotFound in error, got: %v", err) } } + +func TestCreateInlineWithContextResolution(t *testing.T) { + setupTestEnv(t) + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "test-context", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := store.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + + if err := context.SetManualContext(appConfig.DataDir, "test-context"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + id, _ := entry.NewID() + now := time.Now().UTC() + refs := resolveContexts() + + e := entry.Entry{ + ID: id, + Content: "Entry with context", + CreatedAt: now, + UpdatedAt: now, + Contexts: refs, + } + if err := store.Create(e); err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context ref, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "test-context" { + t.Errorf("context name = %q, want %q", got.Contexts[0].ContextName, "test-context") + } +} diff --git a/cmd/jot.go b/cmd/jot.go index 0bce32e..2fb6935 100644 --- a/cmd/jot.go +++ b/cmd/jot.go @@ -76,6 +76,12 @@ func jotRun(w io.Writer, content string, templateName string) error { return fmt.Errorf("updating entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + if jsonOutput { return ui.FormatJSON(w, updated) } diff --git a/cmd/jot_test.go b/cmd/jot_test.go index d4374af..4292edd 100644 --- a/cmd/jot_test.go +++ b/cmd/jot_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/chris-regnier/diaryctl/internal/config" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/daily" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" @@ -183,3 +184,90 @@ func TestJotJSONOutput(t *testing.T) { t.Error("expected jot content in JSON") } } + +func TestJotAttachesContextsToNewEntry(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "sprint-1", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "sprint-1"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + err := jotRun(io.Discard, "hello", "") + if err != nil { + t.Fatalf("jotRun: %v", err) + } + + e, _, err := daily.GetOrCreateToday(s, "") + if err != nil { + t.Fatalf("GetOrCreateToday: %v", err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "sprint-1" { + t.Errorf("context = %q, want sprint-1", got.Contexts[0].ContextName) + } +} + +func TestJotAttachesContextsToExistingEntry(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + _, _, err := daily.GetOrCreateToday(s, "") + if err != nil { + t.Fatalf("GetOrCreateToday: %v", err) + } + + ctx := storage.Context{ + ID: "ctx002", + Name: "bugfix", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "bugfix"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + err = jotRun(io.Discard, "fixed the bug", "") + if err != nil { + t.Fatalf("jotRun: %v", err) + } + + e, _, _ := daily.GetOrCreateToday(s, "") + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "bugfix" { + t.Errorf("context = %q, want bugfix", got.Contexts[0].ContextName) + } +} diff --git a/cmd/root.go b/cmd/root.go index df34ad0..a48ac0a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,10 +63,13 @@ var rootCmd = &cobra.Command{ return todayRun(os.Stdout, false, false) } return ui.RunTUI(store, ui.TUIConfig{ - Editor: editor.ResolveEditor(appConfig.Editor), - DefaultTemplate: appConfig.DefaultTemplate, - MaxWidth: appConfig.MaxWidth, - Theme: ui.ResolveTheme(appConfig.Theme), + Editor: editor.ResolveEditor(appConfig.Editor), + DefaultTemplate: appConfig.DefaultTemplate, + MaxWidth: appConfig.MaxWidth, + Theme: ui.ResolveTheme(appConfig.Theme), + ContextProviders: appConfig.ContextProviders, + ContextResolvers: appConfig.ContextResolvers, + DataDir: appConfig.DataDir, }) }, } diff --git a/cmd/today.go b/cmd/today.go index 508b92e..98b2926 100644 --- a/cmd/today.go +++ b/cmd/today.go @@ -43,6 +43,12 @@ func todayRun(w io.Writer, idOnly bool, contentOnly bool) error { return fmt.Errorf("getting today's entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + if jsonOutput { return ui.FormatJSON(w, e) } @@ -69,6 +75,12 @@ func todayEditRun() error { return fmt.Errorf("getting today's entry: %w", err) } + // Resolve and attach contexts + contextRefs := resolveContexts() + for _, ref := range contextRefs { + _ = store.AttachContext(e.ID, ref.ContextID) + } + editorCmd := editor.ResolveEditor(appConfig.Editor) content, changed, err := editor.Edit(editorCmd, e.Content) if err != nil { diff --git a/cmd/today_test.go b/cmd/today_test.go index c4fbdfd..020a35e 100644 --- a/cmd/today_test.go +++ b/cmd/today_test.go @@ -7,7 +7,9 @@ import ( "time" "github.com/chris-regnier/diaryctl/internal/config" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/daily" + "github.com/chris-regnier/diaryctl/internal/storage" ) func TestTodayShowsExistingEntry(t *testing.T) { @@ -115,3 +117,43 @@ func TestTodayContentOnly(t *testing.T) { t.Errorf("expected content %q, got %q", e.Content, output) } } + +func TestTodayAttachesContexts(t *testing.T) { + s := setupTestStore(t) + store = s + appConfig = &config.Config{} + appConfig.DataDir = t.TempDir() + appConfig.ContextResolvers = []string{} + + ctx := storage.Context{ + ID: "ctx001", + Name: "daily-work", + Source: "manual", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if err := s.CreateContext(ctx); err != nil { + t.Fatalf("CreateContext: %v", err) + } + if err := context.SetManualContext(appConfig.DataDir, "daily-work"); err != nil { + t.Fatalf("SetManualContext: %v", err) + } + + var buf bytes.Buffer + err := todayRun(&buf, false, false) + if err != nil { + t.Fatalf("todayRun: %v", err) + } + + e, _, _ := daily.GetOrCreateToday(s, "") + got, err := s.Get(e.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Contexts) != 1 { + t.Fatalf("expected 1 context, got %d", len(got.Contexts)) + } + if got.Contexts[0].ContextName != "daily-work" { + t.Errorf("context = %q, want daily-work", got.Contexts[0].ContextName) + } +} diff --git a/internal/context/composite.go b/internal/context/composite.go index f5c6d32..9b288c4 100644 --- a/internal/context/composite.go +++ b/internal/context/composite.go @@ -1,5 +1,9 @@ package context +// TODO: Wire CompositeProvider into the application layer (cmd/root.go or TUI) +// to enable MCP-backed context search and filter capabilities. +// See: docs/plans/2026-03-03-context-wiring-design.md + import ( "context" diff --git a/internal/ui/picker.go b/internal/ui/picker.go index 7d18666..a049ac3 100644 --- a/internal/ui/picker.go +++ b/internal/ui/picker.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + dctx "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/editor" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" @@ -57,6 +58,7 @@ type StorageProvider interface { // Context ListContexts() ([]storage.Context, error) CreateContext(c storage.Context) error + GetContextByName(name string) (storage.Context, error) AttachContext(entryID string, contextID string) error DetachContext(entryID string, contextID string) error @@ -670,6 +672,13 @@ func (m pickerModel) formatEntry() string { fmt.Fprintf(&b, "Entry: %s\n", m.entry.ID) fmt.Fprintf(&b, "Created: %s\n", m.entry.CreatedAt.Local().Format("2006-01-02 15:04")) fmt.Fprintf(&b, "Modified: %s\n", m.entry.UpdatedAt.Local().Format("2006-01-02 15:04")) + if len(m.entry.Contexts) > 0 { + names := make([]string, len(m.entry.Contexts)) + for i, ref := range m.entry.Contexts { + names[i] = ref.ContextName + } + fmt.Fprintf(&b, "Contexts: %s\n", strings.Join(names, ", ")) + } fmt.Fprintln(&b) // Render markdown content as rich text @@ -1318,6 +1327,41 @@ func appendTemplatesCallback(m *pickerModel, names []string) tea.Cmd { } } +// resolveContexts resolves active contexts for the TUI using config. +func (m pickerModel) resolveContexts() []entry.ContextRef { + resolvers := buildTUIContextResolvers(m.cfg.ContextResolvers) + manual, err := dctx.LoadManualContexts(m.cfg.DataDir) + if err != nil { + manual = nil + } + refs, _ := dctx.ResolveActiveContexts(resolvers, manual, m.store) + return refs +} + +// buildTUIContentProviders creates ContentProviders from config names. +func buildTUIContentProviders(names []string) []dctx.ContentProvider { + var providers []dctx.ContentProvider + for _, name := range names { + p := dctx.LookupContentProvider(name) + if p != nil { + providers = append(providers, p) + } + } + return providers +} + +// buildTUIContextResolvers creates ContextResolvers from config names. +func buildTUIContextResolvers(names []string) []dctx.ContextResolver { + var resolvers []dctx.ContextResolver + for _, name := range names { + r := dctx.LookupContextResolver(name) + if r != nil { + resolvers = append(resolvers, r) + } + } + return resolvers +} + func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.TemplateRef) (tea.Model, tea.Cmd) { editorCmd := editor.ResolveEditor(m.cfg.Editor) parts := strings.Fields(editorCmd) @@ -1332,8 +1376,11 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp } tmpName := tmpFile.Name() - if initialContent != "" { - if _, err := tmpFile.WriteString(initialContent); err != nil { + providers := buildTUIContentProviders(m.cfg.ContextProviders) + composedContent := dctx.ComposeContent(providers, initialContent) + + if composedContent != "" { + if _, err := tmpFile.WriteString(composedContent); err != nil { tmpFile.Close() os.Remove(tmpName) m.err = fmt.Errorf("failed to write to temp file: %w", err) @@ -1350,6 +1397,7 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp cmdArgs := append(parts[1:], tmpName) c := exec.Command(parts[0], cmdArgs...) templateRefs := refs + contextRefs := m.resolveContexts() return m, tea.ExecProcess(c, func(err error) tea.Msg { defer os.Remove(tmpName) @@ -1375,10 +1423,14 @@ func (m pickerModel) doCreateWithEditor(initialContent string, refs []entry.Temp CreatedAt: now, UpdatedAt: now, Templates: templateRefs, + Contexts: contextRefs, } if err := m.store.Create(e); err != nil { return editorFinishedMsg{err: err} } + for _, ref := range contextRefs { + _ = m.store.AttachContext(e.ID, ref.ContextID) + } return editorFinishedMsg{} }) } @@ -1457,6 +1509,10 @@ func (m pickerModel) doJot(content string) tea.Msg { if err != nil { return jotCompleteMsg{err: err} } + contextRefs := m.resolveContexts() + for _, ref := range contextRefs { + _ = m.store.AttachContext(m.jotTarget.ID, ref.ContextID) + } } else { // No target — create new daily entry (screenToday with no entries) id, err := entry.NewID() @@ -1487,6 +1543,10 @@ func (m pickerModel) doJot(content string) tea.Msg { if err := m.store.Create(e); err != nil { return jotCompleteMsg{err: err} } + contextRefs := m.resolveContexts() + for _, ref := range contextRefs { + _ = m.store.AttachContext(e.ID, ref.ContextID) + } } return jotCompleteMsg{} @@ -1551,10 +1611,13 @@ func (m pickerModel) startEdit(e entry.Entry) (tea.Model, tea.Cmd) { // TUIConfig holds configuration needed by the TUI. type TUIConfig struct { - Editor string // resolved editor command - DefaultTemplate string // default template name - MaxWidth int // maximum viewport width (0 = no limit) - Theme Theme // resolved theme + Editor string // resolved editor command + DefaultTemplate string // default template name + MaxWidth int // maximum viewport width (0 = no limit) + Theme Theme // resolved theme + ContextProviders []string // content provider names from config + ContextResolvers []string // context resolver names from config + DataDir string // data directory for manual contexts state } // newTUIModel creates a new TUI model starting at the today screen. diff --git a/internal/ui/picker_test.go b/internal/ui/picker_test.go index fa9b9a4..4eb6211 100644 --- a/internal/ui/picker_test.go +++ b/internal/ui/picker_test.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbles/list" + "github.com/chris-regnier/diaryctl/internal/context" "github.com/chris-regnier/diaryctl/internal/entry" "github.com/chris-regnier/diaryctl/internal/storage" ) @@ -123,6 +124,15 @@ func (m *mockStorage) CreateContext(c storage.Context) error { return nil } +func (m *mockStorage) GetContextByName(name string) (storage.Context, error) { + for _, c := range m.contexts { + if c.Name == name { + return c, nil + } + } + return storage.Context{}, storage.ErrNotFound +} + func (m *mockStorage) AttachContext(entryID string, contextID string) error { if m.attachError != nil { return m.attachError @@ -1967,3 +1977,153 @@ func TestJotIntoSelectedEntry_TodayScreen(t *testing.T) { t.Error("Jot should NOT have gone to daily entry") } } + +func TestResolveContextsForTUI(t *testing.T) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + ctx1 := storage.Context{ + ID: "ctx001", + Name: "test-ctx", + Source: "manual", + } + mock := &mockStorage{ + entries: map[string][]entry.Entry{today.Format("2006-01-02"): {}}, + byID: map[string]entry.Entry{}, + contexts: []storage.Context{ctx1}, + } + + cfg := TUIConfig{ + Editor: "vi", + DataDir: t.TempDir(), + ContextResolvers: []string{}, + } + m := newTUIModel(mock, cfg) + + context.SetManualContext(cfg.DataDir, "test-ctx") + refs := m.resolveContexts() + if len(refs) != 1 { + t.Fatalf("expected 1 context ref, got %d", len(refs)) + } + if refs[0].ContextName != "test-ctx" { + t.Errorf("context = %q, want test-ctx", refs[0].ContextName) + } +} + +func TestDoJotAttachesContexts(t *testing.T) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) + + existingEntry := entry.Entry{ + ID: "entry001", + Content: "# Today", + CreatedAt: today.Add(8 * time.Hour), + UpdatedAt: today.Add(8 * time.Hour), + } + + ctx1 := storage.Context{ + ID: "ctx001", + Name: "test-jot-ctx", + Source: "manual", + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{ + today.Format("2006-01-02"): {existingEntry}, + }, + byID: map[string]entry.Entry{"entry001": existingEntry}, + contexts: []storage.Context{ctx1}, + } + + dataDir := t.TempDir() + cfg := TUIConfig{ + Editor: "vi", + DataDir: dataDir, + ContextResolvers: []string{}, + } + m := newTUIModel(mock, cfg) + m.jotTarget = &existingEntry + + context.SetManualContext(dataDir, "test-jot-ctx") + + msg := m.doJot("test note") + jotMsg, ok := msg.(jotCompleteMsg) + if !ok { + t.Fatalf("expected jotCompleteMsg, got %T", msg) + } + if jotMsg.err != nil { + t.Fatalf("jot error: %v", jotMsg.err) + } + + attached := mock.entryContexts["entry001"] + if len(attached) != 1 { + t.Fatalf("expected 1 attached context, got %d", len(attached)) + } + if attached[0] != "ctx001" { + t.Errorf("attached context ID = %q, want ctx001", attached[0]) + } +} + +func TestFormatEntryShowsContexts(t *testing.T) { + now := time.Now() + e := entry.Entry{ + ID: "entry001", + Content: "Hello world", + CreatedAt: now, + UpdatedAt: now, + Contexts: []entry.ContextRef{ + {ContextID: "ctx001", ContextName: "feature/auth"}, + {ContextID: "ctx002", ContextName: "sprint-5"}, + }, + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{}, + byID: map[string]entry.Entry{"entry001": e}, + } + + cfg := TUIConfig{Editor: "vi", Theme: presets["default-dark"]} + m := newTUIModel(mock, cfg) + m.entry = e + + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(pickerModel) + + output := m.formatEntry() + if !strings.Contains(output, "Contexts:") { + t.Error("expected 'Contexts:' in formatted entry") + } + if !strings.Contains(output, "feature/auth") { + t.Error("expected 'feature/auth' in formatted entry") + } + if !strings.Contains(output, "sprint-5") { + t.Error("expected 'sprint-5' in formatted entry") + } +} + +func TestFormatEntryNoContextsOmitsLine(t *testing.T) { + now := time.Now() + e := entry.Entry{ + ID: "entry001", + Content: "Hello world", + CreatedAt: now, + UpdatedAt: now, + } + + mock := &mockStorage{ + entries: map[string][]entry.Entry{}, + byID: map[string]entry.Entry{"entry001": e}, + } + + cfg := TUIConfig{Editor: "vi"} + m := newTUIModel(mock, cfg) + m.entry = e + + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(pickerModel) + + output := m.formatEntry() + if strings.Contains(output, "Contexts:") { + t.Error("should not show 'Contexts:' when no contexts are attached") + } +}