MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 94.9% 280 / 0 / 295
Functions: 100.0% 24 / 0 / 24
Branches: 84.5% 359 / 0 / 425

controller/ThreadBuilder.cpp
Line Branch Exec Source
1 #include "ThreadBuilder.h"
2
3 #include "util/MessageId.h"
4
5 #include <QHash>
6 #include <QLoggingCategory>
7 #include <QRegularExpression>
8 #include <QSet>
9
10 #include <algorithm>
11 #include <functional>
12 #include <optional>
13
14
4/7
✓ Branch 2 → 3 taken 16 times.
✓ Branch 2 → 8 taken 924 times.
✓ Branch 4 → 5 taken 16 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 16 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
940 Q_LOGGING_CATEGORY(lcThreadBuilder, "mailjd.threading")
15
16 namespace {
17
18 5353588 bool mailIdLess(const MailHeader &left, const MailHeader &right) {
19
2/2
✓ Branch 2 → 3 taken 559 times.
✓ Branch 2 → 4 taken 5353029 times.
5353588 if (left.folderId != right.folderId)
20 559 return left.folderId < right.folderId;
21
2/2
✓ Branch 4 → 5 taken 5353011 times.
✓ Branch 4 → 6 taken 18 times.
5353029 if (left.uid != right.uid)
22 5353011 return left.uid < right.uid;
23
2/2
✓ Branch 7 → 8 taken 16 times.
✓ Branch 7 → 10 taken 2 times.
18 if (left.messageId != right.messageId)
24 16 return left.messageId < right.messageId;
25 2 return left.subject < right.subject;
26 }
27
28 396486 bool chronologicalLess(const ThreadNode *left, const ThreadNode *right) {
29 396486 const bool leftValid = left->header.date.isValid();
30 396486 const bool rightValid = right->header.date.isValid();
31
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 396486 times.
396486 if (leftValid != rightValid)
32 ✗ return leftValid;
33
5/6
✓ Branch 6 → 7 taken 396486 times.
✗ Branch 6 → 10 not taken.
✓ Branch 8 → 9 taken 388992 times.
✓ Branch 8 → 10 taken 7494 times.
✓ Branch 11 → 12 taken 388992 times.
✓ Branch 11 → 14 taken 7494 times.
396486 if (leftValid && left->header.date != right->header.date)
34 388992 return left->header.date < right->header.date;
35 7494 return mailIdLess(left->header, right->header);
36 }
37
38 331910 bool wouldCreateCycle(const ThreadNode *node, const ThreadNode *candidate) {
39
2/2
✓ Branch 6 → 3 taken 1650472 times.
✓ Branch 6 → 7 taken 330406 times.
1980878 for (const ThreadNode *parent = candidate; parent; parent = parent->parent) {
40
2/2
✓ Branch 3 → 4 taken 1504 times.
✓ Branch 3 → 5 taken 1648968 times.
1650472 if (parent == node)
41 1504 return true;
42 }
43 330406 return false;
44 }
45
46 755034 int currentParentDepth(const ThreadNode *node) {
47 755034 int depth = 0;
48
2/2
✓ Branch 6 → 3 taken 3182074 times.
✓ Branch 6 → 7 taken 755034 times.
3937108 for (const ThreadNode *parent = node->parent; parent;
49 3182074 parent = parent->parent) {
50 3182074 ++depth;
51
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 3182074 times.
3182074 if (depth > ThreadBuilder::MaximumDepth)
52 ✗ break;
53 }
54 755034 return depth;
55 }
56
57 331910 bool canAttachTo(const ThreadNode *node, const ThreadNode *candidate) {
58
6/8
✓ Branch 2 → 3 taken 331910 times.
✗ Branch 2 → 9 not taken.
✓ Branch 3 → 4 taken 331910 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 330406 times.
✓ Branch 5 → 9 taken 1504 times.
✓ Branch 7 → 8 taken 330279 times.
✓ Branch 7 → 9 taken 127 times.
662316 return candidate && node != candidate && !wouldCreateCycle(node, candidate) &&
59 662316 currentParentDepth(candidate) < ThreadBuilder::MaximumDepth;
60 }
61
62 645157 QStringList canonicalReplyIds(const MailHeader &header) {
63 645157 QStringList ids;
64 645157 const QStringList source = header.inReplyToIds.isEmpty()
65 1131240 ? QStringList{header.inReplyTo}
66
6/10
✓ Branch 3 → 4 taken 565620 times.
✓ Branch 3 → 6 taken 79537 times.
✓ Branch 7 → 8 taken 565620 times.
✓ Branch 7 → 12 taken 79537 times.
✓ Branch 9 → 10 taken 565620 times.
✓ Branch 9 → 12 taken 565620 times.
✗ Branch 39 → 40 not taken.
✗ Branch 39 → 44 not taken.
✗ Branch 41 → 42 not taken.
✗ Branch 41 → 44 not taken.
2342017 : header.inReplyToIds;
67
2/2
✓ Branch 34 → 14 taken 645277 times.
✓ Branch 34 → 35 taken 645157 times.
1290434 for (const QString &value : source) {
68
5/5
✓ Branch 15 → 16 taken 645277 times.
✓ Branch 16 → 17 taken 645277 times.
✓ Branch 17 → 18 taken 645277 times.
✓ Branch 26 → 19 taken 91932 times.
✓ Branch 26 → 27 taken 645277 times.
737209 for (const QString &token : MessageId::parseTokens(value)) {
69
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 91932 times.
91932 if (ids.size() >= MessageId::MaximumTokens)
70 ✗ return ids;
71
1/1
✓ Branch 23 → 24 taken 91932 times.
91932 ids.append(token);
72 645277 }
73 }
74 645157 return ids;
75 1210777 }
76
77 645157 QStringList canonicalReferences(const MailHeader &header) {
78 645157 QStringList ids;
79
2/2
✓ Branch 24 → 4 taken 678539 times.
✓ Branch 24 → 25 taken 645157 times.
1323696 for (const QString &value : header.references) {
80
5/5
✓ Branch 5 → 6 taken 678539 times.
✓ Branch 6 → 7 taken 678539 times.
✓ Branch 7 → 8 taken 678539 times.
✓ Branch 16 → 9 taken 678539 times.
✓ Branch 16 → 17 taken 678539 times.
1357078 for (const QString &token : MessageId::parseTokens(value)) {
81
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 678539 times.
678539 if (ids.size() >= MessageId::MaximumTokens)
82 ✗ return ids;
83
1/1
✓ Branch 13 → 14 taken 678539 times.
678539 ids.append(token);
84 678539 }
85 }
86 645157 return ids;
87 ✗ }
88
89 319556 ThreadNode *chooseDuplicateParent(const QList<ThreadNode *> &candidates,
90 const ThreadNode *child) {
91 319556 QList<ThreadNode *> eligible;
92
2/2
✓ Branch 9 → 4 taken 448208 times.
✓ Branch 9 → 10 taken 319556 times.
767764 for (ThreadNode *candidate : candidates) {
93
2/2
✓ Branch 5 → 6 taken 446736 times.
✓ Branch 5 → 7 taken 1472 times.
448208 if (candidate != child)
94
1/1
✓ Branch 6 → 7 taken 446736 times.
446736 eligible.append(candidate);
95 }
96
2/2
✓ Branch 11 → 12 taken 284 times.
✓ Branch 11 → 13 taken 319272 times.
319556 if (eligible.isEmpty())
97 284 return nullptr;
98
99 // Explicit duplicate policy: prefer the same folder, then the closest
100 // candidate not newer than the child. If every copy is newer, use the
101 // earliest copy. MailId is the final deterministic tie-breaker.
102
3/3
✓ Branch 13 → 14 taken 319272 times.
✓ Branch 14 → 15 taken 319272 times.
✓ Branch 15 → 16 taken 319272 times.
319272 std::sort(eligible.begin(), eligible.end(), [child](const ThreadNode *left,
103 const ThreadNode *right) {
104 243388 const bool leftSame = left->header.folderId == child->header.folderId;
105 243388 const bool rightSame = right->header.folderId == child->header.folderId;
106
2/2
✓ Branch 2 → 3 taken 124 times.
✓ Branch 2 → 4 taken 243264 times.
243388 if (leftSame != rightSame)
107 124 return leftSame;
108 243264 const bool childDated = child->header.date.isValid();
109
4/6
✓ Branch 5 → 6 taken 243264 times.
✗ Branch 5 → 12 not taken.
✓ Branch 7 → 8 taken 243264 times.
✗ Branch 7 → 12 not taken.
✓ Branch 10 → 11 taken 116692 times.
✓ Branch 10 → 12 taken 126572 times.
486528 const bool leftPrior = childDated && left->header.date.isValid() &&
110 243264 left->header.date <= child->header.date;
111
4/6
✓ Branch 13 → 14 taken 243264 times.
✗ Branch 13 → 20 not taken.
✓ Branch 15 → 16 taken 243264 times.
✗ Branch 15 → 20 not taken.
✓ Branch 18 → 19 taken 137946 times.
✓ Branch 18 → 20 taken 105318 times.
486528 const bool rightPrior = childDated && right->header.date.isValid() &&
112 243264 right->header.date <= child->header.date;
113
2/2
✓ Branch 21 → 22 taken 74410 times.
✓ Branch 21 → 23 taken 168854 times.
243264 if (leftPrior != rightPrior)
114 74410 return leftPrior;
115
1/2
✓ Branch 24 → 25 taken 168854 times.
✗ Branch 24 → 30 not taken.
168854 if (left->header.date != right->header.date) {
116
2/2
✓ Branch 25 → 26 taken 90114 times.
✓ Branch 25 → 28 taken 78740 times.
168854 if (leftPrior)
117 90114 return left->header.date > right->header.date;
118 78740 return chronologicalLess(left, right);
119 }
120 ✗ return mailIdLess(left->header, right->header);
121 });
122
1/1
✓ Branch 16 → 17 taken 319272 times.
319272 return eligible.first();
123 319556 }
124
125 244065 QSet<QString> participants(const MailHeader &header) {
126 244065 QSet<QString> result;
127 static const QRegularExpression address(
128 14 QStringLiteral(R"(([^\s<>,;\"]+@[^\s<>,;\"]+))"),
129
4/7
✓ Branch 3 → 4 taken 7 times.
✓ Branch 3 → 16 taken 244058 times.
✓ Branch 5 → 6 taken 7 times.
✗ Branch 5 → 16 not taken.
✓ Branch 10 → 11 taken 7 times.
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
244079 QRegularExpression::UseUnicodePropertiesOption);
130
2/2
✓ Branch 35 → 20 taken 488130 times.
✓ Branch 35 → 36 taken 244065 times.
1220325 for (const QString &field : {header.from, header.to}) {
131
1/1
✓ Branch 21 → 22 taken 488130 times.
488130 auto matches = address.globalMatch(field);
132
3/3
✓ Branch 31 → 32 taken 967012 times.
✓ Branch 32 → 23 taken 478882 times.
✓ Branch 32 → 33 taken 488130 times.
967012 while (matches.hasNext())
133
4/4
✓ Branch 23 → 24 taken 478882 times.
✓ Branch 24 → 25 taken 478882 times.
✓ Branch 25 → 26 taken 478882 times.
✓ Branch 26 → 27 taken 478882 times.
478882 result.insert(matches.next().captured(1).toCaseFolded());
134 1220325 }
135 244065 return result;
136 ✗ }
137
138 14930 bool participantOverlap(const QSet<QString> &leftParticipants,
139 const QSet<QString> &rightParticipants) {
140
3/6
✓ Branch 3 → 4 taken 14930 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 14930 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 14930 times.
14930 if (leftParticipants.isEmpty() || rightParticipants.isEmpty())
141 ✗ return false;
142
2/2
✓ Branch 18 → 12 taken 22789 times.
✓ Branch 18 → 19 taken 6452 times.
29241 for (const QString &participant : leftParticipants) {
143
2/2
✓ Branch 14 → 15 taken 8478 times.
✓ Branch 14 → 16 taken 14311 times.
22789 if (rightParticipants.contains(participant))
144 8478 return true;
145 }
146 6452 return false;
147 }
148
149 struct NormalizedSubject {
150 QString text;
151 bool replyOrForward = false;
152 };
153
154 423393 NormalizedSubject normalizedSubject(const QString &subject) {
155 // Deliberately anchored: a mailing-list tag remains part of the comparison
156 // text. The reviewed marker set covers English and common localized forms.
157 static const QRegularExpression prefix(
158 32 QStringLiteral(
159 R"(^\s*(?:re|fwd|fw|aw|wg|sv|vs|tr|antw|doorst)\s*(?:\[\d+\])?\s*:\s*)"),
160 QRegularExpression::CaseInsensitiveOption |
161
4/7
✓ Branch 2 → 3 taken 16 times.
✓ Branch 2 → 15 taken 423377 times.
✓ Branch 4 → 5 taken 16 times.
✗ Branch 4 → 15 not taken.
✓ Branch 9 → 10 taken 16 times.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 44 not taken.
423409 QRegularExpression::UseUnicodePropertiesOption);
162
1/1
✓ Branch 15 → 16 taken 423393 times.
423393 QString value = subject.normalized(QString::NormalizationForm_KC);
163 423393 bool removed = false;
164 while (true) {
165
1/1
✓ Branch 18 → 19 taken 482004 times.
482004 const QRegularExpressionMatch match = prefix.match(value);
166
3/3
✓ Branch 19 → 20 taken 482004 times.
✓ Branch 20 → 21 taken 423393 times.
✓ Branch 20 → 22 taken 58611 times.
482004 if (!match.hasMatch())
167 423393 break;
168
2/2
✓ Branch 22 → 23 taken 58611 times.
✓ Branch 23 → 24 taken 58611 times.
58611 value.remove(0, match.capturedLength());
169 58611 removed = true;
170 540615 }
171
1/1
✓ Branch 29 → 30 taken 423393 times.
1270179 return {value.simplified().toCaseFolded(), removed};
172 846786 }
173
174 14930 bool insideFallbackWindow(const MailHeader &root, const MailHeader &reply) {
175
4/8
✓ Branch 3 → 4 taken 14930 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 14930 times.
✗ Branch 5 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 14930 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 14930 times.
14930 if (!root.date.isValid() || !reply.date.isValid() || root.date > reply.date)
176 ✗ return false;
177 14930 return root.date.secsTo(reply.date) <= ThreadBuilder::FallbackWindowSeconds;
178 }
179
180 } // namespace
181
182 std::vector<std::unique_ptr<ThreadNode>>
183 957 ThreadBuilder::buildThreads(const QList<MailHeader> &headers,
184 const std::function<bool()> &cancelled) {
185
2/2
✓ Branch 3 → 4 taken 17 times.
✓ Branch 3 → 5 taken 940 times.
957 if (headers.isEmpty())
186 17 return {};
187
188 940 std::vector<std::unique_ptr<ThreadNode>> allNodes;
189
1/1
✓ Branch 6 → 7 taken 940 times.
940 allNodes.reserve(static_cast<size_t>(headers.size()));
190
2/2
✓ Branch 49 → 9 taken 424628 times.
✓ Branch 49 → 50 taken 940 times.
425568 for (const MailHeader &header : headers) {
191
5/7
✓ Branch 11 → 12 taken 219775 times.
✓ Branch 11 → 15 taken 204853 times.
✓ Branch 12 → 13 taken 219775 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 219775 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
192
1/1
✓ Branch 18 → 19 taken 424628 times.
424628 auto node = std::make_unique<ThreadNode>();
193 424628 node->header = header;
194
1/1
✓ Branch 21 → 22 taken 424628 times.
424628 node->header.messageId = MessageId::canonicalFirst(header.messageId);
195
1/1
✓ Branch 25 → 26 taken 424628 times.
424628 node->header.inReplyToIds = canonicalReplyIds(header);
196 849256 node->header.inReplyTo = node->header.inReplyToIds.isEmpty()
197
2/2
✓ Branch 31 → 32 taken 359347 times.
✓ Branch 31 → 33 taken 65281 times.
849256 ? QString()
198
1/1
✓ Branch 34 → 35 taken 65281 times.
489909 : node->header.inReplyToIds.first();
199
1/1
✓ Branch 39 → 40 taken 424628 times.
424628 node->header.references = canonicalReferences(header);
200
1/1
✓ Branch 44 → 45 taken 424628 times.
424628 allNodes.push_back(std::move(node));
201 424628 }
202
1/1
✓ Branch 52 → 53 taken 940 times.
940 std::sort(allNodes.begin(), allNodes.end(),
203 5341304 [](const auto &left, const auto &right) {
204 5341304 return mailIdLess(left->header, right->header);
205 });
206
207 940 QHash<QString, QList<ThreadNode *>> nodesById;
208
2/2
✓ Branch 73 → 55 taken 424628 times.
✓ Branch 73 → 74 taken 940 times.
425568 for (const auto &node : allNodes) {
209
5/7
✓ Branch 57 → 58 taken 219775 times.
✓ Branch 57 → 61 taken 204853 times.
✓ Branch 58 → 59 taken 219775 times.
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 219775 times.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
210
2/2
✓ Branch 66 → 67 taken 418338 times.
✓ Branch 66 → 71 taken 6290 times.
424628 if (!node->header.messageId.isEmpty())
211
2/2
✓ Branch 68 → 69 taken 418338 times.
✓ Branch 70 → 71 taken 418338 times.
418338 nodesById[node->header.messageId].append(node.get());
212 }
213
214 940 QHash<ThreadNode *, bool> hadResolvableReference;
215 940 QHash<QString, QList<ThreadNode *>> missingAncestorGroups;
216
2/2
✓ Branch 123 → 77 taken 424628 times.
✓ Branch 123 → 124 taken 940 times.
425568 for (const auto &ownedNode : allNodes) {
217
5/7
✓ Branch 79 → 80 taken 219775 times.
✓ Branch 79 → 83 taken 204853 times.
✓ Branch 80 → 81 taken 219775 times.
✗ Branch 81 → 82 not taken.
✓ Branch 81 → 83 taken 219775 times.
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 86 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
218 424628 ThreadNode *node = ownedNode.get();
219 424628 QStringList candidates = node->header.references;
220
1/1
✓ Branch 88 → 89 taken 424628 times.
424628 candidates.append(node->header.inReplyToIds);
221 424628 bool resolvable = false;
222
2/2
✓ Branch 107 → 91 taken 375104 times.
✓ Branch 107 → 108 taken 106987 times.
482091 for (int index = candidates.size() - 1; index >= 0; --index) {
223 375104 const auto found = nodesById.constFind(candidates.at(index));
224
2/2
✓ Branch 95 → 96 taken 55548 times.
✓ Branch 95 → 97 taken 319556 times.
375104 if (found == nodesById.cend())
225 55832 continue;
226
1/1
✓ Branch 98 → 99 taken 319556 times.
319556 ThreadNode *parent = chooseDuplicateParent(found.value(), node);
227
2/2
✓ Branch 99 → 100 taken 284 times.
✓ Branch 99 → 101 taken 319272 times.
319556 if (!parent)
228 284 continue;
229 319272 resolvable = true;
230
2/2
✓ Branch 102 → 103 taken 317641 times.
✓ Branch 102 → 104 taken 1631 times.
319272 if (canAttachTo(node, parent)) {
231 317641 node->parent = parent;
232 317641 break;
233 }
234 }
235
1/1
✓ Branch 108 → 109 taken 424628 times.
424628 hadResolvableReference.insert(node, resolvable);
236
8/8
✓ Branch 109 → 110 taken 106987 times.
✓ Branch 109 → 114 taken 317641 times.
✓ Branch 110 → 111 taken 105590 times.
✓ Branch 110 → 114 taken 1397 times.
✓ Branch 112 → 113 taken 21864 times.
✓ Branch 112 → 114 taken 83726 times.
✓ Branch 115 → 116 taken 21864 times.
✓ Branch 115 → 119 taken 402764 times.
424628 if (!node->parent && !resolvable && !candidates.isEmpty())
237
3/3
✓ Branch 116 → 117 taken 21864 times.
✓ Branch 117 → 118 taken 21864 times.
✓ Branch 118 → 119 taken 21864 times.
21864 missingAncestorGroups[candidates.last()].append(node);
238 424628 }
239
240 // JWZ-style missing containers carry no physical mail. The UI tree cannot
241 // render an invisible QModelIndex, so prune each useful container into its
242 // oldest physical child and attach its siblings there. This preserves every
243 // mail exactly once and retains the missing-ancestor grouping without a fake
244 // selectable message.
245
1/1
✓ Branch 124 → 125 taken 940 times.
940 for (auto group = missingAncestorGroups.begin();
246
2/2
✓ Branch 170 → 126 taken 17704 times.
✓ Branch 170 → 171 taken 940 times.
18644 group != missingAncestorGroups.end(); ++group) {
247
5/7
✓ Branch 127 → 128 taken 8219 times.
✓ Branch 127 → 131 taken 9485 times.
✓ Branch 128 → 129 taken 8219 times.
✗ Branch 129 → 130 not taken.
✓ Branch 129 → 131 taken 8219 times.
✗ Branch 132 → 133 not taken.
✓ Branch 132 → 134 taken 17704 times.
17704 if (cancelled && cancelled()) return {};
248 17704 QList<ThreadNode *> members;
249
4/4
✓ Branch 135 → 136 taken 17704 times.
✓ Branch 136 → 137 taken 17704 times.
✓ Branch 143 → 138 taken 21864 times.
✓ Branch 143 → 144 taken 17704 times.
39568 for (ThreadNode *node : group.value()) {
250
1/2
✓ Branch 139 → 140 taken 21864 times.
✗ Branch 139 → 141 not taken.
21864 if (!node->parent)
251
1/1
✓ Branch 140 → 141 taken 21864 times.
21864 members.append(node);
252 }
253
2/2
✓ Branch 145 → 146 taken 13806 times.
✓ Branch 145 → 147 taken 3898 times.
17704 if (members.size() < 2)
254 13806 continue;
255
3/3
✓ Branch 147 → 148 taken 3898 times.
✓ Branch 148 → 149 taken 3898 times.
✓ Branch 149 → 150 taken 3898 times.
3898 std::sort(members.begin(), members.end(), chronologicalLess);
256
1/1
✓ Branch 150 → 151 taken 3898 times.
3898 ThreadNode *anchor = members.first();
257
2/2
✓ Branch 159 → 152 taken 4160 times.
✓ Branch 159 → 160 taken 3898 times.
8058 for (int index = 1; index < members.size(); ++index) {
258
1/2
✓ Branch 154 → 155 taken 4160 times.
✗ Branch 154 → 157 not taken.
4160 if (canAttachTo(members.at(index), anchor))
259 4160 members.at(index)->parent = anchor;
260 }
261 17704 }
262
263 struct SubjectRoot {
264 ThreadNode *node;
265 std::optional<QSet<QString>> participants;
266 };
267 940 QHash<QString, QList<SubjectRoot>> subjectRoots;
268
2/2
✓ Branch 209 → 173 taken 424628 times.
✓ Branch 209 → 210 taken 940 times.
425568 for (const auto &node : allNodes) {
269
5/7
✓ Branch 175 → 176 taken 219775 times.
✓ Branch 175 → 179 taken 204853 times.
✓ Branch 176 → 177 taken 219775 times.
✗ Branch 177 → 178 not taken.
✓ Branch 177 → 179 taken 219775 times.
✗ Branch 180 → 181 not taken.
✓ Branch 180 → 182 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
270
6/6
✓ Branch 183 → 184 taken 102827 times.
✓ Branch 183 → 187 taken 321801 times.
✓ Branch 186 → 187 taken 1397 times.
✓ Branch 186 → 188 taken 101430 times.
✓ Branch 189 → 190 taken 323198 times.
✓ Branch 189 → 191 taken 101430 times.
424628 if (node->parent || hadResolvableReference.value(node.get(), false))
271 323198 continue;
272
1/1
✓ Branch 192 → 193 taken 101430 times.
101430 const NormalizedSubject subject = normalizedSubject(node->header.subject);
273
6/6
✓ Branch 193 → 194 taken 86581 times.
✓ Branch 193 → 197 taken 14849 times.
✓ Branch 195 → 196 taken 86579 times.
✓ Branch 195 → 197 taken 2 times.
✓ Branch 198 → 199 taken 86579 times.
✓ Branch 198 → 204 taken 14851 times.
101430 if (!subject.replyOrForward && !subject.text.isEmpty())
274
2/2
✓ Branch 199 → 200 taken 86579 times.
✓ Branch 201 → 202 taken 86579 times.
86579 subjectRoots[subject.text].append(SubjectRoot{node.get(), std::nullopt});
275 101430 }
276
3/3
✓ Branch 210 → 211 taken 940 times.
✓ Branch 220 → 212 taken 64262 times.
✓ Branch 220 → 221 taken 940 times.
65202 for (auto roots = subjectRoots.begin(); roots != subjectRoots.end(); ++roots)
277
3/3
✓ Branch 213 → 214 taken 64262 times.
✓ Branch 215 → 216 taken 64262 times.
✓ Branch 216 → 217 taken 64262 times.
64262 std::sort(roots.value().begin(), roots.value().end(),
278 51352 [](const SubjectRoot &left, const SubjectRoot &right) {
279 51352 return chronologicalLess(left.node, right.node);
280 });
281
282
2/2
✓ Branch 316 → 223 taken 424628 times.
✓ Branch 316 → 317 taken 940 times.
425568 for (const auto &ownedNode : allNodes) {
283
5/7
✓ Branch 225 → 226 taken 219775 times.
✓ Branch 225 → 229 taken 204853 times.
✓ Branch 226 → 227 taken 219775 times.
✗ Branch 227 → 228 not taken.
✓ Branch 227 → 229 taken 219775 times.
✗ Branch 230 → 231 not taken.
✓ Branch 230 → 232 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
284 424628 ThreadNode *node = ownedNode.get();
285
6/6
✓ Branch 233 → 234 taken 102827 times.
✓ Branch 233 → 236 taken 321801 times.
✓ Branch 235 → 236 taken 1397 times.
✓ Branch 235 → 237 taken 101430 times.
✓ Branch 238 → 239 taken 323198 times.
✓ Branch 238 → 240 taken 101430 times.
424628 if (node->parent || hadResolvableReference.value(node, false))
286 410432 continue;
287
1/1
✓ Branch 240 → 241 taken 101430 times.
101430 const NormalizedSubject subject = normalizedSubject(node->header.subject);
288
6/6
✓ Branch 241 → 242 taken 14849 times.
✓ Branch 241 → 244 taken 86581 times.
✓ Branch 243 → 244 taken 1 time.
✓ Branch 243 → 245 taken 14848 times.
✓ Branch 246 → 247 taken 86582 times.
✓ Branch 246 → 248 taken 14848 times.
101430 if (!subject.replyOrForward || subject.text.isEmpty())
289 86582 continue;
290
3/3
✓ Branch 248 → 249 taken 14848 times.
✓ Branch 249 → 250 taken 1 time.
✓ Branch 249 → 251 taken 14847 times.
14848 if (!node->header.date.isValid())
291 1 continue;
292
1/1
✓ Branch 251 → 252 taken 14847 times.
14847 auto roots = subjectRoots.find(subject.text);
293
2/2
✓ Branch 254 → 255 taken 650 times.
✓ Branch 254 → 256 taken 14197 times.
14847 if (roots == subjectRoots.end())
294 650 continue;
295
1/1
✓ Branch 256 → 257 taken 14197 times.
14197 const auto replyParticipants = participants(node->header);
296
2/2
✓ Branch 258 → 259 taken 1 time.
✓ Branch 258 → 260 taken 14196 times.
14197 if (replyParticipants.isEmpty())
297 1 continue;
298 // Roots are ordered by date. The first eligible root must be inside the
299 // same 30-day interval; binary search avoids scanning years of older
300 // messages in a popular subject group for every reply.
301 14196 auto &candidates = roots.value();
302
3/3
✓ Branch 262 → 263 taken 14196 times.
✓ Branch 263 → 264 taken 14196 times.
✓ Branch 264 → 265 taken 14196 times.
14196 const auto first = std::lower_bound(candidates.begin(), candidates.end(),
303
1/1
✓ Branch 261 → 262 taken 14196 times.
28392 node->header.date.addSecs(-ThreadBuilder::FallbackWindowSeconds),
304 35727 [](const SubjectRoot &root, const QDateTime &earliest) {
305
3/4
✓ Branch 3 → 4 taken 35727 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 732 times.
✓ Branch 6 → 8 taken 34995 times.
35727 return root.node->header.date.isValid() && root.node->header.date < earliest;
306 });
307
3/3
✓ Branch 300 → 301 taken 20648 times.
✓ Branch 302 → 267 taken 20244 times.
✓ Branch 302 → 303 taken 404 times.
20648 for (auto candidate = first; candidate != candidates.end(); ++candidate) {
308 20244 auto &root = *candidate;
309
7/8
✓ Branch 268 → 269 taken 20244 times.
✓ Branch 269 → 270 taken 20244 times.
✗ Branch 269 → 273 not taken.
✓ Branch 270 → 271 taken 20244 times.
✓ Branch 272 → 273 taken 5314 times.
✓ Branch 272 → 274 taken 14930 times.
✓ Branch 275 → 276 taken 5314 times.
✓ Branch 275 → 277 taken 14930 times.
20244 if (!root.node->header.date.isValid() || root.node->header.date > node->header.date)
310 5314 break;
311
2/4
✓ Branch 277 → 278 taken 14930 times.
✗ Branch 277 → 281 not taken.
✓ Branch 282 → 283 taken 14930 times.
✗ Branch 282 → 299 not taken.
29860 if (root.node != node &&
312
2/3
✓ Branch 278 → 279 taken 14930 times.
✓ Branch 279 → 280 taken 14930 times.
✗ Branch 279 → 281 not taken.
14930 insideFallbackWindow(root.node->header, node->header)) {
313
2/2
✓ Branch 284 → 285 taken 9341 times.
✓ Branch 284 → 289 taken 5589 times.
14930 if (!root.participants)
314
1/1
✓ Branch 285 → 286 taken 9341 times.
9341 root.participants.emplace(participants(root.node->header));
315
5/5
✓ Branch 290 → 291 taken 14930 times.
✓ Branch 291 → 292 taken 8478 times.
✓ Branch 291 → 294 taken 6452 times.
✓ Branch 296 → 297 taken 6452 times.
✓ Branch 296 → 298 taken 8478 times.
23408 if (!participantOverlap(*root.participants, replyParticipants) ||
316
1/2
✗ Branch 293 → 294 not taken.
✓ Branch 293 → 295 taken 8478 times.
8478 !canAttachTo(node, root.node))
317 6452 continue;
318 8478 node->parent = root.node;
319 8478 break; // roots are oldest first, with MailId tie-breakers
320 }
321 }
322 101431 }
323
324 940 int depthLimitedLinks = 0;
325
2/2
✓ Branch 335 → 319 taken 424628 times.
✓ Branch 335 → 336 taken 940 times.
425568 for (const auto &node : allNodes) {
326
5/7
✓ Branch 321 → 322 taken 219775 times.
✓ Branch 321 → 325 taken 204853 times.
✓ Branch 322 → 323 taken 219775 times.
✗ Branch 323 → 324 not taken.
✓ Branch 323 → 325 taken 219775 times.
✗ Branch 326 → 327 not taken.
✓ Branch 326 → 328 taken 424628 times.
424628 if (cancelled && cancelled()) return {};
327
1/2
✗ Branch 330 → 331 not taken.
✓ Branch 330 → 333 taken 424628 times.
424628 if (currentParentDepth(node.get()) > MaximumDepth) {
328 ✗ node->parent = nullptr;
329 ✗ ++depthLimitedLinks;
330 }
331 }
332
1/2
✗ Branch 336 → 338 not taken.
✓ Branch 336 → 352 taken 940 times.
940 if (depthLimitedLinks > 0) {
333 ✗ qCWarning(lcThreadBuilder)
334 ✗ << "Detached" << depthLimitedLinks
335 ✗ << "thread links beyond maximum depth" << MaximumDepth;
336 }
337
338 940 std::vector<std::unique_ptr<ThreadNode>> roots;
339
2/2
✓ Branch 364 → 354 taken 424628 times.
✓ Branch 364 → 365 taken 940 times.
425568 for (auto &node : allNodes) {
340
2/2
✓ Branch 356 → 357 taken 330279 times.
✓ Branch 356 → 360 taken 94349 times.
424628 if (node->parent)
341
1/1
✓ Branch 359 → 362 taken 330279 times.
330279 node->parent->children.push_back(std::move(node));
342 else
343
1/1
✓ Branch 361 → 362 taken 94349 times.
94349 roots.push_back(std::move(node));
344 }
345
346 std::function<QDateTime(ThreadNode *, int)> finalize =
347 426508 [&](ThreadNode *node, int depth) {
348 424628 node->depth = depth;
349 424628 std::sort(node->children.begin(), node->children.end(),
350 259210 [](const auto &left, const auto &right) {
351 259210 return chronologicalLess(left.get(), right.get());
352 });
353 424628 QDateTime newest = node->header.date;
354
2/2
✓ Branch 25 → 8 taken 330279 times.
✓ Branch 25 → 26 taken 424628 times.
754907 for (auto &child : node->children) {
355
1/1
✓ Branch 10 → 11 taken 330279 times.
330279 const auto childNewest = finalize(child.get(), depth + 1);
356
10/11
✓ Branch 11 → 12 taken 330279 times.
✓ Branch 12 → 13 taken 330274 times.
✓ Branch 12 → 19 taken 5 times.
✓ Branch 13 → 14 taken 330274 times.
✓ Branch 14 → 15 taken 330274 times.
✗ Branch 14 → 18 not taken.
✓ Branch 15 → 16 taken 330274 times.
✓ Branch 17 → 18 taken 279783 times.
✓ Branch 17 → 19 taken 50491 times.
✓ Branch 20 → 21 taken 279783 times.
✓ Branch 20 → 22 taken 50496 times.
330279 if (childNewest.isValid() && (!newest.isValid() || childNewest > newest))
357 279783 newest = childNewest;
358 330279 }
359 424628 return newest;
360 940 };
361 struct DatedRoot {
362 QDateTime newest;
363 std::unique_ptr<ThreadNode> node;
364 };
365 940 std::vector<DatedRoot> datedRoots;
366
1/1
✓ Branch 367 → 368 taken 940 times.
940 datedRoots.reserve(roots.size());
367
2/2
✓ Branch 379 → 370 taken 94349 times.
✓ Branch 379 → 380 taken 940 times.
95289 for (auto &root : roots)
368 94349 datedRoots.push_back({finalize(root.get(), 0), std::move(root)});
369 940 roots.clear();
370
1/1
✓ Branch 383 → 384 taken 940 times.
940 std::sort(datedRoots.begin(), datedRoots.end(),
371 928179 [](const DatedRoot &left, const DatedRoot &right) {
372
2/2
✓ Branch 3 → 4 taken 923389 times.
✓ Branch 3 → 6 taken 4790 times.
928179 if (left.newest != right.newest)
373 923389 return left.newest > right.newest;
374 4790 return mailIdLess(left.node->header, right.node->header);
375 });
376
2/2
✓ Branch 391 → 386 taken 94349 times.
✓ Branch 391 → 392 taken 940 times.
95289 for (auto &dated : datedRoots)
377
1/1
✓ Branch 388 → 389 taken 94349 times.
94349 roots.push_back(std::move(dated.node));
378
379 // P90.6: hot-loop progress only with explicitly enabled debug verbosity;
380 // the default production run stays free of per-batch log lines.
381
7/7
✓ Branch 392 → 393 taken 940 times.
✓ Branch 396 → 397 taken 940 times.
✓ Branch 397 → 398 taken 940 times.
✓ Branch 399 → 400 taken 940 times.
✓ Branch 400 → 401 taken 940 times.
✓ Branch 407 → 394 taken 940 times.
✓ Branch 407 → 408 taken 940 times.
1880 qCDebug(lcThreadBuilder) << "Built" << roots.size() << "threads from"
382
2/2
✓ Branch 402 → 403 taken 940 times.
✓ Branch 403 → 404 taken 940 times.
940 << headers.size() << "headers";
383 940 return roots;
384 95289 }
385
386 220529 QStringList ThreadBuilder::dependencyKeys(const MailHeader &header) {
387 220529 QStringList result;
388 488430 const auto add = [&result](const QString &id) {
389
4/4
✓ Branch 3 → 4 taken 482197 times.
✓ Branch 3 → 13 taken 6233 times.
✓ Branch 7 → 8 taken 482197 times.
✓ Branch 8 → 9 taken 482197 times.
970627 if (!id.isEmpty()) result.append(QStringLiteral("id:") + id);
390 488430 };
391
2/2
✓ Branch 2 → 3 taken 220529 times.
✓ Branch 3 → 4 taken 220529 times.
220529 add(MessageId::canonicalFirst(header.messageId));
392
6/6
✓ Branch 5 → 6 taken 220529 times.
✓ Branch 6 → 7 taken 220529 times.
✓ Branch 7 → 8 taken 220529 times.
✓ Branch 10 → 11 taken 241370 times.
✓ Branch 13 → 9 taken 241370 times.
✓ Branch 13 → 14 taken 220529 times.
461899 for (const auto &id : canonicalReferences(header)) add(id);
393
6/6
✓ Branch 15 → 16 taken 220529 times.
✓ Branch 16 → 17 taken 220529 times.
✓ Branch 17 → 18 taken 220529 times.
✓ Branch 20 → 21 taken 26531 times.
✓ Branch 23 → 19 taken 26531 times.
✓ Branch 23 → 24 taken 220529 times.
247060 for (const auto &id : canonicalReplyIds(header)) add(id);
394
1/1
✓ Branch 25 → 26 taken 220529 times.
220529 result.removeDuplicates();
395 220529 return result;
396 ✗ }
397
398 QList<ThreadBuilder::FallbackCandidate>
399 220533 ThreadBuilder::fallbackCandidates(const MailHeader &header) {
400
2/3
✓ Branch 2 → 3 taken 220533 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 220533 times.
220533 if (!header.date.isValid()) return {};
401
1/1
✓ Branch 5 → 6 taken 220533 times.
220533 const auto subject = normalizedSubject(header.subject);
402
2/2
✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 9 taken 220527 times.
220533 if (subject.text.isEmpty()) return {};
403 220527 QList<FallbackCandidate> result;
404
1/1
✓ Branch 9 → 10 taken 220527 times.
220527 const auto addresses = participants(header);
405
1/1
✓ Branch 11 → 12 taken 220527 times.
220527 result.reserve(addresses.size());
406
2/2
✓ Branch 22 → 14 taken 415301 times.
✓ Branch 22 → 23 taken 220527 times.
635828 for (const auto &address : addresses)
407 415301 result.append({subject.text, address, header.date.toSecsSinceEpoch(),
408 415301 subject.replyOrForward});
409 220527 return result;
410 635834 }
411