diff --git a/internal/helpers/drive_pull.go b/internal/helpers/drive_pull.go index 4231a507..9a40f2db 100644 --- a/internal/helpers/drive_pull.go +++ b/internal/helpers/drive_pull.go @@ -65,6 +65,15 @@ type drivePullResult struct { Items []drivePullItem `json:"items"` } +type drivePullDryRunResult struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + PreviewKind string `json:"preview_kind"` + Operation string `json:"operation"` + IfExists string `json:"if_exists"` + Plan drivePullResult `json:"plan"` +} + // drivePartialFailure 在 summary.failed > 0 时返回:以 exit=1 退出,并把 // {"error":{"type":"partial_failure","detail":{summary,items}}} 原样透传出去。 // 结构上满足 core 的 RawStderrError 与 ExitCoder 接口(无需 import internal 包)。 @@ -174,6 +183,9 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { relPaths = append(relPaths, rel) } sort.Strings(relPaths) + if deps.Caller.DryRun() { + return printDrivePullDryRun(absDir, ifExists, remote, relPaths) + } // 落盘前先按目标文件系统的路径等价规则做一次全局冲突检查:大小写不敏感 FS 上 // A.txt 与 a.txt(或 NFC/NFD 异写)会落到同一本地文件;overwrite 会顺序覆盖、 @@ -234,6 +246,50 @@ func runDrivePull(cmd *cobra.Command, _ []string) error { return deps.Out.PrintJSON(res) } +func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string) error { + plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))} + caseInsensitive := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + collided := detectTargetCollisions(absDir, relPaths, caseInsensitive) + for _, rel := range relPaths { + if collided[rel] { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePullItem{ + RelPath: rel, Action: pullActionFailed, + Error: "多个远端条目在目标平台默认文件系统上映射到同一本地路径,计划已拒绝", + }) + continue + } + localPath, err := resolveLocalTarget(absDir, rel) + if err != nil { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: err.Error()}) + continue + } + action := pullActionDownloaded + if fi, statErr := os.Stat(localPath); statErr == nil && fi.Mode().IsRegular() { + switch ifExists { + case ifExistsSkip: + action = pullActionSkipped + case ifExistsSmart: + rf := remote[rel] + if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime { + action = pullActionSkipped + } + } + } + if action == pullActionSkipped { + plan.Summary.Skipped++ + } else { + plan.Summary.Downloaded++ + } + plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action}) + } + return deps.Out.PrintJSON(drivePullDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull", + IfExists: ifExists, Plan: plan, + }) +} + // pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。 // 返回动作分类(downloaded / skipped / failed)及失败时的 error。 func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) { diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index fcce42c5..741fa7eb 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -64,6 +64,15 @@ type drivePushResult struct { Items []drivePushItem `json:"items"` } +type drivePushDryRunResult struct { + DryRun bool `json:"dry_run"` + Executed bool `json:"executed"` + PreviewKind string `json:"preview_kind"` + Operation string `json:"operation"` + IfExists string `json:"if_exists"` + Plan drivePushResult `json:"plan"` +} + // drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout, // 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。 type drivePushFailure struct{ failed int } @@ -111,6 +120,9 @@ func runDrivePush(cmd *cobra.Command, _ []string) error { } res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + if deps.Caller.DryRun() { + return printDrivePushDryRun(ifExists, remoteFiles, remoteFolders, localDirs, localFiles) + } // 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。 for _, dir := range localDirs { @@ -224,7 +236,7 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f args["nextToken"] = nextToken } - text, err := callMCPToolReturnText(ctx, "list_files", args) + text, err := callDriveListFiles(ctx, args) if err != nil { return err } @@ -275,6 +287,61 @@ func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, f return nil } +func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error { + plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))} + plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs)) + for rel, fileID := range remoteFolders { + plannedFolders[rel] = fileID + } + for _, dir := range localDirs { + if _, ok := plannedFolders[dir]; ok { + continue + } + parentRel, _ := splitRel(dir) + if plannedFolders[parentRel] == "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"}) + continue + } + plannedFolders[dir] = "dry-run-planned-folder" + plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated}) + } + for _, lf := range localFiles { + size := lf.Size + parentRel, _ := splitRel(lf.RelPath) + if plannedFolders[parentRel] == "" { + plan.Summary.Failed++ + plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"}) + continue + } + action := pushActionUploaded + if rf, exists := remoteFiles[lf.RelPath]; exists { + switch ifExists { + case ifExistsSkip: + action = pushActionSkipped + case ifExistsSmart: + if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis { + action = pushActionSkipped + } else { + action = pushActionOverwritten + } + case ifExistsOverwrite: + action = pushActionOverwritten + } + } + if action == pushActionSkipped { + plan.Summary.Skipped++ + } else { + plan.Summary.Uploaded++ + } + plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size}) + } + return deps.Out.PrintJSON(drivePushDryRunResult{ + DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push", + IfExists: ifExists, Plan: plan, + }) +} + // walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前) // 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。 func walkLocalForPush(root string) ([]string, []localPushFile, error) { diff --git a/internal/helpers/drive_push_test.go b/internal/helpers/drive_push_test.go index 984dd582..c424e75f 100644 --- a/internal/helpers/drive_push_test.go +++ b/internal/helpers/drive_push_test.go @@ -163,6 +163,10 @@ func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName str return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (m *driveSyncMockCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + return m.CallTool(ctx, productID, toolName, args) +} + func (m *driveSyncMockCaller) Format() string { return "raw" } func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun } func (m *driveSyncMockCaller) Fields() string { return "" } diff --git a/internal/helpers/drive_status.go b/internal/helpers/drive_status.go index cafe7f80..b62afa8b 100644 --- a/internal/helpers/drive_status.go +++ b/internal/helpers/drive_status.go @@ -121,7 +121,7 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m args["nextToken"] = nextToken } - text, err := callMCPToolReturnText(ctx, "list_files", args) + text, err := callDriveListFiles(ctx, args) if err != nil { return err } @@ -180,6 +180,15 @@ func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out m return nil } +// callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取 +// 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。 +func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) { + if deps.Caller.DryRun() { + return callMCPReadToolReturnTextOnServer(ctx, "drive", "list_files", args) + } + return callMCPToolReturnText(ctx, "list_files", args) +} + // driveItem 是 list_files 返回里的一个 dentry 条目。 // 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。 type driveItem map[string]any diff --git a/internal/helpers/drive_status_coverage_test.go b/internal/helpers/drive_status_coverage_test.go index c1b53fd2..79222ce0 100644 --- a/internal/helpers/drive_status_coverage_test.go +++ b/internal/helpers/drive_status_coverage_test.go @@ -42,6 +42,10 @@ func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName strin return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } +func (m *driveScriptCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + return m.CallTool(ctx, productID, toolName, args) +} + func (m *driveScriptCaller) Format() string { return "raw" } func (m *driveScriptCaller) DryRun() bool { return m.dryRun } func (m *driveScriptCaller) Fields() string { return "" } diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 25db77d4..6f7cbbf2 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -1,6 +1,7 @@ package helpers import ( + "bytes" "context" "os" "path/filepath" @@ -134,6 +135,149 @@ func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testi } } +func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "not-created") + caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`) + caller.dryRun = true + SetHTTPGetFile(func(context.Context, string, map[string]string, string) error { + t.Error("dry-run must not download") + return nil + }) + t.Cleanup(func() { SetHTTPGetFile(nil) }) + + var out bytes.Buffer + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} + os.Args = []string{"dws", "drive", "pull"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + cmd := findDriveSubcommand(t, "pull") + mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run pull: %v", err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("dry-run must not create local root: %v", err) + } + if got := len(caller.callsFor("download_file")); got != 0 { + t.Fatalf("download_file calls = %d, want 0", got) + } + if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) { + t.Fatalf("missing pull plan: %s", text) + } +} + +func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local") + caller := syncCaller(nil) + caller.dryRun = true + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + t.Error("dry-run must not upload") + return nil + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + var out bytes.Buffer + prevDeps, prevArgs := deps, os.Args + deps = &Deps{Caller: caller, Out: &Formatter{w: &out}} + os.Args = []string{"dws", "drive", "push"} + t.Cleanup(func() { deps, os.Args = prevDeps, prevArgs }) + cmd := findDriveSubcommand(t, "push") + mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"}) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("dry-run push: %v", err) + } + for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} { + if got := len(caller.callsFor(tool)); got != 0 { + t.Fatalf("%s calls = %d, want 0", tool, got) + } + } + if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) { + t.Fatalf("missing push plan: %s", text) + } +} + +func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "skip.txt"), "skip") + mustWrite(t, filepath.Join(root, "smart.txt"), "smart") + remote := map[string]*remoteFile{ + "A.txt": {RelPath: "A.txt"}, + "a.txt": {RelPath: "a.txt"}, + "../escape": {RelPath: "../escape"}, + "skip.txt": {RelPath: "skip.txt"}, + "smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true}, + "new.txt": {RelPath: "new.txt"}, + } + var out bytes.Buffer + prevDeps := deps + deps = &Deps{Out: &Formatter{w: &out}} + t.Cleanup(func() { deps = prevDeps }) + + if err := printDrivePullDryRun(root, ifExistsSkip, remote, + []string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}); err != nil { + t.Fatalf("skip plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) { + t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text) + } + out.Reset() + if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}); err != nil { + t.Fatalf("smart plan: %v", err) + } + if !strings.Contains(out.String(), pullActionSkipped) { + t.Fatalf("smart plan must skip newer local file: %s", out.String()) + } +} + +func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) { + var out bytes.Buffer + prevDeps := deps + deps = &Deps{Out: &Formatter{w: &out}} + t.Cleanup(func() { deps = prevDeps }) + remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"} + remoteFiles := map[string]*remoteFile{ + "skip.txt": {RelPath: "skip.txt"}, + "smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true}, + "smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true}, + "overwrite.txt": {RelPath: "overwrite.txt"}, + } + files := func(paths ...string) []localPushFile { + result := make([]localPushFile, 0, len(paths)) + for _, path := range paths { + result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10}) + } + return result + } + + if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders, + []string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil { + t.Fatalf("skip plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) { + t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text) + } + out.Reset() + if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil, + files("smart-skip.txt", "smart-overwrite.txt")); err != nil { + t.Fatalf("smart plan: %v", err) + } + if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) { + t.Fatalf("smart plan did not cover skip/overwrite: %s", text) + } + out.Reset() + if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil { + t.Fatalf("overwrite plan: %v", err) + } + if !strings.Contains(out.String(), pushActionOverwritten) { + t.Fatalf("overwrite plan must overwrite existing file: %s", out.String()) + } +} + // ────────────────────────────────────────────────────────── // 2. 明确确认后才发出精确的工具调用 // ──────────────────────────────────────────────────────────