Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/cli/cmd/run/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof flagsSchema>;
277 changes: 277 additions & 0 deletions packages/cli/src/cli/cmd/run/dry-run.ts
Original file line number Diff line number Diff line change
@@ -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<CmdRunContext>(
[
{
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,
});
}
Comment on lines +105 to +116
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors are silently caught and converted to zero-value results, making it difficult to distinguish between a file with no translations and a file that failed to load. Consider logging the error or showing a warning in the output to help users identify configuration or file access issues. For example: console.warn(chalk.yellow(\⚠ Failed to analyze ${assignedTask.bucketPathPattern}: ${error.message}`))`

Copilot uses AI. Check for mistakes.
},
}));

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,
);
Comment on lines +162 to +165
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable displayPath.

Suggested change
const displayPath = result.task.bucketPathPattern.replace(
"[locale]",
result.task.targetLocale,
);

Copilot uses AI. Check for mistakes.

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.",
),
);
}
23 changes: 17 additions & 6 deletions packages/cli/src/cli/cmd/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Comment on lines +157 to +160
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watch mode (line 178) may not work correctly when combined with --dry-run since ctx.localizer will be null in dry-run mode. The watch mode's triggerRetranslation function calls execute() which requires a valid localizer. Consider adding a check like if (ctx.flags.dryRun && ctx.flags.watch) to either prevent this combination or document the expected behavior.

Copilot uses AI. Check for mistakes.
} 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) {
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/cli/cmd/run/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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";

Expand Down
Loading