• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GothenburgBitFactory / taskwarrior / 13158305484

05 Feb 2025 01:20PM UTC coverage: 85.125% (+0.08%) from 85.045%
13158305484

push

github

web-flow
Test for unusual task data (#3770)

* Test for unusual task data

A task might have any combination of keys and values, but Taskwarrior
often assumes that only valid values can occur, and crashes otherwise.
This is highly inconvenient, as it's often impossible to do anything
with the invalid task -- Taskwarrior just fails without modifying it.

So, this is the beginning of some testing for such invalid tasks, with
the goal of making Taskwarrior due something reasonable. In general, an
invalid attribute value is treated as if it was not set. This is not
exhaustive, and there are likely still bugs of this sort, but as we find
them we can fix and add regression tests to this script.

This introduces a new test-only binary that creates a "bare" task using
TaskChampion, avoiding Taskwarrior's efforts to not create "unusual"
tasks.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

70 of 77 new or added lines in 7 files covered. (90.91%)

1 existing line in 1 file now uncovered.

19555 of 22972 relevant lines covered (85.13%)

23300.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.27
/src/recur.cpp
1
////////////////////////////////////////////////////////////////////////////////
2
//
3
// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez.
4
//
5
// Permission is hereby granted, free of charge, to any person obtaining a copy
6
// of this software and associated documentation files (the "Software"), to deal
7
// in the Software without restriction, including without limitation the rights
8
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
// copies of the Software, and to permit persons to whom the Software is
10
// furnished to do so, subject to the following conditions:
11
//
12
// The above copyright notice and this permission notice shall be included
13
// in all copies or substantial portions of the Software.
14
//
15
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
// SOFTWARE.
22
//
23
// https://www.opensource.org/licenses/mit-license.php
24
//
25
////////////////////////////////////////////////////////////////////////////////
26

27
#include <cmake.h>
28
// cmake.h include header must come first
29

30
#include <Context.h>
31
#include <Datetime.h>
32
#include <Duration.h>
33
#include <Lexer.h>
34
#include <format.h>
35
#include <inttypes.h>
36
#include <main.h>
37
#include <pwd.h>
38
#include <stdio.h>
39
#include <stdlib.h>
40
#include <string.h>
41
#include <sys/types.h>
42
#include <time.h>
43
#include <unicode.h>
44
#include <unistd.h>
45
#include <util.h>
46

47
#include <fstream>
48
#include <iomanip>
49
#include <iostream>
50
#include <limits>
51
#include <optional>
52
#include <sstream>
53

54
// Add a `time_t` delta to a Datetime, checking for and returning nullopt on integer overflow.
55
std::optional<Datetime> checked_add_datetime(Datetime& base, time_t delta) {
50✔
56
  // Datetime::operator+ takes an integer delta, so check that range
57
  if (static_cast<time_t>(std::numeric_limits<int>::max()) < delta) {
50✔
58
    return std::nullopt;
1✔
59
  }
60

61
  // Check for time_t overflow in the Datetime.
62
  if (std::numeric_limits<time_t>::max() - base.toEpoch() < delta) {
49✔
63
    return std::nullopt;
1✔
64
  }
65
  return base + delta;
48✔
66
}
67

68
////////////////////////////////////////////////////////////////////////////////
69
// Scans all tasks, and for any recurring tasks, determines whether any new
70
// child tasks need to be generated to fill gaps.
71
void handleRecurrence() {
898✔
72
  // Recurrence can be disabled.
73
  // Note: This is currently a workaround for TD-44, TW-1520.
74
  if (!Context::getContext().config.getBoolean("recurrence")) return;
2,694✔
75

76
  auto tasks = Context::getContext().tdb2.pending_tasks();
896✔
77
  Datetime now;
896✔
78

79
  // Look at all tasks and find any recurring ones.
80
  for (auto& t : tasks) {
7,401✔
81
    if (t.getStatus() == Task::recurring) {
6,505✔
82
      // Generate a list of due dates for this recurring task, regardless of
83
      // the mask.
84
      std::vector<Datetime> due;
151✔
85
      if (!generateDueDates(t, due)) {
151✔
86
        // Determine the end date.
87
        t.setStatus(Task::deleted);
1✔
88
        Context::getContext().tdb2.modify(t);
1✔
89
        Context::getContext().footnote(onExpiration(t));
1✔
90
        continue;
1✔
91
      }
92

93
      // Get the mask from the parent task.
94
      auto mask = t.get("mask");
150✔
95

96
      // Iterate over the due dates, and check each against the mask.
97
      auto changed = false;
150✔
98
      unsigned int i = 0;
150✔
99
      for (auto& d : due) {
364✔
100
        if (mask.length() <= i) {
214✔
101
          changed = true;
118✔
102

103
          Task rec(t);                       // Clone the parent.
118✔
104
          rec.setStatus(Task::pending);      // Change the status.
118✔
105
          rec.set("uuid", uuid());           // New UUID.
354✔
106
          rec.set("parent", t.get("uuid"));  // Remember mom.
472✔
107
          rec.setAsNow("entry");             // New entry date.
118✔
108
          rec.set("due", format(d.toEpoch()));
354✔
109

110
          if (t.has("wait")) {
236✔
111
            Datetime old_wait(t.get_date("wait"));
2✔
112
            Datetime old_due(t.get_date("due"));
1✔
113
            Datetime due(d);
1✔
114
            auto wait = checked_add_datetime(due, old_wait - old_due);
1✔
115
            if (wait) {
1✔
116
              rec.set("wait", format(wait->toEpoch()));
3✔
117
            } else {
118
              rec.remove("wait");
×
119
            }
120
            rec.setStatus(Task::waiting);
1✔
121
            mask += 'W';
1✔
122
          } else {
123
            mask += '-';
117✔
124
            rec.setStatus(Task::pending);
117✔
125
          }
126

127
          rec.set("imask", i);
354✔
128
          rec.remove("mask");  // Remove the mask of the parent.
118✔
129

130
          // Add the new task to the DB.
131
          Context::getContext().tdb2.add(rec);
118✔
132
        }
118✔
133

134
        ++i;
214✔
135
      }
136

137
      // Only modify the parent if necessary.
138
      if (changed) {
150✔
139
        t.set("mask", mask);
75✔
140
        Context::getContext().tdb2.modify(t);
75✔
141

142
        if (Context::getContext().verbose("recur"))
225✔
143
          Context::getContext().footnote(
116✔
144
              format("Creating recurring task instance '{1}'", t.get("description")));
290✔
145
      }
146
    }
151✔
147
  }
148
}
896✔
149

150
////////////////////////////////////////////////////////////////////////////////
151
// Determine a start date (due), an optional end date (until), and an increment
152
// period (recur).  Then generate a set of corresponding dates.
153
//
154
// Returns false if the parent recurring task is depleted.
155
bool generateDueDates(Task& parent, std::vector<Datetime>& allDue) {
151✔
156
  // Determine due date, recur period and until date.
157
  Datetime due(parent.get_date("due"));
151✔
158
  if (due._date == 0) return false;
151✔
159

160
  std::string recur = parent.get("recur");
150✔
161

162
  bool specificEnd = false;
150✔
163
  Datetime until;
150✔
164
  if (parent.get("until") != "") {
300✔
165
    until = Datetime(parent.get("until"));
15✔
166
    specificEnd = true;
5✔
167
  }
168

169
  auto recurrence_limit = Context::getContext().config.getInteger("recurrence.limit");
300✔
170
  int recurrence_counter = 0;
150✔
171
  Datetime now;
150✔
172
  Datetime i = due;
150✔
173
  while (1) {
174
    allDue.push_back(i);
214✔
175

176
    if (specificEnd && i > until) {
214✔
177
      // If i > until, it means there are no more tasks to generate, and if the
178
      // parent mask contains all + or X, then there never will be another task
179
      // to generate, and this parent task may be safely reaped.
180
      auto mask = parent.get("mask");
3✔
181
      if (mask.length() == allDue.size() && mask.find('-') == std::string::npos) return false;
3✔
182

183
      return true;
3✔
184
    }
3✔
185

186
    if (i > now) ++recurrence_counter;
211✔
187

188
    if (recurrence_counter >= recurrence_limit) return true;
211✔
189
    auto next = getNextRecurrence(i, recur);
66✔
190
    if (next) {
66✔
191
      i = *next;
64✔
192
    } else {
193
      return true;
2✔
194
    }
195
  }
64✔
196

197
  return true;
198
}
150✔
199

200
////////////////////////////////////////////////////////////////////////////////
201
/// Determine the next recurrence of the given period.
202
///
203
/// If no such date can be calculated, such as with a very large period, returns
204
/// nullopt.
205
std::optional<Datetime> getNextRecurrence(Datetime& current, std::string& period) {
66✔
206
  auto m = current.month();
66✔
207
  auto d = current.day();
66✔
208
  auto y = current.year();
66✔
209
  auto ho = current.hour();
66✔
210
  auto mi = current.minute();
66✔
211
  auto se = current.second();
66✔
212

213
  // Some periods are difficult, because they can be vague.
214
  if (period == "monthly" || period == "P1M") {
66✔
215
    if (++m > 12) {
×
216
      m -= 12;
×
217
      ++y;
×
218
    }
219

220
    while (!Datetime::valid(y, m, d)) --d;
×
221

222
    return Datetime(y, m, d, ho, mi, se);
×
223
  }
224

225
  else if (period == "weekdays") {
66✔
226
    auto dow = current.dayOfWeek();
1✔
227
    int days;
228

229
    if (dow == 5)
1✔
230
      days = 3;
1✔
231
    else if (dow == 6)
×
232
      days = 2;
×
233
    else
234
      days = 1;
×
235

236
    return checked_add_datetime(current, days * 86400);
1✔
237
  }
238

239
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'm') {
65✔
240
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
241

242
    if (increment <= 0)
1✔
243
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
244
                   increment);
×
245

246
    m += increment;
1✔
247
    while (m > 12) {
1✔
248
      m -= 12;
×
249
      ++y;
×
250
    }
251

252
    while (!Datetime::valid(y, m, d)) --d;
1✔
253

254
    return Datetime(y, m, d, ho, mi, se);
1✔
255
  }
256

257
  else if (period[0] == 'P' && Lexer::isAllDigits(period.substr(1, period.length() - 2)) &&
64✔
258
           period[period.length() - 1] == 'M') {
×
259
    int increment = strtol(period.substr(1, period.length() - 2).c_str(), nullptr, 10);
×
260

261
    if (increment <= 0)
×
262
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
263
                   increment);
×
264

265
    m += increment;
×
266
    while (m > 12) {
×
267
      m -= 12;
×
268
      ++y;
×
269
    }
270

271
    while (!Datetime::valid(y, m, d)) --d;
×
272

273
    return Datetime(y, m, d);
×
274
  }
275

276
  else if (period == "quarterly" || period == "P3M") {
64✔
277
    m += 3;
×
278
    if (m > 12) {
×
279
      m -= 12;
×
280
      ++y;
×
281
    }
282

283
    while (!Datetime::valid(y, m, d)) --d;
×
284

285
    return Datetime(y, m, d, ho, mi, se);
×
286
  }
287

288
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'q') {
64✔
289
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
290

291
    if (increment <= 0) {
1✔
NEW
292
      Context::getContext().footnote(format(
×
293
          "Recurrence period '{1}' is equivalent to {2} and hence invalid.", period, increment));
NEW
294
      return std::nullopt;
×
295
    }
296

297
    m += 3 * increment;
1✔
298
    while (m > 12) {
3✔
299
      m -= 12;
2✔
300
      ++y;
2✔
301
    }
302

303
    while (!Datetime::valid(y, m, d)) --d;
1✔
304

305
    return Datetime(y, m, d, ho, mi, se);
1✔
306
  }
307

308
  else if (period == "semiannual" || period == "P6M") {
63✔
309
    m += 6;
×
310
    if (m > 12) {
×
311
      m -= 12;
×
312
      ++y;
×
313
    }
314

315
    while (!Datetime::valid(y, m, d)) --d;
×
316

317
    return Datetime(y, m, d, ho, mi, se);
×
318
  }
319

320
  else if (period == "bimonthly" || period == "P2M") {
63✔
321
    m += 2;
×
322
    if (m > 12) {
×
323
      m -= 12;
×
324
      ++y;
×
325
    }
326

327
    while (!Datetime::valid(y, m, d)) --d;
×
328

329
    return Datetime(y, m, d, ho, mi, se);
×
330
  }
331

332
  else if (period == "biannual" || period == "biyearly" || period == "P2Y") {
63✔
333
    y += 2;
×
334

335
    return Datetime(y, m, d, ho, mi, se);
×
336
  }
337

338
  else if (period == "annual" || period == "yearly" || period == "P1Y") {
63✔
339
    y += 1;
16✔
340

341
    // If the due data just happens to be 2/29 in a leap year, then simply
342
    // incrementing y is going to create an invalid date.
343
    if (m == 2 && d == 29) d = 28;
16✔
344

345
    return Datetime(y, m, d, ho, mi, se);
16✔
346
  }
347

348
  // Add the period to current, and we're done.
349
  std::string::size_type idx = 0;
47✔
350
  Duration p;
47✔
351
  if (!p.parse(period, idx)) {
47✔
352
    Context::getContext().footnote(
4✔
353
        format("Warning: The recurrence value '{1}' is not valid.", period));
8✔
354
    return std::nullopt;
2✔
355
  }
356

357
  return checked_add_datetime(current, p.toTime_t());
45✔
358
}
359

360
////////////////////////////////////////////////////////////////////////////////
361
// When the status of a recurring child task changes, the parent task must
362
// update it's mask.
363
void updateRecurrenceMask(Task& task) {
365✔
364
  auto uuid = task.get("parent");
365✔
365
  Task parent;
365✔
366

367
  if (uuid != "" && Context::getContext().tdb2.get(uuid, parent)) {
365✔
368
    unsigned int index = strtol(task.get("imask").c_str(), nullptr, 10);
58✔
369
    auto mask = parent.get("mask");
29✔
370
    if (mask.length() > index) {
29✔
371
      mask[index] = (task.getStatus() == Task::pending)     ? '-'
47✔
372
                    : (task.getStatus() == Task::completed) ? '+'
36✔
373
                    : (task.getStatus() == Task::deleted)   ? 'X'
18✔
374
                    : (task.getStatus() == Task::waiting)   ? 'W'
×
375
                                                            : '?';
376
    } else {
377
      std::string mask;
×
378
      for (unsigned int i = 0; i < index; ++i) mask += "?";
×
379

380
      mask += (task.getStatus() == Task::pending)     ? '-'
×
381
              : (task.getStatus() == Task::completed) ? '+'
×
382
              : (task.getStatus() == Task::deleted)   ? 'X'
×
383
              : (task.getStatus() == Task::waiting)   ? 'W'
×
384
                                                      : '?';
×
385
    }
×
386

387
    parent.set("mask", mask);
29✔
388
    Context::getContext().tdb2.modify(parent);
29✔
389
  }
29✔
390
}
365✔
391

392
////////////////////////////////////////////////////////////////////////////////
393
// Delete expired tasks.
394
void handleUntil() {
898✔
395
  Datetime now;
898✔
396
  auto tasks = Context::getContext().tdb2.pending_tasks();
898✔
397
  for (auto& t : tasks) {
7,405✔
398
    // TODO What about expiring template tasks?
399
    if (t.getStatus() == Task::pending && t.has("until")) {
19,167✔
400
      auto until = Datetime(t.get_date("until"));
14✔
401
      if (until < now) {
14✔
402
        Context::getContext().debug(format("handleUntil: recurrence expired until {1} < now {2}",
8✔
403
                                           until.toISOLocalExtended(), now.toISOLocalExtended()));
8✔
404
        t.setStatus(Task::deleted);
4✔
405
        Context::getContext().tdb2.modify(t);
4✔
406
        Context::getContext().footnote(onExpiration(t));
4✔
407
      }
408
    }
409
  }
410
}
898✔
411

412
////////////////////////////////////////////////////////////////////////////////
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc