trekoon 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,30 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
5
+ isValidCompactTempKey,
3
6
  parseArgs,
7
+ parseCompactFields,
8
+ parseCsvEnumOption,
4
9
  parseStrictNonNegativeInt,
5
10
  parseStrictPositiveInt,
6
11
  readEnumOption,
7
12
  readMissingOptionValue,
8
13
  readOption,
14
+ readOptions,
15
+ readUnexpectedPositionals,
16
+ resolvePreviewApplyMode,
17
+ suggestOptions,
9
18
  } from "./arg-parser";
19
+ import { unexpectedFailureResult } from "./error-utils";
10
20
 
11
21
  import { MutationService } from "../domain/mutation-service";
12
22
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type TaskRecord } from "../domain/types";
23
+ import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type TaskRecord } from "../domain/types";
14
24
  import { formatHumanTable } from "../io/human-table";
15
25
  import { failResult, okResult } from "../io/output";
16
26
  import { type CliContext, type CliResult } from "../runtime/command-types";
17
- import { openTrekoonDatabase } from "../storage/database";
27
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
18
28
 
19
29
  function formatTask(task: TaskRecord): string {
20
30
  return `${task.id} | epic=${task.epicId} | ${task.title} | ${task.status}`;
@@ -26,6 +36,9 @@ const DEFAULT_TASK_LIST_LIMIT = 10;
26
36
  const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
27
37
  const READY_REASON_READY = "all_dependencies_done";
28
38
  const READY_REASON_BLOCKED = "blocked_by_dependencies";
39
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
40
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
41
+ const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
29
42
 
30
43
  interface DependencyBlocker {
31
44
  readonly id: string;
@@ -81,6 +94,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
81
94
  .filter((value) => value.length > 0);
82
95
  }
83
96
 
97
+ function prefixedOptions(options: readonly string[]): string[] {
98
+ return options.map((option) => `--${option}`);
99
+ }
100
+
101
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
102
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
103
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
104
+ return failResult({
105
+ command,
106
+ human: `Unknown option --${option}.${suggestionMessage}`,
107
+ data: {
108
+ option: `--${option}`,
109
+ allowedOptions: prefixedOptions(allowedOptions),
110
+ suggestions,
111
+ },
112
+ error: {
113
+ code: "unknown_option",
114
+ message: `Unknown option --${option}`,
115
+ },
116
+ });
117
+ }
118
+
119
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
120
+ return failResult({
121
+ command,
122
+ human,
123
+ data,
124
+ error: {
125
+ code: "invalid_input",
126
+ message,
127
+ },
128
+ });
129
+ }
130
+
131
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
132
+ if (matches.length === 0) {
133
+ return emptyMessage;
134
+ }
135
+
136
+ return matches
137
+ .map(
138
+ (match) =>
139
+ `${match.kind} ${match.id}: ${match.fields
140
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
141
+ .join(", ")}`,
142
+ )
143
+ .join("\n");
144
+ }
145
+
84
146
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
85
147
  if (rawStatuses === undefined) {
86
148
  return undefined;
@@ -327,29 +389,9 @@ function formatTaskShowTable(taskTree: {
327
389
  }
328
390
 
329
391
  function failFromError(error: unknown): CliResult {
330
- if (error instanceof DomainError) {
331
- return failResult({
332
- command: "task",
333
- human: error.message,
334
- data: {
335
- code: error.code,
336
- ...(error.details ?? {}),
337
- },
338
- error: {
339
- code: error.code,
340
- message: error.message,
341
- },
342
- });
343
- }
344
-
345
- return failResult({
392
+ return unexpectedFailureResult(error, {
346
393
  command: "task",
347
394
  human: "Unexpected task command failure",
348
- data: {},
349
- error: {
350
- code: "internal_error",
351
- message: "Unexpected task command failure",
352
- },
353
395
  });
354
396
  }
355
397
 
@@ -368,10 +410,145 @@ function failMissingOptionValue(command: string, option: string): CliResult {
368
410
  });
369
411
  }
370
412
 
413
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
414
+ return failResult({
415
+ command,
416
+ human,
417
+ data,
418
+ error: {
419
+ code: "invalid_input",
420
+ message: human,
421
+ },
422
+ });
423
+ }
424
+
425
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
426
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
427
+ unexpectedPositionals: unexpected,
428
+ });
429
+ }
430
+
431
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
432
+ return failBatchSpec(command, `${option === "task" ? "Task" : "Spec"} spec ${index + 1} is missing a ${field}.`, {
433
+ option,
434
+ index,
435
+ rawSpec,
436
+ field,
437
+ });
438
+ }
439
+
440
+ function parseTaskCreateManySpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
441
+ const specs: CompactTaskSpec[] = [];
442
+ const seenTempKeys = new Set<string>();
443
+
444
+ for (const [index, rawSpec] of rawSpecs.entries()) {
445
+ const parsed = parseCompactFields(rawSpec);
446
+ if (parsed.invalidEscape !== null) {
447
+ return {
448
+ specs: [],
449
+ error: failBatchSpec("task.create-many", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
450
+ option: "task",
451
+ index,
452
+ rawSpec,
453
+ invalidEscape: parsed.invalidEscape,
454
+ }),
455
+ };
456
+ }
457
+
458
+ if (parsed.hasDanglingEscape) {
459
+ return {
460
+ specs: [],
461
+ error: failBatchSpec("task.create-many", `Trailing escape in --task spec ${index + 1}.`, {
462
+ option: "task",
463
+ index,
464
+ rawSpec,
465
+ }),
466
+ };
467
+ }
468
+
469
+ if (parsed.fields.length !== 4) {
470
+ return {
471
+ specs: [],
472
+ error: failBatchSpec("task.create-many", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
473
+ option: "task",
474
+ index,
475
+ rawSpec,
476
+ fields: parsed.fields,
477
+ }),
478
+ };
479
+ }
480
+
481
+ const tempKey = parsed.fields[0] ?? "";
482
+ const title = parsed.fields[1] ?? "";
483
+ const description = parsed.fields[2] ?? "";
484
+ const status = parsed.fields[3] ?? "";
485
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
486
+ return {
487
+ specs: [],
488
+ error: failBatchSpec("task.create-many", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
489
+ option: "task",
490
+ index,
491
+ rawSpec,
492
+ tempKey,
493
+ }),
494
+ };
495
+ }
496
+
497
+ if (seenTempKeys.has(tempKey)) {
498
+ return {
499
+ specs: [],
500
+ error: failBatchSpec("task.create-many", `Duplicate temp key '${tempKey}' in --task specs.`, {
501
+ option: "task",
502
+ index,
503
+ rawSpec,
504
+ tempKey,
505
+ }),
506
+ };
507
+ }
508
+
509
+ if (!title || title.trim().length === 0) {
510
+ return {
511
+ specs: [],
512
+ error: failBatchSpec("task.create-many", `Task spec ${index + 1} is missing a title.`, {
513
+ option: "task",
514
+ index,
515
+ rawSpec,
516
+ }),
517
+ };
518
+ }
519
+
520
+ if (description.trim().length === 0) {
521
+ return {
522
+ specs: [],
523
+ error: failEmptyCompactField("task.create-many", "task", index, rawSpec, "description"),
524
+ };
525
+ }
526
+
527
+ seenTempKeys.add(tempKey);
528
+ const spec: CompactTaskSpec = status.length > 0
529
+ ? {
530
+ tempKey,
531
+ title,
532
+ description,
533
+ status,
534
+ }
535
+ : {
536
+ tempKey,
537
+ title,
538
+ description,
539
+ };
540
+
541
+ specs.push(spec);
542
+ }
543
+
544
+ return { specs };
545
+ }
546
+
371
547
  export async function runTask(context: CliContext): Promise<CliResult> {
372
- const database = openTrekoonDatabase(context.cwd);
548
+ let database: TrekoonDatabase | undefined;
373
549
 
374
550
  try {
551
+ database = openTrekoonDatabase(context.cwd);
375
552
  const parsed = parseArgs(context.args);
376
553
  const subcommand: string | undefined = parsed.positional[0];
377
554
  const domain = new TrackerDomain(database.db);
@@ -404,6 +581,56 @@ export async function runTask(context: CliContext): Promise<CliResult> {
404
581
  data: { task },
405
582
  });
406
583
  }
584
+ case "create-many": {
585
+ const createManyUnknownOption = findUnknownOption(parsed, CREATE_MANY_OPTIONS);
586
+ if (createManyUnknownOption !== undefined) {
587
+ return unknownOption("task.create-many", createManyUnknownOption, CREATE_MANY_OPTIONS);
588
+ }
589
+
590
+ const missingCreateManyOption = readMissingOptionValue(parsed.missingOptionValues, "epic", "e", "task");
591
+ if (missingCreateManyOption !== undefined) {
592
+ return failMissingOptionValue("task.create-many", missingCreateManyOption);
593
+ }
594
+
595
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
596
+ if (unexpectedPositionals.length > 0) {
597
+ return failUnexpectedPositionals("task.create-many", unexpectedPositionals);
598
+ }
599
+
600
+ const epicId = readOption(parsed.options, "epic", "e");
601
+ if (epicId === undefined || epicId.trim().length === 0) {
602
+ return failBatchSpec("task.create-many", "Provide --epic for task create-many.", {
603
+ option: "epic",
604
+ });
605
+ }
606
+
607
+ const rawSpecs = readOptions(parsed.optionEntries, "task");
608
+ if (rawSpecs.length === 0) {
609
+ return failBatchSpec("task.create-many", "Provide at least one --task spec.", {
610
+ option: "task",
611
+ });
612
+ }
613
+
614
+ const specResult = parseTaskCreateManySpecs(rawSpecs);
615
+ if (specResult.error !== undefined) {
616
+ return specResult.error;
617
+ }
618
+
619
+ const created = mutations.createTaskBatch({
620
+ epicId,
621
+ specs: specResult.specs,
622
+ });
623
+ const result: CompactBatchResultContract = created.result;
624
+ return okResult({
625
+ command: "task.create-many",
626
+ human: `Created ${created.tasks.length} task(s): ${created.tasks.map(formatTask).join("\n")}`,
627
+ data: {
628
+ epicId,
629
+ tasks: created.tasks,
630
+ result,
631
+ },
632
+ });
633
+ }
407
634
  case "list": {
408
635
  const missingListOption =
409
636
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
@@ -763,6 +990,134 @@ export async function runTask(context: CliContext): Promise<CliResult> {
763
990
  },
764
991
  });
765
992
  }
993
+ case "search": {
994
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
995
+ if (searchUnknownOption !== undefined) {
996
+ return unknownOption("task.search", searchUnknownOption, SEARCH_OPTIONS);
997
+ }
998
+
999
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
1000
+ if (missingSearchOption !== undefined) {
1001
+ return failMissingOptionValue("task.search", missingSearchOption);
1002
+ }
1003
+
1004
+ const taskId: string = parsed.positional[1] ?? "";
1005
+ const searchText: string = parsed.positional[2] ?? "";
1006
+ if (taskId.length === 0 || searchText.trim().length === 0) {
1007
+ return invalidSearchInput(
1008
+ "task.search",
1009
+ "Usage: trekoon task search <task-id> \"search text\" [--fields <csv>] [--preview]",
1010
+ "Missing search target",
1011
+ {
1012
+ taskId,
1013
+ },
1014
+ );
1015
+ }
1016
+
1017
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
1018
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
1019
+ return invalidSearchInput("task.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
1020
+ fields: readOption(parsed.options, "fields"),
1021
+ invalidFields: parsedFields.invalidValues,
1022
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
1023
+ });
1024
+ }
1025
+
1026
+ const { matches, summary } = domain.searchTaskScope(taskId, searchText, parsedFields.values);
1027
+
1028
+ return okResult({
1029
+ command: "task.search",
1030
+ human: formatSearchHuman(matches, "No matches found."),
1031
+ data: {
1032
+ scope: {
1033
+ kind: "task",
1034
+ id: taskId,
1035
+ },
1036
+ query: {
1037
+ search: searchText,
1038
+ fields: parsedFields.values,
1039
+ mode: "preview",
1040
+ },
1041
+ summary,
1042
+ matches,
1043
+ },
1044
+ });
1045
+ }
1046
+ case "replace": {
1047
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
1048
+ if (replaceUnknownOption !== undefined) {
1049
+ return unknownOption("task.replace", replaceUnknownOption, REPLACE_OPTIONS);
1050
+ }
1051
+
1052
+ const missingReplaceOption =
1053
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
1054
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
1055
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
1056
+ if (missingReplaceOption !== undefined) {
1057
+ return failMissingOptionValue("task.replace", missingReplaceOption);
1058
+ }
1059
+
1060
+ const taskId: string = parsed.positional[1] ?? "";
1061
+ const searchText = readOption(parsed.options, "search") ?? "";
1062
+ const replacementText = readOption(parsed.options, "replace") ?? "";
1063
+ if (taskId.length === 0 || searchText.trim().length === 0) {
1064
+ return invalidSearchInput(
1065
+ "task.replace",
1066
+ "Usage: trekoon task replace <task-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
1067
+ "Missing replace target",
1068
+ {
1069
+ taskId,
1070
+ search: searchText,
1071
+ },
1072
+ );
1073
+ }
1074
+
1075
+ const rawFields = readOption(parsed.options, "fields");
1076
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
1077
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
1078
+ return invalidSearchInput("task.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
1079
+ fields: rawFields,
1080
+ invalidFields: parsedFields.invalidValues,
1081
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
1082
+ });
1083
+ }
1084
+
1085
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
1086
+ if (previewMode.conflict) {
1087
+ return invalidSearchInput("task.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
1088
+ flags: ["preview", "apply"],
1089
+ });
1090
+ }
1091
+
1092
+ const replacementSummary = previewMode.mode === "apply"
1093
+ ? mutations.applyTaskReplacement(taskId, searchText, replacementText, parsedFields.values)
1094
+ : mutations.previewTaskReplacement(taskId, searchText, replacementText, parsedFields.values);
1095
+ const { matches, summary: matchSummary } = replacementSummary;
1096
+
1097
+ const summary = {
1098
+ ...matchSummary,
1099
+ mode: previewMode.mode,
1100
+ };
1101
+
1102
+ return okResult({
1103
+ command: "task.replace",
1104
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
1105
+ data: {
1106
+ scope: {
1107
+ kind: "task",
1108
+ id: taskId,
1109
+ },
1110
+ query: {
1111
+ search: searchText,
1112
+ replace: replacementText,
1113
+ fields: parsedFields.values,
1114
+ mode: previewMode.mode,
1115
+ },
1116
+ summary,
1117
+ matches,
1118
+ },
1119
+ });
1120
+ }
766
1121
  case "update": {
767
1122
  const missingUpdateOption =
768
1123
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -900,7 +1255,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
900
1255
  default:
901
1256
  return failResult({
902
1257
  command: "task",
903
- human: "Usage: trekoon task <create|list|show|ready|next|update|delete>",
1258
+ human: "Usage: trekoon task <create|create-many|list|show|ready|next|search|replace|update|delete>",
904
1259
  data: {
905
1260
  args: context.args,
906
1261
  },
@@ -913,6 +1268,6 @@ export async function runTask(context: CliContext): Promise<CliResult> {
913
1268
  } catch (error: unknown) {
914
1269
  return failFromError(error);
915
1270
  } finally {
916
- database.close();
1271
+ database?.close();
917
1272
  }
918
1273
  }