From 359d6ed6be4f6548ca6c27494cee83758541bd3e Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 29 Aug 2026 18:27:26 -0600 Subject: [PATCH] cli: compose strings in external editor --- internal/cli/string_create.go | 56 ++++++++- internal/cli/string_create_test.go | 179 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 6 deletions(-) diff --git a/internal/cli/string_create.go b/internal/cli/string_create.go index 2a6b438..327e905 100644 --- a/internal/cli/string_create.go +++ b/internal/cli/string_create.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "io" "os" @@ -8,6 +9,7 @@ import ( "unicode/utf8" "github.com/alyraffauf/tg/internal/app" + xterm "github.com/charmbracelet/x/term" "github.com/spf13/cobra" ) @@ -17,7 +19,16 @@ const bytesPerMiB = 1 << 20 // limit for a text record. const maxStringContents = 100 * bytesPerMiB -func newStringCreateCommand(service *app.Service) *cobra.Command { +var isTerminalInput = func(input io.Reader) bool { + file, ok := input.(*os.File) + return ok && xterm.IsTerminal(file.Fd()) +} + +type stringCreateService interface { + CreateString(context.Context, app.CreateStringInput) (*app.CreatedRecordResult, error) +} + +func newStringCreateCommand(service stringCreateService) *cobra.Command { var description, filenameFlag string command := &cobra.Command{ @@ -25,16 +36,39 @@ func newStringCreateCommand(service *app.Service) *cobra.Command { Short: "Create a string on your Tangled account", Long: `Create a string on your Tangled account. -Contents are read from the given file, or from standard input if no file -is given (or the file is "-"). When reading from standard input, ---filename is required. Contents must be valid UTF-8, at most 100 MiB. +Contents are read from the given file. With no file, tg opens $EDITOR when +standard input is a terminal and otherwise reads standard input. The file +"-" always reads standard input. --filename is required when no file is +given. Contents must be valid UTF-8, at most 100 MiB. Requires authentication (run "tg auth login" first).`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - - contents, filename, err := stringContents(cmd.InOrStdin(), args, filenameFlag) + input := cmd.InOrStdin() + draft := editedDraft{} + var contents, filename string + var err error + if len(args) == 0 && isTerminalInput(input) { + if filenameFlag == "" { + return fmt.Errorf("provide --filename when composing in an editor") + } + draft, err = editDraft(ctx, "string", "tg-string-*", "", input, cmd.OutOrStdout(), cmd.ErrOrStderr()) + if err != nil { + return err + } + if draft.Contents == "" { + removeDraft(draft.Path, "string", cmd.ErrOrStderr()) + fmt.Fprintln(cmd.ErrOrStderr(), "String creation canceled.") + return nil + } + contents, filename, err = validateStringContents(draft.Contents, filenameFlag) + } else { + contents, filename, err = stringContents(input, args, filenameFlag) + } if err != nil { + if draft.Path != "" { + return fmt.Errorf("%w; draft saved to %s", err, draft.Path) + } return err } @@ -44,8 +78,14 @@ Requires authentication (run "tg auth login" first).`, Contents: contents, }) if err != nil { + if draft.Path != "" { + return fmt.Errorf("%w; draft saved to %s", err, draft.Path) + } return err } + if draft.Path != "" { + removeDraft(draft.Path, "string", cmd.ErrOrStderr()) + } return output(cmd, result, func(result *app.CreatedRecordResult) { fmt.Fprintf(cmd.OutOrStdout(), "Created string %s\n", result.URI) }) @@ -81,6 +121,10 @@ func stringContents(stdin io.Reader, args []string, filenameFlag string) (conten } } + return validateStringContents(contents, filename) +} + +func validateStringContents(contents, filename string) (string, string, error) { if contents == "" { return "", "", fmt.Errorf("contents must not be empty") } diff --git a/internal/cli/string_create_test.go b/internal/cli/string_create_test.go index 554d228..2f29246 100644 --- a/internal/cli/string_create_test.go +++ b/internal/cli/string_create_test.go @@ -1,10 +1,15 @@ package cli import ( + "context" + "errors" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/alyraffauf/tg/internal/app" ) func TestStringContents(t *testing.T) { @@ -72,3 +77,177 @@ func TestStringContents(t *testing.T) { }) } } + +func TestStringCreateOpensEditorForTerminalInput(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return true } + pathLog := filepath.Join(t.TempDir(), "path") + t.Setenv("EDITOR", writeDraftEditor(t, pathLog, "package main\n", 0)) + service := &testStringCreateService{} + command := newStringCreateCommand(service) + command.SetArgs([]string{"--filename", "main.go"}) + command.SetIn(strings.NewReader("ignored terminal input")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.input.Filename != "main.go" || service.input.Contents != "package main\n" { + t.Fatalf("CreateString() input = %+v", service.input) + } + pathBytes, err := os.ReadFile(pathLog) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(string(pathBytes)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("submitted draft still exists: %v", err) + } +} + +func TestStringCreateReadsNonterminalInputWithoutEditor(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return false } + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "missing-editor")) + service := &testStringCreateService{} + command := newStringCreateCommand(service) + command.SetArgs([]string{"--filename", "stdin.md"}) + command.SetIn(strings.NewReader("piped contents")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.input.Contents != "piped contents" { + t.Fatalf("contents = %q", service.input.Contents) + } +} + +func TestStringCreateExplicitInputsDoNotOpenEditorForTerminalInput(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return true } + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "missing-editor")) + + t.Run("standard input", func(t *testing.T) { + service := &testStringCreateService{} + command := newStringCreateCommand(service) + command.SetArgs([]string{"-", "--filename", "stdin.md"}) + command.SetIn(strings.NewReader("standard input contents")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.input.Filename != "stdin.md" || service.input.Contents != "standard input contents" { + t.Fatalf("CreateString() input = %+v", service.input) + } + }) + + t.Run("file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.md") + if err := os.WriteFile(path, []byte("file contents"), 0o600); err != nil { + t.Fatal(err) + } + service := &testStringCreateService{} + command := newStringCreateCommand(service) + command.SetArgs([]string{path}) + command.SetIn(strings.NewReader("ignored terminal input")) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.input.Filename != "file.md" || service.input.Contents != "file contents" { + t.Fatalf("CreateString() input = %+v", service.input) + } + }) +} + +func TestStringCreateRetainsDraftWhenSubmissionFails(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return true } + pathLog := filepath.Join(t.TempDir(), "path") + t.Setenv("EDITOR", writeDraftEditor(t, pathLog, "unsubmitted contents\n", 0)) + service := &testStringCreateService{createError: errors.New("network unavailable")} + command := newStringCreateCommand(service) + command.SetArgs([]string{"--filename", "draft.txt"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "network unavailable; draft saved to") { + t.Fatalf("Execute() error = %v", err) + } + pathBytes, readError := os.ReadFile(pathLog) + if readError != nil { + t.Fatal(readError) + } + path := string(pathBytes) + t.Cleanup(func() { _ = os.Remove(path) }) + if _, statError := os.Stat(path); statError != nil { + t.Fatalf("saved draft %q: %v", path, statError) + } +} + +func TestStringCreateRequiresFilenameBeforeOpeningEditor(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return true } + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "missing-editor")) + command := newStringCreateCommand(nil) + command.SetErr(io.Discard) + + err := command.Execute() + if err == nil || err.Error() != "provide --filename when composing in an editor" { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestStringCreateCancelsEmptyEditorDraft(t *testing.T) { + originalIsTerminalInput := isTerminalInput + t.Cleanup(func() { isTerminalInput = originalIsTerminalInput }) + isTerminalInput = func(io.Reader) bool { return true } + pathLog := filepath.Join(t.TempDir(), "path") + t.Setenv("EDITOR", writeDraftEditor(t, pathLog, "", 0)) + service := &testStringCreateService{} + command := newStringCreateCommand(service) + command.SetArgs([]string{"--filename", "empty.txt"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.calls != 0 { + t.Fatalf("CreateString() calls = %d, want 0", service.calls) + } + pathBytes, err := os.ReadFile(pathLog) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(string(pathBytes)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canceled draft still exists: %v", err) + } +} + +type testStringCreateService struct { + createError error + input app.CreateStringInput + calls int +} + +func (service *testStringCreateService) CreateString(_ context.Context, input app.CreateStringInput) (*app.CreatedRecordResult, error) { + service.calls++ + service.input = input + if service.createError != nil { + return nil, service.createError + } + return &app.CreatedRecordResult{URI: "at://did:plc:owner/sh.tangled.string/123"}, nil +} -- 2.51.2