1 /**
2 Copyright: Copyright (c) 2018, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 This module contains different kinds of report methods and statistical
11 analyzers of the data gathered in the database.
12 */
13 module dextool.plugin.mutate.backend.report.analyzers;
14 
15 import logger = std.experimental.logger;
16 import std.algorithm : sum, map, sort, filter, count, cmp, joiner, among;
17 import std.array : array, appender, empty;
18 import std.conv : to;
19 import std.datetime : SysTime;
20 import std.exception : collectException;
21 import std.format : format;
22 import std.range : take, retro, only;
23 import std.typecons : Flag, Yes, No, Tuple, Nullable, tuple;
24 
25 import my.named_type;
26 import my.optional;
27 
28 import dextool.plugin.mutate.backend.database : Database, spinSql, MutationId, MarkedMutant;
29 import dextool.plugin.mutate.backend.diff_parser : Diff;
30 import dextool.plugin.mutate.backend.generate_mutant : MakeMutationTextResult,
31     makeMutationText, makeMutation;
32 import dextool.plugin.mutate.backend.interface_ : FilesysIO;
33 import dextool.plugin.mutate.backend.report.utility : window, windowSize,
34     statusToString, kindToString;
35 import dextool.plugin.mutate.backend.type : Mutation, Offset, TestCase, TestGroup;
36 import dextool.plugin.mutate.backend.utility : Profile;
37 import dextool.plugin.mutate.type : ReportKillSortOrder, ReportSection;
38 import dextool.type;
39 
40 static import dextool.plugin.mutate.backend.database.type;
41 
42 public import dextool.plugin.mutate.backend.report.utility : Table;
43 public import dextool.plugin.mutate.backend.type : MutantTimeProfile;
44 
45 version (unittest) {
46     import unit_threaded.assertions;
47 }
48 
49 @safe:
50 
51 void reportMutationSubtypeStats(ref const long[MakeMutationTextResult] mut_stat, ref Table!4 tbl) @safe nothrow {
52     auto profile = Profile(ReportSection.mut_stat);
53 
54     long total = mut_stat.byValue.sum;
55 
56     foreach (v; mut_stat.byKeyValue.array.sort!((a, b) => a.value > b.value).take(20)) {
57         try {
58             auto percentage = (cast(double) v.value / cast(double) total) * 100.0;
59 
60             // dfmt off
61             typeof(tbl).Row r = [
62                 percentage.to!string,
63                 v.value.to!string,
64                 format("`%s`", window(v.key.original, windowSize)),
65                 format("`%s`", window(v.key.mutation, windowSize)),
66             ];
67             // dfmt on
68             tbl.put(r);
69         } catch (Exception e) {
70             logger.warning(e.msg).collectException;
71         }
72     }
73 }
74 
75 /** Test case score based on how many mutants they killed.
76  */
77 struct TestCaseStat {
78     import dextool.plugin.mutate.backend.database.type : TestCaseInfo;
79 
80     struct Info {
81         double ratio = 0.0;
82         TestCase tc;
83         TestCaseInfo info;
84         alias info this;
85     }
86 
87     Info[TestCase] testCases;
88 
89     /// Returns: the test cases sorted from most kills to least kills.
90     auto toSortedRange() {
91         static bool cmp(T)(ref T a, ref T b) {
92             if (a.killedMutants > b.killedMutants)
93                 return true;
94             else if (a.killedMutants < b.killedMutants)
95                 return false;
96             else if (a.tc.name > b.tc.name)
97                 return true;
98             else if (a.tc.name < b.tc.name)
99                 return false;
100             return false;
101         }
102 
103         return testCases.byValue.array.sort!cmp;
104     }
105 }
106 
107 /** Update the table with the score of test cases and how many mutants they killed.
108  *
109  * Params:
110  *  take_ = how many from the top should be moved to the table
111  *  sort_order = ctrl if the top or bottom of the test cases should be reported
112  *  tbl = table to write the data to
113  */
114 void toTable(ref TestCaseStat st, const long take_,
115         const ReportKillSortOrder sort_order, ref Table!3 tbl) @safe nothrow {
116     auto takeOrder(RangeT)(RangeT range) {
117         final switch (sort_order) {
118         case ReportKillSortOrder.top:
119             return range.take(take_).array;
120         case ReportKillSortOrder.bottom:
121             return range.retro.take(take_).array;
122         }
123     }
124 
125     foreach (v; takeOrder(st.toSortedRange)) {
126         try {
127             typeof(tbl).Row r = [
128                 (100.0 * v.ratio).to!string, v.info.killedMutants.to!string,
129                 v.tc.name
130             ];
131             tbl.put(r);
132         } catch (Exception e) {
133             logger.warning(e.msg).collectException;
134         }
135     }
136 }
137 
138 /** Extract the number of source code mutants that a test case has killed and
139  * how much the kills contributed to the total.
140  */
141 TestCaseStat reportTestCaseStats(ref Database db, const Mutation.Kind[] kinds) @safe nothrow {
142     import dextool.plugin.mutate.backend.database.type : TestCaseInfo;
143 
144     auto profile = Profile(ReportSection.tc_stat);
145 
146     const total = spinSql!(() { return db.totalSrcMutants(kinds).count; });
147     // nothing to do. this also ensure that we do not divide by zero.
148     if (total == 0)
149         return TestCaseStat.init;
150 
151     alias TcInfo = Tuple!(TestCase, "tc", TestCaseInfo, "info");
152     alias TcInfo2 = Tuple!(TestCase, "tc", Nullable!TestCaseInfo, "info");
153     TestCaseStat rval;
154 
155     foreach (v; spinSql!(() { return db.getDetectedTestCases; }).map!(a => TcInfo2(a, spinSql!(() {
156                 return db.getTestCaseInfo(a, kinds);
157             })))
158             .filter!(a => !a.info.isNull)
159             .map!(a => TcInfo(a.tc, a.info.get))) {
160         try {
161             const ratio = cast(double) v.info.killedMutants / cast(double) total;
162             rval.testCases[v.tc] = TestCaseStat.Info(ratio, v.tc, v.info);
163         } catch (Exception e) {
164             logger.warning(e.msg).collectException;
165         }
166     }
167 
168     return rval;
169 }
170 
171 /** The result of analysing the test cases to see how similare they are to each
172  * other.
173  */
174 class TestCaseSimilarityAnalyse {
175     import dextool.plugin.mutate.backend.type : TestCase;
176 
177     static struct Similarity {
178         TestCase testCase;
179         double similarity = 0.0;
180         /// Mutants that are similare between `testCase` and the parent.
181         MutationId[] intersection;
182         /// Unique mutants that are NOT verified by `testCase`.
183         MutationId[] difference;
184     }
185 
186     Similarity[][TestCase] similarities;
187 }
188 
189 /// The result of the similarity analyse
190 private struct Similarity {
191     /// The quota |A intersect B| / |A|. Thus it is how similare A is to B. If
192     /// B ever fully encloses A then the score is 1.0.
193     double similarity = 0.0;
194     MutationId[] intersection;
195     MutationId[] difference;
196 }
197 
198 // The set similairty measures how much of lhs is in rhs. This is a
199 // directional metric.
200 private Similarity setSimilarity(MutationId[] lhs_, MutationId[] rhs_) {
201     import my.set;
202 
203     auto lhs = lhs_.toSet;
204     auto rhs = rhs_.toSet;
205     auto intersect = lhs.intersect(rhs);
206     auto diff = lhs.setDifference(rhs);
207     return Similarity(cast(double) intersect.length / cast(double) lhs.length,
208             intersect.toArray, diff.toArray);
209 }
210 
211 /** Analyse the similarity between test cases.
212  *
213  * TODO: the algorithm used is slow. Maybe matrix representation and sorted is better?
214  *
215  * Params:
216  *  db = ?
217  *  kinds = mutation kinds to use in the distance analyze
218  *  limit = limit the number of test cases to the top `limit`.
219  */
220 TestCaseSimilarityAnalyse reportTestCaseSimilarityAnalyse(ref Database db,
221         const Mutation.Kind[] kinds, ulong limit) @safe {
222     import std.container.binaryheap;
223     import dextool.plugin.mutate.backend.database.type : TestCaseInfo, TestCaseId;
224 
225     auto profile = Profile(ReportSection.tc_similarity);
226 
227     // TODO: reduce the code duplication of the caches.
228     // The DB lookups must be cached or otherwise the algorithm becomes too
229     // slow for practical use.
230 
231     MutationId[][TestCaseId] kill_cache2;
232     MutationId[] getKills(TestCaseId id) @trusted {
233         return kill_cache2.require(id, spinSql!(() {
234                 return db.getTestCaseMutantKills(id, kinds);
235             }));
236     }
237 
238     TestCase[TestCaseId] tc_cache2;
239     TestCase getTestCase(TestCaseId id) @trusted {
240         return tc_cache2.require(id, spinSql!(() {
241                 // assuming it can never be null
242                 return db.getTestCase(id).get;
243             }));
244     }
245 
246     alias TcKills = Tuple!(TestCaseId, "id", MutationId[], "kills");
247 
248     const test_cases = spinSql!(() { return db.getDetectedTestCaseIds; });
249 
250     auto rval = new typeof(return);
251 
252     foreach (tc_kill; test_cases.map!(a => TcKills(a, getKills(a)))
253             .filter!(a => a.kills.length != 0)) {
254         auto app = appender!(TestCaseSimilarityAnalyse.Similarity[])();
255         foreach (tc; test_cases.filter!(a => a != tc_kill.id)
256                 .map!(a => TcKills(a, getKills(a)))
257                 .filter!(a => a.kills.length != 0)) {
258             auto distance = setSimilarity(tc_kill.kills, tc.kills);
259             if (distance.similarity > 0)
260                 app.put(TestCaseSimilarityAnalyse.Similarity(getTestCase(tc.id),
261                         distance.similarity, distance.intersection, distance.difference));
262         }
263         if (app.data.length != 0) {
264             () @trusted {
265                 rval.similarities[getTestCase(tc_kill.id)] = heapify!((a,
266                         b) => a.similarity < b.similarity)(app.data).take(limit).array;
267             }();
268         }
269     }
270 
271     return rval;
272 }
273 
274 /// Statistics about dead test cases.
275 struct TestCaseDeadStat {
276     import std.range : isOutputRange;
277 
278     /// The ratio of dead TC of the total.
279     double ratio = 0.0;
280     TestCase[] testCases;
281     long total;
282 
283     long numDeadTC() @safe pure nothrow const @nogc scope {
284         return testCases.length;
285     }
286 
287     string toString() @safe const {
288         auto buf = appender!string;
289         toString(buf);
290         return buf.data;
291     }
292 
293     void toString(Writer)(ref Writer w) @safe const 
294             if (isOutputRange!(Writer, char)) {
295         import std.ascii : newline;
296         import std.format : formattedWrite;
297         import std.range : put;
298 
299         if (total > 0)
300             formattedWrite(w, "%s/%s = %s of all test cases\n", numDeadTC, total, ratio);
301         foreach (tc; testCases) {
302             put(w, tc.name);
303             if (tc.location.length > 0) {
304                 put(w, " | ");
305                 put(w, tc.location);
306             }
307             put(w, newline);
308         }
309     }
310 }
311 
312 void toTable(ref TestCaseDeadStat st, ref Table!2 tbl) @safe pure nothrow {
313     foreach (tc; st.testCases) {
314         typeof(tbl).Row r = [tc.name, tc.location];
315         tbl.put(r);
316     }
317 }
318 
319 /** Returns: report of test cases that has killed zero mutants.
320  */
321 TestCaseDeadStat reportDeadTestCases(ref Database db) @safe {
322     auto profile = Profile(ReportSection.tc_killed_no_mutants);
323 
324     TestCaseDeadStat r;
325     r.total = db.getNumOfTestCases;
326     r.testCases = db.getTestCasesWithZeroKills;
327     if (r.total > 0)
328         r.ratio = cast(double) r.numDeadTC / cast(double) r.total;
329     return r;
330 }
331 
332 /// Information needed to present the mutant to an user.
333 struct MutationRepr {
334     import dextool.type : Path;
335     import dextool.plugin.mutate.backend.type : SourceLoc;
336 
337     SourceLoc sloc;
338     Path file;
339     MakeMutationTextResult mutation;
340 }
341 
342 alias Mutations = bool[MutationId];
343 alias MutationsMap = Mutations[TestCase];
344 alias MutationReprMap = MutationRepr[MutationId];
345 
346 void reportTestCaseKillMap(WriterTextT, WriterT)(ref const MutationsMap mut_stat,
347         ref const MutationReprMap mutrepr, WriterTextT writer_txt, WriterT writer) @safe {
348     import std.range : put;
349 
350     auto profile = Profile(ReportSection.tc_map);
351 
352     alias MutTable = Table!4;
353     alias Row = MutTable.Row;
354 
355     foreach (tc_muts; mut_stat.byKeyValue) {
356         put(writer_txt, tc_muts.key.toString);
357 
358         MutTable tbl;
359         tbl.heading = ["ID", "File Line:Column", "From", "To"];
360 
361         foreach (mut; tc_muts.value.byKey) {
362             Row row;
363 
364             if (auto v = mut in mutrepr) {
365                 row[1] = format("%s %s:%s", v.file, v.sloc.line, v.sloc.column);
366                 row[2] = format("`%s`", window(v.mutation.original, windowSize));
367                 row[3] = format("`%s`", window(v.mutation.mutation, windowSize));
368             }
369 
370             row[0] = mut.get.to!string;
371             tbl.put(row);
372         }
373 
374         put(writer, tbl);
375     }
376 }
377 
378 void reportMutationTestCaseSuggestion(WriterT)(ref Database db,
379         const MutationId[] tc_sugg, WriterT writer) @safe {
380     import std.range : put;
381 
382     auto profile = Profile(ReportSection.tc_suggestion);
383 
384     alias MutTable = Table!1;
385     alias Row = MutTable.Row;
386 
387     foreach (mut_id; tc_sugg) {
388         MutTable tbl;
389         tbl.heading = [mut_id.get.to!string];
390 
391         try {
392             auto suggestions = db.getSurroundingTestCases(mut_id);
393             if (suggestions.length == 0)
394                 continue;
395 
396             foreach (tc; suggestions) {
397                 Row row;
398                 row[0] = format("`%s`", tc);
399                 tbl.put(row);
400             }
401             put(writer, tbl);
402         } catch (Exception e) {
403             logger.warning(e.msg);
404         }
405     }
406 }
407 
408 /// Only the mutation score thus a subset of all statistics.
409 struct MutationScore {
410     import core.time : Duration;
411 
412     long alive;
413     long killed;
414     long timeout;
415     long total;
416     long noCoverage;
417     long equivalent;
418     MutantTimeProfile totalTime;
419 
420     // Nr of mutants that are alive but tagged with nomut.
421     long aliveNoMut;
422 
423     double score() @safe pure nothrow const @nogc {
424         if (total > 0) {
425             return cast(double)(killed + timeout) / cast(double)(total - aliveNoMut);
426         }
427         return 0.0;
428     }
429 }
430 
431 MutationScore reportScore(ref Database db, const Mutation.Kind[] kinds, string file = null) @safe nothrow {
432     auto profile = Profile("reportScore");
433 
434     const alive = spinSql!(() { return db.aliveSrcMutants(kinds, file); });
435     const noCov = spinSql!(() { return db.noCovSrcMutants(kinds, file); });
436     const aliveNomut = spinSql!(() { return db.aliveNoMutSrcMutants(kinds, file); });
437     const killed = spinSql!(() { return db.killedSrcMutants(kinds, file); });
438     const timeout = spinSql!(() { return db.timeoutSrcMutants(kinds, file); });
439     const equivalent = spinSql!(() => db.equivalentMutants(kinds, file));
440     const total = spinSql!(() { return db.totalSrcMutants(kinds, file); });
441 
442     typeof(return) rval;
443     rval.alive = alive.count;
444     rval.killed = killed.count;
445     rval.timeout = timeout.count;
446     rval.total = total.count;
447     rval.aliveNoMut = aliveNomut.count;
448     rval.noCoverage = noCov.count;
449     rval.equivalent = equivalent.count;
450     rval.totalTime = total.time;
451 
452     return rval;
453 }
454 
455 /// Statistics for a group of mutants.
456 struct MutationStat {
457     import core.time : Duration;
458     import std.range : isOutputRange;
459 
460     long untested;
461     long killedByCompiler;
462     long worklist;
463 
464     long alive() @safe pure nothrow const @nogc {
465         return scoreData.alive;
466     }
467 
468     long noCoverage() @safe pure nothrow const @nogc {
469         return scoreData.noCoverage;
470     }
471 
472     /// Nr of mutants that are alive but tagged with nomut.
473     long aliveNoMut() @safe pure nothrow const @nogc {
474         return scoreData.aliveNoMut;
475     }
476 
477     long killed() @safe pure nothrow const @nogc {
478         return scoreData.killed;
479     }
480 
481     long timeout() @safe pure nothrow const @nogc {
482         return scoreData.timeout;
483     }
484 
485     long equivalent() @safe pure nothrow const @nogc {
486         return scoreData.equivalent;
487     }
488 
489     long total() @safe pure nothrow const @nogc {
490         return scoreData.total;
491     }
492 
493     MutantTimeProfile totalTime() @safe pure nothrow const @nogc {
494         return scoreData.totalTime;
495     }
496 
497     MutationScore scoreData;
498     MutantTimeProfile killedByCompilerTime;
499     Duration predictedDone;
500 
501     /// Adjust the score with the alive mutants that are suppressed.
502     double score() @safe pure nothrow const @nogc {
503         return scoreData.score;
504     }
505 
506     /// Suppressed mutants of the total mutants.
507     double suppressedOfTotal() @safe pure nothrow const @nogc {
508         if (total > 0) {
509             return (cast(double)(aliveNoMut) / cast(double) total);
510         }
511         return 0.0;
512     }
513 
514     string toString() @safe const {
515         auto buf = appender!string;
516         toString(buf);
517         return buf.data;
518     }
519 
520     void toString(Writer)(ref Writer w) const if (isOutputRange!(Writer, char)) {
521         import core.time : dur;
522         import std.ascii : newline;
523         import std.datetime : Clock;
524         import std.format : formattedWrite;
525         import std.range : put;
526         import dextool.plugin.mutate.backend.utility;
527 
528         immutable align_ = 19;
529 
530         formattedWrite(w, "%-*s %s\n", align_, "Time spent:", totalTime);
531         if (untested > 0 && predictedDone > 0.dur!"msecs") {
532             const pred = Clock.currTime + predictedDone;
533             formattedWrite(w, "Remaining: %s (%s)\n", predictedDone, pred.toISOExtString);
534         }
535         if (killedByCompiler > 0) {
536             formattedWrite(w, "%-*s %s\n", align_ * 3,
537                     "Time spent on mutants killed by compiler:", killedByCompilerTime);
538         }
539 
540         put(w, newline);
541 
542         // mutation score and details
543         formattedWrite(w, "%-*s %.3s\n", align_, "Score:", score);
544 
545         formattedWrite(w, "%-*s %s\n", align_, "Total:", total);
546         if (untested > 0) {
547             formattedWrite(w, "%-*s %s\n", align_, "Untested:", untested);
548         }
549         formattedWrite(w, "%-*s %s\n", align_, "Alive:", alive);
550         formattedWrite(w, "%-*s %s\n", align_, "Killed:", killed);
551         if (equivalent > 0)
552             formattedWrite(w, "%-*s %s\n", align_, "Equivalent:", equivalent);
553         formattedWrite(w, "%-*s %s\n", align_, "Timeout:", timeout);
554         formattedWrite(w, "%-*s %s\n", align_, "Killed by compiler:", killedByCompiler);
555         if (worklist > 0) {
556             formattedWrite(w, "%-*s %s\n", align_, "Worklist:", worklist);
557         }
558 
559         if (aliveNoMut > 0) {
560             formattedWrite(w, "%-*s %s (%.3s)\n", align_,
561                     "Suppressed (nomut):", aliveNoMut, suppressedOfTotal);
562         }
563     }
564 }
565 
566 MutationStat reportStatistics(ref Database db, const Mutation.Kind[] kinds, string file = null) @safe nothrow {
567     import core.time : dur;
568     import dextool.plugin.mutate.backend.utility;
569 
570     auto profile = Profile(ReportSection.summary);
571 
572     const untested = spinSql!(() { return db.unknownSrcMutants(kinds, file); });
573     const worklist = spinSql!(() { return db.getWorklistCount; });
574     const killedByCompiler = spinSql!(() {
575         return db.killedByCompilerSrcMutants(kinds, file);
576     });
577 
578     MutationStat st;
579     st.scoreData = reportScore(db, kinds, file);
580     st.untested = untested.count;
581     st.killedByCompiler = killedByCompiler.count;
582     st.worklist = worklist;
583 
584     st.predictedDone = st.total > 0 ? (st.worklist * (st.totalTime.sum / st.total)) : 0
585         .dur!"msecs";
586     st.killedByCompilerTime = killedByCompiler.time;
587 
588     return st;
589 }
590 
591 struct MarkedMutantsStat {
592     Table!6 tbl;
593 }
594 
595 MarkedMutantsStat reportMarkedMutants(ref Database db, const Mutation.Kind[] kinds,
596         string file = null) @safe {
597     MarkedMutantsStat st;
598     st.tbl.heading = [
599         "File", "Line", "Column", "Mutation", "Status", "Rationale"
600     ];
601 
602     foreach (m; db.getMarkedMutants()) {
603         typeof(st.tbl).Row r = [
604             m.path, m.sloc.line.to!string, m.sloc.column.to!string,
605             m.mutText, statusToString(m.toStatus), m.rationale.get
606         ];
607         st.tbl.put(r);
608     }
609     return st;
610 }
611 
612 struct TestCaseOverlapStat {
613     import std.format : formattedWrite;
614     import std.range : put;
615     import my.hash;
616     import dextool.plugin.mutate.backend.database.type : TestCaseId;
617 
618     long overlap;
619     long total;
620     double ratio = 0.0;
621 
622     // map between test cases and the mutants they have killed.
623     TestCaseId[][Murmur3] tc_mut;
624     // map between mutation IDs and the test cases that killed them.
625     long[][Murmur3] mutid_mut;
626     string[TestCaseId] name_tc;
627 
628     string sumToString() @safe const {
629         return format("%s/%s = %s test cases", overlap, total, ratio);
630     }
631 
632     void sumToString(Writer)(ref Writer w) @trusted const {
633         formattedWrite(w, "%s/%s = %s test cases\n", overlap, total, ratio);
634     }
635 
636     string toString() @safe const {
637         auto buf = appender!string;
638         toString(buf);
639         return buf.data;
640     }
641 
642     void toString(Writer)(ref Writer w) @safe const {
643         sumToString(w);
644 
645         foreach (tcs; tc_mut.byKeyValue.filter!(a => a.value.length > 1)) {
646             bool first = true;
647             // TODO this is a bit slow. use a DB row iterator instead.
648             foreach (name; tcs.value.map!(id => name_tc[id])) {
649                 if (first) {
650                     () @trusted {
651                         formattedWrite(w, "%s %s\n", name, mutid_mut[tcs.key].length);
652                     }();
653                     first = false;
654                 } else {
655                     () @trusted { formattedWrite(w, "%s\n", name); }();
656                 }
657             }
658             put(w, "\n");
659         }
660     }
661 }
662 
663 /** Report test cases that completly overlap each other.
664  *
665  * Returns: a string with statistics.
666  */
667 template toTable(Flag!"colWithMutants" colMutants) {
668     static if (colMutants) {
669         alias TableT = Table!3;
670     } else {
671         alias TableT = Table!2;
672     }
673     alias RowT = TableT.Row;
674 
675     void toTable(ref TestCaseOverlapStat st, ref TableT tbl) {
676         foreach (tcs; st.tc_mut.byKeyValue.filter!(a => a.value.length > 1)) {
677             bool first = true;
678             // TODO this is a bit slow. use a DB row iterator instead.
679             foreach (name; tcs.value.map!(id => st.name_tc[id])) {
680                 RowT r;
681                 r[0] = name;
682                 if (first) {
683                     auto muts = st.mutid_mut[tcs.key];
684                     r[1] = muts.length.to!string;
685                     static if (colMutants) {
686                         r[2] = format("%-(%s,%)", muts);
687                     }
688                     first = false;
689                 }
690 
691                 tbl.put(r);
692             }
693             static if (colMutants)
694                 RowT r = ["", "", ""];
695             else
696                 RowT r = ["", ""];
697             tbl.put(r);
698         }
699     }
700 }
701 
702 /// Test cases that kill exactly the same mutants.
703 TestCaseOverlapStat reportTestCaseFullOverlap(ref Database db, const Mutation.Kind[] kinds) @safe {
704     import my.hash;
705     import dextool.plugin.mutate.backend.database.type : TestCaseId;
706 
707     auto profile = Profile(ReportSection.tc_full_overlap);
708 
709     TestCaseOverlapStat st;
710     st.total = db.getNumOfTestCases;
711 
712     foreach (tc_id; db.getTestCasesWithAtLeastOneKill(kinds)) {
713         auto muts = db.getTestCaseMutantKills(tc_id, kinds).sort.map!(a => cast(long) a).array;
714         auto m3 = makeMurmur3(cast(ubyte[]) muts);
715         if (auto v = m3 in st.tc_mut)
716             (*v) ~= tc_id;
717         else {
718             st.tc_mut[m3] = [tc_id];
719             st.mutid_mut[m3] = muts;
720         }
721         st.name_tc[tc_id] = db.getTestCaseName(tc_id);
722     }
723 
724     foreach (tcs; st.tc_mut.byKeyValue.filter!(a => a.value.length > 1)) {
725         st.overlap += tcs.value.count;
726     }
727 
728     if (st.total > 0)
729         st.ratio = cast(double) st.overlap / cast(double) st.total;
730 
731     return st;
732 }
733 
734 class TestGroupSimilarity {
735     static struct TestGroup {
736         string description;
737         string name;
738 
739         /// What the user configured as regex. Useful when e.g. generating reports
740         /// for a user.
741         string userInput;
742 
743         int opCmp(ref const TestGroup s) const {
744             return cmp(name, s.name);
745         }
746     }
747 
748     static struct Similarity {
749         /// The test group that the `key` is compared to.
750         TestGroup comparedTo;
751         /// How similare the `key` is to `comparedTo`.
752         double similarity = 0.0;
753         /// Mutants that are similare between `testCase` and the parent.
754         MutationId[] intersection;
755         /// Unique mutants that are NOT verified by `testCase`.
756         MutationId[] difference;
757     }
758 
759     Similarity[][TestGroup] similarities;
760 }
761 
762 /** Analyze the similarity between the test groups.
763  *
764  * Assuming that a limit on how many test groups to report isn't interesting
765  * because they are few so it is never a problem.
766  *
767  */
768 TestGroupSimilarity reportTestGroupsSimilarity(ref Database db,
769         const(Mutation.Kind)[] kinds, const(TestGroup)[] test_groups) @safe {
770     import dextool.plugin.mutate.backend.database.type : TestCaseInfo, TestCaseId;
771 
772     auto profile = Profile(ReportSection.tc_groups_similarity);
773 
774     alias TgKills = Tuple!(TestGroupSimilarity.TestGroup, "testGroup", MutationId[], "kills");
775 
776     const test_cases = spinSql!(() { return db.getDetectedTestCaseIds; }).map!(
777             a => Tuple!(TestCaseId, "id", TestCase, "tc")(a, spinSql!(() {
778                 return db.getTestCase(a).get;
779             }))).array;
780 
781     MutationId[] gatherKilledMutants(const(TestGroup) tg) {
782         auto kills = appender!(MutationId[])();
783         foreach (tc; test_cases.filter!(a => a.tc.isTestCaseInTestGroup(tg.re))) {
784             kills.put(spinSql!(() {
785                     return db.getTestCaseMutantKills(tc.id, kinds);
786                 }));
787         }
788         return kills.data;
789     }
790 
791     TgKills[] test_group_kills;
792     foreach (const tg; test_groups) {
793         auto kills = gatherKilledMutants(tg);
794         if (kills.length != 0)
795             test_group_kills ~= TgKills(TestGroupSimilarity.TestGroup(tg.description,
796                     tg.name, tg.userInput), kills);
797     }
798 
799     // calculate similarity between all test groups.
800     auto rval = new typeof(return);
801 
802     foreach (tg_parent; test_group_kills) {
803         auto app = appender!(TestGroupSimilarity.Similarity[])();
804         foreach (tg_other; test_group_kills.filter!(a => a.testGroup != tg_parent.testGroup)) {
805             auto similarity = setSimilarity(tg_parent.kills, tg_other.kills);
806             if (similarity.similarity > 0)
807                 app.put(TestGroupSimilarity.Similarity(tg_other.testGroup,
808                         similarity.similarity, similarity.intersection, similarity.difference));
809             if (app.data.length != 0)
810                 rval.similarities[tg_parent.testGroup] = app.data;
811         }
812     }
813 
814     return rval;
815 }
816 
817 class TestGroupStat {
818     import dextool.plugin.mutate.backend.database : MutationId, FileId, MutantInfo;
819 
820     /// Human readable description for the test group.
821     string description;
822     /// Statistics for a test group.
823     MutationStat stats;
824     /// Map between test cases and their test group.
825     TestCase[] testCases;
826     /// Lookup for converting a id to a filename
827     Path[FileId] files;
828     /// Mutants alive in a file.
829     MutantInfo[][FileId] alive;
830     /// Mutants killed in a file.
831     MutantInfo[][FileId] killed;
832 }
833 
834 import std.regex : Regex;
835 
836 private bool isTestCaseInTestGroup(const TestCase tc, const Regex!char tg) {
837     import std.regex : matchFirst;
838 
839     auto m = matchFirst(tc.name, tg);
840     // the regex must match the full test case thus checking that
841     // nothing is left before or after
842     if (!m.empty && m.pre.length == 0 && m.post.length == 0) {
843         return true;
844     }
845     return false;
846 }
847 
848 TestGroupStat reportTestGroups(ref Database db, const(Mutation.Kind)[] kinds,
849         const(TestGroup) test_g) @safe {
850     import dextool.plugin.mutate.backend.database : MutationStatusId;
851     import my.set;
852 
853     auto profile = Profile(ReportSection.tc_groups);
854 
855     static struct TcStat {
856         Set!MutationStatusId alive;
857         Set!MutationStatusId killed;
858         Set!MutationStatusId timeout;
859         Set!MutationStatusId total;
860 
861         // killed by the specific test case
862         Set!MutationStatusId tcKilled;
863     }
864 
865     auto r = new TestGroupStat;
866     r.description = test_g.description;
867     TcStat tc_stat;
868 
869     // map test cases to this test group
870     foreach (tc; db.getDetectedTestCases) {
871         if (tc.isTestCaseInTestGroup(test_g.re))
872             r.testCases ~= tc;
873     }
874 
875     // collect mutation statistics for each test case group
876     foreach (const tc; r.testCases) {
877         foreach (const id; db.testCaseMutationPointAliveSrcMutants(kinds, tc))
878             tc_stat.alive.add(id);
879         foreach (const id; db.testCaseMutationPointKilledSrcMutants(kinds, tc))
880             tc_stat.killed.add(id);
881         foreach (const id; db.testCaseMutationPointTimeoutSrcMutants(kinds, tc))
882             tc_stat.timeout.add(id);
883         foreach (const id; db.testCaseMutationPointTotalSrcMutants(kinds, tc))
884             tc_stat.total.add(id);
885         foreach (const id; db.testCaseKilledSrcMutants(kinds, tc))
886             tc_stat.tcKilled.add(id);
887     }
888 
889     // update the mutation stat for the test group
890     r.stats.scoreData.alive = tc_stat.alive.length;
891     r.stats.scoreData.killed = tc_stat.killed.length;
892     r.stats.scoreData.timeout = tc_stat.timeout.length;
893     r.stats.scoreData.total = tc_stat.total.length;
894 
895     // associate mutants with their file
896     foreach (const m; db.getMutantsInfo(kinds, tc_stat.tcKilled.toArray)) {
897         auto fid = db.getFileId(m.id);
898         r.killed[fid.get] ~= m;
899 
900         if (fid.get !in r.files) {
901             r.files[fid.get] = Path.init;
902             r.files[fid.get] = db.getFile(fid.get).get;
903         }
904     }
905 
906     foreach (const m; db.getMutantsInfo(kinds, tc_stat.alive.toArray)) {
907         auto fid = db.getFileId(m.id);
908         r.alive[fid.get] ~= m;
909 
910         if (fid.get !in r.files) {
911             r.files[fid.get] = Path.init;
912             r.files[fid.get] = db.getFile(fid.get).get;
913         }
914     }
915 
916     return r;
917 }
918 
919 /// High interest mutants.
920 class MutantSample {
921     import dextool.plugin.mutate.backend.database : MutationId, FileId, MutantInfo,
922         MutationStatus, MutationStatusId, MutationEntry, MutationStatusTime;
923 
924     MutationEntry[MutationStatusId] mutants;
925 
926     /// The mutant that had its status updated the furthest back in time.
927     MutationStatusTime[] oldest;
928 
929     /// The mutant that has survived the longest in the system.
930     MutationStatus[] highestPrio;
931 
932     /// The latest mutants that where added and survived.
933     MutationStatusTime[] latest;
934 }
935 
936 /// Returns: samples of mutants that are of high interest to the user.
937 MutantSample reportSelectedAliveMutants(ref Database db, const(Mutation.Kind)[] kinds,
938         long historyNr) {
939     auto profile = Profile(ReportSection.mut_recommend_kill);
940 
941     auto rval = new typeof(return);
942 
943     rval.highestPrio = db.getHighestPrioMutant(kinds, Mutation.Status.alive, historyNr);
944     foreach (const mutst; rval.highestPrio) {
945         auto ids = db.getMutationIds(kinds, [mutst.statusId]);
946         if (ids.length != 0)
947             rval.mutants[mutst.statusId] = db.getMutation(ids[0]).get;
948     }
949 
950     rval.oldest = db.getOldestMutants(kinds, historyNr);
951     foreach (const mutst; rval.oldest) {
952         auto ids = db.getMutationIds(kinds, [mutst.id]);
953         if (ids.length != 0)
954             rval.mutants[mutst.id] = db.getMutation(ids[0]).get;
955     }
956 
957     return rval;
958 }
959 
960 class DiffReport {
961     import dextool.plugin.mutate.backend.database : FileId, MutantInfo;
962     import dextool.plugin.mutate.backend.diff_parser : Diff;
963 
964     /// The mutation score.
965     double score = 0.0;
966 
967     /// The raw diff for a file
968     Diff.Line[][FileId] rawDiff;
969 
970     /// Lookup for converting a id to a filename
971     Path[FileId] files;
972     /// Mutants alive in a file.
973     MutantInfo[][FileId] alive;
974     /// Mutants killed in a file.
975     MutantInfo[][FileId] killed;
976     /// Test cases that killed mutants.
977     TestCase[] testCases;
978 
979     override string toString() @safe const {
980         import std.format : formattedWrite;
981         import std.range : put;
982 
983         auto w = appender!string;
984 
985         foreach (file; files.byKeyValue) {
986             put(w, file.value.toString);
987             foreach (mut; alive[file.key])
988                 formattedWrite(w, "  %s\n", mut);
989             foreach (mut; killed[file.key])
990                 formattedWrite(w, "  %s\n", mut);
991         }
992 
993         formattedWrite(w, "Test Cases killing mutants");
994         foreach (tc; testCases)
995             formattedWrite(w, "  %s", tc);
996 
997         return w.data;
998     }
999 }
1000 
1001 DiffReport reportDiff(ref Database db, const(Mutation.Kind)[] kinds,
1002         ref Diff diff, AbsolutePath workdir) {
1003     import dextool.plugin.mutate.backend.database : MutationId, MutationStatusId;
1004     import dextool.plugin.mutate.backend.type : SourceLoc;
1005     import my.set;
1006 
1007     auto profile = Profile(ReportSection.diff);
1008 
1009     auto rval = new DiffReport;
1010 
1011     Set!MutationStatusId total;
1012     Set!MutationId alive;
1013     Set!MutationId killed;
1014 
1015     foreach (kv; diff.toRange(workdir)) {
1016         auto fid = db.getFileId(kv.key);
1017         if (fid.isNull) {
1018             logger.warning("This file in the diff has not been tested thus skipping it: ", kv.key);
1019             continue;
1020         }
1021 
1022         bool hasMutants;
1023         foreach (id; kv.value
1024                 .toRange
1025                 .map!(line => spinSql!(() => db.getMutationsOnLine(kinds,
1026                     fid.get, SourceLoc(line))))
1027                 .joiner
1028                 .filter!(a => a !in total)) {
1029             hasMutants = true;
1030             total.add(id);
1031 
1032             const info = db.getMutantsInfo(kinds, [id])[0];
1033             if (info.status == Mutation.Status.alive) {
1034                 rval.alive[fid.get] ~= info;
1035                 alive.add(info.id);
1036             } else if (info.status.among(Mutation.Status.killed, Mutation.Status.timeout)) {
1037                 rval.killed[fid.get] ~= info;
1038                 killed.add(info.id);
1039             }
1040         }
1041 
1042         if (hasMutants) {
1043             rval.files[fid.get] = kv.key;
1044             rval.rawDiff[fid.get] = diff.rawDiff[kv.key];
1045         } else {
1046             logger.info("This file in the diff has no mutants on changed lines: ", kv.key);
1047         }
1048     }
1049 
1050     Set!TestCase test_cases;
1051     foreach (tc; killed.toRange.map!(a => db.getTestCases(a)).joiner) {
1052         test_cases.add(tc);
1053     }
1054 
1055     rval.testCases = test_cases.toArray.sort.array;
1056 
1057     if (total.length == 0) {
1058         rval.score = 1.0;
1059     } else {
1060         // TODO: use total to compute e.g. a standard deviation or some other
1061         // useful statistical metric to convey a "confidence" of the value.
1062         rval.score = cast(double) killed.length / cast(double)(killed.length + alive.length);
1063     }
1064 
1065     return rval;
1066 }
1067 
1068 struct MinimalTestSet {
1069     import dextool.plugin.mutate.backend.database.type : TestCaseInfo;
1070 
1071     long total;
1072 
1073     /// Minimal set that achieve the mutation test score.
1074     TestCase[] minimalSet;
1075     /// Test cases that do not contribute to the mutation test score.
1076     TestCase[] redundant;
1077     /// Map between test case name and sum of all the test time of the mutants it killed.
1078     TestCaseInfo[string] testCaseTime;
1079 }
1080 
1081 MinimalTestSet reportMinimalSet(ref Database db, const Mutation.Kind[] kinds) {
1082     import dextool.plugin.mutate.backend.database : TestCaseId, TestCaseInfo;
1083     import my.set;
1084 
1085     auto profile = Profile(ReportSection.tc_min_set);
1086 
1087     alias TcIdInfo = Tuple!(TestCase, "tc", TestCaseId, "id", TestCaseInfo, "info");
1088 
1089     MinimalTestSet rval;
1090 
1091     Set!MutationId killedMutants;
1092 
1093     // start by picking test cases that have the fewest kills.
1094     foreach (const val; db.getDetectedTestCases
1095             .map!(a => tuple(a, db.getTestCaseId(a)))
1096             .filter!(a => !a[1].isNull)
1097             .map!(a => TcIdInfo(a[0], a[1].get, db.getTestCaseInfo(a[0], kinds).get))
1098             .filter!(a => a.info.killedMutants != 0)
1099             .array
1100             .sort!((a, b) => a.info.killedMutants < b.info.killedMutants)) {
1101         rval.testCaseTime[val.tc.name] = val.info;
1102 
1103         const killed = killedMutants.length;
1104         foreach (const id; db.getTestCaseMutantKills(val.id, kinds)) {
1105             killedMutants.add(id);
1106         }
1107 
1108         if (killedMutants.length > killed)
1109             rval.minimalSet ~= val.tc;
1110         else
1111             rval.redundant ~= val.tc;
1112     }
1113 
1114     rval.total = rval.minimalSet.length + rval.redundant.length;
1115 
1116     return rval;
1117 }
1118 
1119 struct TestCaseUniqueness {
1120     MutationId[][TestCase] uniqueKills;
1121 
1122     // test cases that have no unique kills. These are candidates for being
1123     // refactored/removed.
1124     TestCase[] noUniqueKills;
1125 }
1126 
1127 /// Returns: a report of the mutants that a test case is the only one that kills.
1128 TestCaseUniqueness reportTestCaseUniqueness(ref Database db, const Mutation.Kind[] kinds) {
1129     import dextool.plugin.mutate.backend.database.type : TestCaseId;
1130     import my.set;
1131 
1132     auto profile = Profile(ReportSection.tc_unique);
1133 
1134     /// any time a mutant is killed by more than one test case it is removed.
1135     TestCaseId[MutationId] killedBy;
1136     Set!MutationId blacklist;
1137 
1138     foreach (tc_id; db.getTestCasesWithAtLeastOneKill(kinds)) {
1139         auto muts = db.getTestCaseMutantKills(tc_id, kinds);
1140         foreach (m; muts.filter!(a => !blacklist.contains(a))) {
1141             if (m in killedBy) {
1142                 killedBy.remove(m);
1143                 blacklist.add(m);
1144             } else {
1145                 killedBy[m] = tc_id;
1146             }
1147         }
1148     }
1149 
1150     // use a cache to reduce the database access
1151     TestCase[TestCaseId] idToTc;
1152     TestCase getTestCase(TestCaseId id) @trusted {
1153         return idToTc.require(id, spinSql!(() { return db.getTestCase(id).get; }));
1154     }
1155 
1156     typeof(return) rval;
1157     Set!TestCaseId uniqueTc;
1158     foreach (kv; killedBy.byKeyValue) {
1159         rval.uniqueKills[getTestCase(kv.value)] ~= kv.key;
1160         uniqueTc.add(kv.value);
1161     }
1162     foreach (tc_id; db.getDetectedTestCaseIds.filter!(a => !uniqueTc.contains(a))) {
1163         rval.noUniqueKills ~= getTestCase(tc_id);
1164     }
1165 
1166     return rval;
1167 }
1168 
1169 /// Estimate the mutation score.
1170 struct EstimateMutationScore {
1171     import my.signal_theory.kalman : KalmanFilter;
1172 
1173     private KalmanFilter kf;
1174 
1175     void update(const double a) {
1176         kf.updateEstimate(a);
1177     }
1178 
1179     /// The estimated mutation score.
1180     NamedType!(double, Tag!"EstimatedMutationScore", 0.0, TagStringable) value() @safe pure nothrow const @nogc {
1181         return typeof(return)(kf.currentEstimate);
1182     }
1183 
1184     /// The error in the estimate. The unit is the same as `estimate`.
1185     NamedType!(double, Tag!"MutationScoreError", 0.0, TagStringable) error() @safe pure nothrow const @nogc {
1186         return typeof(return)(kf.estimateError);
1187     }
1188 }
1189 
1190 /// Estimate the mutation score.
1191 struct EstimateScore {
1192     import my.signal_theory.kalman : KalmanFilter;
1193 
1194     // 0.5 because then it starts in the middle of range possible values.
1195     // 0.01 such that the trend is "slowly" changing over the last 100 mutants.
1196     // 0.001 is to "insensitive" for an on the fly analysis so it mostly just
1197     //  end up being the current mutation score.
1198     private EstimateMutationScore estimate = EstimateMutationScore(KalmanFilter(0.5, 0.5, 0.01));
1199 
1200     /// Update the estimate with the status of a mutant.
1201     void update(const Mutation.Status s) {
1202         import std.algorithm : among;
1203 
1204         if (s.among(Mutation.Status.unknown, Mutation.Status.killedByCompiler)) {
1205             return;
1206         }
1207 
1208         const v = () {
1209             final switch (s) with (Mutation.Status) {
1210             case unknown:
1211                 goto case;
1212             case killedByCompiler:
1213                 return 0.5; // shouldnt happen but...
1214             case noCoverage:
1215                 goto case;
1216             case alive:
1217                 return 0.0;
1218             case killed:
1219                 goto case;
1220             case timeout:
1221                 goto case;
1222             case equivalent:
1223                 return 1.0;
1224             }
1225         }();
1226 
1227         estimate.update(v);
1228     }
1229 
1230     /// The estimated mutation score.
1231     auto value() @safe pure nothrow const @nogc {
1232         return estimate.value;
1233     }
1234 
1235     /// The error in the estimate. The unit is the same as `estimate`.
1236     auto error() @safe pure nothrow const @nogc {
1237         return estimate.error;
1238     }
1239 }
1240 
1241 /// Estimated trend based on the latest code changes.
1242 struct ScoreTrendByCodeChange {
1243     static struct Point {
1244         SysTime timeStamp;
1245 
1246         /// The estimated mutation score.
1247         NamedType!(double, Tag!"EstimatedMutationScore", 0.0, TagStringable) value;
1248 
1249         /// The error in the estimate. The unit is the same as `estimate`.
1250         NamedType!(double, Tag!"MutationScoreError", 0.0, TagStringable) error;
1251     }
1252 
1253     Point[] sample;
1254 
1255     NamedType!(double, Tag!"EstimatedMutationScore", 0.0, TagStringable) value() @safe pure nothrow const @nogc {
1256         if (sample.empty)
1257             return typeof(return).init;
1258         return sample[$ - 1].value;
1259     }
1260 
1261     NamedType!(double, Tag!"MutationScoreError", 0.0, TagStringable) error() @safe pure nothrow const @nogc {
1262         if (sample.empty)
1263             return typeof(return).init;
1264         return sample[$ - 1].error;
1265     }
1266 }
1267 
1268 /** Estimate the mutation score by running a kalman filter over the mutants in
1269  * the order they have been tested. It gives a rough estimate of where the test
1270  * suites quality is going over time.
1271  *
1272  */
1273 ScoreTrendByCodeChange reportTrendByCodeChange(ref Database db, const Mutation.Kind[] kinds) @trusted nothrow {
1274     auto app = appender!(ScoreTrendByCodeChange.Point[])();
1275     EstimateScore estimate;
1276 
1277     try {
1278         SysTime lastAdded;
1279         SysTime last;
1280         bool first = true;
1281         void fn(const Mutation.Status s, const SysTime added) {
1282             estimate.update(s);
1283             debug logger.trace(estimate.estimate.kf).collectException;
1284 
1285             if (first)
1286                 lastAdded = added;
1287 
1288             if (added != lastAdded) {
1289                 app.put(ScoreTrendByCodeChange.Point(added, estimate.value, estimate.error));
1290                 lastAdded = added;
1291             }
1292 
1293             last = added;
1294             first = false;
1295         }
1296 
1297         db.iterateMutantStatus(kinds, &fn);
1298         app.put(ScoreTrendByCodeChange.Point(last, estimate.value, estimate.error));
1299     } catch (Exception e) {
1300         logger.warning(e.msg).collectException;
1301     }
1302     return ScoreTrendByCodeChange(app.data);
1303 }
1304 
1305 /** History of how the mutation score have evolved over time.
1306  *
1307  * The history is ordered iascending by date. Each day is the average of the
1308  * recorded mutation score.
1309  */
1310 struct MutationScoreHistory {
1311     import dextool.plugin.mutate.backend.database.type : MutationScore;
1312 
1313     static struct Estimate {
1314         SysTime x;
1315         double avg = 0;
1316         SysTime predX;
1317         double predScore = 0;
1318         bool posTrend = 0;
1319     }
1320 
1321     /// only one score for each date.
1322     MutationScore[] data;
1323     Estimate estimate;
1324 
1325     this(MutationScore[] data) {
1326         import std.algorithm : sum, map, min;
1327 
1328         this.data = data;
1329         if (data.length < 6)
1330             return;
1331 
1332         const values = data[$ - 5 .. $];
1333         const avg = sum(values.map!(a => a.score.get)) / 5.0;
1334         const xDiff = values[$ - 1].timeStamp - values[0].timeStamp;
1335         const dy = (values[$ - 1].score.get - avg) / (xDiff.total!"days" / 2.0);
1336 
1337         estimate.x = values[0].timeStamp + xDiff / 2;
1338         estimate.avg = avg;
1339         estimate.predX = values[$ - 1].timeStamp + xDiff / 2;
1340         estimate.predScore = min(1.0, dy * xDiff.total!"days" / 2.0 + values[$ - 1].score.get);
1341         estimate.posTrend = estimate.predScore > values[$ - 1].score.get;
1342     }
1343 }
1344 
1345 MutationScoreHistory reportMutationScoreHistory(ref Database db) @safe {
1346     return reportMutationScoreHistory(db.getMutationScoreHistory);
1347 }
1348 
1349 private MutationScoreHistory reportMutationScoreHistory(
1350         dextool.plugin.mutate.backend.database.type.MutationScore[] data) {
1351     import std.datetime : DateTime, Date, SysTime;
1352     import dextool.plugin.mutate.backend.database.type : MutationScore;
1353 
1354     auto pretty = appender!(MutationScore[])();
1355 
1356     if (data.length < 2) {
1357         return MutationScoreHistory(data);
1358     }
1359 
1360     auto last = (cast(DateTime) data[0].timeStamp).date;
1361     double acc = data[0].score.get;
1362     double nr = 1;
1363     foreach (a; data[1 .. $]) {
1364         auto curr = (cast(DateTime) a.timeStamp).date;
1365         if (curr == last) {
1366             acc += a.score.get;
1367             nr++;
1368         } else {
1369             pretty.put(MutationScore(SysTime(last), typeof(MutationScore.score)(acc / nr)));
1370             last = curr;
1371             acc = a.score.get;
1372             nr = 1;
1373         }
1374     }
1375     pretty.put(MutationScore(SysTime(last), typeof(MutationScore.score)(acc / nr)));
1376 
1377     return MutationScoreHistory(pretty.data);
1378 }
1379 
1380 @("shall calculate the mean of the mutation scores")
1381 unittest {
1382     import core.time : days;
1383     import std.datetime : DateTime;
1384     import dextool.plugin.mutate.backend.database.type : MutationScore;
1385 
1386     auto data = appender!(MutationScore[])();
1387     auto d = DateTime(2000, 6, 1, 10, 30, 0);
1388 
1389     data.put(MutationScore(SysTime(d), typeof(MutationScore.score)(10.0)));
1390     data.put(MutationScore(SysTime(d), typeof(MutationScore.score)(5.0)));
1391     data.put(MutationScore(SysTime(d + 1.days), typeof(MutationScore.score)(5.0)));
1392 
1393     auto res = reportMutationScoreHistory(data.data);
1394 
1395     res.data[0].score.get.shouldEqual(7.5);
1396     res.data[1].score.get.shouldEqual(5.0);
1397 }
1398 
1399 /** Sync status is how old the information about mutants and their status is
1400  * compared to when the tests or source code where last changed.
1401  */
1402 struct SyncStatus {
1403     import dextool.plugin.mutate.backend.database : MutationStatusTime;
1404 
1405     SysTime test;
1406     SysTime code;
1407     SysTime coverage;
1408     MutationStatusTime[] mutants;
1409 }
1410 
1411 SyncStatus reportSyncStatus(ref Database db, const(Mutation.Kind)[] kinds, const long nrMutants) {
1412     import std.datetime : Clock;
1413     import dextool.plugin.mutate.backend.database : TestFile, TestFileChecksum, TestFilePath;
1414 
1415     typeof(return) rval;
1416     rval.test = spinSql!(() => db.getNewestTestFile)
1417         .orElse(TestFile(TestFilePath.init, TestFileChecksum.init, Clock.currTime)).timeStamp;
1418     rval.code = spinSql!(() => db.getNewestFile).orElse(Clock.currTime);
1419     rval.coverage = spinSql!(() => db.getCoverageTimeStamp).orElse(Clock.currTime);
1420     rval.mutants = spinSql!(() => db.getOldestMutants(kinds, nrMutants));
1421     return rval;
1422 }