Repository: JoshuaKGoldberg/bingo (ts)

Issue:
🚀 Feature: Provide a way for Stratum templates to infer --preset during --mode transition
### Bug Report Checklist

- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Coming over from https://github.com/JoshuaKGoldberg/create-typescript-app/issues/1376: Stratum templates don't automatically infer their `--preset`. They prompt in both modes if not explicitly provided. Which is good in general, as folks shouldn't get surprised by a preset they didn't want.

But, during `--mode transition`, if the repo already exists it's annoying and confusing to have to explicitly say the preset again. There should be some way for a template to define how to infer a preset from a repository on disk. I.e. provide the `options` default for the `preset` option.

### Additional Info

Equivalent past feature request on CTA: https://github.com/JoshuaKGoldberg/create-typescript-app/issues/933 -> https://github.com/JoshuaKGoldberg/create-typescript-app/pull/993

💝 

Pull request:
feat(bingo-stratum): infer --preset from files on disk
## PR Checklist

- [x] Addresses an existing open issue: fixes #237
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/bingo/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/bingo/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Adds a default value function with a nifty heuristic for `--preset`. It will:

1. Run all preset blocks using any provided options, swallowing errors
  * A future improvement should be to run the base options first, so the blocks generally shouldn't crash so much...
2. Merge the blocks together
3. Compute the percentage of files that already existed on disk vs. are created by that preset
4. Find the preset with the highest percentage

Applies an arbitrary percentage threshold of 50%: if fewer than half the files created by a preset are present on disk, it won't be inferred.

In order to support this:

* Transition mode options inference is moved to within its own spinner task
* Options contexts are given a `log` function set to the Clack display's `.log` by the CLI

💝 

Patch:
diff --git a/packages/bingo-stratum/package.json b/packages/bingo-stratum/package.json
index 370741c..9315e60 100644
--- a/packages/bingo-stratum/package.json
+++ b/packages/bingo-stratum/package.json
@@ -24,6 +24,7 @@
 		"build": "tsc"
 	},
 	"dependencies": {
+		"chalk": "^5.4.1",
 		"hash-object": "^5.0.1",
 		"octokit": "^4.1.2",
 		"slugify": "^1.6.6",
diff --git a/packages/bingo-stratum/src/creators/inferPreset.ts b/packages/bingo-stratum/src/creators/inferPreset.ts
new file mode 100644
index 0000000..749ba0d
--- /dev/null
+++ b/packages/bingo-stratum/src/creators/inferPreset.ts
@@ -0,0 +1,114 @@
+import {
+	AnyShape,
+	InferredObject,
+	mergeCreations,
+	TemplatePrepareContext,
+} from "bingo";
+import { CreatedDirectory, CreatedEntry } from "bingo-fs";
+import { Options } from "hash-object";
+
+import {
+	produceBlock,
+	ProduceBlockSettings,
+} from "../producers/produceBlock.js";
+import { Block } from "../types/blocks.js";
+import { Preset } from "../types/presets.js";
+import { slugifyPresetName } from "../utils.ts/slugifyPresetName.js";
+
+export function inferPreset<OptionsShape extends AnyShape>(
+	context: Pick<
+		TemplatePrepareContext<Partial<InferredObject<OptionsShape>>>,
+		"files" | "options"
+	>,
+	presets: Preset<OptionsShape>[],
+) {
+	const blockSettings: ProduceBlockSettings<undefined, Options> = {
+		...context,
+
+		// TODO: It would be better to run the base.prepare first to generate option defaults.
+		// ...
+		options: context.options as Options,
+	};
+
+	let record: undefined | { percentage: number; preset: Preset<OptionsShape> };
+
+	for (const preset of presets) {
+		const allProduced = preset.blocks.map((block) => {
+			try {
+				return produceBlock(block as Block<undefined, Options>, blockSettings);
+			} catch {
+				return {};
+			}
+		});
+
+		const produced = allProduced.reduce(mergeCreations);
+		const counted = countMatchedFilePaths(context.files, produced.files);
+		const percentage = counted.matched / counted.created;
+
+		if (record) {
+			if (percentage > record.percentage) {
+				record = { percentage, preset };
+			}
+		} else {
+			record = { percentage, preset };
+		}
+	}
+
+	return (
+		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+		record!.percentage >= 0.35
+			? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+				slugifyPresetName(record!.preset.about.name)
+			: undefined
+	);
+}
+
+function countMatchedFilePaths(
+	created: CreatedEntry | undefined,
+	produced: CreatedEntry | undefined,
+) {
+	const found = {
+		created: 0,
+		matched: 0,
+	};
+
+	const queue: [CreatedEntry | undefined, CreatedEntry | undefined][] = [
+		[created, produced],
+	];
+
+	while (queue.length) {
+		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+		const [currentCreated, currentProduced] = queue.pop()!;
+
+		if (isCreatedFile(currentCreated)) {
+			found.created += 1;
+
+			if (isCreatedFile(currentProduced)) {
+				found.matched += 1;
+			}
+
+			continue;
+		}
+
+		if (
+			isCreatedDirectory(currentCreated) &&
+			isCreatedDirectory(currentProduced)
+		) {
+			for (const key of Object.keys(currentCreated)) {
+				queue.push([currentCreated[key], currentProduced[key]]);
+			}
+		}
+	}
+
+	return found;
+}
+
+function isCreatedDirectory(
+	entry: CreatedEntry | undefined,
+): entry is CreatedDirectory {
+	return !!entry && typeof entry === "object" && !Array.isArray(entry);
+}
+
+function isCreatedFile(entry: CreatedEntry | undefined) {
+	return typeof entry === "string" || Array.isArray(entry);
+}
diff --git a/packages/bingo-stratum/src/types/templates.ts b/packages/bingo-stratum/src/types/templates.ts
index 2f8b871..5b200c9 100644
--- a/packages/bingo-stratum/src/types/templates.ts
+++ b/packages/bingo-stratum/src/types/templates.ts
@@ -15,7 +15,7 @@ export interface StratumTemplate<OptionsShape extends AnyShape = AnyShape> {
 	about?: TemplateAbout;
 	base: Base<OptionsShape>;
 	options: OptionsShape & StratumTemplateOptionsShape;
-	prepare?: TemplatePrepare<InferredObject<OptionsShape>>;
+	prepare: TemplatePrepare<InferredObject<OptionsShape>>;
 	presets: Preset<OptionsShape>[];
 	produce: StratumTemplateProduce<InferredObject<OptionsShape>>;
 }
diff --git a/packages/bingo/src/cli/display/createClackDisplay.ts b/packages/bingo/src/cli/display/createClackDisplay.ts
index 26aeba0..f0751b6 100644
--- a/packages/bingo/src/cli/display/createClackDisplay.ts
+++ b/packages/bingo/src/cli/display/createClackDisplay.ts
@@ -24,7 +24,9 @@ export function createClackDisplay(): ClackDisplay {
 			Object.assign(groups.get(group).get(id), item);
 		},
 		log(message) {
-			spinner.message(message);
+			// TODO: file bug? on clack that there's an extra line
+			process.stdout.moveCursor(0, -1);
+			prompts.log.step(message + "\n");
 		},
 	} satisfies Display;
 
diff --git a/packages/bingo/src/cli/loggers/logStartText.ts b/packages/bingo/src/cli/loggers/logStartText.ts
index 4b2e636..bb5159d 100644
--- a/packages/bingo/src/cli/loggers/logStartText.ts
+++ b/packages/bingo/src/cli/loggers/logStartText.ts
@@ -9,7 +9,7 @@ export function logStartText(
 	type: string,
 	offline: boolean | undefined,
 ) {
-	prompts.log.message(
+	prompts.log.step(
 		[
 			`Running with mode --${mode} using the ${type}:`,
 			`  ${chalk.green(from)}`,
diff --git a/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts b/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
index 18b3cc3..f5ca0f2 100644
--- a/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
+++ b/packages/bingo/src/cli/prompts/promptForOptionSchemas.ts
@@ -1,6 +1,5 @@
 import * as prompts from "@clack/prompts";
 
-import { prepareOptions } from "../../preparation/prepareOptions.js";
 import { AnyShape, InferredObject } from "../../types/shapes.js";
 import { SystemContext } from "../../types/system.js";
 import { Template } from "../../types/templates.js";
@@ -24,7 +23,6 @@ export interface PromptedOptionsProduced<Options extends object> {
 
 export interface PromptForOptionsSettings<OptionsShape extends AnyShape> {
 	existing: Partial<InferredObject<OptionsShape>>;
-	offline?: boolean;
 	system: SystemContext;
 }
 
@@ -32,7 +30,7 @@ export async function promptForOptionSchemas<
 	OptionsShape extends AnyShape = AnyShape,
 >(
 	template: Template<OptionsShape>,
-	{ existing, offline, system }: PromptForOptionsSettings<OptionsShape>,
+	{ existing, system }: PromptForOptionsSettings<OptionsShape>,
 ): Promise<PromptedOptions<InferredObject<OptionsShape>>> {
 	type Options = InferredObject<OptionsShape>;
 
@@ -40,11 +38,6 @@ export async function promptForOptionSchemas<
 	const completed: InferredObject<AnyShape> = {
 		directory,
 		...existing,
-		...(await prepareOptions(template, {
-			...system,
-			existing: { ...existing, directory },
-			offline,
-		})),
 	};
 	const prompted: Partial<Options> = {};
 
diff --git a/packages/bingo/src/cli/setup/runModeSetup.ts b/packages/bingo/src/cli/setup/runModeSetup.ts
index 54a511e..30d7aba 100644
--- a/packages/bingo/src/cli/setup/runModeSetup.ts
+++ b/packages/bingo/src/cli/setup/runModeSetup.ts
@@ -2,6 +2,7 @@ import * as prompts from "@clack/prompts";
 import chalk from "chalk";
 
 import { createSystemContextWithAuth } from "../../contexts/createSystemContextWithAuth.js";
+import { prepareOptions } from "../../preparation/prepareOptions.js";
 import { runTemplate } from "../../runners/runTemplate.js";
 import { AnyShape } from "../../types/shapes.js";
 import { Template } from "../../types/templates.js";
@@ -65,13 +66,20 @@ export async function runModeSetup<OptionsShape extends AnyShape>({
 		offline,
 	});
 
+	const providedOptions = parseZodArgs(args, template.options);
+	const preparedOptions = await prepareOptions(template, {
+		...system,
+		existing: { ...providedOptions, directory },
+		offline,
+	});
+
 	const baseOptions = await promptForOptionSchemas(template, {
 		existing: {
 			directory,
 			repository: repository ?? directory,
-			...parseZodArgs(args, template.options),
+			...providedOptions,
+			...preparedOptions,
 		},
-		offline,
 		system,
 	});
 	if (baseOptions.cancelled) {
diff --git a/packages/bingo/src/cli/transition/runModeTransition.ts b/packages/bingo/src/cli/transition/runModeTransition.ts
index 59207f7..18261ac 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.ts
@@ -1,4 +1,5 @@
 import { createSystemContextWithAuth } from "../../contexts/createSystemContextWithAuth.js";
+import { prepareOptions } from "../../preparation/prepareOptions.js";
 import { runTemplate } from "../../runners/runTemplate.js";
 import { AnyShape } from "../../types/shapes.js";
 import { Template } from "../../types/templates.js";
@@ -67,11 +68,26 @@ export async function runModeTransition<OptionsShape extends AnyShape>({
 		);
 	}
 
-	const baseOptions = await promptForOptionSchemas(template, {
-		existing: {
-			...parseZodArgs(args, template.options),
+	const providedOptions = parseZodArgs(args, template.options);
+	const existingOptions = await runSpinnerTask(
+		display,
+		"Inferring options from existing repository",
+		"Inferred options from existing repository",
+		async () => {
+			return await prepareOptions(template, {
+				...system,
+				existing: { ...providedOptions, directory },
+				offline,
+			});
 		},
-		offline,
+	);
+	if (existingOptions instanceof Error) {
+		logRerunSuggestion(args, providedOptions);
+		return { error: existingOptions, status: CLIStatus.Error };
+	}
+
+	const baseOptions = await promptForOptionSchemas(template, {
+		existing: existingOptions,
 		system,
 	});
 	if (baseOptions.cancelled) {
diff --git a/packages/bingo/src/contexts/createDisplay.ts b/packages/bingo/src/contexts/createDisplay.ts
index ddfb6a4..eba0b31 100644
--- a/packages/bingo/src/contexts/createDisplay.ts
+++ b/packages/bingo/src/contexts/createDisplay.ts
@@ -1,6 +1,6 @@
 export interface Display {
-	item(group: string, id: string, item: Partial<DisplayItem>): void;
-	log(message: string): void;
+	item: (group: string, id: string, item: Partial<DisplayItem>) => void;
+	log: (message: string) => void;
 }
 
 export interface DisplayItem {
diff --git a/packages/bingo/src/preparation/prepareOptions.ts b/packages/bingo/src/preparation/prepareOptions.ts
index 5d8b9d2..b7ebf8b 100644
--- a/packages/bingo/src/preparation/prepareOptions.ts
+++ b/packages/bingo/src/preparation/prepareOptions.ts
@@ -1,5 +1,7 @@
+import { CreatedDirectory, intake } from "bingo-fs";
 import { BingoSystem } from "bingo-systems";
 
+import { Display } from "../contexts/createDisplay.js";
 import { createSystemContextWithAuth } from "../contexts/createSystemContextWithAuth.js";
 import { LazyOptionalOptions, OptionsContext } from "../types/options.js";
 import { AnyShape, InferredObject } from "../types/shapes.js";
@@ -21,7 +23,9 @@ export interface HasOptionsAndMaybePrepare<OptionsShape extends AnyShape> {
 export interface PrepareOptionsSettings<OptionsShape extends AnyShape>
 	extends Partial<BingoSystem> {
 	directory?: string;
+	display?: Display;
 	existing?: Partial<InferredObject<OptionsShape>>;
+	files?: CreatedDirectory;
 	offline?: boolean;
 }
 
@@ -39,13 +43,22 @@ export async function prepareOptions<OptionsShape extends AnyShape>(
 		return existing;
 	}
 
+	const directory = settings.directory ?? ".";
 	const system = await createSystemContextWithAuth({
-		directory: settings.directory ?? ".",
+		directory,
 		...settings,
 	});
 
+	const files =
+		settings.files ??
+		((await intake(directory, {
+			exclude: /node_modules|^\.git$/,
+		})) as CreatedDirectory);
+
 	return await awaitLazyProperties(
 		base.prepare({
+			files,
+			log: system.display.log,
 			options: existing,
 			...system,
 		}),
diff --git a/packages/bingo/src/types/options.ts b/packages/bingo/src/types/options.ts
index 807d672..834b876 100644
--- a/packages/bingo/src/types/options.ts
+++ b/packages/bingo/src/types/options.ts
@@ -1,4 +1,7 @@
+import { CreatedDirectory } from "bingo-fs";
+
 import { TakeInput } from "./inputs.js";
+import { ContextLog } from "./templates.js";
 
 /**
  * Either a value, an async function, or a sync function to generate the value or undefined.
@@ -21,6 +24,16 @@ export type LazyOptionalOptions<Options> = {
  * @see {@link http://create.bingo/build/apis/prepare-options}
  */
 export interface OptionsContext<Options extends object> {
+	/**
+	 * Logs a message to the running user.
+	 */
+	log: ContextLog;
+
+	/**
+	 * Existing directory of files on disk, if available.
+	 */
+	files?: CreatedDirectory;
+
 	/**
 	 * Whether Bingo is being run in an "offline" mode.
 	 * @see {@link http://create.bingo/build/details/contexts#options-offline}
diff --git a/packages/bingo/src/types/templates.ts b/packages/bingo/src/types/templates.ts
index 4c02398..c58e629 100644
--- a/packages/bingo/src/types/templates.ts
+++ b/packages/bingo/src/types/templates.ts
@@ -1,9 +1,16 @@
+import { CreatedDirectory } from "bingo-fs";
+
 import { AboutBase } from "./about.js";
 import { Creation } from "./creations.js";
 import { TakeInput } from "./inputs.js";
 import { LazyOptionalOptions } from "./options.js";
 import { AnyShape, InferredObject } from "./shapes.js";
 
+/**
+ * Logs a message to the running user.
+ */
+export type ContextLog = (message: string) => void;
+
 /**
  * Either a value or a Promise for the value.
  */
@@ -118,6 +125,16 @@ export type TemplatePrepare<Options extends object> = (
  */
 export interface TemplatePrepareContext<Options extends object>
 	extends TemplateContext<Options> {
+	/**
+	 * Logs a message to the running user.
+	 */
+	log: ContextLog;
+
+	/**
+	 * Existing directory of files on disk, if available.
+	 */
+	files?: CreatedDirectory;
+
 	/**
 	 * Runs an Input.
 	 */
diff --git a/packages/site/src/content/docs/build/details/contexts.mdx b/packages/site/src/content/docs/build/details/contexts.mdx
index 38de14c..16fc1b9 100644
--- a/packages/site/src/content/docs/build/details/contexts.mdx
+++ b/packages/site/src/content/docs/build/details/contexts.mdx
@@ -166,6 +166,44 @@ This context object is provided to:
 
 - [`prepareOptions`](/build/apis/prepare-options)
 
+### `log` {#options-log}
+
+Logs a message to the running user.
+
+This can be useful for templates that infer important options from disk.
+For example, if a [Stratum template](/engines/stratum/concepts/templates) infers its [`--preset`](/engines/stratum/concepts/templates#--preset), it will explicitly say so.
+
+For example, this template logs if its name falls back to the default:
+
+```ts
+import { createTemplate } from "bingo";
+
+export default createTemplate({
+	options: {
+		name: z.string().optional(),
+	},
+	prepare({ log, options }) {
+		if (!options.name) {
+			log(`Defaulting to --name "(anonymous)".`);
+		}
+		return {
+			name: "(anonymous)",
+		};
+	},
+	produce() {
+		// ...
+	},
+});
+```
+
+Running that template would add a new log to the Bingo CLI:
+
+```plaintext
+│
+◇  Defaulting to --name "(anonymous)".
+│
+```
+
 ### `offline` {#options-offline}
 
 Whether to hint to the template not to make network requests.
diff --git a/packages/site/src/content/docs/engines/stratum/concepts/templates.mdx b/packages/site/src/content/docs/engines/stratum/concepts/templates.mdx
index d9934a0..db90c67 100644
--- a/packages/site/src/content/docs/engines/stratum/concepts/templates.mdx
+++ b/packages/site/src/content/docs/engines/stratum/concepts/templates.mdx
@@ -50,6 +50,8 @@ $ npx create-typescript-app@beta
 Stratum Templates source their `options` from their [Base](/engines/stratum/concepts/bases).
 Options declared on a Template's Base are what may be set in a configuration's `options`.
 
+### `--preset`
+
 The only added option is `preset`, which is a required string option.
 It is a union of the lowercased labels provided under `presets`.
 
@@ -62,3 +64,7 @@ export default createConfig(template, {
 	options: { preset: "everything" },
 });
 ```
+
+During transition mode, Stratum will attempt to infer a default value for `--preset` based using files on disk.
+That default value is computed by comparing existing files on disk to the files that would be produced by the Blocks.
+If Preset with the greatest percentage matches 35% or more of its created files, it will be used as the default.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ead72ea..12f3dc9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -192,6 +192,9 @@ importers:
       bingo-systems:
         specifier: workspace:^
         version: link:../bingo-systems
+      chalk:
+        specifier: ^5.4.1
+        version: 5.4.1
       hash-object:
         specifier: ^5.0.1
         version: 5.0.1


Test patch:
diff --git a/packages/bingo-stratum-testers/src/testBase.ts b/packages/bingo-stratum-testers/src/testBase.ts
index bf4bfd8..81497bc 100644
--- a/packages/bingo-stratum-testers/src/testBase.ts
+++ b/packages/bingo-stratum-testers/src/testBase.ts
@@ -1,6 +1,7 @@
 import {
 	AnyShape,
 	awaitLazyProperties,
+	ContextLog,
 	InferredObject,
 	TakeInput,
 } from "bingo";
@@ -9,6 +10,7 @@ import { Base } from "bingo-stratum";
 import { createFailingFunction, createFailingObject } from "./utils.js";
 
 export interface BaseContextSettings<OptionsShape extends AnyShape> {
+	log?: ContextLog;
 	options?: InferredObject<OptionsShape>;
 	take?: TakeInput;
 }
@@ -23,6 +25,7 @@ export async function testBase<OptionsShape extends AnyShape>(
 
 	return await awaitLazyProperties(
 		base.prepare({
+			log: createFailingFunction("log", "the Base"),
 			options: createFailingObject(
 				"options",
 				"the Base",
diff --git a/packages/bingo-stratum/src/creators/createStratumTemplate.test.ts b/packages/bingo-stratum/src/creators/createStratumTemplate.test.ts
new file mode 100644
index 0000000..7a0940a
--- /dev/null
+++ b/packages/bingo-stratum/src/creators/createStratumTemplate.test.ts
@@ -0,0 +1,85 @@
+import { awaitLazyProperties } from "bingo";
+import chalk from "chalk";
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+
+import { createBase } from "./createBase.js";
+
+const mockInferPreset = vi.fn();
+
+vi.mock("./inferPreset.js", () => ({
+	get inferPreset() {
+		return mockInferPreset;
+	},
+}));
+
+const base = createBase({
+	options: { name: z.string() },
+});
+
+const preset = base.createPreset({
+	about: { name: "Example" },
+	blocks: [],
+});
+
+const template = base.createStratumTemplate({
+	presets: [preset],
+});
+
+const mockLog = vi.fn();
+
+const mockOptions = { name: "Test Name" };
+
+describe("createStratumTemplate", () => {
+	describe("options", () => {
+		describe("--preset", () => {
+			it("does not call inferPreset when context.files is not provided", async () => {
+				const lazyOptions = template.prepare({
+					log: mockLog,
+					options: mockOptions,
+					take: vi.fn(),
+				});
+
+				await awaitLazyProperties(lazyOptions);
+
+				expect(mockInferPreset).not.toHaveBeenCalled();
+			});
+
+			it("does not display a log when inferPreset returns undefined", async () => {
+				mockInferPreset.mockReturnValueOnce(undefined);
+
+				const lazyOptions = template.prepare({
+					files: {
+						"README.md": "...",
+					},
+					log: mockLog,
+					options: mockOptions,
+					take: vi.fn(),
+				});
+
+				await awaitLazyProperties(lazyOptions);
+
+				expect(mockLog).not.toHaveBeenCalled();
+			});
+
+			it("displays a log when inferPreset returns a preset", async () => {
+				mockInferPreset.mockReturnValueOnce("example");
+
+				const lazyOptions = template.prepare({
+					files: {
+						"README.md": "...",
+					},
+					log: mockLog,
+					options: mockOptions,
+					take: vi.fn(),
+				});
+
+				await awaitLazyProperties(lazyOptions);
+
+				expect(mockLog).toHaveBeenCalledWith(
+					`Detected ${chalk.blue(`--preset example`)} from existing files on disk.`,
+				);
+			});
+		});
+	});
+});
diff --git a/packages/bingo-stratum/src/creators/createStratumTemplate.ts b/packages/bingo-stratum/src/creators/createStratumTemplate.ts
index 4ce9728..3fa6aa6 100644
--- a/packages/bingo-stratum/src/creators/createStratumTemplate.ts
+++ b/packages/bingo-stratum/src/creators/createStratumTemplate.ts
@@ -1,4 +1,5 @@
-import { AnyShape } from "bingo";
+import { AnyShape, InferredObject, LazyOptionalOptions } from "bingo";
+import chalk from "chalk";
 import { z } from "zod";
 
 import { produceStratumTemplate } from "../producers/produceStratumTemplate.js";
@@ -9,11 +10,14 @@ import {
 	ZodPresetNameLiterals,
 } from "../types/templates.js";
 import { slugifyPresetName } from "../utils.ts/slugifyPresetName.js";
+import { inferPreset } from "./inferPreset.js";
 
 export function createStratumTemplate<OptionsShape extends AnyShape>(
 	base: Base<OptionsShape>,
 	templateDefinition: StratumTemplateDefinition<OptionsShape>,
 ): StratumTemplate<OptionsShape> {
+	type Options = InferredObject<OptionsShape>;
+
 	const presetOption = z
 		.union(
 			templateDefinition.presets.map((preset) =>
@@ -33,7 +37,25 @@ export function createStratumTemplate<OptionsShape extends AnyShape>(
 				),
 			) as unknown as z.ZodUnion<ZodPresetNameLiterals>, // TODO: why don't the types allow a ZodDefault here?
 		},
-		prepare: base.prepare,
+		prepare(context) {
+			return {
+				preset: () => {
+					// TODO: It would be better to run the base.prepare first to generate option defaults.
+					// ...
+					const preset =
+						context.files && inferPreset(context, templateDefinition.presets);
+
+					if (preset) {
+						context.log(
+							`Detected ${chalk.blue(`--preset ${preset}`)} from existing files on disk.`,
+						);
+					}
+
+					return preset;
+				},
+				...(base.prepare?.(context) ?? {}),
+			} as LazyOptionalOptions<Partial<Options>>; // TODO: Why is this type assertion necessary?
+		},
 		produce(context) {
 			return produceStratumTemplate(template, context);
 		},
diff --git a/packages/bingo-stratum/src/creators/inferPreset.test.ts b/packages/bingo-stratum/src/creators/inferPreset.test.ts
new file mode 100644
index 0000000..7723d72
--- /dev/null
+++ b/packages/bingo-stratum/src/creators/inferPreset.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+
+import { createBase } from "./createBase.js";
+import { inferPreset } from "./inferPreset.js";
+
+const base = createBase({
+	options: { name: z.string() },
+});
+
+const files = {
+	a: "...",
+	b: "...",
+	c: "...",
+	d: "...",
+	e: "...",
+};
+
+const presetErrors = base.createPreset({
+	about: { name: "Errors" },
+	blocks: [
+		base.createBlock({
+			produce() {
+				throw new Error("Oh no!");
+			},
+		}),
+	],
+});
+
+const presetLowPercentageA = base.createPreset({
+	about: { name: "Low Percentage A" },
+	blocks: [
+		base.createBlock({
+			produce() {
+				return {
+					files: {
+						a: "...",
+					},
+				};
+			},
+		}),
+	],
+});
+
+const presetLowPercentageB = base.createPreset({
+	about: { name: "Low Percentage B" },
+	blocks: [
+		base.createBlock({
+			produce() {
+				return {
+					files: {
+						b: "...",
+					},
+				};
+			},
+		}),
+	],
+});
+
+const presetHighPercentage = base.createPreset({
+	about: { name: "High Percentage" },
+	blocks: [
+		base.createBlock({
+			produce() {
+				return {
+					files: {
+						a: "...",
+						b: "...",
+					},
+				};
+			},
+		}),
+	],
+});
+
+const context = {
+	files,
+	options: {
+		name: "...",
+	},
+};
+
+describe("inferPreset", () => {
+	it("returns undefined without erroring when a block throws an error", () => {
+		const actual = inferPreset(context, [presetErrors]);
+
+		expect(actual).toBe(undefined);
+	});
+
+	it("returns undefined when no preset has a 35% match", () => {
+		const actual = inferPreset(context, [
+			presetLowPercentageA,
+			presetLowPercentageB,
+		]);
+
+		expect(actual).toBe(undefined);
+	});
+
+	it("returns the matching preset when a preset has a 35% match", () => {
+		const actual = inferPreset(context, [
+			presetLowPercentageA,
+			presetLowPercentageB,
+			presetHighPercentage,
+		]);
+
+		expect(actual).toBe("high-percentage");
+	});
+});
diff --git a/packages/bingo/src/cli/loggers/logStartText.test.ts b/packages/bingo/src/cli/loggers/logStartText.test.ts
index 1a1ae78..6d8d12d 100644
--- a/packages/bingo/src/cli/loggers/logStartText.test.ts
+++ b/packages/bingo/src/cli/loggers/logStartText.test.ts
@@ -5,6 +5,7 @@ import { logStartText } from "./logStartText.js";
 const mockError = vi.fn();
 const mockInfo = vi.fn();
 const mockMessage = vi.fn();
+const mockStep = vi.fn();
 
 vi.mock("@clack/prompts", () => ({
 	get log() {
@@ -12,6 +13,7 @@ vi.mock("@clack/prompts", () => ({
 			error: mockError,
 			info: mockInfo,
 			message: mockMessage,
+			step: mockStep,
 		};
 	},
 }));
@@ -20,7 +22,7 @@ describe("logStartText", () => {
 	it("only logs an initial message when offline is falsy", () => {
 		logStartText("transition", "from", "type", false);
 
-		expect(mockMessage.mock.calls).toMatchInlineSnapshot(`
+		expect(mockStep.mock.calls).toMatchInlineSnapshot(`
 			[
 			  [
 			    "Running with mode --transition using the type:
@@ -34,15 +36,19 @@ describe("logStartText", () => {
 		logStartText("transition", "from", "type", true);
 
 		expect(mockMessage.mock.calls).toMatchInlineSnapshot(`
-		[
-		  [
-		    "Running with mode --transition using the type:
-		  from",
-		  ],
-		  [
-		    "--offline enabled. You'll need to git push any changes manually.",
-		  ],
-		]
-	`);
+			[
+			  [
+			    "--offline enabled. You'll need to git push any changes manually.",
+			  ],
+			]
+		`);
+		expect(mockStep.mock.calls).toMatchInlineSnapshot(`
+			[
+			  [
+			    "Running with mode --transition using the type:
+			  from",
+			  ],
+			]
+		`);
 	});
 });
diff --git a/packages/bingo/src/cli/runTemplateCLI.test.ts b/packages/bingo/src/cli/runTemplateCLI.test.ts
index 53b4f56..d70192b 100644
--- a/packages/bingo/src/cli/runTemplateCLI.test.ts
+++ b/packages/bingo/src/cli/runTemplateCLI.test.ts
@@ -8,6 +8,7 @@ const mockLog = {
 	error: vi.fn(),
 	info: vi.fn(),
 	message: vi.fn(),
+	step: vi.fn(),
 };
 
 vi.mock("@clack/prompts", () => ({
diff --git a/packages/bingo/src/cli/transition/runModeTransition.test.ts b/packages/bingo/src/cli/transition/runModeTransition.test.ts
index 91a8aa5..dd447ec 100644
--- a/packages/bingo/src/cli/transition/runModeTransition.test.ts
+++ b/packages/bingo/src/cli/transition/runModeTransition.test.ts
@@ -20,6 +20,14 @@ vi.mock("@clack/prompts", () => ({
 	spinner: vi.fn(),
 }));
 
+const mockPrepareOptions = vi.fn();
+
+vi.mock("../../preparation/prepareOptions.js", () => ({
+	get prepareOptions() {
+		return mockPrepareOptions;
+	},
+}));
+
 const mockRunTemplate = vi.fn();
 
 vi.mock("../../runners/runTemplate.js", () => ({
@@ -156,6 +164,25 @@ describe("runModeTransition", () => {
 		expect(mockLogStartText).not.toHaveBeenCalled();
 	});
 
+	it("returns the error when prepareOptions throws an error", async () => {
+		const error = new Error("Oh no!");
+		mockPrepareOptions.mockRejectedValueOnce(error);
+
+		const actual = await runModeTransition({
+			args,
+			configFile: undefined,
+			display,
+			from,
+			template,
+		});
+
+		expect(actual).toEqual({
+			error,
+			status: CLIStatus.Error,
+		});
+		expect(mockLogRerunSuggestion).toHaveBeenCalledWith(args, {});
+	});
+
 	it("returns the cancellation when promptForOptions is cancelled", async () => {
 		mockPromptForOptionSchemas.mockResolvedValueOnce({
 			cancelled: true,