diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 89c1e42fe..303aeac11 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -17,6 +17,8 @@ import ( "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // driveStatusScopedTokenResolver returns a token with caller-controlled scopes @@ -804,3 +806,59 @@ func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) { t.Fatalf("error must reference --folder-token, got: %v", err) } } + +func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) { + missingRoot := filepath.Join(t.TempDir(), "does-not-exist") + + _, err := walkLocalForStatus(missingRoot, t.TempDir()) + if err == nil { + t.Fatal("expected walkLocalForStatus() to fail for missing root") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "io" { + t.Fatalf("expected io error detail, got %#v", exitErr.Detail) + } + if !strings.Contains(err.Error(), "walk") { + t.Fatalf("expected walk-related error, got: %v", err) + } +} + +func TestHashLocalForStatusWrapsOpenError(t *testing.T) { + config := driveTestConfig() + f, _, _, _ := cmdutil.TestFactory(t, config) + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, config) + runtime.Factory = f + + _, err := hashLocalForStatus(runtime, "missing.txt") + if err == nil { + t.Fatal("expected hashLocalForStatus() to fail for missing file") + } + if !strings.Contains(err.Error(), "missing.txt") { + t.Fatalf("expected error to mention the missing file, got: %v", err) + } +} + +func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) { + config := driveTestConfig() + f, _, _, _ := cmdutil.TestFactory(t, config) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config) + runtime.Factory = f + + _, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing") + if err == nil { + t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured ExitError, got %T", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "network" { + t.Fatalf("expected network detail, got %#v", exitErr.Detail) + } + if !strings.Contains(err.Error(), "download") { + t.Fatalf("expected download-related error, got: %v", err) + } +} diff --git a/shortcuts/drive/drive_sync.go b/shortcuts/drive/drive_sync.go new file mode 100644 index 000000000..6bb31ea75 --- /dev/null +++ b/shortcuts/drive/drive_sync.go @@ -0,0 +1,480 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + driveSyncOnConflictLocalWins = "local-wins" + driveSyncOnConflictRemoteWins = "remote-wins" + driveSyncOnConflictKeepBoth = "keep-both" + driveSyncOnConflictAsk = "ask" +) + +type driveSyncItem struct { + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + Action string `json:"action"` + Direction string `json:"direction,omitempty"` // "pull" or "push" + Error string `json:"error,omitempty"` +} + +// DriveSync performs a two-way sync between a local directory and a Drive +// folder. It computes a diff (like +status), then: +// - new_remote → pull (download to local) +// - new_local → push (upload to Drive) +// - modified → resolve by --on-conflict strategy: +// local-wins: push local over remote; +// remote-wins: pull remote over local; +// keep-both: rename the local file with a hash suffix and pull the remote; +// ask: prompt the user per conflict. +var DriveSync = common.Shortcut{ + Service: "drive", + Command: "+sync", + Description: "Two-way sync between a local directory and a Drive folder", + Risk: "write", + Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download", "drive:file:upload", "space:folder:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, + {Name: "folder-token", Desc: "Drive folder token", Required: true}, + {Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}}, + {Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}}, + {Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"}, + }, + Tips: []string{ + "Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.", + "Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.", + "Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).", + "Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.", + "Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + if localDir == "" { + return common.FlagErrorf("--local-dir is required") + } + if folderToken == "" { + return common.FlagErrorf("--folder-token is required") + } + if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { + return output.ErrValidation("%s", err) + } + info, err := runtime.FileIO().Stat(localDir) + if err != nil { + return common.WrapInputStatError(err) + } + if !info.IsDir() { + return output.ErrValidation("--local-dir is not a directory: %s", localDir) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy."). + GET("/open-apis/drive/v1/files"). + Set("folder_token", runtime.Str("folder-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + localDir := strings.TrimSpace(runtime.Str("local-dir")) + folderToken := strings.TrimSpace(runtime.Str("folder-token")) + onConflict := strings.TrimSpace(runtime.Str("on-conflict")) + if onConflict == "" { + onConflict = driveSyncOnConflictRemoteWins + } + duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote")) + if duplicateRemote == "" { + duplicateRemote = driveDuplicateRemoteFail + } + quick := runtime.Bool("quick") + + safeRoot, err := validate.SafeInputPath(localDir) + if err != nil { + return output.ErrValidation("--local-dir: %s", err) + } + cwdCanonical, err := validate.SafeInputPath(".") + if err != nil { + return output.ErrValidation("could not resolve cwd: %s", err) + } + rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) + if err != nil { + return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + } + + // --- Phase 1: Compute diff (same logic as +status) --- + fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) + localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) + entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "") + if err != nil { + return err + } + if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 { + return duplicateRemotePathError(duplicates) + } + + // Build the exact remote-file views that later execution will use so the + // diff phase classifies files against the same duplicate-resolution choice. + pullRemoteFiles, pullRemotePaths, err := drivePullRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) + } + remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%s", err) + } + + remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles) + + paths := mergeStatusPaths(localFiles, remoteFiles) + + var newLocal, newRemote, modified []driveStatusEntry + var unchanged []driveStatusEntry + for _, relPath := range paths { + localFile, hasLocal := localFiles[relPath] + remoteFile, hasRemote := remoteFiles[relPath] + switch { + case hasLocal && !hasRemote: + newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) + case !hasLocal && hasRemote: + newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}) + default: + entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken} + if quick { + if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) { + unchanged = append(unchanged, entry) + } else { + modified = append(modified, entry) + } + continue + } + localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd) + if err != nil { + return err + } + remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken) + if err != nil { + return err + } + if localHash == remoteHash { + unchanged = append(unchanged, entry) + } else { + modified = append(modified, entry) + } + } + } + + detection := driveStatusDetectionExact + if quick { + detection = driveStatusDetectionQuick + } + + fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n", + len(newLocal), len(newRemote), len(modified), len(unchanged), detection) + + if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil { + return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist") + } + + // --- Phase 2: Execute sync operations --- + var pulled, pushed, skipped, failed int + items := make([]driveSyncItem, 0) + + // Build push infrastructure: local walk for push + remote views + folder cache. + pushLocalFiles, _, err := drivePushWalkLocal(safeRoot, cwdCanonical) + if err != nil { + return err + } + folderCache := map[string]string{"": folderToken} + for relDir, entry := range remoteFolders { + folderCache[relDir] = entry.FileToken + } + + // 2a. Pull new_remote files. + for _, entry := range newRemote { + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + // Non-file type (doc, shortcut, etc.) — skip. + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + } + + // 2b. Push new_local files. + for _, entry := range newLocal { + localFile, ok := pushLocalFiles[entry.RelPath] + if !ok { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"}) + skipped++ + continue + } + parentRel := drivePushParentRel(entry.RelPath) + parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) + if ensureErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()}) + failed++ + continue + } + token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) + if upErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"}) + pushed++ + } + + // 2c. Resolve modified files by --on-conflict strategy. + for _, entry := range modified { + remoteFile := remoteFiles[entry.RelPath] + localFile, hasLocal := pushLocalFiles[entry.RelPath] + if !hasLocal { + // Should not happen — modified means both sides exist. + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"}) + skipped++ + continue + } + + resolved := onConflict + if resolved == driveSyncOnConflictAsk { + resolved, err = driveSyncAskConflict(entry.RelPath, runtime) + if err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "conflict", Error: err.Error()}) + failed++ + continue + } + if resolved == "" { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"}) + skipped++ + continue + } + } + + switch resolved { + case driveSyncOnConflictRemoteWins: + // Pull remote over local. + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"}) + failed++ + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + + case driveSyncOnConflictLocalWins: + // Push local over remote. + existingToken := remoteFile.FileToken + if existingToken == "" { + if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok { + existingToken = chosen.FileToken + } + } + parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) + if parentErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()}) + failed++ + continue + } + token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken) + if upErr != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: upErr.Error()}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"}) + pushed++ + + case driveSyncOnConflictKeepBoth: + // Rename the local file with a hash suffix, then pull the remote. + // Use the remote file token to generate a stable suffix (same + // pattern as +pull --on-duplicate-remote=rename). + occupied := occupiedRemotePaths(entries) + // Add current local paths to occupied set so the renamed + // local file doesn't collide with an existing file. + for p := range pushLocalFiles { + occupied[p] = struct{}{} + } + suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied) + if err != nil { + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()}) + failed++ + continue + } + // Rename the local file. + oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) + newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel)) + if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // FileIO has no Rename; safeRoot is validated. + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)}) + failed++ + continue + } + // Now pull the remote version to the original path. + targetFile, ok := pullRemoteFiles[entry.RelPath] + if !ok { + rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + errMsg := "remote file not found in pull views after rename" + if rollbackErr != nil { + errMsg += "; rollback failed: " + rollbackErr.Error() + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg}) + failed++ + continue + } + target := filepath.Join(rootRelToCwd, entry.RelPath) + if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + errMsg := err.Error() + if rollbackErr != nil { + errMsg += "; rollback failed: " + rollbackErr.Error() + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg}) + failed++ + continue + } + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"}) + items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) + pulled++ + + default: + items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)}) + skipped++ + } + } + + // Ensure pullRemotePaths is used (it was computed for potential + // future delete support, but +sync does not delete by design). + _ = pullRemotePaths + + payload := map[string]interface{}{ + "detection": detection, + "diff": map[string]interface{}{ + "new_local": emptyIfNil(newLocal), + "new_remote": emptyIfNil(newRemote), + "modified": emptyIfNil(modified), + "unchanged": emptyIfNil(unchanged), + }, + "summary": map[string]interface{}{ + "pulled": pulled, + "pushed": pushed, + "skipped": skipped, + "failed": failed, + }, + "items": items, + } + + if failed > 0 { + msg := fmt.Sprintf("%d item(s) failed during +sync", failed) + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_failure", + Message: msg, + Detail: payload, + }, + } + } + + runtime.Out(payload, nil) + return nil + }, +} + +func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile { + remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles)) + for relPath, target := range pullRemoteFiles { + fileToken := target.ItemFileToken + if fileToken == "" { + fileToken = target.DownloadToken + } + remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime} + } + return remoteFiles +} + +// driveSyncAskConflict prompts the user for a conflict resolution strategy +// for a single file. Returns the strategy string, or empty string if the +// user chose to skip. +func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) { + fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath) + if runtime.IO().In == nil { + return "", fmt.Errorf("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath) + } + var answer string + n, err := fmt.Fscanln(runtime.IO().In, &answer) + if err != nil { + if errors.Is(err, io.EOF) { + if strings.TrimSpace(answer) == "" { + return "", fmt.Errorf("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath) + } + } else if n == 0 { + // Blank line keeps the documented default of remote-wins. + return driveSyncOnConflictRemoteWins, nil + } else { + return "", fmt.Errorf("cannot read conflict choice for %q: %w", relPath, err) + } + } + answer = strings.TrimSpace(strings.ToLower(answer)) + switch answer { + case "l", "local", "local-wins": + return driveSyncOnConflictLocalWins, nil + case "k", "keep", "keep-both": + return driveSyncOnConflictKeepBoth, nil + case "s", "skip": + return "", nil + default: + return driveSyncOnConflictRemoteWins, nil + } +} + +func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error { + if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // safeRoot has already bounded the path. + if info.IsDir() { + return fmt.Errorf("original path became a directory during rollback: %s", oldAbsPath) + } + if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // safeRoot has already bounded the path. + return fmt.Errorf("remove partial restored path %q: %w", oldAbsPath, err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat original path %q during rollback: %w", oldAbsPath, err) + } + if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // safeRoot has already bounded the path. + return fmt.Errorf("restore renamed local file %q: %w", oldAbsPath, err) + } + return nil +} diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go new file mode 100644 index 000000000..1c53d6c70 --- /dev/null +++ b/shortcuts/drive/drive_sync_test.go @@ -0,0 +1,2325 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newDriveSyncRuntime(t *testing.T, localDir, folderToken string) (*common.RuntimeContext, *cmdutil.Factory) { + t.Helper() + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, localDir, folderToken) + return runtime, f +} + +func newDriveSyncRuntimeWithFactory(t *testing.T, f *cmdutil.Factory, localDir, folderToken string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "drive +sync"} + cmd.Flags().String("local-dir", "", "") + cmd.Flags().String("folder-token", "", "") + cmd.Flags().String("on-conflict", "", "") + cmd.Flags().String("on-duplicate-remote", "", "") + cmd.Flags().Bool("quick", false, "") + if localDir != "" { + if err := cmd.Flags().Set("local-dir", localDir); err != nil { + t.Fatalf("set --local-dir: %v", err) + } + } + if folderToken != "" { + if err := cmd.Flags().Set("folder-token", folderToken); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + } + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig()) + runtime.Factory = f + return runtime +} + +type failSaveProvider struct { + inner fileio.Provider + failSuffix string + err error +} + +func (p *failSaveProvider) Name() string { return "fail-save" } + +func (p *failSaveProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &failSaveFileIO{inner: p.inner.ResolveFileIO(ctx), failSuffix: p.failSuffix, err: p.err} +} + +type failSaveFileIO struct { + inner fileio.FileIO + failSuffix string + err error +} + +func (f *failSaveFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) } +func (f *failSaveFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *failSaveFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) } + +func (f *failSaveFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + if strings.HasSuffix(path, f.failSuffix) { + return nil, f.err + } + return f.inner.Save(path, opts, body) +} + +type deleteOnCloseProvider struct { + inner fileio.Provider + targetPath string + deletePath string +} + +func (p *deleteOnCloseProvider) Name() string { return "delete-on-close" } + +func (p *deleteOnCloseProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &deleteOnCloseFileIO{inner: p.inner.ResolveFileIO(ctx), targetPath: p.targetPath, deletePath: p.deletePath} +} + +type deleteOnCloseFileIO struct { + inner fileio.FileIO + targetPath string + deletePath string +} + +func (f *deleteOnCloseFileIO) Open(name string) (fileio.File, error) { + file, err := f.inner.Open(name) + if err != nil { + return nil, err + } + if name != f.targetPath { + return file, nil + } + return &deleteOnCloseFile{File: file, deletePath: f.deletePath}, nil +} + +func (f *deleteOnCloseFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *deleteOnCloseFileIO) ResolvePath(path string) (string, error) { + return f.inner.ResolvePath(path) +} +func (f *deleteOnCloseFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + return f.inner.Save(path, opts, body) +} + +type deleteOnCloseFile struct { + fileio.File + deletePath string +} + +func (f *deleteOnCloseFile) Close() error { + err := f.File.Close() + _ = os.Remove(f.deletePath) + return err +} + +type failAfterSaveProvider struct { + inner fileio.Provider + failSuffix string + err error + afterSave func(path string) +} + +func (p *failAfterSaveProvider) Name() string { return "fail-after-save" } + +func (p *failAfterSaveProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + return &failAfterSaveFileIO{inner: p.inner.ResolveFileIO(ctx), failSuffix: p.failSuffix, err: p.err, afterSave: p.afterSave} +} + +type failAfterSaveFileIO struct { + inner fileio.FileIO + failSuffix string + err error + afterSave func(path string) +} + +func (f *failAfterSaveFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) } +func (f *failAfterSaveFileIO) Stat(name string) (fileio.FileInfo, error) { return f.inner.Stat(name) } +func (f *failAfterSaveFileIO) ResolvePath(path string) (string, error) { + return f.inner.ResolvePath(path) +} + +func (f *failAfterSaveFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + res, err := f.inner.Save(path, opts, body) + if strings.HasSuffix(path, f.failSuffix) { + if f.afterSave != nil { + f.afterSave(path) + } + return res, f.err + } + return res, err +} + +type driveSyncReadThenError struct { + stage int +} + +func (r *driveSyncReadThenError) Read(p []byte) (int, error) { + if r.stage == 0 { + r.stage++ + copy(p, []byte("local ")) + return 6, nil + } + return 0, fmt.Errorf("read failure") +} + +// TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal verifies the basic +// two-way sync flow: new_remote files are pulled, new_local files are pushed, +// and modified files use --on-conflict=remote-wins (the default) to pull the +// remote version. +func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-remote-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + // Local layout: + // local/b.txt — only local → push + // local/a.txt — both sides, different content → conflict (remote-wins → pull) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + // Remote listing: a.txt (modified), d.txt (new_remote) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison (exact mode) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download d.txt (new_remote → pull) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download a.txt again (conflict: remote-wins → pull remote over local) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload b.txt (new_local → push) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_b_uploaded", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "downloaded"`) { + t.Errorf("output missing downloaded action\noutput: %s", out) + } + if !strings.Contains(out, `"action": "uploaded"`) { + t.Errorf("output missing uploaded action\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "pull"`) { + t.Errorf("output missing pull direction\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "push"`) { + t.Errorf("output missing push direction\noutput: %s", out) + } + + // Verify local file was overwritten with remote content + data, err := os.ReadFile("local/a.txt") + if err != nil { + t.Fatalf("ReadFile a.txt: %v", err) + } + if string(data) != "remote-a" { + t.Errorf("a.txt content = %q, want %q", string(data), "remote-a") + } + + // Verify d.txt was downloaded + data, err = os.ReadFile("local/d.txt") + if err != nil { + t.Fatalf("ReadFile d.txt: %v", err) + } + if string(data) != "remote-d" { + t.Errorf("d.txt content = %q, want %q", string(data), "remote-d") + } +} + +// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins +// pushes the local version over the remote file. +func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison (exact mode) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload a.txt with overwrite (local-wins → push over remote) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_a", + "version": "v2", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "overwritten"`) { + t.Errorf("output missing overwritten action\noutput: %s", out) + } + if !strings.Contains(out, `"direction": "push"`) { + t.Errorf("output missing push direction\noutput: %s", out) + } +} + +// TestDriveSyncKeepBothRenamesLocalAndPullsRemote verifies that +// --on-conflict=keep-both renames the local file with a hash suffix +// and then downloads the remote version to the original path. +func TestDriveSyncKeepBothRenamesLocalAndPullsRemote(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Download a.txt again (keep-both: pull remote to original path after rename) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"action": "renamed_local"`) { + t.Errorf("output missing renamed_local action\noutput: %s", out) + } + if !strings.Contains(out, `"action": "downloaded"`) { + t.Errorf("output missing downloaded action\noutput: %s", out) + } + + // Original path should now have remote content + data, err := os.ReadFile("local/a.txt") + if err != nil { + t.Fatalf("ReadFile a.txt: %v", err) + } + if string(data) != "remote-a" { + t.Errorf("a.txt content = %q, want %q", string(data), "remote-a") + } + + // There should be a renamed file with __lark_ suffix + entries, err := os.ReadDir("local") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + found := false + for _, e := range entries { + if strings.Contains(e.Name(), "__lark_") && strings.HasSuffix(e.Name(), ".txt") { + found = true + renamedData, err := os.ReadFile("local/" + e.Name()) + if err != nil { + t.Fatalf("ReadFile renamed: %v", err) + } + if string(renamedData) != "local-a" { + t.Errorf("renamed file content = %q, want %q", string(renamedData), "local-a") + } + } + } + if !found { + t.Errorf("expected a file with __lark_ suffix in local/, got entries: %v", entries) + } +} + +// TestDriveSyncKeepBothRollsBackRenameOnPullFailure verifies that keep-both +// restores the original local path if the remote download fails after the +// local file has been renamed. +func TestDriveSyncKeepBothRollsBackRenameOnPullFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rollback", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for the exact diff phase. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected +sync keep-both to fail when the post-rename pull has no stub\nstdout: %s", stdout.String()) + } + + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt after rollback: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content after rollback = %q, want %q", string(data), "local-a") + } + + entries, readDirErr := os.ReadDir("local") + if readDirErr != nil { + t.Fatalf("ReadDir local: %v", readDirErr) + } + if len(entries) != 1 || entries[0].Name() != "a.txt" { + t.Fatalf("expected rollback to restore only local/a.txt, got entries: %v", entries) + } +} + +// TestDriveSyncAskConflictFailsBeforeWritesWithoutStdin verifies that +// --on-conflict=ask fails before any sync writes start when stdin is not +// available and the diff contains modified entries. +func TestDriveSyncAskConflictFailsBeforeWritesWithoutStdin(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for the exact diff phase. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected +sync --on-conflict=ask to fail on EOF\nstdout: %s", stdout.String()) + } + if !strings.Contains(err.Error(), "interactive stdin") { + t.Fatalf("expected interactive stdin validation error, got: %v", err) + } + + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt after ask failure: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content after ask failure = %q, want %q", string(data), "local-a") + } + if _, statErr := os.Stat("local/d.txt"); !os.IsNotExist(statErr) { + t.Fatalf("new_remote download should not start before ask preflight; stat err=%v", statErr) + } +} + +func TestDriveSyncFailsOnDuplicateRemoteFiles(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + registerDuplicateRemoteFiles(reg) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond) + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String()) + } +} + +// TestDriveSyncUsesResolvedDuplicateTargetForDiff verifies that +sync computes +// the diff against the same duplicate-remote selection used during execution. +func TestDriveSyncUsesResolvedDuplicateTargetForDiff(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-duplicate-resolution", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same-as-oldest"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_old", "name": "a.txt", "type": "file", "created_time": "100", "modified_time": "100"}, + map[string]interface{}{"token": "tok_new", "name": "a.txt", "type": "file", "created_time": "200", "modified_time": "200"}, + }, + "has_more": false, + }, + }, + }) + + // The chosen --on-duplicate-remote=oldest target is tok_old. The test omits + // any tok_new download stub so a stale last-seen overwrite bug would fail. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_old/download", + Status: 200, + Body: []byte("same-as-oldest"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-duplicate-remote", "oldest", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"pushed": 0`) || !strings.Contains(out, `"pulled": 0`) { + t.Fatalf("expected unchanged duplicate target to produce no sync actions\noutput: %s", out) + } + if !strings.Contains(out, `"file_token": "tok_old"`) { + t.Fatalf("expected diff to reference the oldest duplicate target token\noutput: %s", out) + } +} + +// TestDriveSyncLocalWinsNestedFileUsesParentFolderToken verifies that local-wins +// overwrites on nested files keep parent_node aligned with the file's parent. +func TestDriveSyncLocalWinsNestedFileUsesParentFolderToken(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-nested", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local/sub", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/sub/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "fld_sub", "name": "sub", "type": "folder"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=fld_sub", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Diff phase exact hash download. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_a", + "version": "v2", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + body := decodeDriveMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != "tok_a" { + t.Fatalf("upload_all file_token = %q, want tok_a", got) + } + if got := body.Fields["parent_node"]; got != "fld_sub" { + t.Fatalf("upload_all parent_node = %q, want fld_sub", got) + } +} + +// TestDriveSyncNewLocalDisappearanceIsReported verifies that files discovered +// during diff but removed before the push phase are surfaced as skipped items +// instead of being silently dropped. +func TestDriveSyncNewLocalDisappearanceIsReported(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-disappeared", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/ephemeral.txt", []byte("temp"), 0o644); err != nil { + t.Fatalf("WriteFile ephemeral.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + OnMatch: func(_ *http.Request) { + if err := os.Remove("local/ephemeral.txt"); err != nil && !os.IsNotExist(err) { + t.Fatalf("Remove ephemeral.txt in OnMatch: %v", err) + } + }, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{}, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped=1 when new_local disappears during execution\noutput: %s", out) + } + if !strings.Contains(out, `"rel_path": "ephemeral.txt"`) || !strings.Contains(out, `"local file disappeared during sync"`) { + t.Fatalf("expected vanished new_local file to be reported in items\noutput: %s", out) + } +} + +// TestDriveSyncQuickModeUsesModifiedTime verifies that --quick mode +// classifies files by modified_time instead of SHA-256 hash. +func TestDriveSyncQuickModeUsesModifiedTime(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-quick", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + // Set a.txt mtime to match remote → unchanged in quick mode + matchTime := time.Unix(1715594880, 0) + if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil { + t.Fatalf("Chtimes a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"}, + }, + "has_more": false, + }, + }, + }) + + // Download d.txt (new_remote → pull) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + // Upload b.txt (new_local → push) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "file_token": "tok_b_uploaded", + }, + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Errorf("output missing detection=quick\noutput: %s", out) + } + // a.txt should be unchanged (mtime matches), not downloaded or uploaded + // It should appear in diff.unchanged but NOT in items[] with a pull/push action + for _, item := range []string{`"downloaded"`, `"uploaded"`, `"overwritten"`} { + if strings.Count(out, item) > 0 { + // Check that a.txt is not the one being downloaded/uploaded + // by verifying it only appears in the diff section, not items + break + } + } + // More precise: a.txt should not appear in items[] at all since it's unchanged + itemsSection := out[strings.Index(out, `"items"`):] + if strings.Contains(itemsSection, `"rel_path": "a.txt"`) { + t.Errorf("a.txt should not appear in items[] (mtime matches remote, should be unchanged)\noutput: %s", out) + } +} + +// TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites verifies the best-effort +// nature of --quick: a timestamp mismatch alone is enough to drive a real sync +// action even when the file bytes are already identical. +func TestDriveSyncQuickModeMTimeMismatchStillTriggersWrites(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-quick-mismatch", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same-content"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + localTime := time.Unix(1715594880, 0) + if err := os.Chtimes("local/a.txt", localTime, localTime); err != nil { + t.Fatalf("Chtimes a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594999"}, + }, + "has_more": false, + }, + }, + }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("same-content"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Fatalf("expected detection=quick\noutput: %s", out) + } + if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"action": "downloaded"`) { + t.Fatalf("expected quick mtime mismatch to trigger a real pull action\noutput: %s", out) + } +} + +// TestDriveSyncNoChangesReportsEmptyItems verifies that when local and remote +// are identical, +sync reports zero pulled/pushed items. +func TestDriveSyncNoChangesReportsEmptyItems(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-no-changes", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("same"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + // Download a.txt for hash comparison → same content → unchanged + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("same"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"pulled": 0`) { + t.Errorf("expected pulled=0\noutput: %s", out) + } + if !strings.Contains(out, `"pushed": 0`) { + t.Errorf("expected pushed=0\noutput: %s", out) + } + if !strings.Contains(out, `"failed": 0`) { + t.Errorf("expected failed=0\noutput: %s", out) + } +} + +func TestDriveSyncValidateRejectsInvalidInputs(t *testing.T) { + t.Run("missing local-dir", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir is required") { + t.Fatalf("Validate() error = %v, want missing --local-dir", err) + } + }) + + t.Run("missing folder-token", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "local", "") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--folder-token is required") { + t.Fatalf("Validate() error = %v, want missing --folder-token", err) + } + }) + + t.Run("malformed folder-token", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "tok\nwithnewline") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--folder-token") { + t.Fatalf("Validate() error = %v, want malformed folder-token error", err) + } + }) + + t.Run("absolute local-dir", func(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "/etc", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir") { + t.Fatalf("Validate() error = %v, want invalid local-dir error", err) + } + }) + + t.Run("missing local-dir path", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + runtime, _ := newDriveSyncRuntime(t, "missing", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("Validate() error = %v, want missing-path error", err) + } + }) + + t.Run("local-dir is file", func(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "not-a-dir.txt", "folder_root") + err := DriveSync.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("Validate() error = %v, want not-a-directory error", err) + } + }) +} + +func TestDriveSyncDryRunUsesFolderToken(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + dry := DriveSync.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + if !strings.Contains(string(data), `"folder_token":"folder_root"`) { + t.Fatalf("dry run missing folder_token, got: %s", string(data)) + } +} + +func TestDriveSyncExecuteRejectsUnsafeLocalDir(t *testing.T) { + runtime, _ := newDriveSyncRuntime(t, "/etc", "folder_root") + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--local-dir") { + t.Fatalf("Execute() error = %v, want unsafe local-dir validation error", err) + } +} + +func TestDriveSyncAskConflictParsesChoices(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "blank line defaults remote wins", input: "\n", want: driveSyncOnConflictRemoteWins}, + {name: "local short form", input: "L\n", want: driveSyncOnConflictLocalWins}, + {name: "keep both long form", input: "keep-both\n", want: driveSyncOnConflictKeepBoth}, + {name: "skip returns empty resolution", input: "skip\n", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader(tt.input) + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("driveSyncAskConflict() error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDriveSyncAskConflictRejectsMissingStdin(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "stdin is not available") { + t.Fatalf("driveSyncAskConflict() error = %v, want stdin availability error", err) + } +} + +func TestDriveSyncAskConflictHandlesEOFAndReadErrors(t *testing.T) { + t.Run("blank EOF without answer fails", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "stdin reached EOF") { + t.Fatalf("driveSyncAskConflict() error = %v, want EOF failure", err) + } + }) + + t.Run("partial token before EOF is still accepted", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("local") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != driveSyncOnConflictLocalWins { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, driveSyncOnConflictLocalWins) + } + }) + + t.Run("unknown answer falls back to remote wins", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = strings.NewReader("what\n") + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + got, err := driveSyncAskConflict("a.txt", runtime) + if err != nil { + t.Fatalf("driveSyncAskConflict() unexpected error: %v", err) + } + if got != driveSyncOnConflictRemoteWins { + t.Fatalf("driveSyncAskConflict() = %q, want %q", got, driveSyncOnConflictRemoteWins) + } + }) + + t.Run("non EOF read failure returns wrapped error", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + f.IOStreams.In = bufio.NewReader(&driveSyncReadThenError{}) + + runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, driveTestConfig()) + runtime.Factory = f + + _, err := driveSyncAskConflict("a.txt", runtime) + if err == nil || !strings.Contains(err.Error(), "cannot read conflict choice") { + t.Fatalf("driveSyncAskConflict() error = %v, want wrapped read failure", err) + } + }) +} + +func TestDriveSyncRollbackRenamedLocalRestoresRenamedFile(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.WriteFile(oldAbsPath, []byte("partial remote"), 0o644); err != nil { + t.Fatalf("WriteFile oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + if err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath); err != nil { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v", err) + } + + data, err := os.ReadFile(oldAbsPath) + if err != nil { + t.Fatalf("ReadFile restored oldAbsPath: %v", err) + } + if got := string(data); got != "original local" { + t.Fatalf("restored content = %q, want %q", got, "original local") + } + if _, err := os.Stat(newAbsPath); !os.IsNotExist(err) { + t.Fatalf("expected renamed path to be removed after rollback, stat err = %v", err) + } +} + +func TestDriveSyncRollbackRenamedLocalWithoutPartialRestore(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + if err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath); err != nil { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v", err) + } + + data, err := os.ReadFile(oldAbsPath) + if err != nil { + t.Fatalf("ReadFile restored oldAbsPath: %v", err) + } + if got := string(data); got != "original local" { + t.Fatalf("restored content = %q, want %q", got, "original local") + } +} + +func TestDriveSyncRollbackRenamedLocalRejectsDirectoryAtOriginalPath(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/a__lark.txt" + + if err := os.Mkdir(oldAbsPath, 0o755); err != nil { + t.Fatalf("Mkdir oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "became a directory") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want directory error", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesRenameFailure(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := tmpDir + "/a.txt" + newAbsPath := tmpDir + "/missing.txt" + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "restore renamed local file") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want rename failure", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesRemoveFailure(t *testing.T) { + tmpDir := t.TempDir() + oldAbsPath := filepath.Join(tmpDir, "a.txt") + newAbsPath := filepath.Join(tmpDir, "a__lark.txt") + + if err := os.WriteFile(oldAbsPath, []byte("partial remote"), 0o644); err != nil { + t.Fatalf("WriteFile oldAbsPath: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + if err := os.Chmod(tmpDir, 0o555); err != nil { + t.Fatalf("Chmod read-only dir: %v", err) + } + defer func() { + _ = os.Chmod(tmpDir, 0o755) + }() + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "remove partial restored path") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want remove failure", err) + } +} + +func TestDriveSyncRollbackRenamedLocalSurfacesStatFailure(t *testing.T) { + tmpDir := t.TempDir() + blockedDir := filepath.Join(tmpDir, "blocked") + oldAbsPath := filepath.Join(blockedDir, "a.txt") + newAbsPath := filepath.Join(blockedDir, "a__lark.txt") + + if err := os.MkdirAll(blockedDir, 0o755); err != nil { + t.Fatalf("MkdirAll blockedDir: %v", err) + } + if err := os.WriteFile(newAbsPath, []byte("original local"), 0o644); err != nil { + t.Fatalf("WriteFile newAbsPath: %v", err) + } + if err := os.Chmod(blockedDir, 0o000); err != nil { + t.Fatalf("Chmod blockedDir: %v", err) + } + defer func() { + _ = os.Chmod(blockedDir, 0o755) + }() + + err := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) + if err == nil || !strings.Contains(err.Error(), "stat original path") { + t.Fatalf("driveSyncRollbackRenamedLocal() error = %v, want stat failure", err) + } +} + +func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-exec-eof", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.IOStreams.In = strings.NewReader("") + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") { + t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail) + } + data, readErr := os.ReadFile("local/a.txt") + if readErr != nil { + t.Fatalf("ReadFile a.txt: %v", readErr) + } + if string(data) != "local-a" { + t.Fatalf("a.txt content = %q, want local-a", string(data)) + } +} + +func TestDriveSyncAskConflictSkipReportsSkippedItem(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-ask-skip", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.IOStreams.In = strings.NewReader("skip\n") + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "ask", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + out := stdout.String() + if !strings.Contains(out, `"action": "skipped"`) || !strings.Contains(out, "user skipped") { + t.Fatalf("expected skipped conflict item, got: %s", out) + } + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped summary count, got: %s", out) + } +} + +func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-remote-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "d.txt"), err: fmt.Errorf("save failed")} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_d/download", + Status: 200, + Body: []byte("remote-d"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected download failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { + t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-ensure-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "a.txt"), []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 9999, + "msg": "create parent failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected ensure failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") { + t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-new-local-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 9999, + "msg": "upload failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected upload failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") { + t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-upload-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 9999, + "msg": "overwrite failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") { + t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rename-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + suffixedRel, err := relPathWithUniqueFileTokenSuffix("a.txt", "tok_a", map[string]struct{}{"a.txt": {}}) + if err != nil { + t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err) + } + if err := os.Mkdir(filepath.Join("local", suffixedRel), 0o755); err != nil { + t.Fatalf("Mkdir suffixed path: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err = mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected keep-both rename failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "conflict" || !strings.Contains(items[0].Error, "rename local") { + t.Fatalf("expected failed rename item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncExecuteReturnsRemoteListError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "API call failed") { + t.Fatalf("Execute() error = %v, want remote list error", err) + } +} + +func TestDriveSyncExecuteReturnsLocalWalkError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + runtime, _ := newDriveSyncRuntime(t, "local", "folder_root") + if err := os.RemoveAll("local"); err != nil { + t.Fatalf("RemoveAll local: %v", err) + } + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "walk") { + t.Fatalf("Execute() error = %v, want local walk error", err) + } +} + +func TestDriveSyncExecuteWrapsInvalidDuplicateStrategyForPullViews(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-duplicate-remote", "invalid-strategy"); err != nil { + t.Fatalf("set --on-duplicate-remote: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "unsupported duplicate remote strategy") { + t.Fatalf("Execute() error = %v, want pull views strategy error", err) + } +} + +func TestDriveSyncExecuteWrapsUnsupportedPushDuplicateStrategy(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-duplicate-remote", driveDuplicateRemoteRename); err != nil { + t.Fatalf("set --on-duplicate-remote: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "unsupported duplicate remote strategy") { + t.Fatalf("Execute() error = %v, want push views strategy error", err) + } +} + +func TestDriveSyncExecuteSurfacesHashLocalError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o000); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + defer func() { _ = os.Chmod("local/a.txt", 0o644) }() + + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("Execute() error = %v, want hashLocal error", err) + } +} + +func TestDriveSyncExecuteSurfacesHashRemoteError(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "download") { + t.Fatalf("Execute() error = %v, want hashRemote error", err) + } +} + +func TestDriveSyncExecuteReturnsPushWalkErrorAfterDiff(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + OnMatch: func(req *http.Request) { + _ = os.RemoveAll("local") + }, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "walk") { + t.Fatalf("Execute() error = %v, want push walk error", err) + } +} + +func TestDriveSyncExecuteUnknownConflictStrategySkipsModifiedFile(t *testing.T) { + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + runtime := newDriveSyncRuntimeWithFactory(t, f, "local", "folder_root") + if err := runtime.Cmd.Flags().Set("on-conflict", "mystery-mode"); err != nil { + t.Fatalf("set --on-conflict: %v", err) + } + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := DriveSync.Execute(context.Background(), runtime) + if err != nil { + t.Fatalf("Execute() unexpected error: %v", err) + } +} + +func TestDriveSyncModifiedFileDisappearingBeforeExecuteIsSkipped(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-modified-disappears", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &deleteOnCloseProvider{ + inner: f.FileIOProvider, + targetPath: filepath.Join("local", "a.txt"), + deletePath: filepath.Join("local", "a.txt"), + } + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + out := stdout.String() + if !strings.Contains(out, `"direction": "conflict"`) || !strings.Contains(out, "local file disappeared during sync") { + t.Fatalf("expected modified file disappearance to be reported, got: %s", out) + } + if !strings.Contains(out, `"skipped": 1`) { + t.Fatalf("expected skipped summary count, got: %s", out) + } +} + +func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-remote-wins-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f.FileIOProvider = &failSaveProvider{inner: f.FileIOProvider, failSuffix: filepath.Join("local", "a.txt"), err: fmt.Errorf("save failed")} + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Reusable: true, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "remote-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { + t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-keep-both-rollback-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + f.FileIOProvider = &failAfterSaveProvider{ + inner: f.FileIOProvider, + failSuffix: filepath.Join("local", "a.txt"), + err: fmt.Errorf("save failed"), + afterSave: func(path string) { + _ = os.Chmod(filepath.Dir(path), 0o555) + }, + } + defer func() { + _ = os.Chmod(filepath.Join(tmpDir, "local"), 0o755) + }() + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Reusable: true, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "keep-both", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") { + t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail) + } +} + +func TestDriveSyncStatusRemoteFilesUsesStableTokens(t *testing.T) { + remoteFiles := driveSyncStatusRemoteFiles(map[string]drivePullTarget{ + "item-token.txt": { + DownloadToken: "download_token_should_not_win", + ItemFileToken: "item_file_token", + ModifiedTime: "111", + }, + "download-token.txt": { + DownloadToken: "download_only_token", + ModifiedTime: "222", + }, + }) + + if got := remoteFiles["item-token.txt"].FileToken; got != "item_file_token" { + t.Fatalf("item-token.txt file_token = %q, want item_file_token", got) + } + if got := remoteFiles["download-token.txt"].FileToken; got != "download_only_token" { + t.Fatalf("download-token.txt file_token = %q, want download_only_token", got) + } + if got := remoteFiles["download-token.txt"].ModifiedTime; got != "222" { + t.Fatalf("download-token.txt modified_time = %q, want 222", got) + } +} + +func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-local-wins-parent-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "sub", "a.txt"), []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_nested", "name": "sub/a.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_nested/download", + Status: 200, + Body: []byte("remote-a"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 9999, + "msg": "create parent failed", + }, + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--on-conflict", "local-wins", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured ExitError, got: %v", err) + } + detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) + items, _ := detailMap["items"].([]driveSyncItem) + if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") { + t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index fcd3d805e..777edabff 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -21,6 +21,7 @@ func Shortcuts() []common.Shortcut { DriveStatus, DrivePush, DrivePull, + DriveSync, DriveTaskResult, DriveApplyPermission, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3116c0c5a..dbdbe78d1 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -24,6 +24,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+status", "+push", "+pull", + "+sync", "+task_result", "+apply-permission", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index ad4c1fcb9..5fb7c9e59 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -244,6 +244,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | | [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | | [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. | +| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides | | [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |