feat(cmdutil): support @file for params and data (#724)

* feat(cmdutil): support @file for --params/--data (issue #705)

Inline JSON values for --params/--data are mangled by Windows
PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape
hatch but supports just one flag at a time.

Extend ResolveInput to accept @<path> (read JSON from a file) and
@@... (escape for a literal @-prefixed value), mirroring the
shortcuts framework's resolveInputFlags semantics. With this, both
--params and --data can be sourced from files in the same call,
sidestepping shell quoting on every platform.

- internal/cmdutil/resolve.go: add @path / @@ handling, trim file
  content like stdin does, error on empty path or empty file
- internal/cmdutil/resolve_test.go: cover file read, whitespace
  trim, missing file, empty path, empty content, @@ escape, plus
  ParseJSONMap / ParseOptionalBody integration through @file
- cmd/api/api.go, cmd/service/service.go: update --params/--data
  help text to mention @file

Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82

* refactor(cmdutil): route @file through fileio.FileIO abstraction

The first cut of @file support called os.ReadFile directly inside
ResolveInput, bypassing the codebase's fileio.FileIO abstraction
(SafeInputPath validation, pluggable provider). That diverged from
how every other file-reading path works: BuildFormdata for --file
uploads and the shortcuts framework's resolveInputFlags both go
through fileio.FileIO.Open with explicit fileio.ErrPathValidation
handling.

Re-route @file through the same path:

- ResolveInput, ParseJSONMap, ParseOptionalBody now take a
  fileio.FileIO; @path uses fileIO.Open which goes through
  SafeInputPath (control-char rejection, abs-path rejection,
  symlink-escape check) — same security posture as --file
- cmd/api and cmd/service callsites pass
  Factory.ResolveFileIO(ctx); the upload path now reuses the
  resolved fileIO instead of resolving twice
- Path-validation errors surface as
  `--params: invalid file path "...": ...` distinct from
  `--params: cannot read file "...": ...` for genuine I/O errors
- Nil fileIO with an @path returns a clear
  "file input (@path) is not available" error
- Tests use localfileio.LocalFileIO with TestChdir(t, dir),
  matching the existing fileupload_test.go pattern; absolute-path
  rejection and nil-fileIO are covered

This makes the feature behave identically under any FileIO
provider (including server mode) instead of being silently bound
to the local filesystem.

Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9

* refactor(cmdutil): share at-file input handling

Change-Id: I92a6eb6ea8fd02054bf8f4925cd81807449d5e51
This commit is contained in:
liangshuo-1
2026-04-30 15:34:45 +08:00
committed by GitHub
parent 4d68e09537
commit c100ca049e
8 changed files with 254 additions and 64 deletions

View File

@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -7,19 +7,20 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data, nil)
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label, nil)
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -4,19 +4,27 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
if raw == "" {
return "", nil
}
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return s, nil
}
// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}
// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return raw, nil
}
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}

View File

@@ -5,12 +5,15 @@ package cmdutil
import (
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
_, err := ResolveInput("-", strings.NewReader(""), nil)
if err == nil {
t.Error("expected error for empty stdin")
}
@@ -44,21 +47,21 @@ type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
_, err := ResolveInput("-", errorReader{}, nil)
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
_, err := ResolveInput("-", nil, nil)
if err == nil {
t.Error("expected error for nil stdin")
}
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
got, err := ResolveInput(tt.in, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
got, err := ResolveInput("", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
func TestResolveInput_AtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@params.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
if got != `{"folder_token":"abc123"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@p.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_NotFound(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
_, err := ResolveInput("@missing.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
fio := &localfileio.LocalFileIO{}
_, err := ResolveInput("@", nil, fio)
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("expected empty-path error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := ResolveInput("@empty.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Errorf("expected empty-file error, got: %v", err)
}
}
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
// When fileIO is nil, @path must error rather than silently fall back.
_, err := ResolveInput("@params.json", nil, nil)
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Errorf("expected unavailable error, got: %v", err)
}
}
func TestResolveInput_DoubleAtEscape(t *testing.T) {
got, err := ResolveInput("@@literal", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@literal" {
t.Errorf("got %q, want %q", got, "@literal")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
got, err := ParseJSONMap("-", "--params", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
}
}
// Integration: @file flows through ParseJSONMap correctly.
func TestParseJSONMap_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
if got["folder_token"] != "abc123" {
t.Errorf("got %v, want folder_token=abc123", got)
}
}
func TestParseOptionalBody_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
got, err := ParseOptionalBody("POST", "-", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -895,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
f, err := rctx.FileIO().Open(path)
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
}
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue

View File

@@ -5,7 +5,6 @@ package common
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
func TestResolveInputFlags_File(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
content := "## Hello\n\nThis is **markdown** from a file.\n"
fpath := filepath.Join(dir, "test.md")
os.WriteFile(fpath, []byte(content), 0644)
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestResolveInputFlags_EmptyInput(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
func TestResolveInputFlags_FileNotFound(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
if !strings.Contains(err.Error(), "file path cannot be empty") {
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
}