From 734e91e462a5b79b5efc799a363225af48c5d6d1 Mon Sep 17 00:00:00 2001 From: Sourav Date: Thu, 30 Oct 2025 07:11:57 -0700 Subject: [PATCH] feat: add --dry-run flag for CLI run command - add --dry-run flag to preview translation tasks without execution - show detailed file analysis: source/target strings, changes breakdown - display summary with total strings to translate - skip authentication and API initialization in dry-run mode - support all existing filter flags (--target-locale, --bucket, --file, --key) this feature allows users to: - preview which files would be translated - see how many strings would be affected - estimate potential costs before running actual translation - verify configuration without making changes --- packages/cli/src/cli/cmd/run/_types.ts | 1 + packages/cli/src/cli/cmd/run/dry-run.ts | 277 ++++++++++++++++++++++++ packages/cli/src/cli/cmd/run/index.ts | 23 +- packages/cli/src/cli/cmd/run/setup.ts | 6 +- 4 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/cli/cmd/run/dry-run.ts diff --git a/packages/cli/src/cli/cmd/run/_types.ts b/packages/cli/src/cli/cmd/run/_types.ts index d5ea1316d..821c3fc20 100644 --- a/packages/cli/src/cli/cmd/run/_types.ts +++ b/packages/cli/src/cli/cmd/run/_types.ts @@ -53,5 +53,6 @@ export const flagsSchema = z.object({ watch: z.boolean().default(false), debounce: z.number().positive().default(5000), // 5 seconds default sound: z.boolean().optional(), + dryRun: z.boolean().default(false), }); export type CmdRunFlags = z.infer; diff --git a/packages/cli/src/cli/cmd/run/dry-run.ts b/packages/cli/src/cli/cmd/run/dry-run.ts new file mode 100644 index 000000000..77b22f097 --- /dev/null +++ b/packages/cli/src/cli/cmd/run/dry-run.ts @@ -0,0 +1,277 @@ +import chalk from "chalk"; +import { Listr } from "listr2"; +import _ from "lodash"; +import { minimatch } from "minimatch"; + +import { colors } from "../../constants"; +import { CmdRunContext, CmdRunTask } from "./_types"; +import { commonTaskRendererOptions } from "./_const"; +import createBucketLoader from "../../loaders"; +import { createDeltaProcessor } from "../../utils/delta"; + +type DryRunResult = { + task: CmdRunTask; + sourceCount: number; + targetCount: number; + added: number; + updated: number; + renamed: number; + removed: number; + toTranslate: number; +}; + +export default async function dryRun(input: CmdRunContext) { + console.log(chalk.hex(colors.orange)(`[Dry Run - Preview Mode]`)); + + const results: DryRunResult[] = []; + + await new Listr( + [ + { + title: "Analyzing translation requirements", + task: async (ctx, task) => { + if (input.tasks.length < 1) { + task.title = `No tasks to analyze.`; + task.skip(); + return; + } + + const subtasks = ctx.tasks.map((assignedTask) => ({ + title: `Analyzing: ${assignedTask.bucketPathPattern.replace( + "[locale]", + assignedTask.targetLocale, + )}`, + task: async () => { + const bucketLoader = createBucketLoader( + assignedTask.bucketType, + assignedTask.bucketPathPattern, + { + defaultLocale: assignedTask.sourceLocale, + injectLocale: assignedTask.injectLocale, + formatter: assignedTask.formatter, + }, + assignedTask.lockedKeys, + assignedTask.lockedPatterns, + assignedTask.ignoredKeys, + ); + bucketLoader.setDefaultLocale(assignedTask.sourceLocale); + + const deltaProcessor = createDeltaProcessor( + assignedTask.bucketPathPattern, + ); + + try { + const sourceData = await bucketLoader.pull( + assignedTask.sourceLocale, + ); + const targetData = await bucketLoader.pull( + assignedTask.targetLocale, + ); + const checksums = await deltaProcessor.loadChecksums(); + const delta = await deltaProcessor.calculateDelta({ + sourceData, + targetData, + checksums, + }); + + const processableData = _.chain(sourceData) + .entries() + .filter( + ([key, value]) => + delta.added.includes(key) || + delta.updated.includes(key) || + !!ctx.flags.force, + ) + .filter( + ([key]) => + !assignedTask.onlyKeys.length || + assignedTask.onlyKeys?.some((pattern) => + minimatch(key, pattern), + ), + ) + .fromPairs() + .value(); + + results.push({ + task: assignedTask, + sourceCount: Object.keys(sourceData).length, + targetCount: Object.keys(targetData).length, + added: delta.added.length, + updated: delta.updated.length, + renamed: delta.renamed.length, + removed: delta.removed.length, + toTranslate: Object.keys(processableData).length, + }); + } catch (error: any) { + results.push({ + task: assignedTask, + sourceCount: 0, + targetCount: 0, + added: 0, + updated: 0, + renamed: 0, + removed: 0, + toTranslate: 0, + }); + } + }, + })); + + return task.newListr(subtasks, { + concurrent: true, + exitOnError: false, + rendererOptions: { + ...commonTaskRendererOptions, + collapseSubtasks: false, + }, + }); + }, + }, + ], + { + exitOnError: false, + rendererOptions: commonTaskRendererOptions, + }, + ).run(input); + + // Display summary + console.log(); + console.log(chalk.hex(colors.orange)("[Dry Run Summary]")); + console.log(); + + if (results.length === 0) { + console.log(chalk.dim("No translation tasks found.")); + return; + } + + // Group results by bucket path pattern + const groupedResults = _.groupBy( + results, + (r) => r.task.bucketPathPattern, + ); + + let totalToTranslate = 0; + let totalAdded = 0; + let totalUpdated = 0; + let totalRenamed = 0; + + for (const [pathPattern, taskResults] of Object.entries(groupedResults)) { + console.log(chalk.hex(colors.yellow)(`${pathPattern}`)); + + for (const result of taskResults) { + const displayPath = result.task.bucketPathPattern.replace( + "[locale]", + result.task.targetLocale, + ); + + console.log( + ` ${chalk.dim("→")} ${chalk.hex(colors.yellow)( + result.task.sourceLocale, + )} -> ${chalk.hex(colors.yellow)(result.task.targetLocale)}`, + ); + console.log( + ` ${chalk.dim("Source strings:")} ${chalk.white( + result.sourceCount, + )}`, + ); + console.log( + ` ${chalk.dim("Target strings:")} ${chalk.white( + result.targetCount, + )}`, + ); + + if (result.added > 0) { + console.log( + ` ${chalk.hex(colors.green)("+")} ${chalk.green( + result.added, + )} ${chalk.dim("new")}`, + ); + } + if (result.updated > 0) { + console.log( + ` ${chalk.hex(colors.orange)("~")} ${chalk.hex(colors.orange)( + result.updated, + )} ${chalk.dim("updated")}`, + ); + } + if (result.renamed > 0) { + console.log( + ` ${chalk.hex(colors.yellow)("↻")} ${chalk.hex(colors.yellow)( + result.renamed, + )} ${chalk.dim("renamed")}`, + ); + } + if (result.removed > 0) { + console.log( + ` ${chalk.red("-")} ${chalk.red(result.removed)} ${chalk.dim( + "removed", + )}`, + ); + } + + if (result.toTranslate > 0) { + console.log( + ` ${chalk.hex(colors.green)("→ Would translate:")} ${chalk.bold( + chalk.hex(colors.green)(result.toTranslate), + )} ${chalk.dim("strings")}`, + ); + } else { + console.log( + ` ${chalk.dim("→ Would translate:")} ${chalk.dim( + "0 strings (up to date)", + )}`, + ); + } + + totalToTranslate += result.toTranslate; + totalAdded += result.added; + totalUpdated += result.updated; + totalRenamed += result.renamed; + + console.log(); + } + } + + // Overall summary + console.log(chalk.hex(colors.orange)("─".repeat(60))); + console.log(); + console.log( + `${chalk.hex(colors.green)("Total files to process:")} ${chalk.bold( + results.length, + )}`, + ); + console.log( + `${chalk.hex(colors.green)("Total strings to translate:")} ${chalk.bold( + totalToTranslate, + )}`, + ); + + if (totalAdded > 0) { + console.log( + ` ${chalk.green("+")} ${chalk.green(totalAdded)} ${chalk.dim( + "new strings", + )}`, + ); + } + if (totalUpdated > 0) { + console.log( + ` ${chalk.hex(colors.orange)("~")} ${chalk.hex(colors.orange)( + totalUpdated, + )} ${chalk.dim("updated strings")}`, + ); + } + if (totalRenamed > 0) { + console.log( + ` ${chalk.hex(colors.yellow)("↻")} ${chalk.hex(colors.yellow)( + totalRenamed, + )} ${chalk.dim("renamed strings")}`, + ); + } + + console.log(); + console.log( + chalk.dim( + "Run without --dry-run to perform the actual translation.", + ), + ); +} diff --git a/packages/cli/src/cli/cmd/run/index.ts b/packages/cli/src/cli/cmd/run/index.ts index 92be8d33e..35ca8af2e 100644 --- a/packages/cli/src/cli/cmd/run/index.ts +++ b/packages/cli/src/cli/cmd/run/index.ts @@ -7,6 +7,7 @@ import setup from "./setup"; import plan from "./plan"; import execute from "./execute"; import watch from "./watch"; +import dryRun from "./dry-run"; import { CmdRunContext, flagsSchema } from "./_types"; import frozen from "./frozen"; import { @@ -117,6 +118,10 @@ export default new Command() "--sound", "Play audio feedback when translations complete (success or failure sounds)", ) + .option( + "--dry-run", + "Preview which files would be translated and how many strings would be affected without performing actual translation", + ) .action(async (args) => { let authId: string | null = null; try { @@ -149,14 +154,20 @@ export default new Command() await plan(ctx); await renderSpacer(); - await frozen(ctx); - await renderSpacer(); + // If dry-run mode is enabled, skip execution and show preview + if (ctx.flags.dryRun) { + await dryRun(ctx); + await renderSpacer(); + } else { + await frozen(ctx); + await renderSpacer(); - await execute(ctx); - await renderSpacer(); + await execute(ctx); + await renderSpacer(); - await renderSummary(ctx.results); - await renderSpacer(); + await renderSummary(ctx.results); + await renderSpacer(); + } // Play sound after main tasks complete if sound flag is enabled if (ctx.flags.sound) { diff --git a/packages/cli/src/cli/cmd/run/setup.ts b/packages/cli/src/cli/cmd/run/setup.ts index ca6652109..04083f2eb 100644 --- a/packages/cli/src/cli/cmd/run/setup.ts +++ b/packages/cli/src/cli/cmd/run/setup.ts @@ -49,6 +49,7 @@ export default async function setup(input: CmdRunContext) { }, { title: "Selecting localization provider", + enabled: (ctx) => !ctx.flags.dryRun, task: async (ctx, task) => { ctx.localizer = createLocalizer( ctx.config?.provider, @@ -67,7 +68,7 @@ export default async function setup(input: CmdRunContext) { }, { title: "Checking authentication", - enabled: (ctx) => ctx.localizer?.id === "Lingo.dev", + enabled: (ctx) => !ctx.flags.dryRun && ctx.localizer?.id === "Lingo.dev", task: async (ctx, task) => { const authStatus = await ctx.localizer!.checkAuth(); if (!authStatus.authenticated) { @@ -80,7 +81,7 @@ export default async function setup(input: CmdRunContext) { }, { title: "Validating configuration", - enabled: (ctx) => ctx.localizer?.id !== "Lingo.dev", + enabled: (ctx) => !ctx.flags.dryRun && ctx.localizer?.id !== "Lingo.dev", task: async (ctx, task) => { const validationStatus = await ctx.localizer!.validateSettings!(); if (!validationStatus.valid) { @@ -93,6 +94,7 @@ export default async function setup(input: CmdRunContext) { }, { title: "Initializing localization provider", + enabled: (ctx) => !ctx.flags.dryRun, async task(ctx, task) { const isLingoDotDev = ctx.localizer!.id === "Lingo.dev";