Skip to content

Commit d038867

Browse files
authored
fix(cli): align empty DM window hints with follow-up queries (#239)
Align empty DM date-window diagnostics with the follow-up command's actual row set. Restrict counts to catalogued conversations only when all date bounds are removed; retain uncatalogued message counts when another bound remains. Preserve archive data, stdout/JSON contracts and retrieval behavior. Verified the original failure and corrected native CLI in an isolated Linux fixture, including one-bound, remaining-bound and both-bound cases. Focused race tests, full exact-head CI and independent review pass. Co-authored-by: Robert Navarro <crshman@gmail.com>
1 parent 2d4e973 commit d038867

6 files changed

Lines changed: 260 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Keep empty DM date-window hints consistent with the suggested follow-up command, excluding uncatalogued conversations only when removing the final date filter. Thanks @rnavarro.
6+
57
## 0.15.4 - 2026-09-20
68

79
- Update Crawlkit to v0.16.4 so normal snapshot updates accept equivalent attachment schemas with reordered columns, preserving locally retained history without requiring forced replacement.

‎docs/commands/dms.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ discrawl dms --with Molty --search "invoice"
3737

3838
`dms` reads the same tables as [`messages`](messages.html) and [`search`](search.html) and prints the same stderr notes when a run returns nothing: the window notes for a `--hours`/`--days`/`--since`/`--before` listing, and the multi-term note for `--search`. They go to stderr, `--json` suppresses them, and stdout and the exit code are unchanged.
3939

40+
When a window note recommends removing all date filters, it counts only messages in catalogued conversations that the default `dms` listing can show. Uncatalogued DMs remain available through message queries, including `dms` with a matching date window. If the recommendation leaves another date filter in place, the note continues to count those messages.
41+
4042
A run that sets `--with` gets no note. `--with` names a person and the query matches it against channel id and channel name alike, which the counts behind a note cannot reproduce, so a `--with` run stays silent rather than reporting numbers taken from every conversation.
4143

4244
## See also

‎internal/cli/zero_result_notes.go‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ const maxZeroResultTermProbes = 4
1818
// run with, so every note below is derived from the same row set the query
1919
// looked at rather than a re-modelled one.
2020
type zeroResultScope struct {
21-
channelID string
22-
guildIDs []string
23-
includeEmpty bool
24-
includeDeleted bool
21+
channelID string
22+
guildIDs []string
23+
includeEmpty bool
24+
includeDeleted bool
25+
cataloguedChannelsOnly bool
2526
}
2627

2728
// listMessagesScope models a store.ListMessages query. ListMessages carries no
@@ -60,10 +61,11 @@ func (r *runtime) newZeroResultScope(channel string, guildIDs []string, includeE
6061

6162
func (s zeroResultScope) storeOptions() store.MessageScopeOptions {
6263
return store.MessageScopeOptions{
63-
ChannelID: s.channelID,
64-
GuildIDs: s.guildIDs,
65-
IncludeEmpty: s.includeEmpty,
66-
IncludeDeleted: s.includeDeleted,
64+
ChannelID: s.channelID,
65+
GuildIDs: s.guildIDs,
66+
IncludeEmpty: s.includeEmpty,
67+
IncludeDeleted: s.includeDeleted,
68+
CataloguedChannelsOnly: s.cataloguedChannelsOnly,
6769
}
6870
}
6971

@@ -151,6 +153,8 @@ func (r *runtime) explainEmptySearch(opts store.SearchOptions, mode string) {
151153
// explainEmptyDirectMessageList explains an empty `dms` listing. Only the
152154
// window notes can apply: `dms` has no --channel, so there is no concrete
153155
// channel for the content notes to describe.
156+
//
157+
// Removing the last date filter switches to catalogued conversation summaries.
154158
func (r *runtime) explainEmptyDirectMessageList(with string, includeEmpty bool, window zeroResultWindow) {
155159
if r.json || strings.TrimSpace(with) != "" {
156160
return
@@ -159,7 +163,19 @@ func (r *runtime) explainEmptyDirectMessageList(with string, includeEmpty bool,
159163
if !ok {
160164
return
161165
}
162-
r.explainEmptyMessages(scope, window)
166+
stats, ok := r.zeroResultStats(scope)
167+
if !ok {
168+
return
169+
}
170+
sinceExcludes, beforeExcludes := window.excludedBounds(stats)
171+
if (window.since.IsZero() || sinceExcludes) && (window.before.IsZero() || beforeExcludes) {
172+
scope.cataloguedChannelsOnly = true
173+
stats, ok = r.zeroResultStats(scope)
174+
if !ok {
175+
return
176+
}
177+
}
178+
r.explainEmptyDateWindow(scope, window, stats)
163179
}
164180

165181
// explainEmptyDirectMessageSearch explains an empty `dms --search`. That path
@@ -244,6 +260,12 @@ func (r *runtime) lookupChannel(channelID string) (store.ChannelRow, bool) {
244260
return row, true
245261
}
246262

263+
func (window zeroResultWindow) excludedBounds(stats store.MessageScopeStats) (since, before bool) {
264+
since = !window.since.IsZero() && !stats.Newest.IsZero() && stats.Newest.Before(window.since)
265+
before = !window.before.IsZero() && !stats.Oldest.IsZero() && !stats.Oldest.Before(window.before) && strings.TrimSpace(window.beforeRaw) != ""
266+
return since, before
267+
}
268+
247269
// explainEmptyDateWindow writes a stderr note when an
248270
// --hours/--days/--since/--before window looks like the reason the query
249271
// returned nothing. Each side fires only when every message in scope sits
@@ -254,8 +276,7 @@ func (r *runtime) explainEmptyDateWindow(scope zeroResultScope, window zeroResul
254276
if stats.Count == 0 {
255277
return
256278
}
257-
sinceExcludes := !window.since.IsZero() && !stats.Newest.IsZero() && stats.Newest.Before(window.since)
258-
beforeExcludes := !window.before.IsZero() && !stats.Oldest.IsZero() && !stats.Oldest.Before(window.before) && strings.TrimSpace(window.beforeRaw) != ""
279+
sinceExcludes, beforeExcludes := window.excludedBounds(stats)
259280
if !sinceExcludes && !beforeExcludes {
260281
return
261282
}

‎internal/cli/zero_result_notes_test.go‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
zeroResultDeletedChannelID = "6666666666666666"
2727
zeroResultDMChannelID = "7777777777777777"
2828
zeroResultOrphanChannelID = "8888888888888888"
29+
zeroResultOrphanDMChannelID = "9999999999999999"
2930
)
3031

3132
func setupZeroResultStore(t *testing.T) (ctx context.Context, cfgPath string) {
@@ -1017,3 +1018,144 @@ func TestExplainEmptyResultsUncataloguedDMDoesNotRecommendEmbedding(t *testing.T
10171018
require.Contains(t, stderr.String(), "embedding jobs are never created for direct messages")
10181019
require.NotContains(t, stderr.String(), "--rebuild")
10191020
}
1021+
1022+
// addUncataloguedDirectMessage stores a direct message whose channel has no
1023+
// channels row, which is what an archive holds after a DM is captured without
1024+
// its conversation ever being catalogued.
1025+
func addUncataloguedDirectMessage(t *testing.T, ctx context.Context, cfgPath string) {
1026+
t.Helper()
1027+
cfg, err := config.Load(cfgPath)
1028+
require.NoError(t, err)
1029+
s, err := store.Open(ctx, cfg.DBPath)
1030+
require.NoError(t, err)
1031+
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: store.DirectMessageGuildID, Name: "Direct Messages", RawJSON: `{}`}))
1032+
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
1033+
ID: "m-dm-uncatalogued",
1034+
GuildID: store.DirectMessageGuildID,
1035+
ChannelID: zeroResultOrphanDMChannelID,
1036+
ChannelName: "Bob",
1037+
AuthorID: "u3",
1038+
AuthorName: "Bob",
1039+
CreatedAt: "2020-07-01T00:00:00Z",
1040+
Content: "quebec has no channels row",
1041+
NormalizedContent: "quebec has no channels row",
1042+
RawJSON: `{}`,
1043+
}))
1044+
require.NoError(t, s.Close())
1045+
}
1046+
1047+
// `dms --days 1` over an archive holding one catalogued conversation and one
1048+
// direct message with no channels row reported 2 messages in scope and the
1049+
// newest timestamp of the row `dms` cannot print.
1050+
// store.DirectMessageConversations selects from channels and joins messages to
1051+
// it, so the uncatalogued message is in no listing at any window. The count
1052+
// behind the note now matches the listing's own row set.
1053+
func TestExplainEmptyResults_DirectMessageWindowNoteCountsOnlyCataloguedConversations(t *testing.T) {
1054+
ctx, cfgPath := setupZeroResultStore(t)
1055+
addUncataloguedDirectMessage(t, ctx, cfgPath)
1056+
1057+
var stdout, stderr bytes.Buffer
1058+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms", "--days", "1"}, &stdout, &stderr))
1059+
require.Empty(t, stdout.String())
1060+
// One conversation, and the newest timestamp is that conversation's, not
1061+
// the uncatalogued message's 2020-07-01.
1062+
require.Contains(t, stderr.String(), "note: 1 messages in scope but none within the last 1 days (newest: 2020-03-01T00:00:00Z); try without --days")
1063+
1064+
// The command the note recommends lists the conversation it counted.
1065+
stdout.Reset()
1066+
stderr.Reset()
1067+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms"}, &stdout, &stderr))
1068+
require.Contains(t, stdout.String(), zeroResultDMChannelID)
1069+
require.NotContains(t, stdout.String(), zeroResultOrphanDMChannelID)
1070+
require.Empty(t, stderr.String())
1071+
}
1072+
1073+
// The same fix from the other side: when every direct message in the archive
1074+
// has no channels row, `dms` returns no conversation at any window, so
1075+
// dropping the window resolves nothing and there is no note to print.
1076+
func TestExplainEmptyResults_DirectMessageWindowNoteSilentWhenNoConversationIsListed(t *testing.T) {
1077+
ctx := context.Background()
1078+
dir := t.TempDir()
1079+
cfgPath := filepath.Join(dir, "config.toml")
1080+
cfg := config.Default()
1081+
cfg.DBPath = filepath.Join(dir, "discrawl.db")
1082+
require.NoError(t, config.Write(cfgPath, cfg))
1083+
addUncataloguedDirectMessage(t, ctx, cfgPath)
1084+
1085+
var stdout, stderr bytes.Buffer
1086+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms", "--days", "1"}, &stdout, &stderr))
1087+
require.Empty(t, stdout.String())
1088+
require.Empty(t, stderr.String())
1089+
1090+
// Dropping the window, which is what a window note would have recommended,
1091+
// returns no conversation either. The human listing still prints its column
1092+
// header, as every table in this CLI does with no rows.
1093+
stdout.Reset()
1094+
stderr.Reset()
1095+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "dms"}, &stdout, &stderr))
1096+
require.Equal(t, "[]\n", stdout.String())
1097+
require.Empty(t, stderr.String())
1098+
1099+
stdout.Reset()
1100+
stderr.Reset()
1101+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms"}, &stdout, &stderr))
1102+
require.NotContains(t, stdout.String(), zeroResultOrphanDMChannelID)
1103+
1104+
// The message is reachable, just not as a conversation, so the archive is
1105+
// not simply empty.
1106+
stdout.Reset()
1107+
stderr.Reset()
1108+
require.NoError(t, Run(ctx, []string{
1109+
"--config", cfgPath, "messages", "--channel", zeroResultOrphanDMChannelID,
1110+
}, &stdout, &stderr))
1111+
require.Contains(t, stdout.String(), "quebec has no channels row")
1112+
}
1113+
1114+
func TestExplainEmptyResults_DirectMessageWindowNoteWithRemainingDateFilter(t *testing.T) {
1115+
ctx, cfgPath := setupZeroResultStore(t)
1116+
addUncataloguedDirectMessage(t, ctx, cfgPath)
1117+
for _, tc := range []struct {
1118+
name string
1119+
args []string
1120+
followup []string
1121+
want string
1122+
}{
1123+
{
1124+
name: "before remains", args: []string{"--since", "2021-01-01T00:00:00Z", "--before", "2030-01-01T00:00:00Z"},
1125+
followup: []string{"--before", "2030-01-01T00:00:00Z"}, want: "note: 2 messages in scope but none since 2021-01-01T00:00:00Z (newest: 2020-07-01T00:00:00Z); try without --since",
1126+
},
1127+
{
1128+
name: "since remains", args: []string{"--since", "2019-01-01T00:00:00Z", "--before", "2019-06-01T00:00:00Z"},
1129+
followup: []string{"--since", "2019-01-01T00:00:00Z"}, want: "note: 2 messages in scope",
1130+
},
1131+
} {
1132+
t.Run(tc.name, func(t *testing.T) {
1133+
var stdout, stderr bytes.Buffer
1134+
require.NoError(t, Run(ctx, append([]string{"--config", cfgPath, "dms"}, tc.args...), &stdout, &stderr))
1135+
require.Empty(t, stdout.String())
1136+
require.Contains(t, stderr.String(), tc.want)
1137+
stdout.Reset()
1138+
stderr.Reset()
1139+
require.NoError(t, Run(ctx, append([]string{"--config", cfgPath, "dms"}, tc.followup...), &stdout, &stderr))
1140+
require.Contains(t, stdout.String(), "quebec has no channels row")
1141+
require.Contains(t, stdout.String(), "delta echo in a direct message")
1142+
require.Empty(t, stderr.String())
1143+
})
1144+
}
1145+
}
1146+
1147+
func TestExplainEmptyResults_DirectMessageWindowNoteDropsBothBounds(t *testing.T) {
1148+
ctx, cfgPath := setupZeroResultStore(t)
1149+
addUncataloguedDirectMessage(t, ctx, cfgPath)
1150+
var stdout, stderr bytes.Buffer
1151+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms", "--since", "2021-01-01T00:00:00Z", "--before", "2019-01-01T00:00:00Z"}, &stdout, &stderr))
1152+
require.Empty(t, stdout.String())
1153+
require.Contains(t, stderr.String(), "both --since and --before exclude all 1 messages")
1154+
require.Contains(t, stderr.String(), "try without both --since and --before")
1155+
stdout.Reset()
1156+
stderr.Reset()
1157+
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms"}, &stdout, &stderr))
1158+
require.Contains(t, stdout.String(), zeroResultDMChannelID)
1159+
require.NotContains(t, stdout.String(), zeroResultOrphanDMChannelID)
1160+
require.Empty(t, stderr.String())
1161+
}

‎internal/store/query.go‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,15 @@ func (s *Store) ChannelMessageBounds(ctx context.Context, channelID string) (str
8787
// SearchMessages filters `deleted_at is null`, ListMessages carries no
8888
// deleted_at predicate and so returns them. A caller sets it to match the
8989
// query it is explaining.
90+
//
91+
// CataloguedChannelsOnly matches conversation listings, which join messages
92+
// to channels by both guild and channel ID.
9093
type MessageScopeOptions struct {
91-
ChannelID string
92-
GuildIDs []string
93-
IncludeEmpty bool
94-
IncludeDeleted bool
94+
ChannelID string
95+
GuildIDs []string
96+
IncludeEmpty bool
97+
IncludeDeleted bool
98+
CataloguedChannelsOnly bool
9599
}
96100

97101
// MessageScopeStats summarises the messages allowed by the scope. Count,
@@ -123,11 +127,16 @@ func messageScopeClauses(opts MessageScopeOptions, column func(string) string) (
123127
args = append(args, guildID)
124128
}
125129
}
130+
if opts.CataloguedChannelsOnly {
131+
// Qualify outer columns so the subquery cannot bind to its own guild_id.
132+
clauses = append(clauses, "exists (select 1 from channels cat where cat.id = "+column("channel_id")+
133+
" and cat.guild_id = "+column("guild_id")+")")
134+
}
126135
return strings.Join(clauses, " and "), args
127136
}
128137

129138
func (s *Store) MessageScopeStats(ctx context.Context, opts MessageScopeOptions) (MessageScopeStats, error) {
130-
where, whereArgs := messageScopeClauses(opts, func(name string) string { return name })
139+
where, whereArgs := messageScopeClauses(opts, func(name string) string { return "messages." + name })
131140
visible := "(? or trim(coalesce(normalized_content, '')) <> '')"
132141
args := []any{opts.IncludeEmpty, opts.IncludeEmpty, opts.IncludeEmpty, opts.IncludeEmpty}
133142
args = append(args, whereArgs...)

‎internal/store/store_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2681,3 +2681,71 @@ func TestMessagePendingEmbeddingJobsCountsWhatEmbedWouldDrain(t *testing.T) {
26812681
require.Equal(t, 1, stats.DirectMessages)
26822682
require.Zero(t, stats.Embeddable)
26832683
}
2684+
2685+
// CataloguedChannelsOnly narrows a scope to the rows
2686+
// DirectMessageConversations can return. That listing selects from channels
2687+
// and joins messages on both channel id and guild id, so a direct message
2688+
// whose channel has no channels row under its guild is in no listing at any
2689+
// window, and a count taken over messages alone reports rows the listing
2690+
// cannot produce.
2691+
func TestMessageScopeStatsCataloguedChannelsOnlyMatchesTheConversationListing(t *testing.T) {
2692+
t.Parallel()
2693+
2694+
ctx := context.Background()
2695+
s, err := Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
2696+
require.NoError(t, err)
2697+
defer func() { _ = s.Close() }()
2698+
2699+
require.NoError(t, s.UpsertGuild(ctx, GuildRecord{ID: DirectMessageGuildID, Name: "Direct Messages", RawJSON: `{}`}))
2700+
require.NoError(t, s.UpsertChannel(ctx, ChannelRecord{
2701+
ID: "c-dm", GuildID: DirectMessageGuildID, Kind: "dm", Name: "Alice", RawJSON: `{}`,
2702+
}))
2703+
dm := func(id, channelID, created string) MessageRecord {
2704+
return MessageRecord{
2705+
ID: id, GuildID: DirectMessageGuildID, ChannelID: channelID, ChannelName: "Alice",
2706+
AuthorID: "u2", CreatedAt: created, Content: "hello", NormalizedContent: "hello", RawJSON: `{}`,
2707+
}
2708+
}
2709+
require.NoError(t, s.UpsertMessage(ctx, dm("m-dm", "c-dm", "2026-01-02T00:00:00Z")))
2710+
require.NoError(t, s.UpsertMessage(ctx, dm("m-dm-orphan", "c-dm-orphan", "2026-01-03T00:00:00Z")))
2711+
2712+
// Both messages are in the guild scope, and the newest of them is the one
2713+
// with no channels row.
2714+
stats, err := s.MessageScopeStats(ctx, MessageScopeOptions{GuildIDs: []string{DirectMessageGuildID}})
2715+
require.NoError(t, err)
2716+
require.Equal(t, 2, stats.Count)
2717+
require.Equal(t, parseTime("2026-01-03T00:00:00Z"), stats.Newest)
2718+
2719+
// The listing returns one conversation, and the narrowed scope counts one.
2720+
rows, err := s.DirectMessageConversations(ctx, DirectMessageConversationOptions{})
2721+
require.NoError(t, err)
2722+
require.Len(t, rows, 1)
2723+
require.Equal(t, "c-dm", rows[0].ChannelID)
2724+
2725+
stats, err = s.MessageScopeStats(ctx, MessageScopeOptions{
2726+
GuildIDs: []string{DirectMessageGuildID}, CataloguedChannelsOnly: true,
2727+
})
2728+
require.NoError(t, err)
2729+
require.Equal(t, 1, stats.Count)
2730+
require.Equal(t, parseTime("2026-01-02T00:00:00Z"), stats.Newest)
2731+
2732+
// The listing joins on guild as well as channel id, so a channels row
2733+
// carrying the same id under a different guild leaves the orphan out. Both
2734+
// tables carry a guild_id, so this also pins the correlated subquery to the
2735+
// messages row: with the guild half dropped, or with its outer reference
2736+
// resolving to the channels row it selects from, the count below is 2 and
2737+
// the newest timestamp is 2026-01-03.
2738+
require.NoError(t, s.UpsertChannel(ctx, ChannelRecord{
2739+
ID: "c-dm-orphan", GuildID: "g1", Kind: "text", Name: "same id, other guild", RawJSON: `{}`,
2740+
}))
2741+
rows, err = s.DirectMessageConversations(ctx, DirectMessageConversationOptions{})
2742+
require.NoError(t, err)
2743+
require.Len(t, rows, 1)
2744+
2745+
stats, err = s.MessageScopeStats(ctx, MessageScopeOptions{
2746+
GuildIDs: []string{DirectMessageGuildID}, CataloguedChannelsOnly: true,
2747+
})
2748+
require.NoError(t, err)
2749+
require.Equal(t, 1, stats.Count)
2750+
require.Equal(t, parseTime("2026-01-02T00:00:00Z"), stats.Newest)
2751+
}

0 commit comments

Comments
 (0)