From 35d2660162215b1df4262662e30a75bd9e159bf4 Mon Sep 17 00:00:00 2001 From: rektdeckard Date: Mon, 30 Dec 2024 12:23:59 -0700 Subject: [PATCH] fix(cmd): relocate temp files across filesystem boundaries --- cmd/lk/app.go | 7 +-- cmd/lk/cloud.go | 5 +- cmd/lk/dispatch.go | 11 ++-- cmd/lk/egress.go | 7 +-- cmd/lk/ingress.go | 7 +-- cmd/lk/project.go | 3 +- cmd/lk/proto.go | 7 +-- cmd/lk/replay.go | 3 +- cmd/lk/room.go | 17 ++++--- cmd/lk/token.go | 3 +- cmd/lk/utils.go | 106 +-------------------------------------- cmd/lk/utils_test.go | 46 ----------------- pkg/util/fs.go | 103 +++++++++++++++++++++++++++++++++++++ pkg/util/json.go | 11 ++++ pkg/util/strings.go | 77 ++++++++++++++++++++++++++++ pkg/util/strings_test.go | 51 +++++++++++++++++++ 16 files changed, 283 insertions(+), 181 deletions(-) create mode 100644 pkg/util/fs.go create mode 100644 pkg/util/json.go create mode 100644 pkg/util/strings.go create mode 100644 pkg/util/strings_test.go diff --git a/cmd/lk/app.go b/cmd/lk/app.go index a4d24957..c36f2ff7 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -27,6 +27,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/livekit/livekit-cli/pkg/bootstrap" "github.com/livekit/livekit-cli/pkg/config" + "github.com/livekit/livekit-cli/pkg/util" "github.com/urfave/cli/v3" ) @@ -192,12 +193,12 @@ func listTemplates(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(templates) + util.PrintJSON(templates) } else { const maxDescLength = 64 table := CreateTable().Headers("Template", "Description").BorderRow(true) for _, t := range templates { - desc := strings.Join(wrapToLines(t.Desc, maxDescLength), "\n") + desc := strings.Join(util.WrapToLines(t.Desc, maxDescLength), "\n") url := theme.Focused.Title.Render(t.URL) tags := theme.Help.ShortDesc.Render("#" + strings.Join(t.Tags, " #")) table.Row( @@ -355,7 +356,7 @@ func cloneTemplate(_ context.Context, cmd *cli.Command, url, appName string) err var stderr string var cmdErr error - tempName, relocate, cleanup := useTempPath(appName) + tempName, relocate, cleanup := util.UseTempPath(appName) defer cleanup() if err := spinner.New(). diff --git a/cmd/lk/cloud.go b/cmd/lk/cloud.go index 4760aefd..d71670a4 100644 --- a/cmd/lk/cloud.go +++ b/cmd/lk/cloud.go @@ -30,6 +30,7 @@ import ( authutil "github.com/livekit/livekit-cli/pkg/auth" "github.com/livekit/livekit-cli/pkg/config" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/auth" ) @@ -250,7 +251,7 @@ func requireToken(_ context.Context, cmd *cli.Command) (string, error) { // construct a token from the chosen project, using the hashed secret as the identity // as a means of preventing any old token generated with this key/secret pair from // deleting it - hash, err := hashString(project.APISecret) + hash, err := util.HashString(project.APISecret) if err != nil { return "", err } @@ -324,7 +325,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { } // make sure name is unique - name, err := URLSafeName(ak.URL) + name, err := util.URLSafeName(ak.URL) if err != nil { return err } diff --git a/cmd/lk/dispatch.go b/cmd/lk/dispatch.go index 1bbcda23..e529b19e 100644 --- a/cmd/lk/dispatch.go +++ b/cmd/lk/dispatch.go @@ -7,6 +7,7 @@ import ( "github.com/urfave/cli/v3" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils" lksdk "github.com/livekit/server-sdk-go/v2" @@ -118,14 +119,14 @@ func listDispatchAndPrint(cmd *cli.Command, req *livekit.ListAgentDispatchReques return cli.ShowSubcommandHelp(cmd) } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } res, err := dispatchClient.ListDispatch(context.Background(), req) if err != nil { return err } if cmd.Bool("json") { - PrintJSON(res) + util.PrintJSON(res) } else { table := CreateTable(). Headers("DispatchID", "Room", "AgentName", "Metadata") @@ -164,7 +165,7 @@ func createAgentDispatch(ctx context.Context, cmd *cli.Command) error { return errors.New("agent-name is required") } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } info, err := dispatchClient.CreateDispatch(context.Background(), req) @@ -173,7 +174,7 @@ func createAgentDispatch(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(info) + util.PrintJSON(info) } else { fmt.Printf("Dispatch created: %v\n", info) } @@ -204,7 +205,7 @@ func deleteAgentDispatch(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(info) + util.PrintJSON(info) } else { fmt.Printf("Dispatch deleted: %v\n", info) } diff --git a/cmd/lk/egress.go b/cmd/lk/egress.go index 53d87cc5..44ba6f8a 100644 --- a/cmd/lk/egress.go +++ b/cmd/lk/egress.go @@ -36,6 +36,7 @@ import ( lksdk "github.com/livekit/server-sdk-go/v2" "github.com/livekit/livekit-cli/pkg/loadtester" + "github.com/livekit/livekit-cli/pkg/util" ) type egressType string @@ -403,7 +404,7 @@ func handleEgressStart(ctx context.Context, cmd *cli.Command) error { case string(EgressTypeTrackComposite): return startTrackCompositeEgress(ctx, cmd) default: - return errors.New("unrecognized egress type " + wrapWith("\"")(cmd.String("type"))) + return errors.New("unrecognized egress type " + util.WrapWith("\"")(cmd.String("type"))) } } @@ -568,7 +569,7 @@ func unmarshalEgressRequest(cmd *cli.Command, req proto.Message) error { } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } return nil } @@ -597,7 +598,7 @@ func listEgress(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(items) + util.PrintJSON(items) } else { table := CreateTable(). Headers("EgressID", "Status", "Type", "Source", "Started At", "Error") diff --git a/cmd/lk/ingress.go b/cmd/lk/ingress.go index 5dfa961c..e9b7d535 100644 --- a/cmd/lk/ingress.go +++ b/cmd/lk/ingress.go @@ -20,6 +20,7 @@ import ( "github.com/urfave/cli/v3" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/livekit" lksdk "github.com/livekit/server-sdk-go/v2" ) @@ -177,7 +178,7 @@ func createIngress(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } info, err := ingressClient.CreateIngress(context.Background(), req) @@ -196,7 +197,7 @@ func updateIngress(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } info, err := ingressClient.UpdateIngress(context.Background(), req) @@ -221,7 +222,7 @@ func listIngress(ctx context.Context, cmd *cli.Command) error { // This is inconsistent with other commands in which verbose is used for debug info, but is // kept for compatibility with the previous behavior. if cmd.Bool("verbose") || cmd.Bool("json") { - PrintJSON(res) + util.PrintJSON(res) } else { table := CreateTable(). Headers("IngressID", "Name", "Room", "StreamKey", "URL", "Status", "Error") diff --git a/cmd/lk/project.go b/cmd/lk/project.go index f7eb0d63..16579b9c 100644 --- a/cmd/lk/project.go +++ b/cmd/lk/project.go @@ -27,6 +27,7 @@ import ( "github.com/urfave/cli/v3" "github.com/livekit/livekit-cli/pkg/config" + "github.com/livekit/livekit-cli/pkg/util" ) var ( @@ -248,7 +249,7 @@ func listProjects(ctx context.Context, cmd *cli.Command) error { selectedStyle := theme.Focused.Title.Padding(0, 1) if cmd.Bool("json") { - PrintJSON(cliConfig.Projects) + util.PrintJSON(cliConfig.Projects) } else { table := CreateTable(). StyleFunc(func(row, col int) lipgloss.Style { diff --git a/cmd/lk/proto.go b/cmd/lk/proto.go index 8b4ce444..eac9c4d5 100644 --- a/cmd/lk/proto.go +++ b/cmd/lk/proto.go @@ -21,6 +21,7 @@ import ( "os" "reflect" + "github.com/livekit/livekit-cli/pkg/util" "github.com/urfave/cli/v3" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -108,7 +109,7 @@ func createAndPrint[T any, P protoTypeValidator[T], R any]( return fmt.Errorf("could not read request: %w", err) } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } if err = req.Validate(); err != nil { return err @@ -132,7 +133,7 @@ func createAndPrintLegacy[T any, P protoType[T], R any]( return err } if cmd.Bool("verbose") { - PrintJSON(req) + util.PrintJSON(req) } info, err := create(ctx, req) if err != nil { @@ -192,7 +193,7 @@ func listAndPrint[ } if cmd.Bool("json") { - PrintJSON(res) + util.PrintJSON(res) } else { table := CreateTable(). Headers(header...) diff --git a/cmd/lk/replay.go b/cmd/lk/replay.go index 0be61f93..c6093842 100644 --- a/cmd/lk/replay.go +++ b/cmd/lk/replay.go @@ -9,6 +9,7 @@ import ( "github.com/urfave/cli/v3" authutil "github.com/livekit/livekit-cli/pkg/auth" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/replay" lksdk "github.com/livekit/server-sdk-go/v2" @@ -127,7 +128,7 @@ func listReplays(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(res.Replays) + util.PrintJSON(res.Replays) } else { table := CreateTable().Headers("ReplayID") for _, info := range res.Replays { diff --git a/cmd/lk/room.go b/cmd/lk/room.go index 6846bd6f..99754aea 100644 --- a/cmd/lk/room.go +++ b/cmd/lk/room.go @@ -27,6 +27,7 @@ import ( "github.com/urfave/cli/v3" "google.golang.org/protobuf/encoding/protojson" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/livekit" @@ -635,7 +636,7 @@ func createRoom(ctx context.Context, cmd *cli.Command) error { return err } - PrintJSON(room) + util.PrintJSON(room) return nil } @@ -644,7 +645,7 @@ func listRooms(ctx context.Context, cmd *cli.Command) error { if cmd.Bool("verbose") && len(names) > 0 { fmt.Printf( "Querying rooms matching %s", - strings.Join(mapStrings(names, wrapWith("\"")), ", "), + strings.Join(util.MapStrings(names, util.WrapWith("\"")), ", "), ) } @@ -659,7 +660,7 @@ func listRooms(ctx context.Context, cmd *cli.Command) error { } if cmd.Bool("json") { - PrintJSON(res) + util.PrintJSON(res) } else { table := CreateTable().Headers("RoomID", "Name", "Participants", "Publishers") for _, rm := range res.Rooms { @@ -688,7 +689,7 @@ func _deprecatedListRoom(ctx context.Context, cmd *cli.Command) error { return nil } rm := res.Rooms[0] - PrintJSON(rm) + util.PrintJSON(rm) return nil } @@ -720,7 +721,7 @@ func updateRoomMetadata(ctx context.Context, cmd *cli.Command) error { } fmt.Println("Updated room metadata") - PrintJSON(res) + util.PrintJSON(res) return nil } @@ -735,7 +736,7 @@ func _deprecatedUpdateRoomMetadata(ctx context.Context, cmd *cli.Command) error } fmt.Println("Updated room metadata") - PrintJSON(res) + util.PrintJSON(res) return nil } @@ -934,7 +935,7 @@ func getParticipant(ctx context.Context, cmd *cli.Command) error { return err } - PrintJSON(res) + util.PrintJSON(res) return nil } @@ -971,7 +972,7 @@ func updateParticipant(ctx context.Context, cmd *cli.Command) error { } fmt.Println("updating participant...") - PrintJSON(req) + util.PrintJSON(req) if _, err := roomClient.UpdateParticipant(ctx, req); err != nil { return err } diff --git a/cmd/lk/token.go b/cmd/lk/token.go index 9f607e05..756944df 100644 --- a/cmd/lk/token.go +++ b/cmd/lk/token.go @@ -25,6 +25,7 @@ import ( "github.com/charmbracelet/huh" "github.com/urfave/cli/v3" + "github.com/livekit/livekit-cli/pkg/util" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" ) @@ -354,7 +355,7 @@ func createToken(ctx context.Context, c *cli.Command) error { } fmt.Println("Token grants:") - PrintJSON(grant) + util.PrintJSON(grant) fmt.Println() fmt.Println("Access token:", token) return nil diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 19868899..588ac8cc 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -15,15 +15,9 @@ package main import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" "fmt" - "net/url" "os" - "path" - "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -31,10 +25,8 @@ import ( "github.com/twitchtv/twirp" "github.com/urfave/cli/v3" - "github.com/livekit/protocol/utils/guid" - "github.com/livekit/protocol/utils/interceptors" - "github.com/livekit/livekit-cli/pkg/config" + "github.com/livekit/protocol/utils/interceptors" ) var ( @@ -146,80 +138,6 @@ func extractFlagOrArg(c *cli.Command, flag string) (string, error) { return value, nil } -func mapStrings(strs []string, fn func(string) string) []string { - res := make([]string, len(strs)) - for i, str := range strs { - res[i] = fn(str) - } - return res -} - -func wrapWith(wrap string) func(string) string { - return func(str string) string { - return wrap + str + wrap - } -} - -func ellipsizeTo(str string, maxLength int) string { - if len(str) <= maxLength { - return str - } - ellipsis := "..." - contentLen := max(0, min(len(str), maxLength-len(ellipsis))) - return str[:contentLen] + ellipsis -} - -func wrapToLines(input string, maxLineLength int) []string { - words := strings.Fields(input) - var lines []string - var currentLine strings.Builder - - for _, word := range words { - if currentLine.Len()+len(word)+1 > maxLineLength { - lines = append(lines, currentLine.String()) - currentLine.Reset() - } - if currentLine.Len() > 0 { - currentLine.WriteString(" ") - } - currentLine.WriteString(word) - } - - if currentLine.Len() > 0 { - lines = append(lines, currentLine.String()) - } - - return lines -} - -// Provides a temporary path, a function to relocate it to a permanent path, -// and a function to clean up the temporary path that should always be deferred -// in the case of a failure to relocate. -func useTempPath(permanentPath string) (string, func() error, func() error) { - tempPath := path.Join(os.TempDir(), guid.New("LK_")) - relocate := func() error { - return os.Rename(tempPath, permanentPath) - } - cleanup := func() error { - return os.RemoveAll(tempPath) - } - return tempPath, relocate, cleanup -} - -func hashString(str string) (string, error) { - hash := sha256.New() - if _, err := hash.Write([]byte(str)); err != nil { - return "", err - } - bytes := hash.Sum(nil) - return hex.EncodeToString(bytes), nil -} - -func PrintJSON(obj any) { - txt, _ := json.MarshalIndent(obj, "", " ") - fmt.Println(string(txt)) -} - func CreateTable() *table.Table { baseStyle := theme.Form.Foreground(fg).Padding(0, 1) headerStyle := baseStyle.Bold(true) @@ -239,15 +157,6 @@ func CreateTable() *table.Table { return t } -func ExpandUser(p string) string { - if strings.HasPrefix(p, "~") { - home, _ := os.UserHomeDir() - return filepath.Join(home, p[1:]) - } - - return p -} - type loadParams struct { requireURL bool } @@ -340,16 +249,3 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf // cannot happen return pc, nil } - -func URLSafeName(projectURL string) (string, error) { - parsed, err := url.Parse(projectURL) - if err != nil { - return "", errors.New("invalid URL") - } - subdomain := strings.Split(parsed.Hostname(), ".")[0] - lastHyphen := strings.LastIndex(subdomain, "-") - if lastHyphen == -1 { - return subdomain, nil - } - return subdomain[:lastHyphen], nil -} diff --git a/cmd/lk/utils_test.go b/cmd/lk/utils_test.go index ff0e4e7f..3f9c64a0 100644 --- a/cmd/lk/utils_test.go +++ b/cmd/lk/utils_test.go @@ -1,8 +1,6 @@ package main import ( - "slices" - "strings" "testing" "github.com/urfave/cli/v3" @@ -43,47 +41,3 @@ func TestHiddenFlag(t *testing.T) { t.Error("hidden should return a new flag with Hidden set to true") } } - -func TestMapStrings(t *testing.T) { - initial := []string{"a1", "b2", "c3"} - mapped := mapStrings(initial, func(s string) string { - return strings.ToUpper(s) - }) - if len(mapped) != len(initial) { - t.Error("mapStrings should return a slice of the same length") - } - if !slices.Equal([]string{"A1", "B2", "C3"}, mapped) { - t.Error("mapStrings should apply the function to all elements") - } -} - -func TestEllipziseTo(t *testing.T) { - str := "This is some long string that should be ellipsized" - ellipsized := ellipsizeTo(str, 12) - if len(ellipsized) != 12 { - t.Error("ellipsizeTo should return a string of the specified length") - } - if ellipsized != "This is s..." { - t.Error("ellipsizeTo should ellipsize the string") - } -} - -func TestWrapToLines(t *testing.T) { - str := "This is a long string that should be wrapped to multiple lines" - wrapped := wrapToLines(str, 10) - if len(wrapped) != 8 { - t.Error("wrapToLines should return a slice of lines") - } - if !slices.Equal([]string{ - "This is a", - "long", - "string", - "that", - "should be", - "wrapped to", - "multiple", - "lines", - }, wrapped) { - t.Error("wrapToLines should wrap the string to the specified width") - } -} diff --git a/pkg/util/fs.go b/pkg/util/fs.go new file mode 100644 index 00000000..a532542a --- /dev/null +++ b/pkg/util/fs.go @@ -0,0 +1,103 @@ +package util + +import ( + "fmt" + "io" + "os" + "path" + "path/filepath" + + "github.com/livekit/protocol/utils/guid" +) + +// Safely copy a file across filesystems, preserving permissions +func CopyFile(src, dest string) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + destFile, err := os.Create(dest) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer destFile.Close() + + if _, err := io.Copy(destFile, srcFile); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + + // Preserve the file permissions + srcInfo, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source file: %w", err) + } + if err := os.Chmod(dest, srcInfo.Mode()); err != nil { + return fmt.Errorf("failed to chmod destination file: %w", err) + } + + return nil +} + +// Safely move a directory across filesystems, preserving permissions +func MoveDir(src, dest string) error { + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("destination directory already exists: %s", dest) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat destination directory: %w", err) + } + + if err := os.MkdirAll(dest, os.ModePerm); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return fmt.Errorf("failed to compute relative path: %w", err) + } + targetPath := filepath.Join(dest, relPath) + + if info.IsDir() { + if err := os.MkdirAll(targetPath, info.Mode()); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } else { + if err := CopyFile(path, targetPath); err != nil { + return fmt.Errorf("failed to copy file: %w", err) + } + } + return nil + }) + if err != nil { + return err + } + + if err := os.RemoveAll(src); err != nil { + return fmt.Errorf("failed to remove source directory: %w", err) + } + + return nil +} + +// Provides a temporary path, a function to relocate it to a permanent path, +// and a function to clean up the temporary path that should always be deferred +// in the case of a failure to relocate. +func UseTempPath(permanentPath string) (string, func() error, func() error) { + tempPath := path.Join(os.TempDir(), guid.New("LK_")) + relocate := func() error { + return MoveDir(tempPath, permanentPath) + } + cleanup := func() error { + if err := os.RemoveAll(tempPath); err != nil { + return fmt.Errorf("failed to remove temporary directory: %w", err) + } + return nil + } + return tempPath, relocate, cleanup +} diff --git a/pkg/util/json.go b/pkg/util/json.go new file mode 100644 index 00000000..99a515d7 --- /dev/null +++ b/pkg/util/json.go @@ -0,0 +1,11 @@ +package util + +import ( + "encoding/json" + "fmt" +) + +func PrintJSON(obj any) { + txt, _ := json.MarshalIndent(obj, "", " ") + fmt.Println(string(txt)) +} diff --git a/pkg/util/strings.go b/pkg/util/strings.go new file mode 100644 index 00000000..b7fade56 --- /dev/null +++ b/pkg/util/strings.go @@ -0,0 +1,77 @@ +package util + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "strings" +) + +func MapStrings(strs []string, fn func(string) string) []string { + res := make([]string, len(strs)) + for i, str := range strs { + res[i] = fn(str) + } + return res +} + +func WrapWith(wrap string) func(string) string { + return func(str string) string { + return wrap + str + wrap + } +} + +func EllipsizeTo(str string, maxLength int) string { + if len(str) <= maxLength { + return str + } + ellipsis := "..." + contentLen := max(0, min(len(str), maxLength-len(ellipsis))) + return str[:contentLen] + ellipsis +} + +func WrapToLines(input string, maxLineLength int) []string { + words := strings.Fields(input) + var lines []string + var currentLine strings.Builder + + for _, word := range words { + if currentLine.Len()+len(word)+1 > maxLineLength { + lines = append(lines, currentLine.String()) + currentLine.Reset() + } + if currentLine.Len() > 0 { + currentLine.WriteString(" ") + } + currentLine.WriteString(word) + } + + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + + return lines +} + +func HashString(str string) (string, error) { + hash := sha256.New() + if _, err := hash.Write([]byte(str)); err != nil { + return "", err + } + bytes := hash.Sum(nil) + return hex.EncodeToString(bytes), nil +} + +func URLSafeName(projectURL string) (string, error) { + parsed, err := url.Parse(projectURL) + if err != nil { + return "", errors.New("invalid URL") + } + subdomain := strings.Split(parsed.Hostname(), ".")[0] + lastHyphen := strings.LastIndex(subdomain, "-") + if lastHyphen == -1 { + return subdomain, nil + } + return subdomain[:lastHyphen], nil +} diff --git a/pkg/util/strings_test.go b/pkg/util/strings_test.go new file mode 100644 index 00000000..d3d0ec54 --- /dev/null +++ b/pkg/util/strings_test.go @@ -0,0 +1,51 @@ +package util + +import ( + "slices" + "strings" + "testing" +) + +func TestMapStrings(t *testing.T) { + initial := []string{"a1", "b2", "c3"} + mapped := MapStrings(initial, func(s string) string { + return strings.ToUpper(s) + }) + if len(mapped) != len(initial) { + t.Error("mapStrings should return a slice of the same length") + } + if !slices.Equal([]string{"A1", "B2", "C3"}, mapped) { + t.Error("mapStrings should apply the function to all elements") + } +} + +func TestEllipziseTo(t *testing.T) { + str := "This is some long string that should be ellipsized" + ellipsized := EllipsizeTo(str, 12) + if len(ellipsized) != 12 { + t.Error("ellipsizeTo should return a string of the specified length") + } + if ellipsized != "This is s..." { + t.Error("ellipsizeTo should ellipsize the string") + } +} + +func TestWrapToLines(t *testing.T) { + str := "This is a long string that should be wrapped to multiple lines" + wrapped := WrapToLines(str, 10) + if len(wrapped) != 8 { + t.Error("wrapToLines should return a slice of lines") + } + if !slices.Equal([]string{ + "This is a", + "long", + "string", + "that", + "should be", + "wrapped to", + "multiple", + "lines", + }, wrapped) { + t.Error("wrapToLines should wrap the string to the specified width") + } +}