1import * as Schema from "effect/Schema";
2import { compileTagsFilter } from "./Tags.ts";
3
4const Branch = Schema.Struct({
5 /** Tag expressions are ANDed, just like repeated --tags flags. Empty selects all. */
6 tags: Schema.Array(Schema.String),
7 concurrency: Schema.optional(
8 Schema.Union([
9 Schema.Int.check(Schema.isGreaterThan(0)),
10 Schema.Literal("unbounded"),
11 ]),
12 ),
13});
14const Plan = Schema.Array(
15 Schema.Union([Branch, Schema.Array(Branch).check(Schema.isMinLength(1))]),
16).check(Schema.isMinLength(1));
17
18export type TestPlan = typeof Plan.Type;
19
20/** Parse before collection so an invalid plan cannot trigger test imports. */
21export const parsePlan = (json: string): TestPlan => {
22 try {
23 const plan = Schema.decodeUnknownSync(Plan, { onExcessProperty: "error" })(
24 JSON.parse(json),
25 );
26 for (const phase of plan) {
27 for (const branch of Array.isArray(phase) ? phase : [phase]) {
28 compileTagsFilter(branch.tags);
29 }
30 }
31 return plan;
32 } catch (cause) {
33 throw new Error(
34 `Invalid --plan: ${cause instanceof Error ? cause.message : String(cause)}`,
35 );
36 }
37};
38
39/** Shared plain/TUI/log representation of a dry-run plan. */
40export const formatPlanPreview = (
41 phases: Extract<
42 import("./Reporter.ts").TestEvent,
43 { _tag: "PlanPreview" }
44 >["phases"],
45): string =>
46 [
47 "Dry run — no tests or hooks executed",
48 ...phases.flatMap((branches, index) => [
49 `Phase ${index + 1}${branches.length > 1 ? " (parallel)" : ""}:`,
50 ...branches.map(
51 (branch, i) =>
52 ` Branch ${i + 1}: tags=${JSON.stringify(branch.tags)}, concurrency=${branch.concurrency} — ${branch.tests} tests in ${branch.files} files${branch.skipped ? ` (${branch.skipped} skipped/todo)` : ""}`,
53 ),
54 ]),
55 ].join("\n");