trekoon 0.1.8 → 0.2.0

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,16 +1,21 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
3
5
  parseArgs,
6
+ parseCsvEnumOption,
4
7
  parseStrictNonNegativeInt,
5
8
  parseStrictPositiveInt,
6
9
  readEnumOption,
7
10
  readMissingOptionValue,
8
11
  readOption,
12
+ resolvePreviewApplyMode,
13
+ suggestOptions,
9
14
  } from "./arg-parser";
10
15
 
11
16
  import { MutationService } from "../domain/mutation-service";
12
17
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type TaskRecord } from "../domain/types";
18
+ import { DomainError, type SearchEntityMatch, type TaskRecord } from "../domain/types";
14
19
  import { formatHumanTable } from "../io/human-table";
15
20
  import { failResult, okResult } from "../io/output";
16
21
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -26,6 +31,8 @@ const DEFAULT_TASK_LIST_LIMIT = 10;
26
31
  const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
27
32
  const READY_REASON_READY = "all_dependencies_done";
28
33
  const READY_REASON_BLOCKED = "blocked_by_dependencies";
34
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
35
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
29
36
 
30
37
  interface DependencyBlocker {
31
38
  readonly id: string;
@@ -81,6 +88,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
81
88
  .filter((value) => value.length > 0);
82
89
  }
83
90
 
91
+ function prefixedOptions(options: readonly string[]): string[] {
92
+ return options.map((option) => `--${option}`);
93
+ }
94
+
95
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
96
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
97
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
98
+ return failResult({
99
+ command,
100
+ human: `Unknown option --${option}.${suggestionMessage}`,
101
+ data: {
102
+ option: `--${option}`,
103
+ allowedOptions: prefixedOptions(allowedOptions),
104
+ suggestions,
105
+ },
106
+ error: {
107
+ code: "unknown_option",
108
+ message: `Unknown option --${option}`,
109
+ },
110
+ });
111
+ }
112
+
113
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
114
+ return failResult({
115
+ command,
116
+ human,
117
+ data,
118
+ error: {
119
+ code: "invalid_input",
120
+ message,
121
+ },
122
+ });
123
+ }
124
+
125
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
126
+ if (matches.length === 0) {
127
+ return emptyMessage;
128
+ }
129
+
130
+ return matches
131
+ .map(
132
+ (match) =>
133
+ `${match.kind} ${match.id}: ${match.fields
134
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
135
+ .join(", ")}`,
136
+ )
137
+ .join("\n");
138
+ }
139
+
84
140
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
85
141
  if (rawStatuses === undefined) {
86
142
  return undefined;
@@ -537,7 +593,29 @@ export async function runTask(context: CliContext): Promise<CliResult> {
537
593
  command: "task.list",
538
594
  human,
539
595
  data: { tasks },
540
- ...(context.mode === "human" ? {} : { meta: { pagination: listed.pagination } }),
596
+ ...(context.mode === "human"
597
+ ? {}
598
+ : {
599
+ meta: {
600
+ pagination: listed.pagination,
601
+ defaults: {
602
+ statuses: !includeAll && statuses === undefined ? [...DEFAULT_OPEN_TASK_STATUSES] : null,
603
+ limit: !includeAll && parsedLimit === undefined ? DEFAULT_TASK_LIST_LIMIT : null,
604
+ cursor: parsedCursor === undefined ? 0 : null,
605
+ view: view === undefined ? "table" : null,
606
+ },
607
+ filters: {
608
+ epicId: epicId ?? null,
609
+ statuses: selectedStatuses ?? null,
610
+ includeAll,
611
+ },
612
+ truncation: {
613
+ applied: listed.pagination.hasMore,
614
+ returned: tasks.length,
615
+ limit: selectedLimit ?? null,
616
+ },
617
+ },
618
+ }),
541
619
  });
542
620
  }
543
621
  case "show": {
@@ -593,6 +671,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
593
671
  command: "task.show",
594
672
  human: formatTask(task),
595
673
  data: { task, includeAll: false },
674
+ ...(context.mode === "human"
675
+ ? {}
676
+ : {
677
+ meta: {
678
+ defaults: {
679
+ view: view === undefined ? effectiveView : null,
680
+ },
681
+ filters: {
682
+ includeAll: false,
683
+ },
684
+ truncation: {
685
+ applied: true,
686
+ scope: "compact",
687
+ },
688
+ },
689
+ }),
596
690
  });
597
691
  }
598
692
 
@@ -603,6 +697,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
603
697
  command: "task.show",
604
698
  human: formatTaskShowTree(taskTree),
605
699
  data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
700
+ ...(context.mode === "human"
701
+ ? {}
702
+ : {
703
+ meta: {
704
+ defaults: {
705
+ view: view === undefined ? effectiveView : null,
706
+ },
707
+ filters: {
708
+ includeAll: true,
709
+ },
710
+ truncation: {
711
+ applied: effectiveView === "tree",
712
+ scope: "tree",
713
+ },
714
+ },
715
+ }),
606
716
  });
607
717
  }
608
718
 
@@ -610,6 +720,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
610
720
  command: "task.show",
611
721
  human: effectiveView === "table" ? formatTaskShowTable(taskTree) : formatTaskShowDetail(taskTree),
612
722
  data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
723
+ ...(context.mode === "human"
724
+ ? {}
725
+ : {
726
+ meta: {
727
+ defaults: {
728
+ view: view === undefined ? effectiveView : null,
729
+ },
730
+ filters: {
731
+ includeAll: true,
732
+ },
733
+ truncation: {
734
+ applied: false,
735
+ scope: "full",
736
+ },
737
+ },
738
+ }),
613
739
  });
614
740
  }
615
741
  case "ready": {
@@ -693,6 +819,134 @@ export async function runTask(context: CliContext): Promise<CliResult> {
693
819
  },
694
820
  });
695
821
  }
822
+ case "search": {
823
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
824
+ if (searchUnknownOption !== undefined) {
825
+ return unknownOption("task.search", searchUnknownOption, SEARCH_OPTIONS);
826
+ }
827
+
828
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
829
+ if (missingSearchOption !== undefined) {
830
+ return failMissingOptionValue("task.search", missingSearchOption);
831
+ }
832
+
833
+ const taskId: string = parsed.positional[1] ?? "";
834
+ const searchText: string = parsed.positional[2] ?? "";
835
+ if (taskId.length === 0 || searchText.trim().length === 0) {
836
+ return invalidSearchInput(
837
+ "task.search",
838
+ "Usage: trekoon task search <task-id> \"search text\" [--fields <csv>] [--preview]",
839
+ "Missing search target",
840
+ {
841
+ taskId,
842
+ },
843
+ );
844
+ }
845
+
846
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
847
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
848
+ return invalidSearchInput("task.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
849
+ fields: readOption(parsed.options, "fields"),
850
+ invalidFields: parsedFields.invalidValues,
851
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
852
+ });
853
+ }
854
+
855
+ const { matches, summary } = domain.searchTaskScope(taskId, searchText, parsedFields.values);
856
+
857
+ return okResult({
858
+ command: "task.search",
859
+ human: formatSearchHuman(matches, "No matches found."),
860
+ data: {
861
+ scope: {
862
+ kind: "task",
863
+ id: taskId,
864
+ },
865
+ query: {
866
+ search: searchText,
867
+ fields: parsedFields.values,
868
+ mode: "preview",
869
+ },
870
+ summary,
871
+ matches,
872
+ },
873
+ });
874
+ }
875
+ case "replace": {
876
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
877
+ if (replaceUnknownOption !== undefined) {
878
+ return unknownOption("task.replace", replaceUnknownOption, REPLACE_OPTIONS);
879
+ }
880
+
881
+ const missingReplaceOption =
882
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
883
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
884
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
885
+ if (missingReplaceOption !== undefined) {
886
+ return failMissingOptionValue("task.replace", missingReplaceOption);
887
+ }
888
+
889
+ const taskId: string = parsed.positional[1] ?? "";
890
+ const searchText = readOption(parsed.options, "search") ?? "";
891
+ const replacementText = readOption(parsed.options, "replace") ?? "";
892
+ if (taskId.length === 0 || searchText.trim().length === 0) {
893
+ return invalidSearchInput(
894
+ "task.replace",
895
+ "Usage: trekoon task replace <task-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
896
+ "Missing replace target",
897
+ {
898
+ taskId,
899
+ search: searchText,
900
+ },
901
+ );
902
+ }
903
+
904
+ const rawFields = readOption(parsed.options, "fields");
905
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
906
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
907
+ return invalidSearchInput("task.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
908
+ fields: rawFields,
909
+ invalidFields: parsedFields.invalidValues,
910
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
911
+ });
912
+ }
913
+
914
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
915
+ if (previewMode.conflict) {
916
+ return invalidSearchInput("task.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
917
+ flags: ["preview", "apply"],
918
+ });
919
+ }
920
+
921
+ const replacementSummary = previewMode.mode === "apply"
922
+ ? mutations.applyTaskReplacement(taskId, searchText, replacementText, parsedFields.values)
923
+ : mutations.previewTaskReplacement(taskId, searchText, replacementText, parsedFields.values);
924
+ const { matches, summary: matchSummary } = replacementSummary;
925
+
926
+ const summary = {
927
+ ...matchSummary,
928
+ mode: previewMode.mode,
929
+ };
930
+
931
+ return okResult({
932
+ command: "task.replace",
933
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
934
+ data: {
935
+ scope: {
936
+ kind: "task",
937
+ id: taskId,
938
+ },
939
+ query: {
940
+ search: searchText,
941
+ replace: replacementText,
942
+ fields: parsedFields.values,
943
+ mode: previewMode.mode,
944
+ },
945
+ summary,
946
+ matches,
947
+ },
948
+ });
949
+ }
696
950
  case "update": {
697
951
  const missingUpdateOption =
698
952
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -830,7 +1084,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
830
1084
  default:
831
1085
  return failResult({
832
1086
  command: "task",
833
- human: "Usage: trekoon task <create|list|show|ready|next|update|delete>",
1087
+ human: "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete>",
834
1088
  data: {
835
1089
  args: context.args,
836
1090
  },
@@ -3,7 +3,83 @@ import { type Database } from "bun:sqlite";
3
3
  import { appendEventWithGitContext } from "../sync/event-writes";
4
4
  import { ENTITY_OPERATIONS } from "./mutation-operations";
5
5
  import { TrackerDomain } from "./tracker-domain";
6
- import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "./types";
6
+ import {
7
+ type DependencyRecord,
8
+ type EpicRecord,
9
+ type SearchEntityMatch,
10
+ type SearchField,
11
+ type SearchNode,
12
+ type SearchSummary,
13
+ type SubtaskRecord,
14
+ type TaskRecord,
15
+ } from "./types";
16
+
17
+ function countMatches(value: string, searchText: string): number {
18
+ if (searchText.length === 0) {
19
+ return 0;
20
+ }
21
+
22
+ let count = 0;
23
+ let offset = 0;
24
+ while (offset <= value.length - searchText.length) {
25
+ const nextIndex = value.indexOf(searchText, offset);
26
+ if (nextIndex === -1) {
27
+ return count;
28
+ }
29
+
30
+ count += 1;
31
+ offset = nextIndex + searchText.length;
32
+ }
33
+
34
+ return count;
35
+ }
36
+
37
+ function replaceMatches(value: string, searchText: string, replacement: string): string {
38
+ return searchText.length === 0 ? value : value.split(searchText).join(replacement);
39
+ }
40
+
41
+ function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
42
+ if (searchText.length === 0) {
43
+ return "";
44
+ }
45
+
46
+ const matchIndex = value.indexOf(searchText);
47
+ if (matchIndex === -1) {
48
+ return "";
49
+ }
50
+
51
+ const start = Math.max(0, matchIndex - contextSize);
52
+ const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
53
+ const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
54
+ const prefix = start > 0 ? "…" : "";
55
+ const suffix = end < value.length ? "…" : "";
56
+ return `${prefix}${rawSnippet}${suffix}`;
57
+ }
58
+
59
+ function buildReplacementSnippet(value: string, replacementIndex: number, replacementLength: number, contextSize = 24): string {
60
+ const start = Math.max(0, replacementIndex - contextSize);
61
+ const end = Math.min(value.length, replacementIndex + replacementLength + contextSize);
62
+ const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
63
+ const prefix = start > 0 ? "…" : "";
64
+ const suffix = end < value.length ? "…" : "";
65
+ return `${prefix}${rawSnippet}${suffix}`;
66
+ }
67
+
68
+ function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
69
+ return {
70
+ matchedEntities: matches.length,
71
+ matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
72
+ totalMatches: matches.reduce(
73
+ (total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
74
+ 0,
75
+ ),
76
+ };
77
+ }
78
+
79
+ interface ScopeReplacementResult {
80
+ readonly matches: readonly SearchEntityMatch[];
81
+ readonly summary: SearchSummary;
82
+ }
7
83
 
8
84
  export class MutationService {
9
85
  readonly #db: Database;
@@ -153,6 +229,60 @@ export class MutationService {
153
229
  })();
154
230
  }
155
231
 
232
+ previewEpicReplacement(
233
+ epicId: string,
234
+ searchText: string,
235
+ replacementText: string,
236
+ fields: readonly SearchField[],
237
+ ): ScopeReplacementResult {
238
+ return this.#previewScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
239
+ }
240
+
241
+ applyEpicReplacement(
242
+ epicId: string,
243
+ searchText: string,
244
+ replacementText: string,
245
+ fields: readonly SearchField[],
246
+ ): ScopeReplacementResult {
247
+ return this.#applyScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
248
+ }
249
+
250
+ previewTaskReplacement(
251
+ taskId: string,
252
+ searchText: string,
253
+ replacementText: string,
254
+ fields: readonly SearchField[],
255
+ ): ScopeReplacementResult {
256
+ return this.#previewScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
257
+ }
258
+
259
+ applyTaskReplacement(
260
+ taskId: string,
261
+ searchText: string,
262
+ replacementText: string,
263
+ fields: readonly SearchField[],
264
+ ): ScopeReplacementResult {
265
+ return this.#applyScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
266
+ }
267
+
268
+ previewSubtaskReplacement(
269
+ subtaskId: string,
270
+ searchText: string,
271
+ replacementText: string,
272
+ fields: readonly SearchField[],
273
+ ): ScopeReplacementResult {
274
+ return this.#previewScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
275
+ }
276
+
277
+ applySubtaskReplacement(
278
+ subtaskId: string,
279
+ searchText: string,
280
+ replacementText: string,
281
+ fields: readonly SearchField[],
282
+ ): ScopeReplacementResult {
283
+ return this.#applyScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
284
+ }
285
+
156
286
  #appendEntityEvent(
157
287
  entityKind: "epic" | "task" | "subtask" | "dependency",
158
288
  entityId: string,
@@ -166,4 +296,115 @@ export class MutationService {
166
296
  fields,
167
297
  });
168
298
  }
299
+
300
+ #previewScopeReplacement(
301
+ nodes: readonly SearchNode[],
302
+ searchText: string,
303
+ replacementText: string,
304
+ fields: readonly SearchField[],
305
+ ): ScopeReplacementResult {
306
+ return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
307
+ }
308
+
309
+ #applyScopeReplacement(
310
+ nodes: readonly SearchNode[],
311
+ searchText: string,
312
+ replacementText: string,
313
+ fields: readonly SearchField[],
314
+ ): ScopeReplacementResult {
315
+ const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
316
+
317
+ this.#db.transaction((): void => {
318
+ for (const node of nodes) {
319
+ const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
320
+ const nextDescription = fields.includes("description")
321
+ ? replaceMatches(node.description, searchText, replacementText)
322
+ : node.description;
323
+
324
+ if (nextTitle === node.title && nextDescription === node.description) {
325
+ continue;
326
+ }
327
+
328
+ if (node.kind === "epic") {
329
+ const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
330
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
331
+ title: epic.title,
332
+ description: epic.description,
333
+ status: epic.status,
334
+ });
335
+ continue;
336
+ }
337
+
338
+ if (node.kind === "task") {
339
+ const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
340
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
341
+ epic_id: task.epicId,
342
+ title: task.title,
343
+ description: task.description,
344
+ status: task.status,
345
+ });
346
+ continue;
347
+ }
348
+
349
+ const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
350
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
351
+ task_id: subtask.taskId,
352
+ title: subtask.title,
353
+ description: subtask.description,
354
+ status: subtask.status,
355
+ });
356
+ }
357
+ })();
358
+
359
+ return result;
360
+ }
361
+
362
+ #buildScopeReplacementResult(
363
+ nodes: readonly SearchNode[],
364
+ searchText: string,
365
+ replacementText: string,
366
+ fields: readonly SearchField[],
367
+ mode: "preview" | "apply" = "preview",
368
+ ): ScopeReplacementResult {
369
+ const matches: SearchEntityMatch[] = [];
370
+
371
+ for (const node of nodes) {
372
+ const fieldMatches = fields
373
+ .map((field) => {
374
+ const value = field === "title" ? node.title : node.description;
375
+ const matchIndex = value.indexOf(searchText);
376
+ const nextValue = replaceMatches(value, searchText, replacementText);
377
+ const count = nextValue === value ? 0 : countMatches(value, searchText);
378
+
379
+ if (count === 0) {
380
+ return null;
381
+ }
382
+
383
+ return {
384
+ field,
385
+ count,
386
+ snippet:
387
+ mode === "apply"
388
+ ? buildReplacementSnippet(nextValue, matchIndex, replacementText.length)
389
+ : buildMatchSnippet(value, searchText),
390
+ };
391
+ })
392
+ .filter((fieldMatch) => fieldMatch !== null);
393
+
394
+ if (fieldMatches.length === 0) {
395
+ continue;
396
+ }
397
+
398
+ matches.push({
399
+ kind: node.kind,
400
+ id: node.id,
401
+ fields: fieldMatches,
402
+ });
403
+ }
404
+
405
+ return {
406
+ matches,
407
+ summary: summarizeMatches(matches),
408
+ };
409
+ }
169
410
  }