MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - controller - ThreadBuilder.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 100.0 % 87 87
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 8 8
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 73.2 % 142 104

             Branch data     Line data    Source code
       1                 :             : #include "ThreadBuilder.h"
       2                 :             : 
       3                 :             : #include <QHash>
       4                 :             : #include <QLoggingCategory>
       5                 :             : #include <QRegularExpression>
       6                 :             : 
       7                 :             : #include <algorithm>
       8                 :             : 
       9   [ +  +  +  -  :          73 : Q_LOGGING_CATEGORY(lcThreadBuilder, "mailjd.threading")
             +  -  -  - ]
      10                 :             : 
      11                 :             : // T-79.B1/M9: true if 'candidate' or any of its ancestors is 'node'.
      12                 :             : // Linking node under candidate would then close a parent cycle — both
      13                 :             : // mails would end up unreachable from the roots (invisible + leaked).
      14                 :          38 : static bool wouldCreateCycle(const ThreadNode *node,
      15                 :             :                              const ThreadNode *candidate) {
      16         [ +  + ]:          78 :   for (const ThreadNode *p = candidate; p; p = p->parent) {
      17         [ +  + ]:          44 :     if (p == node)
      18                 :           4 :       return true;
      19                 :             :   }
      20                 :          34 :   return false;
      21                 :             : }
      22                 :             : 
      23                 :             : std::vector<std::unique_ptr<ThreadNode>>
      24                 :          80 : ThreadBuilder::buildThreads(const QList<MailHeader> &headers) {
      25         [ +  + ]:          80 :   if (headers.isEmpty())
      26                 :           7 :     return {};
      27                 :             : 
      28                 :             :   // Step 1: Build messageId → ThreadNode index
      29                 :             :   // All nodes are created as unique_ptr and stored in allNodes.
      30                 :             :   // Raw pointers in nodeById are non-owning references for linking.
      31                 :          73 :   QHash<QString, ThreadNode *> nodeById;
      32                 :          73 :   std::vector<std::unique_ptr<ThreadNode>> allNodes;
      33                 :             : 
      34         [ +  + ]:         275 :   for (int i = 0; i < headers.size(); ++i) {
      35         [ +  - ]:         202 :     auto node = std::make_unique<ThreadNode>();
      36                 :         202 :     node->header = headers[i]; // T-79.B1/H7: node owns its header by value
      37                 :         202 :     auto *raw = node.get();
      38         [ +  - ]:         202 :     allNodes.push_back(std::move(node));
      39                 :             : 
      40         [ +  + ]:         202 :     if (!headers[i].messageId.isEmpty()) {
      41         [ +  - ]:         174 :       nodeById.insert(headers[i].messageId, raw);
      42                 :             :     }
      43                 :         202 :   }
      44                 :             : 
      45                 :             :   // Step 2: Link parents via In-Reply-To, then References fallback.
      46                 :             :   // T-79.B1/M9: refuse links that would close a cycle (covers the old
      47                 :             :   // self-reference guard too) — forged mutual In-Reply-To/References
      48                 :             :   // otherwise made both mails vanish from the thread view.
      49         [ +  + ]:         275 :   for (auto &node : allNodes) {
      50                 :             :     // Try In-Reply-To first (most reliable)
      51         [ +  + ]:         202 :     if (!node->header.inReplyTo.isEmpty()) {
      52                 :          22 :       auto *parent = nodeById.value(node->header.inReplyTo, nullptr);
      53   [ +  +  +  +  :          22 :       if (parent && !wouldCreateCycle(node.get(), parent)) {
                   +  + ]
      54                 :          19 :         node->parent = parent;
      55                 :             :         // Transfer ownership: move from allNodes to parent->children later
      56                 :          19 :         continue; // linked via In-Reply-To
      57                 :             :       }
      58                 :             :     }
      59                 :             :     // T-432: Fallback — try References header (last entry = direct parent)
      60         [ +  + ]:         183 :     if (!node->header.references.isEmpty()) {
      61         [ +  + ]:          14 :       for (int r = node->header.references.size() - 1; r >= 0; --r) {
      62                 :             :         auto *refParent =
      63         [ +  - ]:          12 :             nodeById.value(node->header.references[r], nullptr);
      64   [ +  +  +  +  :          12 :         if (refParent && !wouldCreateCycle(node.get(), refParent)) {
                   +  + ]
      65                 :           8 :           node->parent = refParent;
      66                 :           8 :           break;
      67                 :             :         }
      68                 :             :       }
      69                 :             :     }
      70                 :             :   }
      71                 :             : 
      72                 :             :   // Step 3: Subject-based fallback for orphaned replies
      73                 :             :   // Group by normalized subject → if a Re:/Fwd: mail has no parent,
      74                 :             :   // try to attach it to the oldest non-reply mail with the same subject.
      75                 :          73 :   QHash<QString, ThreadNode *> subjectRoots;
      76         [ +  + ]:         275 :   for (auto &node : allNodes) {
      77         [ +  + ]:         202 :     if (node->parent)
      78                 :          28 :       continue; // already threaded
      79                 :             : 
      80         [ +  - ]:         175 :     QString normSubject = normalizeSubject(node->header.subject);
      81         [ +  + ]:         175 :     if (normSubject.isEmpty())
      82                 :           1 :       continue;
      83                 :             : 
      84         [ +  - ]:         174 :     bool isReply = (normSubject != node->header.subject.trimmed());
      85         [ +  + ]:         174 :     if (!isReply) {
      86                 :             :       // This is a root-level subject (not a reply)
      87         [ +  + ]:         164 :       if (!subjectRoots.contains(normSubject)) {
      88         [ +  - ]:         161 :         subjectRoots.insert(normSubject, node.get());
      89                 :             :       }
      90                 :             :     }
      91         [ +  + ]:         175 :   }
      92                 :             : 
      93                 :             :   // Now attach orphaned replies to subject roots
      94         [ +  + ]:         275 :   for (auto &node : allNodes) {
      95         [ +  + ]:         202 :     if (node->parent)
      96                 :          27 :       continue;
      97                 :             : 
      98         [ +  - ]:         175 :     QString normSubject = normalizeSubject(node->header.subject);
      99         [ +  - ]:         175 :     bool isReply = (normSubject != node->header.subject.trimmed());
     100   [ +  +  +  +  :         175 :     if (isReply && subjectRoots.contains(normSubject)) {
                   +  + ]
     101                 :           7 :       auto *root = subjectRoots.value(normSubject);
     102         [ +  - ]:           7 :       if (!wouldCreateCycle(node.get(), root)) {
     103                 :           7 :         node->parent = root;
     104                 :             :       }
     105                 :             :     }
     106                 :         175 :   }
     107                 :             : 
     108                 :             :   // Step 4: Transfer children ownership from allNodes to parent->children
     109                 :             :   // and collect root nodes.
     110                 :             :   // We must iterate with index because we move elements out of allNodes.
     111                 :          73 :   std::vector<std::unique_ptr<ThreadNode>> roots;
     112         [ +  + ]:         275 :   for (size_t i = 0; i < allNodes.size(); ++i) {
     113         [ +  + ]:         202 :     if (allNodes[i]->parent) {
     114         [ +  - ]:          34 :       allNodes[i]->parent->children.push_back(std::move(allNodes[i]));
     115                 :             :     } else {
     116         [ +  - ]:         168 :       roots.push_back(std::move(allNodes[i]));
     117                 :             :     }
     118                 :             :   }
     119                 :             : 
     120                 :             :   // Set depths recursively
     121                 :         348 :   std::function<void(ThreadNode *, int)> setDepths = [&](ThreadNode *node,
     122                 :             :                                                          int depth) {
     123                 :         202 :     node->depth = depth;
     124         [ +  + ]:         236 :     for (auto &child : node->children) {
     125         [ +  - ]:          34 :       setDepths(child.get(), depth + 1);
     126                 :             :     }
     127                 :         275 :   };
     128         [ +  + ]:         241 :   for (auto &root : roots) {
     129         [ +  - ]:         168 :     setDepths(root.get(), 0);
     130                 :             :   }
     131                 :             : 
     132                 :             :   // Step 5: Sort children by date (oldest first)
     133                 :         348 :   std::function<void(ThreadNode *)> sortChildren = [&](ThreadNode *node) {
     134                 :         202 :     std::sort(node->children.begin(), node->children.end(),
     135                 :          15 :               [](const std::unique_ptr<ThreadNode> &a,
     136                 :             :                  const std::unique_ptr<ThreadNode> &b) {
     137                 :          15 :                 return a->header.date < b->header.date;
     138                 :             :               });
     139         [ +  + ]:         236 :     for (auto &child : node->children) {
     140         [ +  - ]:          34 :       sortChildren(child.get());
     141                 :             :     }
     142                 :         275 :   };
     143         [ +  + ]:         241 :   for (auto &root : roots) {
     144         [ +  - ]:         168 :     sortChildren(root.get());
     145                 :             :   }
     146                 :             : 
     147                 :             :   // Step 6: Sort root nodes by newest descendant date (DESC)
     148         [ +  - ]:          73 :   std::sort(roots.begin(), roots.end(),
     149                 :         177 :             [](const std::unique_ptr<ThreadNode> &a,
     150                 :             :                const std::unique_ptr<ThreadNode> &b) {
     151   [ +  -  +  -  :         177 :               return a->newestDate() > b->newestDate();
                   +  - ]
     152                 :             :             });
     153                 :             : 
     154   [ +  -  +  -  :         146 :   qCInfo(lcThreadBuilder) << "Built" << roots.size() << "threads from"
          +  -  +  -  +  
                -  +  + ]
     155   [ +  -  +  - ]:          73 :                           << headers.size() << "headers";
     156                 :          73 :   return roots;
     157                 :          73 : }
     158                 :             : 
     159                 :         350 : QString ThreadBuilder::normalizeSubject(const QString &subject) {
     160                 :             :   // Strip Re:/Fwd:/AW:/WG: prefixes (case-insensitive, repeating)
     161                 :             :   static QRegularExpression prefixRx(
     162                 :             :       R"(^(?:\s*(?:Re|Fwd|Fw|AW|WG)\s*(?:\[\d+\])?\s*:\s*)+)",
     163   [ +  +  +  -  :         350 :       QRegularExpression::CaseInsensitiveOption);
          +  -  +  -  -  
                      - ]
     164                 :         350 :   QString result = subject;
     165         [ +  - ]:         350 :   result.replace(prefixRx, QString());
     166         [ +  - ]:         700 :   return result.trimmed();
     167                 :         350 : }
        

Generated by: LCOV version 2.0-1