diff --git a/cmd/api/api.go b/cmd/api/api.go index 2964d80af..83e963059 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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 } diff --git a/cmd/service/service.go b/cmd/service/service.go index 56392c48b..20c5e025f 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -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 } diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 65c639b46..817aad446 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -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) } diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index fed7f927c..44a7a51d7 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -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 diff --git a/internal/cmdutil/resolve.go b/internal/cmdutil/resolve.go index a40475680..3d0d12d4b 100644 --- a/internal/cmdutil/resolve.go +++ b/internal/cmdutil/resolve.go @@ -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 +// - "@" → read all bytes from the file at via fileIO +// - "@@..." → strip leading @ (escape for a literal @-prefixed value) +// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility) +// - other → return as-is +// +// fileIO is required for "@" inputs and goes through path validation +// (SafeInputPath); pass nil only when callers know "@" inputs are not possible. // -// 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) { +// 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) +} diff --git a/internal/cmdutil/resolve_test.go b/internal/cmdutil/resolve_test.go index c83f188cf..a68a996ec 100644 --- a/internal/cmdutil/resolve_test.go +++ b/internal/cmdutil/resolve_test.go @@ -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,31 +111,153 @@ 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 != `{"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 != "@something" { - t.Errorf("got %q, want %q", got, "@something") + 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) + } + if len(got) != 2 { + t.Errorf("got %d keys, want 2", len(got)) + } +} + +// 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 diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e68352f46..ed136c849 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -882,17 +882,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 diff --git a/shortcuts/common/runner_input_test.go b/shortcuts/common/runner_input_test.go index 058e28708..47a42c138 100644 --- a/shortcuts/common/runner_input_test.go +++ b/shortcuts/common/runner_input_test.go @@ -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) } }