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

GothenburgBitFactory / taskwarrior / 29215228104

13 Jul 2026 12:23AM UTC coverage: 84.992% (+0.008%) from 84.984%
29215228104

Pull #4144

github

web-flow
Merge 410efc548 into 23fa4e331
Pull Request #4144: Fix scheduled date handling in recurring tasks

7 of 7 new or added lines in 1 file covered. (100.0%)

35 existing lines in 1 file now uncovered.

19651 of 23121 relevant lines covered (84.99%)

24099.85 hits per line

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

74.88
/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 <feedback.h>
35
#include <format.h>
36
#include <pwd.h>
37
#include <recur.h>
38
#include <stdlib.h>
39
#include <sys/types.h>
40
#include <time.h>
41
#include <unicode.h>
42
#include <unistd.h>
43
#include <util.h>
44

45
#include <limits>
46
#include <optional>
47

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

55
  // Check for time_t overflow in the Datetime.
56
  if (std::numeric_limits<time_t>::max() - base.toEpoch() < delta) {
51✔
57
    return std::nullopt;
1✔
58
  }
59
  return base + delta;
50✔
60
}
61

62
////////////////////////////////////////////////////////////////////////////////
63
// Scans all tasks, and for any recurring tasks, determines whether any new
64
// child tasks need to be generated to fill gaps.
65
void handleRecurrence() {
885✔
66
  // Recurrence can be disabled.
67
  // Note: This is currently a workaround for TD-44, TW-1520.
68
  if (!Context::getContext().config.getBoolean("recurrence")) return;
2,655✔
69

70
  auto tasks = Context::getContext().tdb2.pending_tasks();
883✔
71
  Datetime now;
883✔
72

73
  // Look at all tasks and find any recurring ones.
74
  for (auto& t : tasks) {
7,342✔
75
    if (t.getStatus() == Task::recurring) {
6,459✔
76
      // Generate a list of due dates for this recurring task, regardless of
77
      // the mask.
78
      std::vector<Datetime> due;
152✔
79
      if (!generateDueDates(t, due)) {
152✔
80
        // Determine the end date.
81
        t.setStatus(Task::deleted);
1✔
82
        Context::getContext().tdb2.modify(t);
1✔
83
        Context::getContext().footnote(onExpiration(t));
1✔
84
        continue;
1✔
85
      }
86

87
      // Get the mask from the parent task.
88
      auto mask = t.get("mask");
151✔
89

90
      // Iterate over the due dates, and check each against the mask.
91
      auto changed = false;
151✔
92
      unsigned int i = 0;
151✔
93
      for (auto& d : due) {
368✔
94
        if (mask.length() <= i) {
217✔
95
          changed = true;
121✔
96

97
          Task rec(t);                       // Clone the parent.
121✔
98
          rec.setStatus(Task::pending);      // Change the status.
121✔
99
          rec.set("uuid", uuid());           // New UUID.
363✔
100
          rec.set("parent", t.get("uuid"));  // Remember mom.
484✔
101
          rec.setAsNow("entry");             // New entry date.
121✔
102
          rec.set("due", format(d.toEpoch()));
363✔
103

104
          if (t.has("wait")) {
242✔
105
            Datetime old_wait(t.get_date("wait"));
2✔
106
            Datetime old_due(t.get_date("due"));
1✔
107
            Datetime due(d);
1✔
108
            auto wait = checked_add_datetime(due, old_wait - old_due);
1✔
109
            if (wait) {
1✔
110
              rec.set("wait", format(wait->toEpoch()));
3✔
111
            } else {
112
              rec.remove("wait");
×
113
            }
114
            rec.setStatus(Task::waiting);
1✔
115
            mask += 'W';
1✔
116
          } else {
117
            mask += '-';
120✔
118
            rec.setStatus(Task::pending);
120✔
119
          }
120

121
          if (t.has("scheduled")) {
242✔
122
            Datetime old_scheduled(t.get_date("scheduled"));
2✔
123
            Datetime old_due(t.get_date("due"));
1✔
124
            Datetime due(d);
1✔
125
            rec.set("scheduled", format((due + (old_scheduled - old_due)).toEpoch()));
3✔
126
          }
127

128
          rec.set("imask", i);
363✔
129
          rec.remove("mask");  // Remove the mask of the parent.
121✔
130

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

135
        ++i;
217✔
136
      }
137

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

143
        if (Context::getContext().verbose("recur"))
228✔
144
          Context::getContext().footnote(
118✔
145
              format("Creating recurring task instance '{1}'", t.get("description")));
295✔
146
      }
147
    }
152✔
148
  }
149
}
883✔
150

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

161
  std::string recur = parent.get("recur");
151✔
162

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

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

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

184
      return true;
3✔
185
    }
3✔
186

187
    if (i > now) ++recurrence_counter;
214✔
188

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

198
  return true;
199
}
151✔
200

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

358
  return checked_add_datetime(current, p.toTime_t());
47✔
359
}
360

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

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

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

388
    parent.set("mask", mask);
32✔
389
    Context::getContext().tdb2.modify(parent);
32✔
390
  }
32✔
391
}
376✔
392

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

413
////////////////////////////////////////////////////////////////////////////////
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