@da1z/chop 0.0.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.
Files changed (42) hide show
  1. package/.claude/rules/use-bun-instead-of-node-vite-npm-pnpm.md +109 -0
  2. package/.claude/settings.local.json +12 -0
  3. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  4. package/.devcontainer/Dockerfile +102 -0
  5. package/.devcontainer/devcontainer.json +58 -0
  6. package/.devcontainer/init-firewall.sh +137 -0
  7. package/.github/workflows/publish.yml +76 -0
  8. package/CLAUDE.md +44 -0
  9. package/README.md +15 -0
  10. package/index.ts +2 -0
  11. package/loop.sh +206 -0
  12. package/package.json +27 -0
  13. package/specs/chop.md +313 -0
  14. package/src/commands/add.ts +74 -0
  15. package/src/commands/archive.ts +72 -0
  16. package/src/commands/completion.ts +232 -0
  17. package/src/commands/done.ts +38 -0
  18. package/src/commands/edit.ts +228 -0
  19. package/src/commands/init.ts +72 -0
  20. package/src/commands/list.ts +48 -0
  21. package/src/commands/move.ts +92 -0
  22. package/src/commands/pop.ts +45 -0
  23. package/src/commands/purge.ts +41 -0
  24. package/src/commands/show.ts +32 -0
  25. package/src/commands/status.ts +43 -0
  26. package/src/config/paths.ts +61 -0
  27. package/src/errors.ts +56 -0
  28. package/src/index.ts +41 -0
  29. package/src/models/id-generator.ts +39 -0
  30. package/src/models/task.ts +98 -0
  31. package/src/storage/file-lock.ts +124 -0
  32. package/src/storage/storage-resolver.ts +63 -0
  33. package/src/storage/task-store.ts +173 -0
  34. package/src/types.ts +42 -0
  35. package/src/utils/display.ts +139 -0
  36. package/src/utils/git.ts +80 -0
  37. package/src/utils/prompts.ts +88 -0
  38. package/tests/errors.test.ts +86 -0
  39. package/tests/models/id-generator.test.ts +46 -0
  40. package/tests/models/task.test.ts +186 -0
  41. package/tests/storage/file-lock.test.ts +152 -0
  42. package/tsconfig.json +9 -0
@@ -0,0 +1,63 @@
1
+ import { NotInitializedError } from "../errors.ts";
2
+ import {
3
+ getLocalStorageDir,
4
+ getTasksFilePath,
5
+ getArchivedTasksFilePath,
6
+ } from "../config/paths.ts";
7
+ import type { StorageLocation } from "../types.ts";
8
+
9
+ export interface StorageInfo {
10
+ location: StorageLocation;
11
+ tasksPath: string;
12
+ archivedPath: string;
13
+ }
14
+
15
+ // Resolve which storage location to use for the current project
16
+ // Resolution order: local (.chop/) first, then global
17
+ export async function resolveStorage(): Promise<StorageInfo> {
18
+ // Check for local storage first
19
+ const localTasksPath = await getTasksFilePath("local");
20
+ const localFile = Bun.file(localTasksPath);
21
+
22
+ if (await localFile.exists()) {
23
+ return {
24
+ location: "local",
25
+ tasksPath: localTasksPath,
26
+ archivedPath: await getArchivedTasksFilePath("local"),
27
+ };
28
+ }
29
+
30
+ // Check for global storage
31
+ const globalTasksPath = await getTasksFilePath("global");
32
+ const globalFile = Bun.file(globalTasksPath);
33
+
34
+ if (await globalFile.exists()) {
35
+ return {
36
+ location: "global",
37
+ tasksPath: globalTasksPath,
38
+ archivedPath: await getArchivedTasksFilePath("global"),
39
+ };
40
+ }
41
+
42
+ // Neither exists
43
+ throw new NotInitializedError();
44
+ }
45
+
46
+ // Check if the project has been initialized
47
+ export async function isInitialized(): Promise<boolean> {
48
+ try {
49
+ await resolveStorage();
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // Check if local storage exists (for init command)
57
+ export async function hasLocalStorage(): Promise<boolean> {
58
+ const localDir = await getLocalStorageDir();
59
+ const localDirFile = Bun.file(localDir);
60
+ // Check if the directory exists by checking for tasks.json
61
+ const localTasksPath = await getTasksFilePath("local");
62
+ return await Bun.file(localTasksPath).exists();
63
+ }
@@ -0,0 +1,173 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname } from "path";
3
+ import type { Task, TasksFile, StorageLocation } from "../types.ts";
4
+ import { withLock } from "./file-lock.ts";
5
+ import { resolveStorage, type StorageInfo } from "./storage-resolver.ts";
6
+ import { getLockFilePath, getTasksFilePath, getArchivedTasksFilePath } from "../config/paths.ts";
7
+
8
+ // Create an empty tasks file structure
9
+ function createEmptyTasksFile(): TasksFile {
10
+ return {
11
+ version: 1,
12
+ lastSequence: 0,
13
+ tasks: [],
14
+ };
15
+ }
16
+
17
+ // Task store class for managing tasks with file locking
18
+ export class TaskStore {
19
+ private storageInfo: StorageInfo;
20
+
21
+ private constructor(storageInfo: StorageInfo) {
22
+ this.storageInfo = storageInfo;
23
+ }
24
+
25
+ // Create a TaskStore instance by resolving storage location
26
+ static async create(): Promise<TaskStore> {
27
+ const storageInfo = await resolveStorage();
28
+ return new TaskStore(storageInfo);
29
+ }
30
+
31
+ // Create a TaskStore for a specific location (used by init)
32
+ static async createForLocation(location: StorageLocation): Promise<TaskStore> {
33
+ const tasksPath = await getTasksFilePath(location);
34
+ const archivedPath = await getArchivedTasksFilePath(location);
35
+ return new TaskStore({ location, tasksPath, archivedPath });
36
+ }
37
+
38
+ // Get the lock file path
39
+ private get lockPath(): string {
40
+ return getLockFilePath(this.storageInfo.tasksPath);
41
+ }
42
+
43
+ // Read tasks file (with lock)
44
+ async readTasks(): Promise<TasksFile> {
45
+ return withLock(this.lockPath, async () => {
46
+ const file = Bun.file(this.storageInfo.tasksPath);
47
+ if (!(await file.exists())) {
48
+ return createEmptyTasksFile();
49
+ }
50
+ const content = await file.text();
51
+ return JSON.parse(content) as TasksFile;
52
+ });
53
+ }
54
+
55
+ // Write tasks file (with lock) - internal use only
56
+ private async writeTasks(data: TasksFile): Promise<void> {
57
+ await Bun.write(this.storageInfo.tasksPath, JSON.stringify(data, null, 2));
58
+ }
59
+
60
+ // Atomic read-modify-write operation
61
+ async atomicUpdate<T>(
62
+ operation: (data: TasksFile) => { data: TasksFile; result: T }
63
+ ): Promise<T> {
64
+ return withLock(this.lockPath, async () => {
65
+ const file = Bun.file(this.storageInfo.tasksPath);
66
+ let data: TasksFile;
67
+
68
+ if (await file.exists()) {
69
+ const content = await file.text();
70
+ data = JSON.parse(content) as TasksFile;
71
+ } else {
72
+ data = createEmptyTasksFile();
73
+ }
74
+
75
+ const { data: newData, result } = operation(data);
76
+ await this.writeTasks(newData);
77
+ return result;
78
+ });
79
+ }
80
+
81
+ // Read archived tasks
82
+ async readArchivedTasks(): Promise<TasksFile> {
83
+ return withLock(this.lockPath, async () => {
84
+ const file = Bun.file(this.storageInfo.archivedPath);
85
+ if (!(await file.exists())) {
86
+ return createEmptyTasksFile();
87
+ }
88
+ const content = await file.text();
89
+ return JSON.parse(content) as TasksFile;
90
+ });
91
+ }
92
+
93
+ // Write archived tasks
94
+ private async writeArchivedTasks(data: TasksFile): Promise<void> {
95
+ await Bun.write(this.storageInfo.archivedPath, JSON.stringify(data, null, 2));
96
+ }
97
+
98
+ // Archive a task (move from tasks to archived)
99
+ async archiveTask(taskId: string): Promise<Task> {
100
+ return withLock(this.lockPath, async () => {
101
+ // Read both files
102
+ const tasksFile = Bun.file(this.storageInfo.tasksPath);
103
+ const tasksContent = await tasksFile.text();
104
+ const tasksData: TasksFile = JSON.parse(tasksContent);
105
+
106
+ const archivedFile = Bun.file(this.storageInfo.archivedPath);
107
+ let archivedData: TasksFile;
108
+ if (await archivedFile.exists()) {
109
+ const archivedContent = await archivedFile.text();
110
+ archivedData = JSON.parse(archivedContent);
111
+ } else {
112
+ archivedData = createEmptyTasksFile();
113
+ }
114
+
115
+ // Find and remove the task from active tasks
116
+ const taskIndex = tasksData.tasks.findIndex((t) => t.id === taskId);
117
+ if (taskIndex === -1) {
118
+ throw new Error(`Task ${taskId} not found`);
119
+ }
120
+
121
+ const task = tasksData.tasks.splice(taskIndex, 1)[0]!;
122
+ task.status = "archived";
123
+ task.updatedAt = new Date().toISOString();
124
+
125
+ // Add to archived
126
+ archivedData.tasks.push(task);
127
+
128
+ // Write both files
129
+ await this.writeTasks(tasksData);
130
+ await this.writeArchivedTasks(archivedData);
131
+
132
+ return task;
133
+ });
134
+ }
135
+
136
+ // Purge all archived tasks
137
+ async purgeArchived(): Promise<number> {
138
+ return withLock(this.lockPath, async () => {
139
+ const archivedFile = Bun.file(this.storageInfo.archivedPath);
140
+ if (!(await archivedFile.exists())) {
141
+ return 0;
142
+ }
143
+
144
+ const content = await archivedFile.text();
145
+ const data: TasksFile = JSON.parse(content);
146
+ const count = data.tasks.length;
147
+
148
+ // Clear archived tasks
149
+ data.tasks = [];
150
+ await this.writeArchivedTasks(data);
151
+
152
+ return count;
153
+ });
154
+ }
155
+
156
+ // Initialize storage (create directory and empty tasks file)
157
+ static async initialize(location: StorageLocation): Promise<void> {
158
+ const tasksPath = await getTasksFilePath(location);
159
+ const dir = dirname(tasksPath);
160
+
161
+ // Create directory if it doesn't exist
162
+ await mkdir(dir, { recursive: true });
163
+
164
+ // Create empty tasks file
165
+ const emptyTasks = createEmptyTasksFile();
166
+ await Bun.write(tasksPath, JSON.stringify(emptyTasks, null, 2));
167
+ }
168
+
169
+ // Get storage info
170
+ getStorageInfo(): StorageInfo {
171
+ return this.storageInfo;
172
+ }
173
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Core task status
2
+ export type TaskStatus = "draft" | "open" | "in-progress" | "done" | "archived";
3
+
4
+ // Task model
5
+ export interface Task {
6
+ id: string; // format: "a1b2c3d-1"
7
+ title: string;
8
+ description?: string;
9
+ status: TaskStatus;
10
+ dependsOn: string[]; // task IDs
11
+ createdAt: string; // ISO timestamp
12
+ updatedAt: string; // ISO timestamp
13
+ }
14
+
15
+ // Tasks file format (tasks.json)
16
+ export interface TasksFile {
17
+ version: 1;
18
+ lastSequence: number;
19
+ tasks: Task[];
20
+ }
21
+
22
+ // Storage location type
23
+ export type StorageLocation = "local" | "global";
24
+
25
+ // Project-specific config
26
+ export interface ProjectConfig {
27
+ storage: StorageLocation;
28
+ }
29
+
30
+ // Global config file format
31
+ export interface Config {
32
+ defaultStorage: StorageLocation;
33
+ projects: Record<string, ProjectConfig>;
34
+ }
35
+
36
+ // Edit format for YAML editing
37
+ export interface TaskEditData {
38
+ title: string;
39
+ description?: string;
40
+ status: TaskStatus;
41
+ depends_on: string[];
42
+ }
@@ -0,0 +1,139 @@
1
+ import type { Task } from "../types.ts";
2
+ import { isBlocked } from "../models/task.ts";
3
+
4
+ // ANSI color codes
5
+ const colors = {
6
+ reset: "\x1b[0m",
7
+ dim: "\x1b[2m",
8
+ green: "\x1b[32m",
9
+ yellow: "\x1b[33m",
10
+ blue: "\x1b[34m",
11
+ cyan: "\x1b[36m",
12
+ };
13
+
14
+ // Status display styles
15
+ const statusStyles: Record<string, { text: string; color: string }> = {
16
+ draft: { text: "draft", color: colors.cyan },
17
+ open: { text: "open", color: colors.blue },
18
+ "in-progress": { text: "in-progress", color: colors.yellow },
19
+ done: { text: "done", color: colors.green },
20
+ archived: { text: "archived", color: colors.dim },
21
+ };
22
+
23
+ // Format a task status for display
24
+ function formatStatus(status: string): string {
25
+ const style = statusStyles[status] || { text: status, color: colors.reset };
26
+ return `${style.color}${style.text}${colors.reset}`;
27
+ }
28
+
29
+ // Get visible length of a string (excluding ANSI codes)
30
+ function visibleLength(str: string): number {
31
+ return str.replace(/\x1b\[[0-9;]*m/g, "").length;
32
+ }
33
+
34
+ // Pad a string to a specific width
35
+ function pad(str: string, width: number): string {
36
+ const len = visibleLength(str);
37
+ const padding = width - len;
38
+ return str + " ".repeat(Math.max(0, padding));
39
+ }
40
+
41
+ // Truncate a string to a max visible length, accounting for ANSI codes
42
+ function truncate(str: string, maxLength: number): string {
43
+ if (maxLength <= 0) return "";
44
+ if (visibleLength(str) <= maxLength) return str;
45
+
46
+ // Simple truncation: iterate through and count visible chars
47
+ let result = "";
48
+ let visible = 0;
49
+ let i = 0;
50
+
51
+ while (i < str.length && visible < maxLength - 1) {
52
+ // Check for ANSI escape sequence
53
+ if (str[i] === "\x1b" && str[i + 1] === "[") {
54
+ const end = str.indexOf("m", i);
55
+ if (end !== -1) {
56
+ result += str.slice(i, end + 1);
57
+ i = end + 1;
58
+ continue;
59
+ }
60
+ }
61
+ result += str[i];
62
+ visible++;
63
+ i++;
64
+ }
65
+
66
+ return result + "\u2026"; // ellipsis character
67
+ }
68
+
69
+ // Get terminal width or default
70
+ function getTerminalWidth(): number {
71
+ return process.stdout.columns || 80;
72
+ }
73
+
74
+ // Format a single task for list display
75
+ export function formatTaskRow(task: Task, allTasks: Task[]): string {
76
+ const blocked = task.status === "open" && isBlocked(task, allTasks);
77
+ const blockedMarker = blocked ? `${colors.dim}[blocked]${colors.reset} ` : "";
78
+
79
+ const id = pad(task.id, 12);
80
+ const status = pad(formatStatus(task.status), 22); // Extra width for ANSI codes
81
+
82
+ // Calculate available width for title: terminal width - id (12) - status (12) - spaces (2)
83
+ const termWidth = getTerminalWidth();
84
+ const titleMaxWidth = termWidth - 12 - 12 - 2;
85
+
86
+ const blockedMarkerLen = visibleLength(blockedMarker);
87
+ const titleWidth = titleMaxWidth - blockedMarkerLen;
88
+ const truncatedTitle = truncate(task.title, titleWidth);
89
+ const title = blockedMarker + truncatedTitle;
90
+
91
+ return `${id} ${status} ${title}`;
92
+ }
93
+
94
+ // Format task list as a table
95
+ export function formatTaskTable(tasks: Task[], allTasks: Task[]): string {
96
+ if (tasks.length === 0) {
97
+ return "No tasks found.";
98
+ }
99
+
100
+ const header = `${pad("ID", 12)} ${pad("STATUS", 12)} TITLE`;
101
+ const separator = "-".repeat(60);
102
+ const rows = tasks.map((task) => formatTaskRow(task, allTasks));
103
+
104
+ return [header, separator, ...rows].join("\n");
105
+ }
106
+
107
+ // Format a single task for detailed display (after pop, add, etc.)
108
+ export function formatTaskDetail(task: Task, allTasks?: Task[]): string {
109
+ const lines = [
110
+ `${colors.cyan}ID:${colors.reset} ${task.id}`,
111
+ `${colors.cyan}Title:${colors.reset} ${task.title}`,
112
+ `${colors.cyan}Status:${colors.reset} ${formatStatus(task.status)}`,
113
+ ];
114
+
115
+ if (task.description) {
116
+ lines.push(`${colors.cyan}Description:${colors.reset}`);
117
+ lines.push(task.description.split("\n").map((l) => ` ${l}`).join("\n"));
118
+ }
119
+
120
+ if (task.dependsOn.length > 0) {
121
+ lines.push(`${colors.cyan}Depends on:${colors.reset} ${task.dependsOn.join(", ")}`);
122
+ }
123
+
124
+ if (allTasks && task.status === "open" && isBlocked(task, allTasks)) {
125
+ lines.push(`${colors.yellow}Status: BLOCKED${colors.reset}`);
126
+ }
127
+
128
+ return lines.join("\n");
129
+ }
130
+
131
+ // Format success message
132
+ export function success(message: string): string {
133
+ return `${colors.green}${message}${colors.reset}`;
134
+ }
135
+
136
+ // Format error message
137
+ export function error(message: string): string {
138
+ return message; // Errors go to stderr, keep simple
139
+ }
@@ -0,0 +1,80 @@
1
+ import { $ } from "bun";
2
+ import { NotInGitRepoError } from "../errors.ts";
3
+
4
+ // Get the root directory of the current git repository
5
+ export async function getGitRepoRoot(): Promise<string> {
6
+ try {
7
+ const result = await $`git rev-parse --show-toplevel`.text();
8
+ return result.trim();
9
+ } catch {
10
+ throw new NotInGitRepoError();
11
+ }
12
+ }
13
+
14
+ // Get the git remote origin URL (if available)
15
+ export async function getGitRemoteUrl(): Promise<string | null> {
16
+ try {
17
+ const result = await $`git remote get-url origin`.text();
18
+ return result.trim();
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // Normalize a remote URL to a consistent project ID format
25
+ // e.g., "git@github.com:user/repo.git" -> "github.com/user/repo"
26
+ // e.g., "https://github.com/user/repo.git" -> "github.com/user/repo"
27
+ function normalizeRemoteUrl(url: string): string {
28
+ // Remove .git suffix
29
+ let normalized = url.replace(/\.git$/, "");
30
+
31
+ // Handle SSH format: git@github.com:user/repo
32
+ if (normalized.startsWith("git@")) {
33
+ normalized = normalized.replace(/^git@/, "").replace(":", "/");
34
+ }
35
+
36
+ // Handle HTTPS format: https://github.com/user/repo
37
+ if (normalized.startsWith("https://")) {
38
+ normalized = normalized.replace(/^https:\/\//, "");
39
+ }
40
+
41
+ if (normalized.startsWith("http://")) {
42
+ normalized = normalized.replace(/^http:\/\//, "");
43
+ }
44
+
45
+ return normalized;
46
+ }
47
+
48
+ // Hash a string to create a short identifier
49
+ function hashString(str: string): string {
50
+ let hash = 0;
51
+ for (let i = 0; i < str.length; i++) {
52
+ const char = str.charCodeAt(i);
53
+ hash = (hash << 5) - hash + char;
54
+ hash = hash & hash; // Convert to 32-bit integer
55
+ }
56
+ return Math.abs(hash).toString(16).slice(0, 8);
57
+ }
58
+
59
+ // Get a unique project identifier for the current repository
60
+ // Uses remote URL if available, otherwise uses repo path hash
61
+ export async function getProjectId(): Promise<string> {
62
+ const remoteUrl = await getGitRemoteUrl();
63
+ if (remoteUrl) {
64
+ return normalizeRemoteUrl(remoteUrl);
65
+ }
66
+
67
+ // Fall back to repo path hash for local-only repos
68
+ const repoRoot = await getGitRepoRoot();
69
+ return hashString(repoRoot);
70
+ }
71
+
72
+ // Check if we're inside a git repository
73
+ export async function isInGitRepo(): Promise<boolean> {
74
+ try {
75
+ await $`git rev-parse --git-dir`.quiet();
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
@@ -0,0 +1,88 @@
1
+ import prompts from "prompts";
2
+
3
+ // Prompt for a yes/no confirmation
4
+ export async function confirm(message: string, defaultYes = false): Promise<boolean> {
5
+ const response = await prompts({
6
+ type: "confirm",
7
+ name: "value",
8
+ message,
9
+ initial: defaultYes,
10
+ });
11
+
12
+ // Handle cancellation (Ctrl+C)
13
+ if (response.value === undefined) {
14
+ process.exit(0);
15
+ }
16
+
17
+ return response.value;
18
+ }
19
+
20
+ // Prompt for text input
21
+ export async function prompt(message: string, defaultValue?: string): Promise<string> {
22
+ const response = await prompts({
23
+ type: "text",
24
+ name: "value",
25
+ message,
26
+ initial: defaultValue,
27
+ });
28
+
29
+ // Handle cancellation (Ctrl+C)
30
+ if (response.value === undefined) {
31
+ process.exit(0);
32
+ }
33
+
34
+ return response.value;
35
+ }
36
+
37
+ // Prompt for a selection from a list of options
38
+ export async function select<T extends string>(
39
+ message: string,
40
+ options: { value: T; label: string }[]
41
+ ): Promise<T> {
42
+ const response = await prompts({
43
+ type: "select",
44
+ name: "value",
45
+ message,
46
+ choices: options.map((opt) => ({
47
+ title: opt.label,
48
+ value: opt.value,
49
+ })),
50
+ });
51
+
52
+ // Handle cancellation (Ctrl+C)
53
+ if (response.value === undefined) {
54
+ process.exit(0);
55
+ }
56
+
57
+ return response.value;
58
+ }
59
+
60
+ // Prompt for selecting multiple tasks (for --depends-on without ID)
61
+ export async function selectTasks(
62
+ message: string,
63
+ tasks: { id: string; title: string }[]
64
+ ): Promise<string[]> {
65
+ if (tasks.length === 0) {
66
+ console.log("No tasks available to select.");
67
+ return [];
68
+ }
69
+
70
+ const response = await prompts({
71
+ type: "multiselect",
72
+ name: "value",
73
+ message,
74
+ choices: tasks.map((task) => ({
75
+ title: `${task.id} - ${task.title}`,
76
+ value: task.id,
77
+ })),
78
+ hint: "- Space to select. Return to submit",
79
+ instructions: false,
80
+ });
81
+
82
+ // Handle cancellation (Ctrl+C)
83
+ if (response.value === undefined) {
84
+ process.exit(0);
85
+ }
86
+
87
+ return response.value;
88
+ }
@@ -0,0 +1,86 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import {
3
+ ChopError,
4
+ NotInGitRepoError,
5
+ NotInitializedError,
6
+ TaskNotFoundError,
7
+ LockError,
8
+ InvalidStatusError,
9
+ HasDependentsError,
10
+ AlreadyInitializedError,
11
+ } from "../src/errors.ts";
12
+
13
+ describe("ChopError", () => {
14
+ test("creates error with message prefix", () => {
15
+ const error = new ChopError("test message");
16
+ expect(error.message).toBe("Error: test message");
17
+ expect(error.name).toBe("ChopError");
18
+ });
19
+
20
+ test("is an instance of Error", () => {
21
+ const error = new ChopError("test");
22
+ expect(error).toBeInstanceOf(Error);
23
+ });
24
+ });
25
+
26
+ describe("NotInGitRepoError", () => {
27
+ test("creates error with correct message", () => {
28
+ const error = new NotInGitRepoError();
29
+ expect(error.message).toBe("Error: Not in a git repository");
30
+ expect(error.name).toBe("ChopError");
31
+ });
32
+ });
33
+
34
+ describe("NotInitializedError", () => {
35
+ test("creates error with correct message", () => {
36
+ const error = new NotInitializedError();
37
+ expect(error.message).toBe("Error: Project not initialized. Run 'chop init'");
38
+ expect(error.name).toBe("ChopError");
39
+ });
40
+ });
41
+
42
+ describe("TaskNotFoundError", () => {
43
+ test("creates error with task id in message", () => {
44
+ const error = new TaskNotFoundError("abc123");
45
+ expect(error.message).toBe("Error: Task abc123 not found");
46
+ expect(error.name).toBe("ChopError");
47
+ });
48
+ });
49
+
50
+ describe("LockError", () => {
51
+ test("creates error with correct message", () => {
52
+ const error = new LockError();
53
+ expect(error.message).toBe("Error: Cannot acquire lock. Another process is accessing tasks");
54
+ expect(error.name).toBe("ChopError");
55
+ });
56
+ });
57
+
58
+ describe("InvalidStatusError", () => {
59
+ test("creates error with status in message", () => {
60
+ const error = new InvalidStatusError("invalid-status");
61
+ expect(error.message).toBe("Error: Invalid status: invalid-status. Use: open, in-progress, or done");
62
+ expect(error.name).toBe("ChopError");
63
+ });
64
+ });
65
+
66
+ describe("HasDependentsError", () => {
67
+ test("creates error with single dependent id", () => {
68
+ const error = new HasDependentsError(["task1"]);
69
+ expect(error.message).toBe("Error: Task has unarchived dependents: task1");
70
+ expect(error.name).toBe("ChopError");
71
+ });
72
+
73
+ test("creates error with multiple dependent ids", () => {
74
+ const error = new HasDependentsError(["task1", "task2", "task3"]);
75
+ expect(error.message).toBe("Error: Task has unarchived dependents: task1, task2, task3");
76
+ expect(error.name).toBe("ChopError");
77
+ });
78
+ });
79
+
80
+ describe("AlreadyInitializedError", () => {
81
+ test("creates error with correct message", () => {
82
+ const error = new AlreadyInitializedError();
83
+ expect(error.message).toBe("Error: Project already initialized");
84
+ expect(error.name).toBe("ChopError");
85
+ });
86
+ });