1import { assert, describe, it } from "@effect/vitest";
2import {
3 CommandId,
4 EventId,
5 ProjectId,
6 ProviderInstanceId,
7 type ModelSelection,
8 type ProjectScript,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Result from "effect/Result";
12
13import {
14 planProjectCommand,
15 type ProjectCommand,
16 type ProjectCommandState,
17} from "./ProjectCommands.ts";
18import type { ProjectRow } from "./ProjectStore.ts";
19
20const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
21const projectId = ProjectId.make("project-scripts");
22
23const row = (overrides: Partial<ProjectRow> = {}): ProjectRow => ({
24 projectId,
25 title: "Scripts",
26 workspaceRoot: "/tmp/scripts",
27 defaultModelSelection: null,
28 defaultThreadEnvMode: null,
29 autoPull: false,
30 faviconPath: null,
31 projectIcon: null,
32 scripts: [],
33 createdAt: "2026-01-01T00:00:00.000Z",
34 updatedAt: "2026-01-01T00:00:00.000Z",
35 deletedAt: null,
36 ...overrides,
37});
38
39const script = (id: string): ProjectScript => ({
40 id,
41 name: "Install dependencies",
42 command: "vp i",
43 icon: "configure",
44 runOnWorktreeCreate: false,
45});
46
47const plan = (command: ProjectCommand, state: Partial<ProjectCommandState> = {}) =>
48 planProjectCommand({
49 command,
50 state: { project: undefined, workspaceOwner: undefined, ...state },
51 eventId: EventId.make("event:planned"),
52 now,
53 });
54
55const update = (
56 fields: Omit<
57 Extract<ProjectCommand, { type: "project.meta.update" }>,
58 "type" | "commandId" | "projectId"
59 >,
60) =>
61 plan(
62 {
63 type: "project.meta.update",
64 commandId: CommandId.make("cmd-update"),
65 projectId,
66 ...fields,
67 },
68 { project: row() },
69 );
70
71const payloadOf = (result: ReturnType<typeof plan>) => {
72 assert.isTrue(Result.isSuccess(result));
73 return Result.getOrThrow(result).payload as Record<string, unknown>;
74};
75
76const failureOf = (result: ReturnType<typeof plan>) => {
77 assert.isTrue(Result.isFailure(result));
78 return Result.isFailure(result) ? result.failure : assert.fail("expected a rejection");
79};
80
81describe("planProjectCommand", () => {
82 it("creates projects with empty scripts and no model default", () => {
83 const result = plan({
84 type: "project.create",
85 commandId: CommandId.make("cmd-create"),
86 projectId,
87 title: "Scripts",
88 workspaceRoot: "/tmp/scripts",
89 });
90 const event = Result.getOrThrow(result);
91 assert.equal(event.type, "project.created");
92 assert.equal(event.occurredAt, "2026-01-01T00:00:00.000Z");
93 assert.deepInclude(event.payload, { scripts: [], defaultModelSelection: null });
94 });
95
96 it("only treats metadata updates as explicit model defaults", () => {
97 const selection: ModelSelection = {
98 instanceId: ProviderInstanceId.make("codex"),
99 model: "gpt-5.6-sol",
100 options: [{ id: "reasoningEffort", value: "high" }],
101 };
102 assert.deepEqual(
103 payloadOf(update({ defaultModelSelection: selection })).defaultModelSelection,
104 selection,
105 );
106 });
107
108 it("carries every edited field and omits the rest", () => {
109 const scripts = [script("lint")];
110 const payload = payloadOf(
111 update({
112 scripts,
113 defaultThreadEnvMode: "worktree",
114 autoPull: true,
115 faviconPath: "brand/icon.svg",
116 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
117 }),
118 );
119 assert.deepInclude(payload, {
120 scripts,
121 defaultThreadEnvMode: "worktree",
122 autoPull: true,
123 faviconPath: "brand/icon.svg",
124 projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" },
125 });
126 const renamed = payloadOf(update({ title: "Renamed" }));
127 assert.isFalse("defaultThreadEnvMode" in renamed);
128 assert.isNull(payloadOf(update({ defaultThreadEnvMode: null })).defaultThreadEnvMode);
129 });
130
131 it.each(["install-javascript-dependencies", "A", "a.b", "a b", "-a", "a".repeat(25)])(
132 "rejects a new script ID that cannot have a shortcut: %s",
133 (id) => {
134 const failure = failureOf(update({ scripts: [script("lint"), script(id)] }));
135 assert.equal(failure._tag, "ProjectCommandInvariantError");
136 assert.include(failure.message, "Script ID");
137 assert.include(failure.message, "24");
138 // The detail is persisted in the rejected receipt, so it omits the raw ID.
139 assert.notInclude(failure.message, `'${id}'`);
140 },
141 );
142
143 it("accepts a script ID at the shortcut length limit", () => {
144 const scripts = [script("a".repeat(24))];
145 assert.deepEqual(payloadOf(update({ scripts })).scripts, scripts);
146 });
147
148 it("keeps legacy scripts editable and removable while rejecting new invalid ones", () => {
149 const legacy = script("install-javascript-dependencies");
150 const withLegacy = (scripts: ReadonlyArray<ProjectScript>) =>
151 plan(
152 {
153 type: "project.meta.update",
154 commandId: CommandId.make("cmd-repair-script"),
155 projectId,
156 scripts,
157 },
158 { project: row({ scripts: [legacy] }) },
159 );
160 for (const scripts of [[{ ...legacy, command: "vp install" }, script("lint")], []]) {
161 assert.deepEqual(payloadOf(withLegacy(scripts)).scripts, scripts);
162 }
163 assert.equal(
164 failureOf(withLegacy([legacy, script("another.invalid.id")]))._tag,
165 "ProjectCommandInvariantError",
166 );
167 });
168
169 it("limits monograms to two graphemes", () => {
170 for (const text of ["T3", "é", "किखि", "क्ष्म", "각"]) {
171 const monogram = { kind: "monogram", text, color: "violet" } as const;
172 assert.deepEqual(payloadOf(update({ projectIcon: monogram })).projectIcon, monogram);
173 }
174 for (const text of ["ABC", "किखिगि"]) {
175 assert.equal(
176 failureOf(update({ projectIcon: { kind: "monogram", text, color: "violet" } }))._tag,
177 "ProjectCommandInvariantError",
178 );
179 }
180 });
181
182 it("rejects a workspace root held by another active project", () => {
183 const owner = row({
184 projectId: ProjectId.make("project-existing"),
185 workspaceRoot: "/tmp/project",
186 });
187 const create = failureOf(
188 plan(
189 {
190 type: "project.create",
191 commandId: CommandId.make("cmd-duplicate-root"),
192 projectId: ProjectId.make("project-duplicate-root"),
193 title: "Duplicate",
194 workspaceRoot: "/tmp/project",
195 },
196 { workspaceOwner: owner },
197 ),
198 );
199 assert.equal(create._tag, "ProjectWorkspaceConflictError");
200 assert.equal(
201 create.message,
202 "Active project 'project-existing' already exists for workspace root '/tmp/project'.",
203 );
204 const move = failureOf(
205 plan(
206 {
207 type: "project.meta.update",
208 commandId: CommandId.make("cmd-move-root"),
209 projectId,
210 workspaceRoot: "/tmp/project",
211 },
212 { project: row(), workspaceOwner: owner },
213 ),
214 );
215 assert.equal(move._tag, "ProjectWorkspaceConflictError");
216 });
217
218 it("requires the project to exist, and to be absent on create", () => {
219 const create: ProjectCommand = {
220 type: "project.create",
221 commandId: CommandId.make("cmd-create-twice"),
222 projectId,
223 title: "Twice",
224 workspaceRoot: "/tmp/twice",
225 };
226 assert.include(failureOf(plan(create, { project: row() })).message, "cannot be created twice");
227 const deleted = row({ deletedAt: "2026-01-01T00:00:00.000Z" });
228 assert.include(
229 failureOf(plan(create, { project: deleted })).message,
230 "cannot be created twice",
231 );
232 for (const command of [
233 { type: "project.meta.update", commandId: CommandId.make("cmd-missing"), projectId },
234 { type: "project.delete", commandId: CommandId.make("cmd-missing"), projectId },
235 ] as const) {
236 for (const project of [undefined, deleted]) {
237 assert.equal(
238 failureOf(plan(command, { project }))._tag,
239 "ProjectCommandMissingProjectError",
240 );
241 }
242 }
243 });
244
245 it("deletes with a single project.deleted event", () => {
246 const event = Result.getOrThrow(
247 plan(
248 { type: "project.delete", commandId: CommandId.make("cmd-delete"), projectId },
249 { project: row() },
250 ),
251 );
252 assert.equal(event.type, "project.deleted");
253 assert.deepEqual(event.payload, { projectId, deletedAt: "2026-01-01T00:00:00.000Z" });
254 });
255});