diff --git a/.golangci.yml b/.golangci.yml index 2e52c66698..9428d7a2bb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,20 +73,20 @@ linters: - forbidigo # errs-typed-only enforced on paths already migrated to errs.NewXxxError. # Add a path when its migration is complete. - - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index a1e404ff65..742b506dd3 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -27,6 +27,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/mail/", "shortcuts/minutes/", "shortcuts/okr/", + "shortcuts/sheets/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index e75bb3fca9..e7bba084cf 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -28,6 +28,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/mail/", "shortcuts/minutes/", "shortcuts/okr/", + "shortcuts/sheets/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 410bb6f8e0..22a7b396cf 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -954,6 +954,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "shortcuts/drive/drive_search.go", "shortcuts/mail/mail_send.go", "shortcuts/okr/okr_progress_create.go", + "shortcuts/sheets/helpers.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_query.go", } @@ -1021,6 +1022,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/sheets/backward/helpers.go b/shortcuts/sheets/backward/helpers.go index 9c8f3284c3..46fdc14fe9 100644 --- a/shortcuts/sheets/backward/helpers.go +++ b/shortcuts/sheets/backward/helpers.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -27,7 +27,7 @@ var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", " // getFirstSheetID queries the spreadsheet and returns the first sheet's ID. func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) + data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) if err != nil { return "", err } @@ -38,7 +38,7 @@ func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (s return id, nil } } - return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet") + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet") } // extractSpreadsheetToken extracts spreadsheet token from URL. @@ -104,7 +104,7 @@ func validateSheetRangeInput(sheetID, input string) error { return nil } if looksLikeRelativeRange(input) { - return common.FlagErrorf("--range %q requires --sheet-id or a ! prefix", input) + return common.ValidationErrorf("--range %q requires --sheet-id or a ! prefix", input).WithParam("--range") } return nil } @@ -127,7 +127,7 @@ func validateSingleCellRange(input string) error { if strings.EqualFold(parts[0], parts[1]) { return nil } - return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) + return common.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input).WithParam("--range") } return nil } @@ -197,11 +197,11 @@ func matrixDimensions(values interface{}) (rows, cols int) { func offsetCell(cell string, rowOffset, colOffset int) (string, error) { matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell)) if len(matches) != 3 { - return "", fmt.Errorf("invalid cell reference: %s", cell) + return "", fmt.Errorf("invalid cell reference: %s", cell) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back } colIndex := columnNameToIndex(matches[1]) if colIndex < 1 { - return "", fmt.Errorf("invalid column: %s", matches[1]) + return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back } rowIndex, err := strconv.Atoi(matches[2]) if err != nil { diff --git a/shortcuts/sheets/backward/lark_sheets_cell_data.go b/shortcuts/sheets/backward/lark_sheets_cell_data.go index 59d6fa74e9..77a1571be2 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_data.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_data.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -15,10 +16,10 @@ import ( func parseValues2DJSON(raw string) ([][]interface{}, error) { var rows [][]interface{} if err := json.Unmarshal([]byte(raw), &rows); err != nil { - return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values") } if rows == nil { - return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values") } return rows, nil } @@ -46,7 +47,7 @@ var SheetRead = common.Shortcut{ } if r := runtime.Str("range"); r != "" { if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range") } } return nil @@ -90,7 +91,7 @@ var SheetRead = common.Shortcut{ params["valueRenderOption"] = renderOption } - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) + data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil) if err != nil { return err } @@ -167,7 +168,7 @@ var SheetWrite = common.Shortcut{ writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) } - data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ "range": writeRange, "values": values, @@ -247,7 +248,7 @@ var SheetAppend = common.Shortcut{ appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) } - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ "range": appendRange, "values": values, @@ -288,7 +289,7 @@ var SheetFind = common.Shortcut{ } if r := runtime.Str("range"); r != "" { if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range") } } return nil @@ -336,7 +337,7 @@ var SheetFind = common.Shortcut{ "find": findText, } - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData) + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData) if err != nil { return err } @@ -373,7 +374,7 @@ var SheetReplace = common.Shortcut{ } if r := runtime.Str("range"); r != "" { if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") { - return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range") } } return nil @@ -415,7 +416,7 @@ var SheetReplace = common.Shortcut{ findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range")) } - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID), diff --git a/shortcuts/sheets/backward/lark_sheets_cell_images.go b/shortcuts/sheets/backward/lark_sheets_cell_images.go index d2c0af692f..6f1a3cb566 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_images.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_images.go @@ -11,8 +11,8 @@ import ( "os" "path/filepath" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -38,7 +38,7 @@ var SheetWriteImage = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { return err @@ -91,7 +91,7 @@ var SheetWriteImage = common.Shortcut{ imageBytes, err := io.ReadAll(imageFile) if err != nil { - return output.ErrValidation("cannot read image file: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err) } imageName := runtime.Str("name") @@ -101,7 +101,7 @@ var SheetWriteImage = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange) - data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "range": pointRange, "image": imageBytes, "name": imageName, @@ -116,35 +116,35 @@ var SheetWriteImage = common.Shortcut{ func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) { if fio == nil { - return nil, output.ErrValidation("no file I/O provider registered") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered") } stat, err := fio.Stat(imagePath) if err != nil { return nil, wrapSheetWriteImageStatError(err, imagePath) } if stat.IsDir() || !stat.Mode().IsRegular() { - return nil, output.ErrValidation("image must be a regular file: %s", imagePath) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image") } const maxImageSize int64 = 20 * 1024 * 1024 if stat.Size() > maxImageSize { - return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image") } return stat, nil } func wrapSheetWriteImageStatError(err error, imagePath string) error { if errors.Is(err, fileio.ErrPathValidation) { - return output.ErrValidation("unsafe image path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err) } if os.IsNotExist(err) { - return output.ErrValidation("image file not found: %s", imagePath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err) } - return output.ErrValidation("cannot stat image file: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err) } func wrapSheetWriteImageOpenError(err error) error { if errors.Is(err, fileio.ErrPathValidation) { - return output.ErrValidation("unsafe image path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err) } - return output.ErrValidation("cannot read image file: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err) } diff --git a/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go index 520f91e367..30622c052c 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -15,40 +16,40 @@ import ( func validateBatchStyleData(raw string) error { var data interface{} if err := json.Unmarshal([]byte(raw), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data") } arr, ok := data.([]interface{}) if !ok || len(arr) == 0 { - return common.FlagErrorf("--data must be a non-empty JSON array") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a non-empty JSON array").WithParam("--data") } for i, item := range arr { entry, ok := item.(map[string]interface{}) if !ok { - return common.FlagErrorf("--data[%d] must be an object with ranges and style", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d] must be an object with ranges and style", i).WithParam("--data") } rangesRaw, ok := entry["ranges"] if !ok { - return common.FlagErrorf("--data[%d].ranges is required", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges is required", i).WithParam("--data") } ranges, ok := rangesRaw.([]interface{}) if !ok || len(ranges) == 0 { - return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges must be a non-empty array of strings", i).WithParam("--data") } for j, r := range ranges { s, ok := r.(string) if !ok || s == "" { - return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] must be a non-empty string", i, j).WithParam("--data") } if _, _, ok := splitSheetRange(s); !ok { - return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s).WithParam("--data") } } styleRaw, ok := entry["style"] if !ok { - return common.FlagErrorf("--data[%d].style is required", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style is required", i).WithParam("--data") } if _, ok := styleRaw.(map[string]interface{}); !ok { - return common.FlagErrorf("--data[%d].style must be a JSON object", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style must be a JSON object", i).WithParam("--data") } } return nil @@ -74,14 +75,14 @@ var SheetSetStyle = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } var style interface{} if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style") } if _, ok := style.(map[string]interface{}); !ok { - return common.FlagErrorf("--style must be a JSON object, got %T", style) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be a JSON object, got %T", style).WithParam("--style") } if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { return err @@ -115,10 +116,10 @@ var SheetSetStyle = common.Shortcut{ r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) var style interface{} if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { - return common.FlagErrorf("--style must be valid JSON: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style") } - data, err := runtime.CallAPI("PUT", + data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)), nil, map[string]interface{}{ @@ -154,7 +155,7 @@ var SheetBatchSetStyle = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return validateBatchStyleData(runtime.Str("data")) }, @@ -181,11 +182,11 @@ var SheetBatchSetStyle = common.Shortcut{ var data interface{} if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { - return common.FlagErrorf("--data must be valid JSON: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data") } normalizeBatchStyleRanges(data) - result, err := runtime.CallAPI("PUT", + result, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), nil, map[string]interface{}{ @@ -242,7 +243,7 @@ var SheetMergeCells = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { return err @@ -271,7 +272,7 @@ var SheetMergeCells = common.Shortcut{ r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)), nil, map[string]interface{}{ @@ -306,7 +307,7 @@ var SheetUnmergeCells = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil { return err @@ -334,7 +335,7 @@ var SheetUnmergeCells = common.Shortcut{ r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)), nil, map[string]interface{}{ diff --git a/shortcuts/sheets/backward/lark_sheets_dropdown.go b/shortcuts/sheets/backward/lark_sheets_dropdown.go index e5645af65e..6e07dad3ee 100644 --- a/shortcuts/sheets/backward/lark_sheets_dropdown.go +++ b/shortcuts/sheets/backward/lark_sheets_dropdown.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -27,7 +28,7 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) { token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return token, nil } @@ -35,10 +36,10 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) { func parseJSONStringArray(flagName, value string) ([]interface{}, error) { var typed []string if err := json.Unmarshal([]byte(value), &typed); err != nil { - return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array of strings: %v", flagName, err).WithParam("--" + flagName) } if typed == nil { - return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array, got null", flagName).WithParam("--" + flagName) } arr := make([]interface{}, len(typed)) for i, s := range typed { @@ -53,12 +54,12 @@ func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) { return nil, err } if len(ranges) == 0 { - return nil, common.FlagErrorf("--ranges must not be empty") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges must not be empty").WithParam("--ranges") } for i, r := range ranges { s, _ := r.(string) if _, _, ok := splitSheetRange(s); !ok { - return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)", i, s) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)", i, s).WithParam("--ranges") } } return ranges, nil @@ -70,7 +71,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, return nil, err } if len(condValues) == 0 { - return nil, common.FlagErrorf("--condition-values must not be empty") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--condition-values must not be empty").WithParam("--condition-values") } dv := map[string]interface{}{ @@ -90,7 +91,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, return nil, err } if len(colors) != len(condValues) { - return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)).WithParam("--colors") } opts["colors"] = colors } @@ -123,7 +124,7 @@ var SheetSetDropdown = common.Shortcut{ return err } if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { - return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)").WithParam("--range") } _, err := buildDropdownBody(runtime) return err @@ -147,7 +148,7 @@ var SheetSetDropdown = common.Shortcut{ return err } - data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil, + data, err := runtime.CallAPITyped("POST", dataValidationBasePath(token), nil, map[string]interface{}{ "range": runtime.Str("range"), "dataValidationType": "list", @@ -214,7 +215,7 @@ var SheetUpdateDropdown = common.Shortcut{ return err } - data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil, + data, err := runtime.CallAPITyped("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil, map[string]interface{}{ "ranges": ranges, "dataValidationType": "list", @@ -247,7 +248,7 @@ var SheetGetDropdown = common.Shortcut{ return err } if _, _, ok := splitSheetRange(runtime.Str("range")); !ok { - return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. !A2:A100)").WithParam("--range") } return nil }, @@ -259,7 +260,7 @@ var SheetGetDropdown = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateDropdownToken(runtime) - data, err := runtime.CallAPI("GET", dataValidationBasePath(token), + data, err := runtime.CallAPITyped("GET", dataValidationBasePath(token), map[string]interface{}{ "range": runtime.Str("range"), "dataValidationType": "list", @@ -319,7 +320,7 @@ var SheetDeleteDropdown = common.Shortcut{ dvRanges = append(dvRanges, map[string]interface{}{"range": r}) } - data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil, + data, err := runtime.CallAPITyped("DELETE", dataValidationBasePath(token), nil, map[string]interface{}{ "dataValidationRanges": dvRanges, }, diff --git a/shortcuts/sheets/backward/lark_sheets_filter_views.go b/shortcuts/sheets/backward/lark_sheets_filter_views.go index b76a473f3e..fc8105e185 100644 --- a/shortcuts/sheets/backward/lark_sheets_filter_views.go +++ b/shortcuts/sheets/backward/lark_sheets_filter_views.go @@ -9,7 +9,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -59,7 +59,7 @@ var SheetCreateFilterView = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range must not be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range") } return nil }, @@ -85,7 +85,7 @@ var SheetCreateFilterView = common.Shortcut{ if s := runtime.Str("filter-view-id"); s != "" { body["filter_view_id"] = s } - data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) + data, err := runtime.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body) if err != nil { return err } @@ -115,7 +115,7 @@ var SheetUpdateFilterView = common.Shortcut{ } if !hasNonEmptyStringFlag(runtime, "range") && !hasNonEmptyStringFlag(runtime, "filter-view-name") { - return common.FlagErrorf("specify at least one of --range or --filter-view-name") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name").WithParams(errs.InvalidParam{Name: "--range", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--filter-view-name", Reason: "required; specify at least one"}) } return nil }, @@ -141,7 +141,7 @@ var SheetUpdateFilterView = common.Shortcut{ if s := runtime.Str("filter-view-name"); s != "" { body["filter_view_name"] = s } - data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) + data, err := runtime.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) if err != nil { return err } @@ -174,7 +174,7 @@ var SheetListFilterViews = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + data, err := runtime.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) if err != nil { return err } @@ -208,7 +208,7 @@ var SheetGetFilterView = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + data, err := runtime.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) if err != nil { return err } @@ -242,7 +242,7 @@ var SheetDeleteFilterView = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) + data, err := runtime.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil) if err != nil { return err } @@ -284,7 +284,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) body := buildConditionBody(runtime, true) - data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) + data, err := runtime.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body) if err != nil { return err } @@ -317,7 +317,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{ if !hasNonEmptyStringFlag(runtime, "filter-type") && !hasNonEmptyStringFlag(runtime, "compare-type") && !hasNonEmptyStringFlag(runtime, "expected") { - return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected").WithParams(errs.InvalidParam{Name: "--filter-type", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--compare-type", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--expected", Reason: "required; specify at least one"}) } if s := runtime.Str("expected"); s != "" { return validateExpectedFlag(s) @@ -335,7 +335,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) body := buildConditionBody(runtime, false) - data, err := runtime.CallAPI("PUT", + data, err := runtime.CallAPITyped("PUT", filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), nil, body) if err != nil { @@ -371,7 +371,7 @@ var SheetListFilterViewConditions = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", + data, err := runtime.CallAPITyped("GET", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query", nil, nil) if err != nil { @@ -409,7 +409,7 @@ var SheetGetFilterViewCondition = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("GET", + data, err := runtime.CallAPITyped("GET", filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), nil, nil) if err != nil { @@ -447,7 +447,7 @@ var SheetDeleteFilterViewCondition = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFilterViewToken(runtime) - data, err := runtime.CallAPI("DELETE", + data, err := runtime.CallAPITyped("DELETE", filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")), nil, nil) if err != nil { @@ -464,7 +464,7 @@ func validateExpectedFlag(s string) error { } var arr []interface{} if err := json.Unmarshal([]byte(s), &arr); err != nil { - return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected") } return nil } diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index a0b7bf4904..a06cb7f204 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -8,8 +8,8 @@ import ( "fmt" "path/filepath" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -115,10 +115,14 @@ var SheetMediaUpload = common.Shortcut{ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) { stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return "", nil, common.WrapInputStatError(err, "file not found") + wrapped := common.WrapInputStatErrorTyped(err, "file not found") + if v, ok := wrapped.(*errs.ValidationError); ok { + return "", nil, v.WithParam("--file") + } + return "", nil, wrapped } if !stat.Mode().IsRegular() { - return "", nil, output.ErrValidation("file must be a regular file: %s", filePath) + return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file") } return filePath, stat, nil } @@ -131,7 +135,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro } } if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return token, nil } @@ -181,7 +185,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) { } } if token == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return token, nil } @@ -194,7 +198,7 @@ func validateFloatImageRange(sheetID, rangeVal string) error { return err } if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID { - return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range") } return nil } @@ -206,7 +210,7 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { runtime.Cmd.Flags().Changed("offset-x") || runtime.Cmd.Flags().Changed("offset-y") if !hasField { - return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update").WithParams(errs.InvalidParam{Name: "--range", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--width", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--height", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--offset-x", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--offset-y", Reason: "required; specify at least one"}) } return nil } @@ -214,22 +218,22 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error { func validateFloatImageDims(runtime *common.RuntimeContext) error { if runtime.Cmd.Flags().Changed("width") { if v := runtime.Int("width"); v < 20 { - return common.FlagErrorf("--width must be >= 20 pixels, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width") } } if runtime.Cmd.Flags().Changed("height") { if v := runtime.Int("height"); v < 20 { - return common.FlagErrorf("--height must be >= 20 pixels, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height") } } if runtime.Cmd.Flags().Changed("offset-x") { if v := runtime.Int("offset-x"); v < 0 { - return common.FlagErrorf("--offset-x must be >= 0, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x") } } if runtime.Cmd.Flags().Changed("offset-y") { if v := runtime.Int("offset-y"); v < 0 { - return common.FlagErrorf("--offset-y must be >= 0, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y") } } return nil @@ -304,7 +308,7 @@ var SheetCreateFloatImage = common.Shortcut{ if s := runtime.Str("float-image-id"); s != "" { body["float_image_id"] = s } - data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body) + data, err := runtime.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body) if err != nil { return err } @@ -353,7 +357,7 @@ var SheetUpdateFloatImage = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFloatImageToken(runtime) body := buildFloatImageBody(runtime, false) - data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body) + data, err := runtime.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body) if err != nil { return err } @@ -387,7 +391,7 @@ var SheetGetFloatImage = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + data, err := runtime.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) if err != nil { return err } @@ -420,7 +424,7 @@ var SheetListFloatImages = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) + data, err := runtime.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil) if err != nil { return err } @@ -454,7 +458,7 @@ var SheetDeleteFloatImage = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateFloatImageToken(runtime) - data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) + data, err := runtime.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil) if err != nil { return err } diff --git a/shortcuts/sheets/backward/lark_sheets_row_column_management.go b/shortcuts/sheets/backward/lark_sheets_row_column_management.go index 581c8eb0ed..ea1e913a85 100644 --- a/shortcuts/sheets/backward/lark_sheets_row_column_management.go +++ b/shortcuts/sheets/backward/lark_sheets_row_column_management.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -31,7 +32,7 @@ var SheetAddDimension = common.Shortcut{ } length := runtime.Int("length") if length < 1 || length > 5000 { - return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--length must be between 1 and 5000, got %d", length).WithParam("--length") } return nil }, @@ -51,7 +52,7 @@ var SheetAddDimension = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), nil, map[string]interface{}{ @@ -91,10 +92,10 @@ var SheetInsertDimension = common.Shortcut{ return err } if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index") } if runtime.Int("end-index") <= runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be greater than --start-index") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be greater than --start-index").WithParam("--end-index") } return nil }, @@ -131,7 +132,7 @@ var SheetInsertDimension = common.Shortcut{ body["inheritStyle"] = s } - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), nil, body, ) @@ -165,16 +166,16 @@ var SheetUpdateDimension = common.Shortcut{ return err } if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index") } if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index") } if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { - return common.FlagErrorf("specify at least one of --visible or --fixed-size") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --visible or --fixed-size").WithParams(errs.InvalidParam{Name: "--visible", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--fixed-size", Reason: "required; specify at least one"}) } if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { - return common.FlagErrorf("--fixed-size must be >= 1") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fixed-size must be >= 1").WithParam("--fixed-size") } return nil }, @@ -211,7 +212,7 @@ var SheetUpdateDimension = common.Shortcut{ props["fixedSize"] = runtime.Int("fixed-size") } - data, err := runtime.CallAPI("PUT", + data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), nil, map[string]interface{}{ @@ -253,13 +254,13 @@ var SheetMoveDimension = common.Shortcut{ return err } if runtime.Int("start-index") < 0 { - return common.FlagErrorf("--start-index must be >= 0") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index") } if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index") } if runtime.Int("destination-index") < 0 { - return common.FlagErrorf("--destination-index must be >= 0") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--destination-index must be >= 0").WithParam("--destination-index") } return nil }, @@ -281,7 +282,7 @@ var SheetMoveDimension = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", validate.EncodePathSegment(token), validate.EncodePathSegment(runtime.Str("sheet-id")), @@ -324,10 +325,10 @@ var SheetDeleteDimension = common.Shortcut{ return err } if runtime.Int("start-index") < 1 { - return common.FlagErrorf("--start-index must be >= 1") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index") } if runtime.Int("end-index") < runtime.Int("start-index") { - return common.FlagErrorf("--end-index must be >= --start-index") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index") } return nil }, @@ -348,7 +349,7 @@ var SheetDeleteDimension = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("DELETE", + data, err := runtime.CallAPITyped("DELETE", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), nil, map[string]interface{}{ diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go index afe456b5d7..60d5435c9c 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go @@ -76,6 +76,23 @@ func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) { } } +func TestSheetExportDryRunRejectsUnsafeOutputPath(t *testing.T) { + t.Parallel() + + f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetExport, []string{ + "+export", + "--spreadsheet-token", "shtTOKEN", + "--file-extension", "xlsx", + "--output-path", "../escape.xlsx", + "--dry-run", + "--as", "user", + }, f, nil) + if err == nil || !strings.Contains(err.Error(), "unsafe output path") { + t.Fatalf("expected unsafe output-path validation error, got: %v", err) + } +} + func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) { t.Parallel() diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go index 1a8115b7a6..ea52fe159b 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go @@ -6,14 +6,13 @@ package backward import ( "context" "encoding/json" - "errors" "reflect" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/tidwall/gjson" ) @@ -402,38 +401,26 @@ func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing. t.Fatal("expected move failure, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err) - } - if exitErr.Detail.Code != 1310211 { - t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code) - } - if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) { - t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message) - } - if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") { - t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint) - } - if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) } - - detail, _ := exitErr.Detail.Detail.(map[string]interface{}) - if detail["partial_success"] != true { - t.Fatalf("partial_success = %#v, want true", detail["partial_success"]) + if p.Code != 1310211 { + t.Fatalf("error code = %d, want 1310211", p.Code) } - if detail["sheet_id"] != "sheet_copy" { - t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy") + if !strings.Contains(p.Message, `sheet copied successfully as "sheet_copy"`) { + t.Fatalf("message missing copied sheet id: %q", p.Message) } - if detail["requested_index"] != 2 { - t.Fatalf("requested_index = %#v, want 2", detail["requested_index"]) + if !strings.Contains(p.Hint, "do not retry +copy-sheet") { + t.Fatalf("hint missing retry guard: %q", p.Hint) } - if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" { - t.Fatalf("retry_command = %#v", detail["retry_command"]) + // The recovery command in the hint is the AI-actionable signal: retry only + // the move (not the whole +copy-sheet, which would duplicate the sheet). + if !strings.Contains(p.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") { + t.Fatalf("hint missing recovery command: %q", p.Hint) } - if detail["log_id"] != "log-move-failed" { - t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed") + if p.LogID != "log-move-failed" { + t.Fatalf("log_id = %q, want %q", p.LogID, "log-move-failed") } } diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_management.go b/shortcuts/sheets/backward/lark_sheets_sheet_management.go index 0484a6cd46..4988fdd4c0 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_management.go @@ -5,11 +5,10 @@ package backward import ( "context" - "errors" "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -21,63 +20,63 @@ func sheetBatchUpdatePath(token string) string { } func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) { - if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { + if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil { return "", err } if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { - return "", common.FlagErrorf("%v", err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err) } return token, nil } url := strings.TrimSpace(runtime.Str("url")) if url == "" { - return "", common.FlagErrorf("specify --url or --spreadsheet-token") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } token := extractSpreadsheetToken(url) if token == "" || token == url { - return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/").WithParam("--url") } if err := validate.RejectControlChars(token, "url"); err != nil { - return "", common.FlagErrorf("%v", err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err) } return token, nil } func validateSheetID(flagName, sheetID string) error { if strings.TrimSpace(sheetID) == "" { - return common.FlagErrorf("specify --%s", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName) } if err := validate.RejectControlChars(sheetID, flagName); err != nil { - return common.FlagErrorf("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err) } return nil } func validateSheetTitle(flagName, title string) error { if title == "" { - return common.FlagErrorf("--%s must not be empty", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName) } if strings.ContainsAny(title, "\t\r\n") { - return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName) } if err := validate.RejectControlChars(title, flagName); err != nil { - return common.FlagErrorf("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err) } if len([]rune(title)) > 100 { - return common.FlagErrorf("--%s must be <= 100 characters", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName) } if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) { - return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName) } return nil } func validateNonNegativeInt(flagName string, value int) error { if value < 0 { - return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName) } return nil } @@ -287,36 +286,18 @@ func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interfac return out } -func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} { - if len(overlay) == 0 { - return detail - } - if detail == nil { - return overlay - } - if existing, ok := detail.(map[string]interface{}); ok { - merged := map[string]interface{}{} - for k, v := range existing { - merged[k] = v - } - for k, v := range overlay { - merged[k] = v - } - return merged - } - - merged := map[string]interface{}{} - for k, v := range overlay { - merged[k] = v - } - merged["cause_detail"] = detail - return merged -} - func copySheetMoveRetryCommand(token, sheetID string, index int) string { return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index) } +// wrapCopySheetMoveError reports a +copy-sheet that created the new sheet but +// then failed to move it to the requested index. The copy already succeeded, so +// the recovery is to retry only the move (not the whole +copy-sheet, which would +// duplicate the sheet) — that guard and the exact retry command go into the +// hint. The underlying move error is already a typed errs.* error from +// CallAPITyped; its category/subtype/code/log_id are preserved in place +// (mirroring drive's enrichDriveSearchError) so the failure stays accurately +// classified, with only the partial-success context folded into message and hint. func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { if strings.TrimSpace(sheetID) == "" { return err @@ -329,46 +310,22 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { sheetID, retryCommand, ) - detail := map[string]interface{}{ - "partial_success": true, - "failed_step": "move_copied_sheet", - "spreadsheet_token": token, - "sheet_id": sheetID, - "requested_index": index, - "retry_command": retryCommand, - } - - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" { - hint = upstreamHint + "\n" + hint - } - return &output.ExitError{ - Code: exitErr.Code, - Detail: &output.ErrDetail{ - Type: exitErr.Detail.Type, - Code: exitErr.Detail.Code, - Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message), - Hint: hint, - ConsoleURL: exitErr.Detail.ConsoleURL, - Risk: exitErr.Detail.Risk, - Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail), - }, - Err: err, - Raw: exitErr.Raw, + + if p, ok := errs.ProblemOf(err); ok { + if upstream := strings.TrimSpace(p.Message); upstream != "" { + p.Message = fmt.Sprintf("%s: %s", msg, upstream) + } else { + p.Message = msg } + if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" { + p.Hint = upstreamHint + "\n" + hint + } else { + p.Hint = hint + } + return err } - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Message: fmt.Sprintf("%s: %v", msg, err), - Hint: hint, - Detail: detail, - }, - Err: err, - } + return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(err) } func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { @@ -397,7 +354,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { } if runtime.Changed("lock-info") { if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil { - return common.FlagErrorf("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err) } } @@ -405,24 +362,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { if hasProtectConfig { lock := runtime.Str("lock") if !runtime.Changed("lock") { - return common.FlagErrorf("specify --lock when updating protection settings") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock") } if runtime.Changed("lock-info") && lock != "LOCK" { - return common.FlagErrorf("--lock-info requires --lock LOCK") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info") } if runtime.Changed("user-ids") { if lock != "LOCK" { - return common.FlagErrorf("--user-ids requires --lock LOCK") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids") } if runtime.Str("user-id-type") == "" { - return common.FlagErrorf("--user-ids requires --user-id-type") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type") } userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids")) if err != nil { return err } if len(userIDs) == 0 { - return common.FlagErrorf("--user-ids must not be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids") } } } @@ -434,7 +391,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error { runtime.Changed("frozen-col-count") || hasProtectConfig if !hasUpdate { - return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids").WithParams(errs.InvalidParam{Name: "--title", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--index", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--hidden", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--frozen-row-count", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--frozen-col-count", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--lock", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--lock-info", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--user-ids", Reason: "required; specify at least one"}) } return nil @@ -530,7 +487,7 @@ var SheetCreateSheet = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime)) + data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime)) if err != nil { return err } @@ -593,7 +550,7 @@ var SheetCopySheet = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime)) + data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime)) if err != nil { return err } @@ -604,7 +561,7 @@ var SheetCopySheet = common.Shortcut{ } if runtime.Changed("index") { copiedSheetID, _ := out["sheet_id"].(string) - moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index"))) + moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index"))) if err != nil { return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index")) } @@ -644,7 +601,7 @@ var SheetDeleteSheet = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, _ := validateSheetManageToken(runtime) - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id"))) + data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id"))) if err != nil { return err } @@ -707,7 +664,7 @@ var SheetUpdateSheet = common.Shortcut{ params = map[string]interface{}{"user_id_type": userIDType} } - data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body) + data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body) if err != nil { return err } diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go index 5aa1fdaec1..2f9314f92f 100644 --- a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -13,8 +13,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -36,7 +36,7 @@ var SheetInfo = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } if token == "" { - return common.FlagErrorf("specify --url or --spreadsheet-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return nil }, @@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } - spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) + spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil) if err != nil { return err } @@ -95,13 +95,13 @@ var SheetCreate = common.Shortcut{ if headersStr := runtime.Str("headers"); headersStr != "" { var headers []interface{} if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers") } } if dataStr := runtime.Str("data"); dataStr != "" { var rows [][]interface{} if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data") } } return nil @@ -129,7 +129,7 @@ var SheetCreate = common.Shortcut{ if headersStr != "" { var headers []interface{} if err := json.Unmarshal([]byte(headersStr), &headers); err != nil { - return common.FlagErrorf("--headers invalid JSON, must be a 1D array") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers") } if len(headers) > 0 { allRows = append(allRows, any(headers)) @@ -139,7 +139,7 @@ var SheetCreate = common.Shortcut{ if dataStr != "" { var rows []interface{} if err := json.Unmarshal([]byte(dataStr), &rows); err != nil { - return common.FlagErrorf("--data invalid JSON, must be a 2D array") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data") } if len(rows) > 0 { allRows = append(allRows, rows...) @@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{ createData["folder_token"] = folderToken } - data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) + data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData) if err != nil { return err } @@ -164,7 +164,7 @@ var SheetCreate = common.Shortcut{ if err != nil { return err } - if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ + if _, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ "range": appendRange, "values": allRows, @@ -211,8 +211,11 @@ var SheetExport = common.Shortcut{ if _, err := validateSheetManageToken(runtime); err != nil { return err } + if err := validateSheetExportOutputPath(runtime); err != nil { + return err + } if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { - return common.FlagErrorf("--sheet-id is required when --file-extension is csv") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id") } return nil }, @@ -238,10 +241,8 @@ var SheetExport = common.Shortcut{ outputPath := runtime.Str("output-path") sheetID := runtime.Str("sheet-id") - if outputPath != "" { - if _, err := runtime.ResolveSavePath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } + if err := validateSheetExportOutputPath(runtime); err != nil { + return err } exportData := map[string]interface{}{ @@ -253,7 +254,7 @@ var SheetExport = common.Shortcut{ exportData["sub_id"] = sheetID } - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) + data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData) if err != nil { return err } @@ -280,7 +281,7 @@ var SheetExport = common.Shortcut{ } if fileToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "export task timed out") + return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable() } fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken) @@ -298,7 +299,7 @@ var SheetExport = common.Shortcut{ ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return output.ErrNetwork("download failed: %s", err) + return wrapSheetsNetworkErr(err, "download failed: %s", err) } defer resp.Body.Close() @@ -307,7 +308,7 @@ var SheetExport = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return common.WrapSaveErrorTyped(err) } savedPath, _ := runtime.ResolveSavePath(outputPath) @@ -321,3 +322,14 @@ var SheetExport = common.Shortcut{ return nil }, } + +func validateSheetExportOutputPath(runtime *common.RuntimeContext) error { + outputPath := strings.TrimSpace(runtime.Str("output-path")) + if outputPath == "" { + return nil + } + if _, err := runtime.ResolveSavePath(outputPath); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output-path").WithCause(err) + } + return nil +} diff --git a/shortcuts/sheets/backward/sheets_errors.go b/shortcuts/sheets/backward/sheets_errors.go new file mode 100644 index 0000000000..65e3bc7a61 --- /dev/null +++ b/shortcuts/sheets/backward/sheets_errors.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import "github.com/larksuite/cli/errs" + +// wrapSheetsNetworkErr preserves typed boundary errors and only classifies raw +// transport failures that still surface from stream/download paths. +func wrapSheetsNetworkErr(err error, format string, args ...any) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err) +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 271d4eeca1..5f7d492fa4 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -5,8 +5,6 @@ package sheets import ( "strings" - - "github.com/larksuite/cli/shortcuts/common" ) // ─── +batch-update sub-op dispatch ───────────────────────────────────── @@ -198,7 +196,7 @@ var batchOpDispatch = map[string]batchOpMapping{ // turned into a file_token. Callers must pass --image-token / --image-uri. func rejectLocalImageInBatch(fv flagView) error { if strings.TrimSpace(fv.Str("image")) != "" { - return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead") + return sheetsValidationForFlag("image", "--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead") } return nil } @@ -208,23 +206,23 @@ func rejectLocalImageInBatch(fv flagView) error { // auto-derives sheet_id / source_index, so both must be supplied explicitly. func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if sheetID == "" { - return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)") + return nil, sheetsValidationForFlag("sheet-id", "+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)") } if !fv.Changed("source-index") { - return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") + return nil, sheetsValidationForFlag("source-index", "+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") } if fv.Int("source-index") < 0 { - return nil, common.FlagErrorf("--source-index must be >= 0") + return nil, sheetsValidationForFlag("source-index", "--source-index must be >= 0") } // Standalone +sheet-move requires --index (see SheetMove.Validate). A batch // sub-op skips that path, and mapFlagView falls back to the flag default (0), // which would silently move the sheet to the front. Require it explicitly so // the batch contract matches the standalone one. if !fv.Changed("index") { - return nil, common.FlagErrorf("+sheet-move in +batch-update requires index") + return nil, sheetsValidationForFlag("index", "+sheet-move in +batch-update requires index") } if fv.Int("index") < 0 { - return nil, common.FlagErrorf("--index must be >= 0") + return nil, sheetsValidationForFlag("index", "--index must be >= 0") } return map[string]interface{}{ "excel_id": token, @@ -254,19 +252,20 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"} func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) { op, ok := raw.(map[string]interface{}) if !ok { - return nil, common.FlagErrorf("operations[%d] must be a JSON object", index) + return nil, sheetsValidationForFlag("operations", "operations[%d] must be a JSON object", index) } scRaw, present := op["shortcut"] if !present { - return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index) + return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index) } sc, ok := scRaw.(string) if !ok || sc == "" { - return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw) + return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw) } mapping, ok := batchOpDispatch[sc] if !ok { - return nil, common.FlagErrorf( + return nil, sheetsValidationForFlag( + "operations", "operations[%d]: shortcut %q not allowed in +batch-update "+ "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+ "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", @@ -280,12 +279,13 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte } else { input, ok = inputRaw.(map[string]interface{}) if !ok { - return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw) + return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw) } } // 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。 if _, has := input["operation"]; has { - return nil, common.FlagErrorf( + return nil, sheetsValidationForFlag( + "operations", "operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name", index, sc, ) @@ -293,7 +293,8 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte // 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。 for _, k := range reservedSubOpKeys { if _, has := input[k]; has { - return nil, common.FlagErrorf( + return nil, sheetsValidationForFlag( + "operations", "operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token", index, sc, k, ) @@ -302,7 +303,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte // 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。 for k := range op { if k != "shortcut" && k != "input" { - return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) + return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) } } fv := newMapFlagViewForCommand(sc, input) @@ -310,14 +311,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte // sub-op's scalar fields here before the translator reads them via // Int/Bool/Float64 (which would otherwise coerce a wrong type to zero). if err := fv.validateRawTypes(); err != nil { - return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) + return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) } sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc) sheetID := strings.TrimSpace(fv.Str(sheetIDFlag)) sheetName := strings.TrimSpace(fv.Str(sheetNameFlag)) body, err := mapping.translate(fv, token, sheetID, sheetName) if err != nil { - return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) + return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) } return map[string]interface{}{ "tool_name": mapping.mcpToolName, @@ -328,7 +329,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte // translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { if len(rawOps) == 0 { - return nil, common.FlagErrorf("--operations must be a non-empty JSON array") + return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array") } out := make([]interface{}, 0, len(rawOps)) for i, raw := range rawOps { diff --git a/shortcuts/sheets/csv_put_guard_test.go b/shortcuts/sheets/csv_put_guard_test.go index 5191e45ee2..29a47815c7 100644 --- a/shortcuts/sheets/csv_put_guard_test.go +++ b/shortcuts/sheets/csv_put_guard_test.go @@ -4,10 +4,12 @@ package sheets import ( + "errors" "os" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" @@ -41,6 +43,16 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) { if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") { t.Errorf("error should flag the file and suggest @data.csv, got: %v", err) } + if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("problem = %+v, want validation/invalid_argument", p) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("guard error = %T, want *errs.ValidationError", err) + } + if ve.Param != "--csv" { + t.Errorf("param = %q, want --csv", ve.Param) + } // Content that is not a real file must pass through unchanged. for _, v := range []string{ diff --git a/shortcuts/sheets/csv_put_range_alias_test.go b/shortcuts/sheets/csv_put_range_alias_test.go index 4a631d6f6d..e4157c22bb 100644 --- a/shortcuts/sheets/csv_put_range_alias_test.go +++ b/shortcuts/sheets/csv_put_range_alias_test.go @@ -21,7 +21,6 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) { {"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"}, {"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"}, {"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"}, - {"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -38,6 +37,21 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) { } } +func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) { + fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{ + "csv": "a,b", + "start-cell": "C3", + "range": "A1:H17", + }) + _, err := csvPutInput(fv, "tok", "sid", "") + if err == nil { + t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error") + } + if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") { + t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error()) + } +} + // With neither --start-cell nor --range explicitly set, csvPutInput rejects the // call instead of silently anchoring at the "A1" flag default. Standalone never // reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 8cd24bb256..c04a8caabf 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -5,11 +5,12 @@ package sheets import ( "encoding/json" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) // TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and @@ -453,16 +454,27 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) { if err == nil { t.Fatalf("expected a partial-success error; got nil\nout=%s", out) } - exitErr, ok := err.(*output.ExitError) + p, ok := errs.ProblemOf(err) if !ok { - t.Fatalf("error type = %T, want *output.ExitError (structured)", err) + t.Fatalf("error type = %T, want typed problem", err) } - if exitErr.Detail == nil { - t.Fatal("ExitError.Detail is nil; want structured detail carrying the token") + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype) } - detail, _ := exitErr.Detail.Detail.(map[string]interface{}) - if detail["spreadsheet_token"] != "shtNEW" { - t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"]) + if !strings.Contains(p.Message, "shtNEW") { + t.Errorf("message = %q, want spreadsheet token for recovery", p.Message) + } + if !strings.Contains(p.Hint, "spreadsheet_token") { + t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint) + } + // The underlying fill failure is preserved as the cause so its subtype and + // log_id stay diagnosable rather than being flattened into the message. + inner := errors.Unwrap(err) + if inner == nil { + t.Fatalf("expected the underlying fill failure preserved as the cause") + } + if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner) } } diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index fb3299f2cd..701a859a1a 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -8,8 +8,6 @@ import ( "fmt" "sort" "strings" - - "github.com/larksuite/cli/shortcuts/common" ) // ─── schema-driven flag validation ──────────────────────────────────── @@ -95,7 +93,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err var schema schemaProperty json.Unmarshal(raw, &schema) if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil { - return common.FlagErrorf("--%s: %s", name, vErr.Error()) + return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error()) } return nil } diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 3465484cb0..621f214eb4 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -12,20 +12,52 @@ import ( "encoding/json" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) +func sheetsFlagParam(name string) string { + if strings.HasPrefix(name, "--") { + return name + } + return "--" + name +} + +func sheetsInvalidParam(name, reason string) errs.InvalidParam { + return errs.InvalidParam{Name: sheetsFlagParam(name), Reason: reason} +} + +func sheetsValidationForFlag(name, format string, args ...any) *errs.ValidationError { + return common.ValidationErrorf(format, args...).WithParam(sheetsFlagParam(name)) +} + +func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationError { + return common.ValidationErrorf("%v", cause).WithParam(sheetsFlagParam(name)).WithCause(cause) +} + +// sheetsInputStatError wraps a local input-file stat/open failure as a typed +// validation error tagged with the flag the path came from, so callers learn +// which flag to fix. It reuses the shared common.WrapInputStatErrorTyped +// classification and only adds the domain's flag param. +func sheetsInputStatError(flag string, err error) error { + wrapped := common.WrapInputStatErrorTyped(err) + if v, ok := wrapped.(*errs.ValidationError); ok { + return v.WithParam(sheetsFlagParam(flag)) + } + return wrapped +} + // resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR // pair shared by every sheets canonical shortcut and returns the resolved // token. Network-free, safe to call from Validate and DryRun. func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) { - if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { + if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil { return "", err } if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { - return "", common.FlagErrorf("%v", err) + return "", sheetsValidationCauseForFlag("spreadsheet-token", err) } return token, nil } @@ -33,10 +65,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) { url := strings.TrimSpace(runtime.Str("url")) token := extractSpreadsheetToken(url) if token == "" || token == url { - return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/") } if err := validate.RejectControlChars(token, "url"); err != nil { - return "", common.FlagErrorf("%v", err) + return "", sheetsValidationCauseForFlag("url", err) } return token, nil } @@ -64,18 +96,18 @@ func extractSpreadsheetToken(input string) string { // Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers // pass both through to the tool input; the server picks whichever fits. func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) { - if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil { + if err := common.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil { return "", "", err } if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" { if err := validate.RejectControlChars(id, "sheet-id"); err != nil { - return "", "", common.FlagErrorf("%v", err) + return "", "", sheetsValidationCauseForFlag("sheet-id", err) } return id, "", nil } name := strings.TrimSpace(runtime.Str("sheet-name")) if err := validate.RejectControlChars(name, "sheet-name"); err != nil { - return "", "", common.FlagErrorf("%v", err) + return "", "", sheetsValidationCauseForFlag("sheet-name", err) } return "", name, nil } @@ -116,18 +148,26 @@ func requireSheetSelector(sheetID, sheetName string) error { sheetID = strings.TrimSpace(sheetID) sheetName = strings.TrimSpace(sheetName) if sheetID == "" && sheetName == "" { - return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name") + return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name"). + WithParams( + sheetsInvalidParam("sheet-id", "required; specify at least one"), + sheetsInvalidParam("sheet-name", "required; specify at least one"), + ) } if sheetID != "" && sheetName != "" { - return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive"). + WithParams( + sheetsInvalidParam("sheet-id", "mutually exclusive"), + sheetsInvalidParam("sheet-name", "mutually exclusive"), + ) } if sheetID != "" { if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil { - return common.FlagErrorf("%v", err) + return sheetsValidationCauseForFlag("sheet-id", err) } } else { if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil { - return common.FlagErrorf("%v", err) + return sheetsValidationCauseForFlag("sheet-name", err) } } return nil @@ -152,15 +192,19 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string) sheetID = strings.TrimSpace(sheetID) sheetName = strings.TrimSpace(sheetName) if sheetID != "" && sheetName != "" { - return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName) + return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName). + WithParams( + sheetsInvalidParam(idFlagName, "mutually exclusive"), + sheetsInvalidParam(nameFlagName, "mutually exclusive"), + ) } if sheetID != "" { if err := validate.RejectControlChars(sheetID, idFlagName); err != nil { - return common.FlagErrorf("%v", err) + return sheetsValidationCauseForFlag(idFlagName, err) } } else if sheetName != "" { if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil { - return common.FlagErrorf("%v", err) + return sheetsValidationCauseForFlag(nameFlagName, err) } } return nil @@ -197,7 +241,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) { } var out interface{} if err := json.Unmarshal([]byte(raw), &out); err != nil { - return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err) + return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err) } // Schema-driven flag validation at the user-input boundary. Skips // --properties (validated at the input-builder tail after enhance @@ -216,11 +260,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e return nil, err } if v == nil { - return nil, common.FlagErrorf("--%s is required", name) + return nil, sheetsValidationForFlag(name, "--%s is required", name) } m, ok := v.(map[string]interface{}) if !ok { - return nil, common.FlagErrorf("--%s must be a JSON object", name) + return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name) } return m, nil } @@ -232,11 +276,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) { return nil, err } if v == nil { - return nil, common.FlagErrorf("--%s is required", name) + return nil, sheetsValidationForFlag(name, "--%s is required", name) } a, ok := v.([]interface{}) if !ok { - return nil, common.FlagErrorf("--%s must be a JSON array", name) + return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name) } return a, nil } @@ -293,7 +337,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) { } m, ok := v.(map[string]interface{}) if !ok { - return nil, common.FlagErrorf("--border-styles must be a JSON object") + return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object") } return m, nil } @@ -307,5 +351,10 @@ func requireAnyStyleFlag(runtime flagView) error { if runtime.Str("border-styles") != "" { return nil } - return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)") + return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)"). + WithParams( + sheetsInvalidParam("background-color", "required; specify at least one style flag"), + sheetsInvalidParam("font-weight", "required; specify at least one style flag"), + sheetsInvalidParam("border-styles", "required; specify at least one style flag"), + ) } diff --git a/shortcuts/sheets/helpers_test.go b/shortcuts/sheets/helpers_test.go index 15eb06166d..686676955c 100644 --- a/shortcuts/sheets/helpers_test.go +++ b/shortcuts/sheets/helpers_test.go @@ -6,11 +6,13 @@ package sheets import ( "bytes" "encoding/json" + "errors" "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -79,6 +81,71 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs return stdout.String(), err } +func TestSheetHelpersValidationMetadata(t *testing.T) { + t.Parallel() + + t.Run("missing sheet selector reports both params", func(t *testing.T) { + t.Parallel() + err := requireSheetSelector("", "") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if len(validationErr.Params) != 2 { + t.Fatalf("params = %#v, want two structured params", validationErr.Params) + } + if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" { + t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params) + } + }) + + t.Run("spreadsheet url shape reports url param", func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "sheets"} + cmd.Flags().String("url", "not-a-sheet-url", "") + cmd.Flags().String("spreadsheet-token", "", "") + _, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t))) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if validationErr.Param != "--url" { + t.Fatalf("param = %q, want --url", validationErr.Param) + } + }) + + t.Run("sheet selector control char keeps param and cause", func(t *testing.T) { + t.Parallel() + err := requireSheetSelector("bad\x00id", "") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if validationErr.Param != "--sheet-id" { + t.Fatalf("param = %q, want --sheet-id", validationErr.Param) + } + if validationErr.Unwrap() == nil { + t.Fatalf("expected control-char validation cause to be preserved") + } + }) + + t.Run("invalid json flag keeps param and cause", func(t *testing.T) { + t.Parallel() + fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"}) + _, err := parseJSONFlag(fv, "cells") + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if validationErr.Param != "--cells" { + t.Fatalf("param = %q, want --cells", validationErr.Param) + } + if validationErr.Unwrap() == nil { + t.Fatalf("expected JSON parse cause to be preserved") + } + }) +} + // parseDryRunBody runs the shortcut in --dry-run and returns the first // api call's body. The dry-run output format is: // diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 696c40f239..ad58db0f33 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -132,7 +132,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er return nil, err } if v == nil { - return nil, common.FlagErrorf("--operations is required") + return nil, sheetsValidationForFlag("operations", "--operations is required") } if arr, ok := v.([]interface{}); ok { return arr, nil @@ -142,7 +142,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er return ops, nil } } - return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)") + return nil, sheetsValidationForFlag("operations", "--operations must be a JSON array (or { operations: [...] } envelope)") } // CellsBatchSetStyle stamps one style block across many sheet-prefixed @@ -222,7 +222,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[ } rows, cols, err := rangeDimensions(sub) if err != nil { - return nil, common.FlagErrorf("range %q: %v", rng, err) + return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ @@ -386,7 +386,7 @@ var DropdownDelete = common.Shortcut{ return err } if len(ranges) > 100 { - return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges)) + return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges)) } return nil }, @@ -439,7 +439,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool } rows, cols, err := rangeDimensions(sub) if err != nil { - return nil, common.FlagErrorf("range %q: %v", rng, err) + return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ @@ -471,21 +471,21 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { for i, v := range raw { s, ok := v.(string) if !ok { - return nil, common.FlagErrorf("--ranges[%d] must be a string", i) + return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i) } s = strings.TrimSpace(s) if !strings.Contains(s, "!") { - return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) + return nil, sheetsValidationForFlag("ranges", "--ranges[%d] (%q) must include a sheet prefix", i, s) } // Validate the sheet!range shape up front so malformed entries like // "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail // here at Validate instead of slipping through to DryRun/Execute. _, sub, err := splitSheetPrefixedRange(s) if err != nil { - return nil, common.FlagErrorf("--ranges[%d]: %v", i, err) + return nil, sheetsValidationForFlag("ranges", "--ranges[%d]: %v", i, err) } if _, _, err := rangeDimensions(sub); err != nil { - return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err) + return nil, sheetsValidationForFlag("ranges", "--ranges[%d] (%q): %v", i, s, err) } out = append(out, s) } @@ -496,7 +496,7 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { idx := strings.Index(rng, "!") if idx <= 0 || idx == len(rng)-1 { - return "", "", common.FlagErrorf("range %q must use sheet!range form", rng) + return "", "", sheetsValidationForFlag("range", "range %q must use sheet!range form", rng) } return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 221b9d03d9..410be3d870 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -251,7 +251,7 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec return nil, err } if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { - return nil, common.FlagErrorf("--%s is required", spec.idFlag) + return nil, sheetsValidationForFlag(spec.idFlag, "--%s is required", spec.idFlag) } props, err := requireJSONObject(runtime, "properties") if err != nil { @@ -335,7 +335,7 @@ func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec return nil, err } if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { - return nil, common.FlagErrorf("--%s is required", spec.idFlag) + return nil, sheetsValidationForFlag(spec.idFlag, "--%s is required", spec.idFlag) } input := map[string]interface{}{ "excel_id": token, @@ -517,16 +517,16 @@ func validateSparklineUpdateItems(input map[string]interface{}) error { } arr, ok := raw.([]interface{}) if !ok { - return common.FlagErrorf("+sparkline-update properties.sparklines must be an array") + return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines must be an array") } for i, item := range arr { m, _ := item.(map[string]interface{}) if m == nil { - return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i) + return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines[%d] must be an object", i) } id, _ := m["sparkline_id"].(string) if strings.TrimSpace(id) == "" { - return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i) + return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i) } } return nil @@ -595,20 +595,44 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm } } if set == 0 && requireImageSource { - return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required") + return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required").WithParams(sheetsInvalidParam("image", "required; specify one"), sheetsInvalidParam("image-token", "required; specify one"), sheetsInvalidParam("image-uri", "required; specify one")) } if set > 1 { - return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive") + params := make([]errs.InvalidParam, 0, 3) + if img != "" { + params = append(params, sheetsInvalidParam("image", "mutually exclusive")) + } + if token != "" { + params = append(params, sheetsInvalidParam("image-token", "mutually exclusive")) + } + if uri != "" { + params = append(params, sheetsInvalidParam("image-uri", "mutually exclusive")) + } + return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive").WithParams(params...) } name := floatImageName(runtime) if name == "" { - return nil, common.FlagErrorf("--image-name is required") + return nil, sheetsValidationForFlag("image-name", "--image-name is required") } if !runtime.Changed("position-row") || !runtime.Changed("position-col") { - return nil, common.FlagErrorf("--position-row and --position-col are required") + params := make([]errs.InvalidParam, 0, 2) + if !runtime.Changed("position-row") { + params = append(params, sheetsInvalidParam("position-row", "required")) + } + if !runtime.Changed("position-col") { + params = append(params, sheetsInvalidParam("position-col", "required")) + } + return nil, common.ValidationErrorf("--position-row and --position-col are required").WithParams(params...) } if !runtime.Changed("size-width") || !runtime.Changed("size-height") { - return nil, common.FlagErrorf("--size-width and --size-height are required") + params := make([]errs.InvalidParam, 0, 2) + if !runtime.Changed("size-width") { + params = append(params, sheetsInvalidParam("size-width", "required")) + } + if !runtime.Changed("size-height") { + params = append(params, sheetsInvalidParam("size-height", "required")) + } + return nil, common.ValidationErrorf("--size-width and --size-height are required").WithParams(params...) } props := map[string]interface{}{ "image_name": name, @@ -626,7 +650,9 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm // Local file: validate path safety here so --dry-run also rejects // unsafe paths; Execute uploads it and passes the real token in. if _, err := validate.SafeLocalFlagPath("--image", img); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err). + WithParam("--image"). + WithCause(err) } if uploadedImageToken != "" { props["image_token"] = uploadedImageToken @@ -746,7 +772,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st } info, err := runtime.FileIO().Stat(img) if err != nil { - return "", common.WrapInputStatError(err) + return "", sheetsInputStatError("image", err) } return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: img, @@ -762,7 +788,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string return nil, err } if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { - return nil, common.FlagErrorf("--float-image-id is required") + return nil, sheetsValidationForFlag("float-image-id", "--float-image-id is required") } props, err := floatImageProperties(runtime, uploadedImageToken, op == "create") if err != nil { @@ -882,7 +908,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[ return nil, err } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), @@ -957,10 +983,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[ return nil, err } if sheetID == "" { - return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + return nil, sheetsValidationForFlag("sheet-id", "+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } props, err := requireJSONObject(runtime, "properties") if err != nil { @@ -1031,7 +1057,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[ return nil, err } if sheetID == "" { - return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + return nil, sheetsValidationForFlag("sheet-id", "+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") } input := map[string]interface{}{ "excel_id": token, diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 669e5eaf52..188fdfe73f 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -5,10 +5,9 @@ package sheets import ( "context" - "errors" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -76,7 +75,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st return nil, err } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } input := map[string]interface{}{ "excel_id": token, @@ -108,22 +107,22 @@ func normalizeClearType(scope string) string { // pivot-occupied A1 with cells-clear; point the agent at the object's own // delete command instead. Non-matching errors pass through untouched. func annotateEmbeddedBlockClearErr(err error) error { - var ee *output.ExitError - if !errors.As(err, &ee) || ee.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok { return err } - if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") { + if !strings.Contains(strings.ToLower(p.Message), "embedded block") { return err } const hint = "the range overlaps an embedded object (pivot table / chart); " + "cells-clear only clears cell values/formats and cannot delete it — " + "delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)" - if ee.Detail.Hint == "" { - ee.Detail.Hint = hint + if p.Hint == "" { + p.Hint = hint } else { - ee.Detail.Hint += "; " + hint + p.Hint += "; " + hint } - return ee + return err } // CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the @@ -191,7 +190,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg return nil, err } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } input := map[string]interface{}{ "excel_id": token, @@ -345,36 +344,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) return nil, err } if !runtime.Changed("range") { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } rangeStr := strings.TrimSpace(runtime.Str("range")) parsedDim, _, _, err := parseA1Range(rangeStr) if err != nil { - return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "invalid --range %q: %v", rangeStr, err) } if parsedDim != dimension { want := "row numbers (e.g. \"2:10\")" if dimension == "column" { want = "column letters (e.g. \"A:E\")" } - return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want) + return nil, sheetsValidationForFlag("range", "--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want) } if !strings.Contains(rangeStr, ":") { rangeStr = rangeStr + ":" + rangeStr } typ := strings.TrimSpace(runtime.Str("type")) if typ == "" { - return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension)) + return nil, sheetsValidationForFlag("type", "--type is required (pixel / standard%s)", autoSuffix(dimension)) } if dimension == "column" && typ == "auto" { - return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize") + return nil, sheetsValidationForFlag("type", "--type auto is rows-only (column widths do not support auto-fit); use +rows-resize") } hasSize := runtime.Changed("size") && runtime.Int("size") > 0 if typ == "pixel" && !hasSize { - return nil, common.FlagErrorf("--type pixel requires --size ") + return nil, common.ValidationErrorf("--type pixel requires --size ").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required")) } if typ != "pixel" && hasSize { - return nil, common.FlagErrorf("--size is only valid with --type pixel") + return nil, common.ValidationErrorf("--size is only valid with --type pixel").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive")) } input := map[string]interface{}{ "excel_id": token, @@ -567,10 +566,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri return nil, err } if strings.TrimSpace(runtime.Str("source-range")) == "" { - return nil, common.FlagErrorf("--source-range is required") + return nil, sheetsValidationForFlag("source-range", "--source-range is required") } if strings.TrimSpace(runtime.Str("target-range")) == "" { - return nil, common.FlagErrorf("--target-range is required") + return nil, sheetsValidationForFlag("target-range", "--target-range is required") } input := map[string]interface{}{ "excel_id": token, @@ -609,10 +608,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str return nil, err } if strings.TrimSpace(runtime.Str("source-range")) == "" { - return nil, common.FlagErrorf("--source-range is required") + return nil, sheetsValidationForFlag("source-range", "--source-range is required") } if strings.TrimSpace(runtime.Str("target-range")) == "" { - return nil, common.FlagErrorf("--target-range is required") + return nil, sheetsValidationForFlag("target-range", "--target-range is required") } input := map[string]interface{}{ "excel_id": token, @@ -641,7 +640,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str return nil, err } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } // requireJSONArray runs the embedded JSON Schema for --sort-keys // via parseJSONFlag → validateParsedJSONFlag, so each item is diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index e0f4647090..0c85bbb667 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -16,34 +16,35 @@ func TestAnnotateEmbeddedBlockClearErr(t *testing.T) { t.Parallel() t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) { - in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ - Type: "api", - Message: `tool "clear_cell_range" failed: [500] can not find embedded block`, - }} - var ee *output.ExitError - if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil { - t.Fatal("expected ExitError with detail") + in := errs.NewAPIError(errs.SubtypeServerError, `tool "clear_cell_range" failed: [500] can not find embedded block`) + p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in)) + if !ok { + t.Fatal("expected typed problem") } - if !strings.Contains(ee.Detail.Hint, "+pivot-delete") { - t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint) + if !strings.Contains(p.Hint, "+pivot-delete") { + t.Errorf("hint should point at +pivot-delete, got %q", p.Hint) } }) t.Run("appends to existing hint", func(t *testing.T) { - in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ - Message: "embedded block missing", Hint: "preexisting", - }} - out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) - if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") { - t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint) + in := errs.NewAPIError(errs.SubtypeServerError, "embedded block missing").WithHint("preexisting") + p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in)) + if !ok { + t.Fatal("expected typed problem") + } + if !strings.HasPrefix(p.Hint, "preexisting; ") { + t.Errorf("existing hint should be preserved and appended, got %q", p.Hint) } }) - t.Run("passes through unrelated ExitError untouched", func(t *testing.T) { - in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}} - out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) - if out.Detail.Hint != "" { - t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint) + t.Run("passes through unrelated typed error untouched", func(t *testing.T) { + in := errs.NewAPIError(errs.SubtypeServerError, "some other failure") + p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in)) + if !ok { + t.Fatal("expected typed problem") + } + if p.Hint != "" { + t.Errorf("unrelated error should not gain a hint, got %q", p.Hint) } }) diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 40044c94ac..034e541bf0 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--range is required") } return nil }, @@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--range is required") } return nil }, @@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--range is required") } return nil }, diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index 6e0b8ecb36..4777d24547 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("find")) == "" { - return common.FlagErrorf("--find is required") + return sheetsValidationForFlag("find", "--find is required") } return nil }, @@ -151,10 +151,10 @@ func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[strin return nil, err } if strings.TrimSpace(runtime.Str("find")) == "" { - return nil, common.FlagErrorf("--find is required") + return nil, sheetsValidationForFlag("find", "--find is required") } if !runtime.Changed("replacement") { - return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") + return nil, sheetsValidationForFlag("replacement", "--replacement is required (pass an empty string to delete matches)") } input := map[string]interface{}{ "excel_id": token, diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index fcdd9667a0..41228578ca 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -164,18 +164,18 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str return nil, err } if !runtime.Changed("position") { - return nil, common.FlagErrorf("--position is required") + return nil, sheetsValidationForFlag("position", "--position is required") } if !runtime.Changed("count") { - return nil, common.FlagErrorf("--count is required") + return nil, sheetsValidationForFlag("count", "--count is required") } position := strings.TrimSpace(runtime.Str("position")) if _, _, err := parseA1Position(position); err != nil { - return nil, common.FlagErrorf("invalid --position %q: %v", position, err) + return nil, sheetsValidationForFlag("position", "invalid --position %q: %v", position, err) } count := runtime.Int("count") if count <= 0 { - return nil, common.FlagErrorf("--count must be > 0 (got %d)", count) + return nil, sheetsValidationForFlag("count", "--count must be > 0 (got %d)", count) } input := map[string]interface{}{ "excel_id": token, @@ -326,13 +326,13 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str return nil, err } if !runtime.Changed("dimension") { - return nil, common.FlagErrorf("--dimension is required") + return nil, sheetsValidationForFlag("dimension", "--dimension is required") } if !runtime.Changed("count") { - return nil, common.FlagErrorf("--count is required (0 unfreezes)") + return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)") } if runtime.Int("count") < 0 { - return nil, common.FlagErrorf("--count must be >= 0") + return nil, sheetsValidationForFlag("count", "--count must be >= 0") } dim := runtime.Str("dimension") count := runtime.Int("count") @@ -361,11 +361,11 @@ func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (ma return nil, err } if !runtime.Changed("range") { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } rangeStr := strings.TrimSpace(runtime.Str("range")) if _, _, _, err := parseA1Range(rangeStr); err != nil { - return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "invalid --range %q: %v", rangeStr, err) } input := map[string]interface{}{ "excel_id": token, @@ -611,7 +611,7 @@ var DimMove = common.Shortcut{ } sheetID = lookedID } - data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime)) + data, err := runtime.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime)) if err != nil { return err } @@ -632,20 +632,20 @@ type dimMovePlan struct { // target dimension matches the source. Used by both Validate and Execute. func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) { if !runtime.Changed("source-range") || !runtime.Changed("target") { - return nil, common.FlagErrorf("--source-range and --target are required") + return nil, common.ValidationErrorf("--source-range and --target are required").WithParams(sheetsInvalidParam("source-range", "required"), sheetsInvalidParam("target", "required")) } src := strings.TrimSpace(runtime.Str("source-range")) dim, startIdx, endIdx, err := parseA1Range(src) if err != nil { - return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err) + return nil, sheetsValidationForFlag("source-range", "invalid --source-range %q: %v", src, err) } tgt := strings.TrimSpace(runtime.Str("target")) tgtDim, tgtIdx, err := parseA1Position(tgt) if err != nil { - return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err) + return nil, sheetsValidationForFlag("target", "invalid --target %q: %v", tgt, err) } if tgtDim != dim { - return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim) + return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim).WithParams(sheetsInvalidParam("target", "dimension mismatch"), sheetsInvalidParam("source-range", "dimension mismatch")) } return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil } diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 6ea725b5aa..aed687f361 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -13,9 +13,9 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -122,13 +122,13 @@ var SheetCreate = common.Shortcut{ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) { if strings.TrimSpace(runtime.Str("title")) == "" { - return nil, common.FlagErrorf("--title is required") + return nil, sheetsValidationForFlag("title", "--title is required") } if n := runtime.Int("row-count"); n < 0 || n > 50000 { - return nil, common.FlagErrorf("--row-count must be between 0 and 50000") + return nil, sheetsValidationForFlag("row-count", "--row-count must be between 0 and 50000") } if n := runtime.Int("col-count"); n < 0 || n > 200 { - return nil, common.FlagErrorf("--col-count must be between 0 and 200") + return nil, sheetsValidationForFlag("col-count", "--col-count must be between 0 and 200") } input := map[string]interface{}{ "excel_id": token, @@ -167,7 +167,7 @@ func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[s return nil, err } if strings.TrimSpace(runtime.Str("title")) == "" { - return nil, common.FlagErrorf("--title is required") + return nil, sheetsValidationForFlag("title", "--title is required") } input := map[string]interface{}{ "excel_id": token, @@ -192,7 +192,7 @@ func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) ( return nil, err } if !runtime.Changed("color") { - return nil, common.FlagErrorf("--color is required (empty string clears)") + return nil, sheetsValidationForFlag("color", "--color is required (empty string clears)") } input := map[string]interface{}{ "excel_id": token, @@ -311,13 +311,13 @@ var SheetMove = common.Shortcut{ return err } if !runtime.Changed("index") { - return common.FlagErrorf("--index is required") + return sheetsValidationForFlag("index", "--index is required") } if runtime.Int("index") < 0 { - return common.FlagErrorf("--index must be >= 0") + return sheetsValidationForFlag("index", "--index must be >= 0") } if runtime.Changed("source-index") && runtime.Int("source-index") < 0 { - return common.FlagErrorf("--source-index must be >= 0") + return sheetsValidationForFlag("source-index", "--source-index must be >= 0") } return nil }, @@ -561,7 +561,7 @@ var WorkbookCreate = common.Shortcut{ Flags: flagsFor("+workbook-create"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if strings.TrimSpace(runtime.Str("title")) == "" { - return common.FlagErrorf("--title is required") + return sheetsValidationForFlag("title", "--title is required") } if runtime.Str("headers") != "" { v, err := parseJSONFlag(runtime, "headers") @@ -569,7 +569,7 @@ var WorkbookCreate = common.Shortcut{ return err } if _, ok := v.([]interface{}); !ok { - return common.FlagErrorf("--headers must be a JSON array") + return sheetsValidationForFlag("headers", "--headers must be a JSON array") } } if runtime.Str("values") != "" { @@ -579,11 +579,11 @@ var WorkbookCreate = common.Shortcut{ } rows, ok := v.([]interface{}) if !ok { - return common.FlagErrorf("--values must be a JSON 2D array") + return sheetsValidationForFlag("values", "--values must be a JSON 2D array") } for i, r := range rows { if _, ok := r.([]interface{}); !ok { - return common.FlagErrorf("--values[%d] must be an array", i) + return sheetsValidationForFlag("values", "--values[%d] must be an array", i) } } } @@ -613,7 +613,7 @@ var WorkbookCreate = common.Shortcut{ if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { body["folder_token"] = v } - data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) + data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) if err != nil { return err } @@ -623,7 +623,7 @@ var WorkbookCreate = common.Shortcut{ token = common.GetString(ss, "token") } if token == "" { - return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response") } result := map[string]interface{}{"spreadsheet": ss} @@ -641,14 +641,12 @@ var WorkbookCreate = common.Shortcut{ // response doesn't echo the default sheet's id, so read it back. firstSheetID, err := lookupFirstSheetID(ctx, runtime, token) if err != nil { - return workbookCreatedButFillFailed(token, ss, - fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err)) + return workbookCreatedButFillFailed(token, "resolving its first sheet for initial fill failed", err) } fill["sheet_id"] = firstSheetID fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) if err != nil { - return workbookCreatedButFillFailed(token, ss, - fmt.Sprintf("initial fill failed: %v", err)) + return workbookCreatedButFillFailed(token, "initial fill failed", err) } result["initial_fill"] = fillOut } @@ -660,24 +658,17 @@ var WorkbookCreate = common.Shortcut{ }, } -// workbookCreatedButFillFailed builds a structured partial-success error for the -// window where the spreadsheet POST succeeded but the follow-up initial fill did -// not. The new spreadsheet_token is surfaced in the error detail so callers can -// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only -// finding the token interpolated into a bare error string. -func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error { - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_success", - Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason), - Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it", - Detail: map[string]interface{}{ - "spreadsheet_token": token, - "spreadsheet": spreadsheet, - }, - }, - } +// workbookCreatedButFillFailed reports the window where the spreadsheet POST +// succeeded but the follow-up initial fill did not. The spreadsheet now exists, +// so the caller must change state (retry the fill against the returned token, or +// delete the orphan) rather than re-run create — a failed_precondition. The +// underlying fill failure is kept as the cause so its subtype and log_id stay +// diagnosable, and the created spreadsheet_token is named in the message and +// recovery hint so the caller can act on it. +func workbookCreatedButFillFailed(token, reason string, cause error) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "spreadsheet %s created but %s", token, reason). + WithCause(cause). + WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token (+cells-set / +csv-put), or delete it") } // buildInitialFillInput zips --headers + --values into a single set_cell_range @@ -765,7 +756,7 @@ var WorkbookExport = common.Shortcut{ ext = "xlsx" } if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { - return common.FlagErrorf("--sheet-id is required when --file-extension=csv") + return sheetsValidationForFlag("sheet-id", "--sheet-id is required when --file-extension=csv") } return nil }, @@ -813,13 +804,13 @@ var WorkbookExport = common.Shortcut{ if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { body["sub_id"] = sid } - taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body) if err != nil { return err } ticket := common.GetString(taskData, "ticket") if ticket == "" { - return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing") } result := map[string]interface{}{ @@ -847,9 +838,9 @@ var WorkbookExport = common.Shortcut{ continue default: // any non-zero status outside the in-progress window is a failure if status.JobErrorMsg != "" { - return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg) + return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed: %s", ticket, status.JobErrorMsg) } - return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus) + return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus) } } if fileToken == "" { @@ -887,7 +878,7 @@ type exportTaskStatus struct { } func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), map[string]interface{}{"token": token}, @@ -898,7 +889,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor } result := common.GetMap(data, "result") if result == nil { - return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket) + return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket) } js, _ := util.ToFloat64(result["job_status"]) fs, _ := util.ToFloat64(result["file_size"]) @@ -918,10 +909,10 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), }, larkcore.WithFileDownload()) if err != nil { - return "", output.ErrNetwork("download failed: %s", err) + return "", sheetsDownloadRequestError(err) } if apiResp.StatusCode >= 400 { - return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + return "", sheetsDownloadHTTPStatusError(apiResp) } target := outPath if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() { @@ -935,7 +926,7 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil ContentType: apiResp.Header.Get("Content-Type"), ContentLength: int64(len(apiResp.RawBody)), }, strings.NewReader(string(apiResp.RawBody))); err != nil { - return "", common.WrapSaveErrorByCategory(err, "io") + return "", common.WrapSaveErrorTyped(err) } resolved, _ := runtime.FileIO().ResolvePath(target) if resolved == "" { @@ -944,6 +935,57 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil return resolved, nil } +func sheetsDownloadRequestError(err error) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err) +} + +func sheetsDownloadHTTPStatusError(resp *larkcore.ApiResp) error { + status := resp.StatusCode + body := strings.TrimSpace(string(resp.RawBody)) + if body == "" { + body = http.StatusText(status) + } + logID := sheetsDownloadResponseLogID(resp) + if status >= http.StatusInternalServerError { + err := errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d: %s", status, body). + WithCode(status). + WithRetryable() + if logID != "" { + err = err.WithLogID(logID) + } + return err + } + if status == http.StatusTooManyRequests { + err := errs.NewAPIError(errs.SubtypeRateLimit, "download failed: HTTP %d: %s", status, body). + WithCode(status). + WithRetryable() + if logID != "" { + err = err.WithLogID(logID) + } + return err + } + subtype := errs.SubtypeUnknown + if status == http.StatusNotFound { + subtype = errs.SubtypeNotFound + } + err := errs.NewAPIError(subtype, "download failed: HTTP %d: %s", status, body).WithCode(status) + if logID != "" { + err = err.WithLogID(logID) + } + return err +} + +func sheetsDownloadResponseLogID(resp *larkcore.ApiResp) string { + logID := strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyLogId)) + if logID == "" { + logID = strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyRequestId)) + } + return logID +} + // lookupSheetIndex finds a sub-sheet by id or name and returns its canonical // id + current 0-based index. Caller is responsible for ensuring at least one // of sheetID/sheetName is non-empty. @@ -956,7 +998,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token } m, ok := out.(map[string]interface{}) if !ok { - return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output") } sheets, _ := m["sheets"].([]interface{}) for _, raw := range sheets { @@ -975,7 +1017,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) { idx, ok := util.ToFloat64(sm["index"]) if !ok { - return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field") + return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field") } return id, int(idx), nil } @@ -984,7 +1026,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token if target == "" { target = sheetName } - return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target)) + return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target) } // lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the @@ -1001,7 +1043,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok } m, ok := out.(map[string]interface{}) if !ok { - return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output") } sheets, _ := m["sheets"].([]interface{}) bestID := "" @@ -1029,7 +1071,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok } } if bestID == "" { - return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets") } return bestID, nil } diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index c7cad75f92..48baa5baf8 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -4,9 +4,14 @@ package sheets import ( + "errors" + "net/http" "strings" "testing" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -391,6 +396,92 @@ func TestWorkbookExport_DryRun(t *testing.T) { }) } +func TestWorkbookExportDownloadErrorClassification(t *testing.T) { + t.Parallel() + + t.Run("preserves typed request errors", func(t *testing.T) { + t.Parallel() + in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123) + got := sheetsDownloadRequestError(in) + if got != in { + t.Fatalf("typed error was not preserved: got %T %v", got, got) + } + }) + + t.Run("wraps raw request errors as network transport", func(t *testing.T) { + t.Parallel() + got := sheetsDownloadRequestError(errors.New("dial refused")) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected typed problem, got %T %v", got, got) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport) + } + }) + + tests := []struct { + name string + status int + wantCategory errs.Category + wantSubtype errs.Subtype + wantRetryable bool + }{ + { + name: "5xx is retryable network server error", + status: http.StatusBadGateway, + wantCategory: errs.CategoryNetwork, + wantSubtype: errs.SubtypeNetworkServer, + wantRetryable: true, + }, + { + name: "404 is API not found", + status: http.StatusNotFound, + wantCategory: errs.CategoryAPI, + wantSubtype: errs.SubtypeNotFound, + }, + { + name: "429 is retryable API rate limit", + status: http.StatusTooManyRequests, + wantCategory: errs.CategoryAPI, + wantSubtype: errs.SubtypeRateLimit, + wantRetryable: true, + }, + { + name: "other 4xx is API unknown", + status: http.StatusForbidden, + wantCategory: errs.CategoryAPI, + wantSubtype: errs.SubtypeUnknown, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{ + StatusCode: tt.status, + RawBody: []byte("body"), + Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}}, + }) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected typed problem, got %T %v", got, got) + } + if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype { + t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype) + } + if p.Code != tt.status { + t.Fatalf("code = %d, want %d", p.Code, tt.status) + } + if p.LogID != "log123" { + t.Fatalf("log_id = %q, want log123", p.LogID) + } + if p.Retryable != tt.wantRetryable { + t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable) + } + }) + } +} + // assertInputEquals compares the decoded tool input map against the wanted // fields. Extra fields in `got` are allowed (defaults, optional fields); // every key in `want` must match exactly. diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 9cb51fbae9..09030e5067 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -15,7 +15,7 @@ import ( "strconv" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" @@ -82,7 +82,7 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri return nil, err } if strings.TrimSpace(runtime.Str("range")) == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } cells, err := requireJSONArray(runtime, "cells") if err != nil { @@ -156,11 +156,11 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map } rangeStr := strings.TrimSpace(runtime.Str("range")) if rangeStr == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(rangeStr) if err != nil { - return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err) } if err := requireAnyStyleFlag(runtime); err != nil { return nil, err @@ -218,6 +218,7 @@ var CsvPut = common.Shortcut{ delete(fl.Annotations, cobra.BashCompOneRequiredFlag) } cmd.MarkFlagsOneRequired("start-cell", "range") + cmd.MarkFlagsMutuallyExclusive("start-cell", "range") }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := guardCSVValueIsNotFilePath(runtime); err != nil { @@ -324,7 +325,7 @@ func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error { if err != nil || info == nil || info.IsDir() { return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @ } - return common.FlagErrorf( + return sheetsValidationForFlag("csv", "--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)", raw, raw, ) @@ -335,7 +336,10 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string return nil, err } if strings.TrimSpace(runtime.Str("csv")) == "" { - return nil, common.FlagErrorf("--csv is required") + return nil, sheetsValidationForFlag("csv", "--csv is required") + } + if runtime.Changed("start-cell") && runtime.Changed("range") { + return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive").WithParams(sheetsInvalidParam("start-cell", "mutually exclusive"), sheetsInvalidParam("range", "mutually exclusive")) } anchor := strings.TrimSpace(runtime.Str("start-cell")) // --range is accepted as an alias for --start-cell. +csv-get and +cells-set @@ -346,23 +350,27 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string // collapses to its top-left cell; +csv-put pastes from the anchor and // auto-expands, so the range's lower-right bound is irrelevant. // - // Standalone enforces "one of --start-cell / --range" via cobra's - // MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs - // cobra, so without an explicit check the default "A1" silently wins and the - // paste lands at A1 instead of failing like the standalone command. Mirror - // the standalone contract: when --start-cell is absent, --range is mandatory. + // Standalone enforces exactly one of --start-cell / --range via cobra's + // flag groups (see PostMount). A +batch-update sub-op never runs cobra, so + // without explicit checks the default "A1" silently wins and the paste lands + // at A1 instead of failing like the standalone command. Mirror the + // standalone contract: double-set is invalid, and when --start-cell is + // absent, --range is mandatory. if !runtime.Changed("start-cell") { rng := strings.TrimSpace(runtime.Str("range")) if rng == "" { - return nil, common.FlagErrorf("--start-cell or --range is required") + return nil, common.ValidationErrorf("--start-cell or --range is required").WithParams(sheetsInvalidParam("start-cell", "required; specify exactly one"), sheetsInvalidParam("range", "required; specify exactly one")) } anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) + if idx := strings.Index(anchor, "!"); idx >= 0 { + anchor = anchor[idx+1:] + } } if anchor == "" { - return nil, common.FlagErrorf("--start-cell is required") + return nil, sheetsValidationForFlag("start-cell", "--start-cell is required") } if _, _, ok := splitCellRef(anchor); !ok { - return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor) + return nil, sheetsValidationForFlag("start-cell", "--start-cell %q must be a single cell ref (e.g. A1)", anchor) } input := map[string]interface{}{ "excel_id": token, @@ -433,11 +441,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s } rangeStr := strings.TrimSpace(runtime.Str("range")) if rangeStr == "" { - return nil, common.FlagErrorf("--range is required") + return nil, sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(rangeStr) if err != nil { - return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err) } validation, err := buildDropdownValidation(runtime) if err != nil { @@ -496,7 +504,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { return nil, err } if len(colors) > sourceSize { - return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) + return nil, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } dv["highlight_colors"] = colors } @@ -518,9 +526,9 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) sourceRange := strings.TrimSpace(runtime.Str("source-range")) switch { case optsRaw != "" && sourceRange != "": - return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one") + return 0, nil, common.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one").WithParams(sheetsInvalidParam("options", "mutually exclusive"), sheetsInvalidParam("source-range", "mutually exclusive")) case optsRaw == "" && sourceRange == "": - return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required") + return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required").WithParams(sheetsInvalidParam("options", "required; specify exactly one"), sheetsInvalidParam("source-range", "required; specify exactly one")) case optsRaw != "": options, err := requireJSONArray(runtime, "options") if err != nil { @@ -533,7 +541,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) default: // sourceRange != "" rows, cols, err := rangeDimensions(sourceRange) if err != nil { - return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err) + return 0, nil, sheetsValidationForFlag("source-range", "--source-range %q: %v", sourceRange, err) } return rows * cols, map[string]interface{}{ "type": "listFromRange", @@ -558,7 +566,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) { return 0, err } if len(colors) > sourceSize { - return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) + return 0, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } } return sourceSize, nil @@ -731,18 +739,18 @@ var CellsSetImage = common.Shortcut{ } r := strings.TrimSpace(runtime.Str("range")) if r == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(r) if err != nil { - return common.FlagErrorf("--range %q: %v", r, err) + return sheetsValidationForFlag("range", "--range %q: %v", r, err) } if rows != 1 || cols != 1 { - return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) + return sheetsValidationForFlag("range", "--range %q must be exactly one cell (got %d×%d)", r, rows, cols) } imgPath := strings.TrimSpace(runtime.Str("image")) if imgPath == "" { - return common.FlagErrorf("--image is required") + return sheetsValidationForFlag("image", "--image is required") } // Validate path safety here (not just at Execute) so --dry-run also // rejects unsafe paths instead of giving a false-positive preview. @@ -750,7 +758,9 @@ var CellsSetImage = common.Shortcut{ // not existence, so legitimate relative paths still dry-run cleanly; // the Execute-time Stat below still reports a missing/unreadable file. if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err). + WithParam("--image"). + WithCause(err) } return nil }, @@ -806,16 +816,18 @@ var CellsSetImage = common.Shortcut{ } info, err := runtime.FileIO().Stat(imgPath) if err != nil { - return common.WrapInputStatError(err) + return sheetsInputStatError("image", err) } imgFile, err := runtime.FileIO().Open(imgPath) if err != nil { - return common.WrapInputStatError(err) + return sheetsInputStatError("image", err) } imgCfg, _, err := image.DecodeConfig(imgFile) imgFile.Close() if err != nil { - return fmt.Errorf("decode image dimensions: %w", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "decode image dimensions: %s", err). + WithParam("--image"). + WithCause(err) } fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: imgPath, @@ -844,7 +856,7 @@ var CellsSetImage = common.Shortcut{ sheetSelectorForToolInput(setCellInput, sheetID, sheetName) setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput) if err != nil { - return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err) + return wrapCellsSetImageWriteError(err, fileToken) } runtime.Out(map[string]interface{}{ "file_token": fileToken, @@ -857,3 +869,18 @@ var CellsSetImage = common.Shortcut{ "--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.", }, } + +func wrapCellsSetImageWriteError(err error, fileToken string) error { + hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken) + if p, ok := errs.ProblemOf(err); ok { + if strings.TrimSpace(p.Hint) != "" { + p.Hint += "\n" + hint + } else { + p.Hint = hint + } + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err). + WithHint(hint). + WithCause(err) +} diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go index eb43684763..78560d88bc 100644 --- a/shortcuts/sheets/sheet_ai_api.go +++ b/shortcuts/sheets/sheet_ai_api.go @@ -8,7 +8,7 @@ import ( "encoding/json" "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -42,7 +42,7 @@ func toolInvokePath(token string, kind ToolKind) string { func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) { inputJSON, err := json.Marshal(input) if err != nil { - return nil, fmt.Errorf("encode tool input: %w", err) + return nil, errs.NewInternalError(errs.SubtypeSDKError, "encode tool input: %v", err).WithCause(err) } return map[string]interface{}{ "tool_name": toolName, @@ -77,13 +77,14 @@ func callTool( envelope, ok := raw.(map[string]interface{}) if !ok { - return nil, output.Errorf(output.ExitAPI, "tool_response", + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tool %q: unexpected non-JSON-object response: %v", toolName, raw) } code, _ := util.ToFloat64(envelope["code"]) if code != 0 { msg, _ := envelope["msg"].(string) - return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"]) + return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg). + WithCode(int(code)) } data, _ := envelope["data"].(map[string]interface{}) rawOutput, _ := data["output"].(string) @@ -93,8 +94,8 @@ func callTool( var out interface{} if err := json.Unmarshal([]byte(rawOutput), &out); err != nil { - return nil, output.Errorf(output.ExitAPI, "tool_output", - "tool %q returned invalid JSON output: %v", toolName, err) + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "tool %q returned invalid JSON output: %v", toolName, err).WithCause(err) } return out, nil } diff --git a/shortcuts/sheets/validation_params_test.go b/shortcuts/sheets/validation_params_test.go new file mode 100644 index 0000000000..c12177737c --- /dev/null +++ b/shortcuts/sheets/validation_params_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestValidationParamMetadata locks the structured-param contract across the +// sheets domain: a validation failure must carry typed metadata +// (category/subtype via errs.ProblemOf) and tag the offending flag(s) via +// Param/Params, so consumers (agents) know which flag to fix without parsing +// the human message. Covers the four representative shapes — required, +// at-least-one, mutually-exclusive, and local-input-file errors. +func TestValidationParamMetadata(t *testing.T) { + t.Parallel() + + // assertValidationProblem checks the typed metadata (category + subtype) via + // errs.ProblemOf and returns the ValidationError for param-level assertions. + assertValidationProblem := func(t *testing.T, err error) *errs.ValidationError { + t.Helper() + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T %v, want typed problem", err, err) + } + if p.Category != errs.CategoryValidation { + t.Errorf("category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error = %T, want *errs.ValidationError", err) + } + return ve + } + + assertParam := func(t *testing.T, err error, want string) { + t.Helper() + if ve := assertValidationProblem(t, err); ve.Param != want { + t.Fatalf("param = %q, want %q", ve.Param, want) + } + } + + assertParams := func(t *testing.T, err error, want ...string) { + t.Helper() + ve := assertValidationProblem(t, err) + got := map[string]bool{} + for _, p := range ve.Params { + got[p.Name] = true + } + if len(ve.Params) != len(want) { + t.Fatalf("params = %#v, want %v", ve.Params, want) + } + for _, w := range want { + if !got[w] { + t.Fatalf("params = %#v, missing %q", ve.Params, w) + } + } + } + + t.Run("required flag tags single param", func(t *testing.T) { + t.Parallel() + // --image-token satisfies the image source, so the missing --image-name + // trips the single-flag required check routed through sheetsValidationForFlag. + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{"image-token": "tok"}) + _, err := floatImageProperties(fv, "", true) + assertParam(t, err, "--image-name") + }) + + t.Run("at-least-one tags every candidate flag", func(t *testing.T) { + t.Parallel() + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{}) + _, err := floatImageProperties(fv, "", true) + assertParams(t, err, "--image", "--image-token", "--image-uri") + }) + + t.Run("mutually exclusive tags only the conflicting flags", func(t *testing.T) { + t.Parallel() + // Only --image and --image-token are set; the param list must not blame + // the untouched --image-uri. + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{ + "image": "a.png", + "image-token": "tok", + }) + _, err := floatImageProperties(fv, "", true) + assertParams(t, err, "--image", "--image-token") + }) + + t.Run("local input file error tags flag and preserves cause", func(t *testing.T) { + t.Parallel() + cause := errors.New("stat failed") + err := sheetsInputStatError("image", cause) + assertParam(t, err, "--image") + if !errors.Is(err, cause) { + t.Errorf("expected the original stat error preserved as the cause") + } + }) +}