import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { Command } from "effect/unstable/cli"; import { Prompt } from "effect/unstable/cli"; import type { Resolution } from "@packall/core"; import { Layout, summarize } from "@packall/core"; import type { Config } from "../config.js"; import { DEFAULT_LAYOUT_PROMPT_THRESHOLD } from "../descriptors.js"; import { pluralize } from "../format.js"; /** Duplication at or above which `single` is a materially smaller bundle. */ const DUPLICATION_PROMPT_RATIO = 2; /** What to do about the layout, once the resolution is known. */ export type LayoutDecision = /** Leave it as it is. */ | { readonly _tag: "Keep" } /** Ask, because nobody has said what they want. */ | { readonly _tag: "Ask" } /** Switch without asking: a threshold was given, so the question is answered. */ | { readonly _tag: "Switch"; readonly layout: Layout }; export type LayoutInput = { readonly explicitLayout: boolean; readonly yes: boolean; readonly json: boolean; readonly dryRun: boolean; readonly interactive: boolean; /** * `--layout-threshold`, when it was given. * * Absent and present mean different things, which is why this is an `Option` * rather than a number with a default. Absent leaves the old behaviour — * ask, at `DEFAULT_LAYOUT_PROMPT_THRESHOLD`. Present is somebody stating the * answer in advance, so it switches instead of asking, and works where no * prompt could: under `--yes`, `--json`, or a pipe. */ readonly threshold: Option.Option; readonly perSpecArchives: number; readonly perSpecEntries: number; readonly uniquePackages: number; }; /** * Whether this resolution is one `single` would materially improve. * * Two independent triggers: too many separate archives to import one at a time, * or closures overlapping so heavily that deduplicating changes the order of * size. Entry count alone is a bad signal, because a handful of ordinary * packages drags in hundreds of transitive dependencies without any of them * being duplicated — 8 archives holding 376 entries across 289 distinct * packages is 1.3×, which is simply what a dependency tree looks like. */ const exceeds = (input: LayoutInput, threshold: number): boolean => { if (threshold <= 0) return false; // What you asked for — the roots — not what the dependency walk found under them. if (input.perSpecArchives > threshold) return true; if (input.uniquePackages === 0) return false; return input.perSpecEntries / input.uniquePackages >= DUPLICATION_PROMPT_RATIO; }; export const decideLayout = (input: LayoutInput): LayoutDecision => { // Saying `--layout` outranks everything: it is the most explicit statement // available, and second-guessing it would be rude. if (input.explicitLayout) return { _tag: "Keep" }; const given = Option.getOrUndefined(input.threshold); if (given !== undefined) { // `0` declines both behaviours — neither ask nor switch. return exceeds(input, given) ? { _tag: "Switch", layout: Layout.Single } : { _tag: "Keep" }; } // No threshold given, so the question has not been answered in advance and // the only way to settle it is to ask — which needs somebody to ask. if (input.yes || input.json || input.dryRun) return { _tag: "Keep" }; if (!input.interactive) return { _tag: "Keep" }; return exceeds(input, DEFAULT_LAYOUT_PROMPT_THRESHOLD) ? { _tag: "Ask" } : { _tag: "Keep" }; }; export const chooseLayout = ( resolution: Resolution, config: Config, current: Layout, interactive: boolean, // `Prompt.run` needs the CLI `Environment` (Terminal, Stdio, FileSystem, // Path). The command runtime provides it, so it stays in the context here // rather than being discharged. ): Effect.Effect => Effect.gen(function* () { const summary = summarize(resolution); const decision = decideLayout({ explicitLayout: Option.isSome(config.layout), yes: config.yes, json: config.json, dryRun: config.dryRun, interactive, threshold: config.layoutThreshold, perSpecArchives: summary.perSpecArchives, perSpecEntries: summary.perSpecEntries, uniquePackages: summary.uniquePackages, }); if (decision._tag === "Keep") return current; if (decision._tag === "Switch") return decision.layout; const duplication = summary.perSpecEntries / Math.max(1, summary.uniquePackages); return yield* Prompt.run( Prompt.select({ message: `This will write ${pluralize(summary.perSpecArchives, "archive")} holding ` + `${summary.perSpecEntries} entries in total, but only ` + `${pluralize(summary.uniquePackages, "distinct package")} ` + `(~${duplication.toFixed(1)}× duplication). How should it be laid out?`, choices: [ { title: `per-spec — ${summary.perSpecArchives} tarballs, import and roll back one at a time`, value: Layout.PerSpec, description: "The default. Larger on disk, but each package stays independent.", }, { title: `single — one tarball, ${summary.uniquePackages} packages, deduplicated`, value: Layout.Single, description: "Smallest output and one import step. All-or-nothing to roll back.", }, { title: "dir — unpacked tree, no archive", value: Layout.Dir, description: "For `jf rt upload` or rsync straight out of the directory.", }, { title: "flat — every tarball in one directory, no archive", value: Layout.Flat, description: "For tooling that globs `*.tgz` out of one directory.", }, ], }), ).pipe( // A prompt that fails — no real TTY after all, or Ctrl-C at the question — // should not take the run down. Fall through to the default. Effect.orElseSucceed(() => current), ); });