diff --git a/src/core/execution-plan.ts b/src/core/execution-plan.ts new file mode 100644 index 0000000..2dcf89c --- /dev/null +++ b/src/core/execution-plan.ts @@ -0,0 +1,75 @@ +import { FileItem } from '../types/platform'; +import { Task } from '../types/core'; +import { + ConflictResolution, + ConflictResult, + resolveBatchConflicts, +} from './conflict-detector'; + +export interface ExecutionPlanInput { + files: FileItem[]; + newNames: string[]; + conflicts?: Map; + resolution?: ConflictResolution | null; + skipUnchanged?: boolean; +} + +export interface ExecutionPlanResult { + resolvedNames: string[]; + tasks: Task[]; + skippedIndexes: number[]; +} + +export function buildExecutionPlan({ + files, + newNames, + conflicts, + resolution, + skipUnchanged = true, +}: ExecutionPlanInput): ExecutionPlanResult { + if (files.length !== newNames.length) { + throw new Error('Files and newNames length mismatch'); + } + + const conflictMap = conflicts ?? new Map(); + + let resolvedNames = newNames.slice(); + + if (resolution && conflictMap.size > 0) { + switch (resolution) { + case ConflictResolution.AUTO_NUMBER: + resolvedNames = resolveBatchConflicts(files, newNames, conflictMap); + break; + case ConflictResolution.SKIP: + resolvedNames = newNames.map((name, index) => { + const file = files[index]; + const conflict = conflictMap.get(file.id); + return conflict?.hasConflict ? file.name : name; + }); + break; + case ConflictResolution.OVERWRITE: + default: + resolvedNames = newNames.slice(); + break; + } + } + + const tasks: Task[] = []; + const skippedIndexes: number[] = []; + + resolvedNames.forEach((resolvedName, index) => { + const file = files[index]; + if (skipUnchanged && resolvedName === file.name) { + skippedIndexes.push(index); + return; + } + + tasks.push({ + file, + newName: resolvedName, + index, + }); + }); + + return { resolvedNames, tasks, skippedIndexes }; +} diff --git a/tests/unit/execution-plan.test.ts b/tests/unit/execution-plan.test.ts new file mode 100644 index 0000000..ee77af1 --- /dev/null +++ b/tests/unit/execution-plan.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { buildExecutionPlan } from '../../src/core/execution-plan'; +import { checkBatchConflicts, ConflictResolution } from '../../src/core/conflict-detector'; + +const files = [ + { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 }, + { id: '2', name: 'b.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 }, +]; + +describe('buildExecutionPlan', () => { + it('auto-number resolves duplicate names and skips unchanged', () => { + const newNames = ['same.txt', 'same.txt']; + const conflicts = checkBatchConflicts(files as any, newNames); + + const plan = buildExecutionPlan({ + files: files as any, + newNames, + conflicts, + resolution: ConflictResolution.AUTO_NUMBER, + }); + + expect(plan.resolvedNames[0]).toMatch(/same\(1\)\.txt/); + expect(plan.resolvedNames[1]).toMatch(/same\(2\)\.txt/); + expect(plan.tasks.length).toBe(2); + }); + + it('skip strategy preserves original names', () => { + const newNames = ['same.txt', 'same.txt']; + const conflicts = checkBatchConflicts(files as any, newNames); + + const plan = buildExecutionPlan({ + files: files as any, + newNames, + conflicts, + resolution: ConflictResolution.SKIP, + }); + + expect(plan.resolvedNames).toEqual(['a.txt', 'b.txt']); + }); +});