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

GothenburgBitFactory / taskwarrior / 12343201393

15 Dec 2024 11:30PM UTC coverage: 84.419% (-1.1%) from 85.522%
12343201393

Pull #3724

github

web-flow
Merge 532931b9f into ddae5c4ba
Pull Request #3724: Support importing Taskwarrior v2.x data files

15 of 145 new or added lines in 4 files covered. (10.34%)

183 existing lines in 48 files now uncovered.

19289 of 22849 relevant lines covered (84.42%)

23168.82 hits per line

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

93.5
/src/Hooks.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 <Hooks.h>
31

32
#include <algorithm>
33
// If <iostream> is included, put it after <stdio.h>, because it includes
34
// <stdio.h>, and therefore would ignore the _WITH_GETLINE.
35
#ifdef FREEBSD
36
#define _WITH_GETLINE
37
#endif
38
#include <Context.h>
39
#include <DOM.h>
40
#include <FS.h>
41
#include <JSON.h>
42
#include <Lexer.h>
43
#include <Timer.h>
44
#include <Variant.h>
45
#include <format.h>
46
#include <shared.h>
47
#include <stdio.h>
48
#include <sys/types.h>
49
#include <sys/wait.h>
50
#include <unistd.h>
51
#include <util.h>
52

53
#define STRING_HOOK_ERROR_OBJECT "Hook Error: JSON Object '{...}' expected from hook script: {1}"
54
#define STRING_HOOK_ERROR_NODESC \
55
  "Hook Error: JSON Object missing 'description' attribute from hook script: {1}"
56
#define STRING_HOOK_ERROR_NOUUID \
57
  "Hook Error: JSON Object missing 'uuid' attribute from hook script: {1}"
58
#define STRING_HOOK_ERROR_SYNTAX "Hook Error: JSON syntax error in: {1}"
59
#define STRING_HOOK_ERROR_JSON "Hook Error: JSON "
60
#define STRING_HOOK_ERROR_NOPARSE "Hook Error: JSON failed to parse: "
61
#define STRING_HOOK_ERROR_BAD_NUM \
62
  "Hook Error: Expected {1} JSON task(s), found {2}, in hook script: {3}"
63
#define STRING_HOOK_ERROR_SAME1 \
64
  "Hook Error: JSON must be for the same task: {1}, in hook script: {2}"
65
#define STRING_HOOK_ERROR_SAME2 \
66
  "Hook Error: JSON must be for the same task: {1} != {2}, in hook script: {3}"
67
#define STRING_HOOK_ERROR_NOFEEDBACK "Hook Error: Expected feedback from failing hook script: {1}"
68

69
////////////////////////////////////////////////////////////////////////////////
70
void Hooks::initialize() {
4,493✔
71
  _debug = Context::getContext().config.getInteger("debug.hooks");
8,986✔
72

73
  // Scan <rc.hooks.location>
74
  //      <rc.data.location>/hooks
75
  Directory d;
4,493✔
76
  if (Context::getContext().config.has("hooks.location")) {
13,479✔
77
    d = Directory(Context::getContext().config.get("hooks.location"));
×
78
  } else {
79
    d = Directory(Context::getContext().config.get("data.location"));
13,479✔
80
    d += "hooks";
8,986✔
81
  }
82

83
  if (d.is_directory() && d.readable()) {
4,493✔
84
    _scripts = d.list();
43✔
85
    std::sort(_scripts.begin(), _scripts.end());
43✔
86

87
    if (_debug >= 1) {
43✔
88
      for (auto& i : _scripts) {
5✔
89
        Path p(i);
4✔
90
        if (!p.is_directory()) {
4✔
91
          std::string name = p.name();
4✔
92
          if (name.substr(0, 6) == "on-add" || name.substr(0, 9) == "on-modify" ||
8✔
93
              name.substr(0, 9) == "on-launch" || name.substr(0, 7) == "on-exit")
8✔
94
            Context::getContext().debug("Found hook script " + i);
1✔
95
          else
96
            Context::getContext().debug("Found misnamed hook script " + i);
3✔
97
        }
4✔
98
      }
4✔
99
    }
100
  } else if (_debug >= 1)
4,450✔
101
    Context::getContext().debug("Hook directory not readable: " + d._data);
5✔
102

103
  _enabled = Context::getContext().config.getBoolean("hooks");
8,986✔
104
}
4,493✔
105

106
////////////////////////////////////////////////////////////////////////////////
107
bool Hooks::enable(bool value) {
×
108
  bool old_value = _enabled;
×
109
  _enabled = value;
×
110
  return old_value;
×
111
}
112

113
////////////////////////////////////////////////////////////////////////////////
114
// The on-launch event is triggered once, after initialization, before any
115
// processing occurs, i.e first
116
//
117
// Input:
118
// - none
119
//
120
// Output:
121
// - JSON not allowed.
122
// - all emitted non-JSON lines are considered feedback or error messages
123
//   depending on the status code.
124
//
125
void Hooks::onLaunch() const {
4,493✔
126
  if (!_enabled) return;
4,493✔
127

128
  Timer timer;
152✔
129

130
  std::vector<std::string> matchingScripts = scripts("on-launch");
152✔
131
  if (matchingScripts.size()) {
152✔
132
    for (auto& script : matchingScripts) {
11✔
133
      std::vector<std::string> input;
7✔
134
      std::vector<std::string> output;
7✔
135
      int status = callHookScript(script, input, output);
7✔
136

137
      std::vector<std::string> outputJSON;
7✔
138
      std::vector<std::string> outputFeedback;
7✔
139
      separateOutput(output, outputJSON, outputFeedback);
7✔
140

141
      assertNTasks(outputJSON, 0, script);
7✔
142

143
      if (status == 0) {
6✔
144
        for (auto& message : outputFeedback) Context::getContext().footnote(message);
27✔
145
      } else {
146
        assertFeedback(outputFeedback, script);
2✔
147
        for (auto& message : outputFeedback) Context::getContext().error(message);
6✔
148

149
        throw 0;  // This is how hooks silently terminate processing.
2✔
150
      }
151
    }
16✔
152
  }
153

154
  Context::getContext().time_hooks_us += timer.total_us();
149✔
155
}
152✔
156

157
////////////////////////////////////////////////////////////////////////////////
158
// The on-exit event is triggered once, after all processing is complete, i.e.
159
// last
160
//
161
// Input:
162
// - read-only line of JSON for each task added/modified
163
//
164
// Output:
165
// - all emitted JSON is ignored
166
// - all emitted non-JSON lines are considered feedback or error messages
167
//   depending on the status code.
168
//
169
void Hooks::onExit() const {
4,014✔
170
  if (!_enabled) return;
4,014✔
171

172
  Timer timer;
135✔
173

174
  std::vector<std::string> matchingScripts = scripts("on-exit");
135✔
175
  if (matchingScripts.size()) {
135✔
176
    // Get the set of changed tasks.
177
    std::vector<Task> tasks;
5✔
178
    Context::getContext().tdb2.get_changes(tasks);
5✔
179

180
    // Convert to a vector of strings.
181
    std::vector<std::string> input;
5✔
182
    input.reserve(tasks.size());
5✔
183
    for (auto& t : tasks) input.push_back(t.composeJSON());
6✔
184

185
    // Call the hook scripts, with the invariant input.
186
    for (auto& script : matchingScripts) {
8✔
187
      std::vector<std::string> output;
5✔
188
      int status = callHookScript(script, input, output);
5✔
189

190
      std::vector<std::string> outputJSON;
5✔
191
      std::vector<std::string> outputFeedback;
5✔
192
      separateOutput(output, outputJSON, outputFeedback);
5✔
193

194
      assertNTasks(outputJSON, 0, script);
5✔
195

196
      if (status == 0) {
4✔
197
        for (auto& message : outputFeedback) Context::getContext().footnote(message);
10✔
198
      } else {
199
        assertFeedback(outputFeedback, script);
1✔
200
        for (auto& message : outputFeedback) Context::getContext().error(message);
3✔
201

202
        throw 0;  // This is how hooks silently terminate processing.
1✔
203
      }
204
    }
9✔
205
  }
7✔
206

207
  Context::getContext().time_hooks_us += timer.total_us();
133✔
208
}
135✔
209

210
////////////////////////////////////////////////////////////////////////////////
211
// The on-add event is triggered separately for each task added
212
//
213
// Input:
214
// - line of JSON for the task added
215
//
216
// Output:
217
// - emitted JSON for the input task is added, if the exit code is zero,
218
//   otherwise ignored.
219
// - all emitted non-JSON lines are considered feedback or error messages
220
//   depending on the status code.
221
//
222
void Hooks::onAdd(Task& task) const {
2,984✔
223
  if (!_enabled) return;
2,984✔
224

225
  Timer timer;
60✔
226

227
  std::vector<std::string> matchingScripts = scripts("on-add");
60✔
228
  if (matchingScripts.size()) {
60✔
229
    // Convert task to a vector of strings.
230
    std::vector<std::string> input;
9✔
231
    input.push_back(task.composeJSON());
9✔
232

233
    // Call the hook scripts.
234
    for (auto& script : matchingScripts) {
11✔
235
      std::vector<std::string> output;
9✔
236
      int status = callHookScript(script, input, output);
9✔
237

238
      std::vector<std::string> outputJSON;
9✔
239
      std::vector<std::string> outputFeedback;
9✔
240
      separateOutput(output, outputJSON, outputFeedback);
9✔
241

242
      if (status == 0) {
9✔
243
        assertNTasks(outputJSON, 1, script);
7✔
244
        assertValidJSON(outputJSON, script);
5✔
245
        assertSameTask(outputJSON, task, script);
3✔
246

247
        // Propagate forward to the next script.
248
        input[0] = outputJSON[0];
2✔
249

250
        for (auto& message : outputFeedback) Context::getContext().footnote(message);
6✔
251
      } else {
252
        assertFeedback(outputFeedback, script);
2✔
253
        for (auto& message : outputFeedback) Context::getContext().error(message);
6✔
254

255
        throw 0;  // This is how hooks silently terminate processing.
2✔
256
      }
257
    }
23✔
258

259
    // Transfer the modified task back to the original task.
260
    task = Task(input[0]);
2✔
261
  }
9✔
262

263
  Context::getContext().time_hooks_us += timer.total_us();
53✔
264
}
60✔
265

266
////////////////////////////////////////////////////////////////////////////////
267
// The on-modify event is triggered separately for each task added or modified
268
//
269
// Input:
270
// - line of JSON for the original task
271
// - line of JSON for the modified task, the diff being the modification
272
//
273
// Output:
274
// - emitted JSON for the input task is saved, if the exit code is zero,
275
//   otherwise ignored.
276
// - all emitted non-JSON lines are considered feedback or error messages
277
//   depending on the status code.
278
//
279
void Hooks::onModify(const Task& before, Task& after) const {
576✔
280
  if (!_enabled) return;
576✔
281

282
  Timer timer;
25✔
283

284
  std::vector<std::string> matchingScripts = scripts("on-modify");
25✔
285
  if (matchingScripts.size()) {
25✔
286
    // Convert vector of tasks to a vector of strings.
287
    std::vector<std::string> input;
8✔
288
    input.push_back(before.composeJSON());  // [line 0] original, never changes
8✔
289
    input.push_back(after.composeJSON());   // [line 1] modified
8✔
290

291
    // Call the hook scripts.
292
    for (auto& script : matchingScripts) {
10✔
293
      std::vector<std::string> output;
8✔
294
      int status = callHookScript(script, input, output);
8✔
295

296
      std::vector<std::string> outputJSON;
8✔
297
      std::vector<std::string> outputFeedback;
8✔
298
      separateOutput(output, outputJSON, outputFeedback);
8✔
299

300
      if (status == 0) {
8✔
301
        assertNTasks(outputJSON, 1, script);
7✔
302
        assertValidJSON(outputJSON, script);
5✔
303
        assertSameTask(outputJSON, before, script);
3✔
304

305
        // Propagate accepted changes forward to the next script.
306
        input[1] = outputJSON[0];
2✔
307

308
        for (auto& message : outputFeedback) Context::getContext().footnote(message);
5✔
309
      } else {
310
        assertFeedback(outputFeedback, script);
1✔
311
        for (auto& message : outputFeedback) Context::getContext().error(message);
3✔
312

313
        throw 0;  // This is how hooks silently terminate processing.
1✔
314
      }
315
    }
20✔
316

317
    after = Task(input[1]);
2✔
318
  }
8✔
319

320
  Context::getContext().time_hooks_us += timer.total_us();
19✔
321
}
25✔
322

323
////////////////////////////////////////////////////////////////////////////////
324
std::vector<std::string> Hooks::list() const { return _scripts; }
7✔
325

326
////////////////////////////////////////////////////////////////////////////////
327
std::vector<std::string> Hooks::scripts(const std::string& event) const {
372✔
328
  std::vector<std::string> matching;
372✔
329
  for (const auto& i : _scripts) {
756✔
330
    if (i.find("/" + event) != std::string::npos) {
384✔
331
      File script(i);
29✔
332
      if (script.executable()) matching.push_back(i);
29✔
333
    }
29✔
334
  }
335

336
  return matching;
372✔
UNCOV
337
}
×
338

339
////////////////////////////////////////////////////////////////////////////////
340
void Hooks::separateOutput(const std::vector<std::string>& output, std::vector<std::string>& json,
29✔
341
                           std::vector<std::string>& feedback) const {
342
  for (auto& i : output) {
118✔
343
    if (isJSON(i))
89✔
344
      json.push_back(i);
18✔
345
    else
346
      feedback.push_back(i);
71✔
347
  }
348
}
29✔
349

350
////////////////////////////////////////////////////////////////////////////////
351
bool Hooks::isJSON(const std::string& input) const {
89✔
352
  return input.length() > 2 && input[0] == '{' && input[input.length() - 1] == '}';
89✔
353
}
354

355
////////////////////////////////////////////////////////////////////////////////
356
void Hooks::assertValidJSON(const std::vector<std::string>& input,
10✔
357
                            const std::string& script) const {
358
  for (auto& i : input) {
16✔
359
    if (i.length() < 3 || i[0] != '{' || i[i.length() - 1] != '}') {
10✔
360
      Context::getContext().error(format(STRING_HOOK_ERROR_OBJECT, Path(script).name()));
×
361
      throw 0;
×
362
    }
363

364
    try {
365
      json::value* root = json::parse(i);
10✔
366
      if (root->type() != json::j_object) {
8✔
367
        Context::getContext().error(format(STRING_HOOK_ERROR_OBJECT, Path(script).name()));
×
368
        throw 0;
×
369
      }
370

371
      if (((json::object*)root)->_data.find("description") == ((json::object*)root)->_data.end()) {
24✔
372
        Context::getContext().error(format(STRING_HOOK_ERROR_NODESC, Path(script).name()));
×
373
        throw 0;
×
374
      }
375

376
      if (((json::object*)root)->_data.find("uuid") == ((json::object*)root)->_data.end()) {
24✔
377
        Context::getContext().error(format(STRING_HOOK_ERROR_NOUUID, Path(script).name()));
6✔
378
        throw 0;
2✔
379
      }
380

381
      delete root;
6✔
382
    }
383

384
    catch (const std::string& e) {
4✔
385
      Context::getContext().error(format(STRING_HOOK_ERROR_SYNTAX, i));
6✔
386
      if (_debug) Context::getContext().error(STRING_HOOK_ERROR_JSON + e);
2✔
387
      throw 0;
2✔
388
    }
2✔
389

390
    catch (...) {
2✔
391
      Context::getContext().error(STRING_HOOK_ERROR_NOPARSE + i);
2✔
392
      throw 0;
2✔
393
    }
2✔
394
  }
395
}
6✔
396

397
////////////////////////////////////////////////////////////////////////////////
398
void Hooks::assertNTasks(const std::vector<std::string>& input, unsigned int n,
26✔
399
                         const std::string& script) const {
400
  if (input.size() != n) {
26✔
401
    Context::getContext().error(
12✔
402
        format(STRING_HOOK_ERROR_BAD_NUM, n, (int)input.size(), Path(script).name()));
24✔
403
    throw 0;
6✔
404
  }
405
}
20✔
406

407
////////////////////////////////////////////////////////////////////////////////
408
void Hooks::assertSameTask(const std::vector<std::string>& input, const Task& task,
6✔
409
                           const std::string& script) const {
410
  std::string uuid = task.get("uuid");
6✔
411

412
  for (auto& i : input) {
10✔
413
    auto root_obj = (json::object*)json::parse(i);
6✔
414

415
    // If there is no UUID at all.
416
    auto u = root_obj->_data.find("uuid");
12✔
417
    if (u == root_obj->_data.end() || u->second->type() != json::j_string) {
6✔
418
      Context::getContext().error(format(STRING_HOOK_ERROR_SAME1, uuid, Path(script).name()));
×
419
      throw 0;
×
420
    }
421

422
    auto up = (json::string*)u->second;
6✔
423
    auto text = up->dump();
6✔
424
    Lexer::dequote(text);
6✔
425
    std::string json_uuid = json::decode(text);
6✔
426
    if (json_uuid != uuid) {
6✔
427
      Context::getContext().error(
4✔
428
          format(STRING_HOOK_ERROR_SAME2, uuid, json_uuid, Path(script).name()));
8✔
429
      throw 0;
2✔
430
    }
431

432
    delete root_obj;
4✔
433
  }
8✔
434
}
6✔
435

436
////////////////////////////////////////////////////////////////////////////////
437
void Hooks::assertFeedback(const std::vector<std::string>& input, const std::string& script) const {
6✔
438
  bool foundSomething = false;
6✔
439
  for (auto& i : input)
18✔
440
    if (nontrivial(i)) foundSomething = true;
12✔
441

442
  if (!foundSomething) {
6✔
443
    Context::getContext().error(format(STRING_HOOK_ERROR_NOFEEDBACK, Path(script).name()));
×
444
    throw 0;
×
445
  }
446
}
6✔
447

448
////////////////////////////////////////////////////////////////////////////////
449
std::vector<std::string>& Hooks::buildHookScriptArgs(std::vector<std::string>& args) const {
29✔
450
  Variant v;
29✔
451

452
  // Hooks API version.
453
  args.push_back("api:2");
58✔
454

455
  // Command line Taskwarrior was called with.
456
  getDOM("context.args", v);
29✔
457
  args.push_back("args:" + std::string(v));
29✔
458

459
  // Command to be executed.
460
  args.push_back("command:" + Context::getContext().cli2.getCommand());
29✔
461

462
  // rc file used after applying all overrides.
463
  args.push_back("rc:" + Context::getContext().rc_file._data);
29✔
464

465
  // Directory containing *.data files.
466
  args.push_back("data:" + Context::getContext().data_dir._data);
29✔
467

468
  // Taskwarrior version, same as returned by "task --version"
469
  args.push_back("version:" + std::string(VERSION));
29✔
470

471
  return args;
29✔
472
}
29✔
473

474
////////////////////////////////////////////////////////////////////////////////
475
int Hooks::callHookScript(const std::string& script, const std::vector<std::string>& input,
29✔
476
                          std::vector<std::string>& output) const {
477
  if (_debug >= 1) Context::getContext().debug("Hook: Calling " + script);
29✔
478

479
  if (_debug >= 2) {
29✔
480
    Context::getContext().debug("Hook: input");
2✔
481
    for (const auto& i : input) Context::getContext().debug("  " + i);
1✔
482
  }
483

484
  std::string inputStr;
29✔
485
  for (const auto& i : input) inputStr += i + "\n";
55✔
486

487
  std::vector<std::string> args;
29✔
488
  buildHookScriptArgs(args);
29✔
489
  if (_debug >= 2) {
29✔
490
    Context::getContext().debug("Hooks: args");
2✔
491
    for (const auto& arg : args) Context::getContext().debug("  " + arg);
7✔
492
  }
493

494
  // Measure time for each hook if running in debug
495
  int status;
496
  std::string outputStr;
29✔
497
  if (_debug >= 2) {
29✔
498
    Timer timer;
1✔
499
    status = execute(script, args, inputStr, outputStr);
1✔
500
    Context::getContext().debugTiming(format("Hooks::execute ({1})", script), timer);
3✔
501
  } else
502
    status = execute(script, args, inputStr, outputStr);
28✔
503

504
  output = split(outputStr, '\n');
29✔
505

506
  if (_debug >= 2) {
29✔
507
    Context::getContext().debug("Hook: output");
2✔
508
    for (const auto& i : output)
8✔
509
      if (i != "") Context::getContext().debug("  " + i);
7✔
510

511
    Context::getContext().debug(format("Hook: Completed with status {1}", status));
2✔
512
    Context::getContext().debug(" ");  // Blank line
3✔
513
  }
514

515
  return status;
29✔
516
}
29✔
517

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

© 2025 Coveralls, Inc