From 4e488928c02ebd2fd2bffe81b32b8d6572807c14 Mon Sep 17 00:00:00 2001 From: Harry Dhillon Date: Tue, 14 Oct 2025 22:23:07 -0600 Subject: [PATCH] Feat: Add comprehensive error handling and user interface enhancements - Introduce `OrkError` for structured error handling with categorized error types. - Add convenience methods for creating and managing common error scenarios. - Enhance `ps` command with improved error handling and user hints. - Add a UI package for styled terminal output (e.g., formatted errors, hints, and status indicators). Signed-off-by: Harry Dhillon --- go.mod | 13 ++ go.sum | 32 ++++ internal/cli/ps.go | 115 +++++++----- internal/ui/output.go | 293 +++++++++++++++++++++++++++++++ internal/ui/progress.go | 281 +++++++++++++++++++++++++++++ internal/ui/table.go | 355 +++++++++++++++++++++++++++++++++++++ pkg/utils/errors.go | 379 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1428 insertions(+), 40 deletions(-) create mode 100644 internal/ui/output.go create mode 100644 internal/ui/progress.go create mode 100644 internal/ui/table.go create mode 100644 pkg/utils/errors.go diff --git a/go.mod b/go.mod index e10ba85..c1ef7d5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ork-cli/ork go 1.25.2 require ( + github.com/charmbracelet/lipgloss v1.1.0 github.com/docker/docker v28.5.1+incompatible github.com/docker/go-connections v0.6.0 github.com/spf13/cobra v1.10.1 @@ -12,6 +13,12 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -22,15 +29,21 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index a8adacd..683c9f2 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,24 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -40,6 +56,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -50,6 +72,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -58,6 +82,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -70,6 +97,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= @@ -90,8 +119,11 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/internal/cli/ps.go b/internal/cli/ps.go index 32c593c..8a7622d 100644 --- a/internal/cli/ps.go +++ b/internal/cli/ps.go @@ -7,6 +7,8 @@ import ( "github.com/ork-cli/ork/internal/config" "github.com/ork-cli/ork/internal/docker" + "github.com/ork-cli/ork/internal/ui" + "github.com/ork-cli/ork/pkg/utils" "github.com/spf13/cobra" ) @@ -29,7 +31,7 @@ var psCmd = &cobra.Command{ showAll, _ := cmd.Flags().GetBool("all") if err := runPS(showAll); err != nil { - fmt.Printf("āŒ Error: %v\n", err) + handlePSError(err) return } }, @@ -70,7 +72,12 @@ func runPS(showAll bool) error { ctx := context.Background() containers, err := dockerClient.List(ctx, cfg.Project) if err != nil { - return fmt.Errorf("failed to list containers: %w", err) + return utils.DockerError( + "ps.list", + "Failed to list containers", + "Try running 'ork doctor' to diagnose issues", + err, + ) } // Filter out stopped containers if --all not specified @@ -92,7 +99,12 @@ func runPS(showAll bool) error { func loadConfig() (*config.Config, error) { cfg, err := config.Load() if err != nil { - return nil, fmt.Errorf("failed to load config: %w\nšŸ’” Make sure ork.yml exists in current directory", err) + return nil, utils.ConfigError( + "ps.load", + "Failed to load configuration", + "Make sure ork.yml exists in current directory", + err, + ) } return cfg, nil } @@ -105,7 +117,12 @@ func loadConfig() (*config.Config, error) { func createDockerClientForPS() (*docker.Client, error) { client, err := docker.NewClient() if err != nil { - return nil, fmt.Errorf("failed to create Docker client: %w", err) + return nil, utils.DockerError( + "ps.docker", + "Failed to connect to Docker", + "Make sure Docker is running with 'docker ps' or run 'ork doctor'", + err, + ) } return client, nil } @@ -132,34 +149,27 @@ func filterRunningContainers(containers []docker.ContainerInfo) []docker.Contain // Private Helpers - Display // ============================================================================ -// displayContainers prints containers in a nice table format +// displayContainers prints containers in a beautiful table format func displayContainers(containers []docker.ContainerInfo, projectName string) { - if len(containers) == 0 { - fmt.Printf("No containers found for project '%s'\n", projectName) - fmt.Printf("šŸ’” Start services with: ork up \n") - return - } - - // Print header - fmt.Printf("Services for project: %s\n\n", projectName) - fmt.Printf("%-20s %-15s %-30s %-20s\n", "SERVICE", "STATUS", "PORTS", "CONTAINER ID") - fmt.Printf("%s\n", strings.Repeat("-", 85)) - - // Print each container + // Convert containers to table rows + var rows []ui.ServiceRow for _, c := range containers { serviceName := extractServiceName(c.Labels) - status := formatStatus(c.Status) - ports := formatPortsList(c.Ports) - - fmt.Printf("%-20s %-15s %-30s %-20s\n", - serviceName, - status, - ports, - c.ID, - ) + status := normalizeStatus(c.Status) + uptime := extractUptime(c.Status) + + rows = append(rows, ui.ServiceRow{ + Service: serviceName, + Status: status, + Ports: c.Ports, + ContainerID: c.ID, + Uptime: uptime, + }) } - fmt.Printf("\n") + // Render beautiful table + table := ui.ServiceTable(projectName, rows) + fmt.Print(table) } // extractServiceName gets the service name from labels @@ -170,24 +180,49 @@ func extractServiceName(labels map[string]string) string { return "unknown" } -// formatStatus formats the container status with color indicators -func formatStatus(status string) string { +// normalizeStatus converts Docker status to our normalized format +func normalizeStatus(status string) string { if strings.HasPrefix(status, "Up") { - return "🟢 " + status + return "running" + } else if strings.HasPrefix(status, "Exited") { + return "stopped" + } else if strings.Contains(strings.ToLower(status), "restarting") { + return "starting" } - return "šŸ”“ " + status + return "stopped" } -// formatPortsList formats the port list for display -func formatPortsList(ports []string) string { - if len(ports) == 0 { - return "-" +// extractUptime extracts uptime from Docker status string +func extractUptime(status string) string { + // Docker status format: "Up 2 hours" or "Up 5 minutes" or "Exited (0) 2 hours ago" + if strings.HasPrefix(status, "Up ") { + // Extract the time portion + uptime := strings.TrimPrefix(status, "Up ") + // Clean up any parenthetical info + if idx := strings.Index(uptime, "("); idx != -1 { + uptime = strings.TrimSpace(uptime[:idx]) + } + return uptime } + return "" +} - // If there are many ports, just show the first few - if len(ports) > 2 { - return strings.Join(ports[:2], ", ") + "..." +// handlePSError formats and displays errors with hints +func handlePSError(err error) { + if orkErr, ok := err.(*utils.OrkError); ok { + // Display structured error with hints + ui.Error(orkErr.Message) + if orkErr.Hint != "" { + ui.Hint(orkErr.Hint) + } + if len(orkErr.Details) > 0 { + ui.EmptyLine() + for _, detail := range orkErr.Details { + ui.List(detail) + } + } + } else { + // Fallback for non-Ork errors + ui.Error(fmt.Sprintf("Error: %v", err)) } - - return strings.Join(ports, ", ") } diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000..388da0a --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,293 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================ +// Color Scheme - Ork Brand Colors +// ============================================================================ + +var ( + // ColorPrimary Primary colors + ColorPrimary = lipgloss.Color("#7C3AED") // Purple - main brand + ColorSecondary = lipgloss.Color("#06B6D4") // Cyan - accents + + // ColorSuccess Status colors + ColorSuccess = lipgloss.Color("#10B981") // Green + ColorWarning = lipgloss.Color("#F59E0B") // Amber + ColorError = lipgloss.Color("#EF4444") // Red + ColorInfo = lipgloss.Color("#3B82F6") // Blue + + // ColorRunning State colors + ColorRunning = lipgloss.Color("#10B981") // Green + ColorStarting = lipgloss.Color("#F59E0B") // Amber + ColorStopped = lipgloss.Color("#6B7280") // Gray + ColorFailed = lipgloss.Color("#EF4444") // Red + + // ColorText Text colors + ColorText = lipgloss.Color("#E5E7EB") // Light gray + ColorTextDim = lipgloss.Color("#9CA3AF") // Dim gray + ColorTextBold = lipgloss.Color("#F9FAFB") // Almost white + + // ColorBgDark Background colors + ColorBgDark = lipgloss.Color("#1F2937") // Dark gray + ColorBgMedium = lipgloss.Color("#374151") // Medium gray + ColorBgLight = lipgloss.Color("#4B5563") // Light gray + ColorBgSuccess = lipgloss.Color("#064E3B") // Dark green + ColorBgError = lipgloss.Color("#7F1D1D") // Dark red +) + +// ============================================================================ +// Base Styles +// ============================================================================ + +var ( + // StyleBold Text styles + StyleBold = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorTextBold) + + StyleDim = lipgloss.NewStyle(). + Foreground(ColorTextDim) + + StyleCode = lipgloss.NewStyle(). + Foreground(ColorSecondary). + Background(ColorBgDark). + Padding(0, 1) + + // StyleSuccess Status styles + StyleSuccess = lipgloss.NewStyle(). + Foreground(ColorSuccess). + Bold(true) + + StyleWarning = lipgloss.NewStyle(). + Foreground(ColorWarning). + Bold(true) + + StyleError = lipgloss.NewStyle(). + Foreground(ColorError). + Bold(true) + + StyleInfo = lipgloss.NewStyle(). + Foreground(ColorInfo). + Bold(true) + + // StyleSuccessBox Box styles for callouts + StyleSuccessBox = lipgloss.NewStyle(). + Foreground(ColorSuccess). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorSuccess). + Padding(0, 1) + + StyleErrorBox = lipgloss.NewStyle(). + Foreground(ColorError). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorError). + Padding(0, 1) + + StyleInfoBox = lipgloss.NewStyle(). + Foreground(ColorInfo). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorInfo). + Padding(0, 1) + + // StyleHeader Header styles + StyleHeader = lipgloss.NewStyle(). + Foreground(ColorPrimary). + Bold(true). + Underline(true). + MarginBottom(1) + + StyleSubheader = lipgloss.NewStyle(). + Foreground(ColorSecondary). + Bold(true) +) + +// ============================================================================ +// Status Indicators - Unicode symbols for terminal output +// ============================================================================ + +const ( + // Status symbols + SymbolSuccess = "āœ“" // Checkmark + SymbolError = "āœ—" // X mark + SymbolWarning = "⚠" // Warning triangle + SymbolInfo = "ℹ" // Info + SymbolRunning = "ā—" // Filled circle + SymbolStarting = "◐" // Half-filled circle + SymbolStopped = "ā—‹" // Empty circle + SymbolArrow = "→" // Right arrow + SymbolBullet = "•" // Bullet point + SymbolSparkle = "✨" // Sparkle (for special messages) + SymbolRocket = "šŸš€" // Rocket (for deployments/starts) + SymbolPackage = "šŸ“¦" // Package (for containers) + SymbolGear = "āš™" // Gear (for configuration) + SymbolDoctor = "🩺" // Doctor (for health checks) + SymbolLightbulb = "šŸ’”" // Lightbulb (for tips/hints) +) + +// ============================================================================ +// Formatted Output Functions +// ============================================================================ + +// Success prints a success message with a checkmark +func Success(message string) { + fmt.Println(StyleSuccess.Render(SymbolSuccess + " " + message)) +} + +// Error prints an error message with X mark +func Error(message string) { + fmt.Println(StyleError.Render(SymbolError + " " + message)) +} + +// Warning prints a warning message with a warning symbol +func Warning(message string) { + fmt.Println(StyleWarning.Render(SymbolWarning + " " + message)) +} + +// Info prints an info message with an info symbol +func Info(message string) { + fmt.Println(StyleInfo.Render(SymbolInfo + " " + message)) +} + +// Hint prints a helpful hint/tip with lightbulb +func Hint(message string) { + fmt.Println(StyleInfo.Render(SymbolLightbulb + " " + message)) +} + +// Header prints a section header +func Header(message string) { + fmt.Println(StyleHeader.Render(message)) +} + +// Subheader prints a subsection header +func Subheader(message string) { + fmt.Println(StyleSubheader.Render(message)) +} + +// ============================================================================ +// Status-Specific Formatters +// ============================================================================ + +// StatusRunning returns a formatted "running" status +func StatusRunning(text string) string { + return lipgloss.NewStyle(). + Foreground(ColorRunning). + Render(SymbolRunning + " " + text) +} + +// StatusStarting returns a formatted "starting" status +func StatusStarting(text string) string { + return lipgloss.NewStyle(). + Foreground(ColorStarting). + Render(SymbolStarting + " " + text) +} + +// StatusStopped returns a formatted "stopped" status +func StatusStopped(text string) string { + return lipgloss.NewStyle(). + Foreground(ColorStopped). + Render(SymbolStopped + " " + text) +} + +// StatusFailed returns a formatted "failed" status +func StatusFailed(text string) string { + return lipgloss.NewStyle(). + Foreground(ColorFailed). + Render(SymbolError + " " + text) +} + +// ============================================================================ +// Service Status Formatters (for containers) +// ============================================================================ + +// FormatServiceStatus formats a service status with an appropriate color and symbol +func FormatServiceStatus(status string) string { + switch status { + case "running", "up": + return StatusRunning("Running") + case "starting": + return StatusStarting("Starting") + case "stopped", "exited": + return StatusStopped("Stopped") + case "failed", "error": + return StatusFailed("Failed") + default: + return lipgloss.NewStyle(). + Foreground(ColorTextDim). + Render(SymbolBullet + " " + status) + } +} + +// ============================================================================ +// Inline Text Formatters (for use within strings) +// ============================================================================ + +// Bold returns bolded text +func Bold(text string) string { + return StyleBold.Render(text) +} + +// Dim returns dimmed text +func Dim(text string) string { + return StyleDim.Render(text) +} + +// Code returns text styled as code/command +func Code(text string) string { + return StyleCode.Render(text) +} + +// Highlight returns text in the primary brand color +func Highlight(text string) string { + return lipgloss.NewStyle(). + Foreground(ColorPrimary). + Bold(true). + Render(text) +} + +// ============================================================================ +// Box Formatters (for callouts and important messages) +// ============================================================================ + +// SuccessBox prints a success message in a box +func SuccessBox(message string) { + fmt.Println(StyleSuccessBox.Render(SymbolSuccess + " " + message)) +} + +// ErrorBox prints an error message in a box +func ErrorBox(message string) { + fmt.Println(StyleErrorBox.Render(SymbolError + " " + message)) +} + +// InfoBox prints an info message in a box +func InfoBox(message string) { + fmt.Println(StyleInfoBox.Render(SymbolInfo + " " + message)) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// Separator prints a visual separator line +func Separator() { + fmt.Println(StyleDim.Render("────────────────────────────────────────────────────────────────")) +} + +// EmptyLine prints a blank line for spacing +func EmptyLine() { + fmt.Println() +} + +// List prints a bulleted list item +func List(item string) { + fmt.Printf(" %s %s\n", StyleDim.Render(SymbolBullet), item) +} + +// ListItem prints a bulleted list item with a custom prefix +func ListItem(prefix, item string) { + fmt.Printf(" %s %s\n", prefix, item) +} diff --git a/internal/ui/progress.go b/internal/ui/progress.go new file mode 100644 index 0000000..61b310d --- /dev/null +++ b/internal/ui/progress.go @@ -0,0 +1,281 @@ +package ui + +import ( + "fmt" + "sync" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================ +// Simple Spinner - No bubble tea, just clean terminal output +// ============================================================================ + +// Spinner represents a simple terminal spinner for indicating progress +type Spinner struct { + message string + frames []string + frameIndex int + isRunning bool + done chan bool + mu sync.Mutex + style lipgloss.Style +} + +// Default spinner frames (dots) +var defaultFrames = []string{ + "ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā ", +} + +// ============================================================================ +// Constructor +// ============================================================================ + +// NewSpinner creates a new spinner with a message +func NewSpinner(message string) *Spinner { + return &Spinner{ + message: message, + frames: defaultFrames, + frameIndex: 0, + isRunning: false, + done: make(chan bool), + style: lipgloss.NewStyle(). + Foreground(ColorSecondary), + } +} + +// ============================================================================ +// Lifecycle Methods +// ============================================================================ + +// Start begins the spinner animation +func (s *Spinner) Start() { + s.mu.Lock() + if s.isRunning { + s.mu.Unlock() + return + } + s.isRunning = true + s.mu.Unlock() + + go s.run() +} + +// Stop stops the spinner and clears the line +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.isRunning { + s.mu.Unlock() + return + } + s.isRunning = false + s.mu.Unlock() + + s.done <- true + s.clearLine() +} + +// Success stops the spinner and shows a success message +func (s *Spinner) Success(message string) { + s.Stop() + Success(message) +} + +// Error stops the spinner and shows an error message +func (s *Spinner) Error(message string) { + s.Stop() + Error(message) +} + +// Warning stops the spinner and shows a warning message +func (s *Spinner) Warning(message string) { + s.Stop() + Warning(message) +} + +// UpdateMessage changes the spinner message while it's running +func (s *Spinner) UpdateMessage(message string) { + s.mu.Lock() + s.message = message + s.mu.Unlock() +} + +// ============================================================================ +// Private Methods +// ============================================================================ + +// run is the main spinner loop +func (s *Spinner) run() { + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-s.done: + return + case <-ticker.C: + s.render() + s.nextFrame() + } + } +} + +// render draws the current spinner frame +func (s *Spinner) render() { + s.mu.Lock() + frame := s.frames[s.frameIndex] + message := s.message + s.mu.Unlock() + + // Clear line and print spinner and message + fmt.Printf("\r%s %s", s.style.Render(frame), message) +} + +// clearLine clears the current line +func (s *Spinner) clearLine() { + fmt.Print("\r\033[K") // ANSI escape: clear line +} + +// nextFrame advances to the next spinner frame +func (s *Spinner) nextFrame() { + s.mu.Lock() + s.frameIndex = (s.frameIndex + 1) % len(s.frames) + s.mu.Unlock() +} + +// ============================================================================ +// Alternative Spinner Styles +// ============================================================================ + +// WithDots creates a spinner with dot frames (default) +func (s *Spinner) WithDots() *Spinner { + s.frames = defaultFrames + return s +} + +// WithLine creates a spinner with line frames +func (s *Spinner) WithLine() *Spinner { + s.frames = []string{"-", "\\", "|", "/"} + return s +} + +// WithArrow creates a spinner with arrow frames +func (s *Spinner) WithArrow() *Spinner { + s.frames = []string{"←", "↖", "↑", "↗", "→", "ā†˜", "↓", "↙"} + return s +} + +// WithCircle creates a spinner with circle frames +func (s *Spinner) WithCircle() *Spinner { + s.frames = []string{"◐", "ā—“", "ā—‘", "ā—’"} + return s +} + +// WithColor sets a custom color for the spinner +func (s *Spinner) WithColor(color lipgloss.Color) *Spinner { + s.style = s.style.Foreground(color) + return s +} + +// ============================================================================ +// Multi-Step Progress Tracker +// ============================================================================ + +// ProgressTracker tracks progress through multiple steps +type ProgressTracker struct { + steps []string + currentStep int + totalSteps int + mu sync.Mutex +} + +// NewProgressTracker creates a new progress tracker +func NewProgressTracker(steps []string) *ProgressTracker { + return &ProgressTracker{ + steps: steps, + currentStep: 0, + totalSteps: len(steps), + } +} + +// Start prints the initial progress state +func (p *ProgressTracker) Start() { + p.mu.Lock() + defer p.mu.Unlock() + + Header(fmt.Sprintf("Starting %d steps", p.totalSteps)) + EmptyLine() +} + +// NextStep advances to the next step and prints progress +func (p *ProgressTracker) NextStep() { + p.mu.Lock() + if p.currentStep > 0 { + // Mark the previous step as done + prevStep := p.steps[p.currentStep-1] + Success(prevStep) + } + + if p.currentStep < p.totalSteps { + // Show the current step + currentStep := p.steps[p.currentStep] + progress := fmt.Sprintf("[%d/%d]", p.currentStep+1, p.totalSteps) + fmt.Printf("%s %s %s\n", + StyleDim.Render(progress), + SymbolArrow, + Bold(currentStep), + ) + } + + p.currentStep++ + p.mu.Unlock() +} + +// Complete marks all steps as complete +func (p *ProgressTracker) Complete(message string) { + p.mu.Lock() + if p.currentStep == p.totalSteps { + // Mark the last step as done + lastStep := p.steps[p.totalSteps-1] + Success(lastStep) + } + p.mu.Unlock() + + EmptyLine() + SuccessBox(message) +} + +// Fail marks the progress as failed +func (p *ProgressTracker) Fail(message string) { + p.mu.Lock() + currentStep := p.steps[p.currentStep-1] + p.mu.Unlock() + + Error(fmt.Sprintf("%s failed", currentStep)) + EmptyLine() + ErrorBox(message) +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +// ShowSpinner creates, starts, and returns a spinner (convenience function) +func ShowSpinner(message string) *Spinner { + spinner := NewSpinner(message) + spinner.Start() + return spinner +} + +// WithProgress wraps a long-running operation with a spinner +func WithProgress(message string, fn func() error) error { + spinner := ShowSpinner(message) + err := fn() + if err != nil { + spinner.Error(message + " failed") + return err + } + spinner.Success(message) + return nil +} diff --git a/internal/ui/table.go b/internal/ui/table.go new file mode 100644 index 0000000..c0947b9 --- /dev/null +++ b/internal/ui/table.go @@ -0,0 +1,355 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +// ============================================================================ +// Table Styles +// ============================================================================ + +var ( + // Table header style + styleTableHeader = lipgloss.NewStyle(). + Bold(true). + Foreground(ColorPrimary). + Align(lipgloss.Center) + + // Table cell style + styleTableCell = lipgloss.NewStyle(). + Padding(0, 1) + + // Table border style + styleTableBorder = lipgloss.NewStyle(). + Foreground(ColorTextDim) +) + +// ============================================================================ +// Service Table - For 'ork ps' command +// ============================================================================ + +// ServiceRow represents a single row in the service table +type ServiceRow struct { + Service string + Status string + Ports []string + ContainerID string + Uptime string +} + +// ServiceTable creates and renders a beautiful table for services +func ServiceTable(projectName string, rows []ServiceRow) string { + if len(rows) == 0 { + return renderEmptyState(projectName) + } + + // Create a table with headers + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styleTableBorder). + StyleFunc(func(row, col int) lipgloss.Style { + // Header row + if row == 0 { + return styleTableHeader + } + // Regular cells + return styleTableCell + }). + Headers("SERVICE", "STATUS", "PORTS", "UPTIME", "CONTAINER") + + // Add rows + for _, r := range rows { + ports := formatPorts(r.Ports) + uptime := r.Uptime + if uptime == "" { + uptime = Dim("-") + } + + // Format container ID (short version) + containerID := r.ContainerID + if len(containerID) > 12 { + containerID = containerID[:12] + } + containerID = Dim(containerID) + + t.Row( + r.Service, + FormatServiceStatus(r.Status), + ports, + uptime, + containerID, + ) + } + + // Build output with a header + var output strings.Builder + headerText := StyleSubheader.Render(fmt.Sprintf("%s Services for project: %s", SymbolPackage, Bold(projectName))) + output.WriteString(headerText) + output.WriteString("\n\n") + output.WriteString(t.String()) + output.WriteString("\n") + + return output.String() +} + +// ============================================================================ +// Port Table - For 'ork ports' command (future) +// ============================================================================ + +// PortRow represents a single row in the port allocation table +type PortRow struct { + Port string + Service string + Project string + Host string +} + +// PortTable creates and renders a table for port allocations +func PortTable(rows []PortRow) string { + if len(rows) == 0 { + return renderNoPortsAllocated() + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styleTableBorder). + StyleFunc(func(row, col int) lipgloss.Style { + if row == 0 { + return styleTableHeader + } + return styleTableCell + }). + Headers("PORT", "SERVICE", "PROJECT", "HOST") + + for _, r := range rows { + t.Row( + Bold(r.Port), + r.Service, + r.Project, + Dim(r.Host), + ) + } + + var output strings.Builder + headerText := StyleSubheader.Render(fmt.Sprintf("%s Port Allocations", SymbolGear)) + output.WriteString(headerText) + output.WriteString("\n\n") + output.WriteString(t.String()) + output.WriteString("\n") + + return output.String() +} + +// ============================================================================ +// Key-Value Table - For displaying configuration +// ============================================================================ + +// KeyValueRow represents a key-value pair +type KeyValueRow struct { + Key string + Value string +} + +// KeyValueTable creates a simple two-column table for configuration display +func KeyValueTable(title string, rows []KeyValueRow) string { + if len(rows) == 0 { + return "" + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styleTableBorder). + StyleFunc(func(row, col int) lipgloss.Style { + if row == 0 { + return styleTableHeader + } + // Bold the keys + if col == 0 { + return styleTableCell.Bold(true).Foreground(ColorSecondary) + } + return styleTableCell + }). + Headers("KEY", "VALUE") + + for _, r := range rows { + t.Row(r.Key, r.Value) + } + + var output strings.Builder + if title != "" { + headerText := StyleSubheader.Render(title) + output.WriteString(headerText) + output.WriteString("\n\n") + } + output.WriteString(t.String()) + output.WriteString("\n") + + return output.String() +} + +// ============================================================================ +// Health Check Table - For 'ork doctor' command +// ============================================================================ + +// HealthCheckRow represents a health check result +type HealthCheckRow struct { + Check string + Status string // "pass", "fail", "warn" + Detail string +} + +// HealthCheckTable creates a table for health check results +func HealthCheckTable(category string, rows []HealthCheckRow) string { + if len(rows) == 0 { + return "" + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(styleTableBorder). + StyleFunc(func(row, col int) lipgloss.Style { + if row == 0 { + return styleTableHeader + } + return styleTableCell + }). + Headers("CHECK", "STATUS", "DETAILS") + + for _, r := range rows { + status := formatHealthStatus(r.Status) + detail := r.Detail + if detail == "" { + detail = Dim("-") + } + + t.Row(r.Check, status, detail) + } + + var output strings.Builder + headerText := StyleSubheader.Render(fmt.Sprintf("%s %s", SymbolDoctor, category)) + output.WriteString(headerText) + output.WriteString("\n\n") + output.WriteString(t.String()) + output.WriteString("\n") + + return output.String() +} + +// ============================================================================ +// Private Helper Functions +// ============================================================================ + +// formatPorts formats port list for display +func formatPorts(ports []string) string { + if len(ports) == 0 { + return Dim("-") + } + + // Show the first 2 ports, add "..." if more + if len(ports) > 2 { + return lipgloss.NewStyle(). + Foreground(ColorSecondary). + Render(strings.Join(ports[:2], ", ") + "...") + } + + return lipgloss.NewStyle(). + Foreground(ColorSecondary). + Render(strings.Join(ports, ", ")) +} + +// formatHealthStatus formats health check status with color +func formatHealthStatus(status string) string { + switch status { + case "pass", "ok": + return StyleSuccess.Render(SymbolSuccess + " Pass") + case "fail", "error": + return StyleError.Render(SymbolError + " Fail") + case "warn", "warning": + return StyleWarning.Render(SymbolWarning + " Warn") + default: + return Dim(status) + } +} + +// renderEmptyState renders a message when no services are running +func renderEmptyState(projectName string) string { + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorTextDim). + Padding(1, 2). + Align(lipgloss.Center) + + message := fmt.Sprintf( + "%s\n\n%s\n%s %s", + Dim("No services running"), + fmt.Sprintf("Project: %s", Bold(projectName)), + SymbolLightbulb, + Dim("Start services with: "+Code("ork up")), + ) + + return box.Render(message) + "\n" +} + +// renderNoPortsAllocated renders a message when no ports are allocated +func renderNoPortsAllocated() string { + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ColorTextDim). + Padding(1, 2). + Align(lipgloss.Center) + + message := fmt.Sprintf( + "%s\n\n%s %s", + Dim("No ports allocated"), + SymbolLightbulb, + Dim("Ports are allocated when services start"), + ) + + return box.Render(message) + "\n" +} + +// ============================================================================ +// Simple List Renderer (alternative to tables for simpler output) +// ============================================================================ + +// RenderList renders a simple styled list +func RenderList(title string, items []string) string { + var output strings.Builder + + if title != "" { + headerText := StyleSubheader.Render(title) + output.WriteString(headerText) + output.WriteString("\n\n") + } + + for _, item := range items { + output.WriteString(fmt.Sprintf(" %s %s\n", SymbolBullet, item)) + } + + return output.String() +} + +// RenderNumberedList renders a numbered list +func RenderNumberedList(title string, items []string) string { + var output strings.Builder + + if title != "" { + headerText := StyleSubheader.Render(title) + output.WriteString(headerText) + output.WriteString("\n\n") + } + + for i, item := range items { + number := lipgloss.NewStyle(). + Foreground(ColorSecondary). + Bold(true). + Render(fmt.Sprintf("%d.", i+1)) + output.WriteString(fmt.Sprintf(" %s %s\n", number, item)) + } + + return output.String() +} diff --git a/pkg/utils/errors.go b/pkg/utils/errors.go new file mode 100644 index 0000000..c22dca8 --- /dev/null +++ b/pkg/utils/errors.go @@ -0,0 +1,379 @@ +package utils + +import ( + "fmt" + "strings" +) + +// ============================================================================ +// Error Types - Categorized for better handling +// ============================================================================ + +// ErrorKind represents the category of error +type ErrorKind string + +const ( + ErrorConfig ErrorKind = "config" // Configuration errors + ErrorDocker ErrorKind = "docker" // Docker-related errors + ErrorNetwork ErrorKind = "network" // Network/port errors + ErrorGit ErrorKind = "git" // Git operations + ErrorService ErrorKind = "service" // Service management + ErrorFile ErrorKind = "file" // File system errors + ErrorValidation ErrorKind = "validation" // Validation failures + ErrorInternal ErrorKind = "internal" // Unexpected internal errors +) + +// ============================================================================ +// OrkError - Structured error with context and hints +// ============================================================================ + +// OrkError is a rich error type that provides context, hints, and suggestions +type OrkError struct { + // Op is the operation that failed (e.g., "docker.start", "config.load") + Op string + + // Kind categorizes the error + Kind ErrorKind + + // Err is the underlying error + Err error + + // Message is a user-friendly description of what went wrong + Message string + + // Hint provides a suggestion for how to fix the error + Hint string + + // Details provides additional context (optional) + Details []string + + // Suggestions provide "did you mean?" style suggestions (optional) + Suggestions []string +} + +// Error implements the error interface +func (e *OrkError) Error() string { + var parts []string + + // Add operation context if available + if e.Op != "" { + parts = append(parts, fmt.Sprintf("operation: %s", e.Op)) + } + + // Add the main message + if e.Message != "" { + parts = append(parts, e.Message) + } else if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + + return strings.Join(parts, ": ") +} + +// Unwrap returns the underlying error (for errors.Is and errors.As) +func (e *OrkError) Unwrap() error { + return e.Err +} + +// ============================================================================ +// Error Constructors - Convenience functions for common error types +// ============================================================================ + +// ConfigError creates a configuration-related error +func ConfigError(op, message, hint string, err error) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorConfig, + Err: err, + Message: message, + Hint: hint, + } +} + +// DockerError creates a Docker-related error +func DockerError(op, message, hint string, err error) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorDocker, + Err: err, + Message: message, + Hint: hint, + } +} + +// NetworkError creates a network/port-related error +func NetworkError(op, message, hint string, err error) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorNetwork, + Err: err, + Message: message, + Hint: hint, + } +} + +// ServiceError creates a service management error +func ServiceError(op, message, hint string, err error) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorService, + Err: err, + Message: message, + Hint: hint, + } +} + +// ValidationError creates a validation error with suggestions +func ValidationError(op, message string, suggestions []string) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorValidation, + Message: message, + Suggestions: suggestions, + Hint: "Check your configuration for errors", + } +} + +// FileError creates a file system error +func FileError(op, message, hint string, err error) *OrkError { + return &OrkError{ + Op: op, + Kind: ErrorFile, + Err: err, + Message: message, + Hint: hint, + } +} + +// ============================================================================ +// Common Error Scenarios - Pre-defined errors for frequent cases +// ============================================================================ + +// ErrDockerNotRunning creates an error for when the Docker daemon is not running +func ErrDockerNotRunning(err error) *OrkError { + return &OrkError{ + Op: "docker.connect", + Kind: ErrorDocker, + Err: err, + Message: "Docker daemon is not running", + Hint: "Start Docker Desktop or run 'sudo systemctl start docker'", + Details: []string{ + "Check if Docker is installed: docker --version", + "Verify Docker daemon status: docker ps", + "Run diagnostics: ork doctor", + }, + } +} + +// ErrConfigNotFound creates an error for when ork.yml is missing +func ErrConfigNotFound(path string) *OrkError { + return &OrkError{ + Op: "config.load", + Kind: ErrorConfig, + Message: fmt.Sprintf("Configuration file not found: %s", path), + Hint: "Run 'ork init' to create a new ork.yml", + Details: []string{ + "Make sure you're in the right directory", + "Check if the file is named ork.yml or .ork.yml", + }, + } +} + +// ErrServiceNotFound creates an error for unknown service names +func ErrServiceNotFound(serviceName string, availableServices []string) *OrkError { + return &OrkError{ + Op: "service.resolve", + Kind: ErrorService, + Message: fmt.Sprintf("Service '%s' not found in configuration", serviceName), + Hint: "Check service names in your ork.yml", + Suggestions: availableServices, + } +} + +// ErrPortInUse creates an error for port conflicts +func ErrPortInUse(port, service, conflictingProcess string) *OrkError { + details := []string{ + fmt.Sprintf("Port %s is required by service '%s'", port, service), + } + if conflictingProcess != "" { + details = append(details, fmt.Sprintf("Currently used by: %s", conflictingProcess)) + } + + return &OrkError{ + Op: "network.allocate", + Kind: ErrorNetwork, + Message: fmt.Sprintf("Port %s is already in use", port), + Hint: "Stop the conflicting service or change the port in your ork.yml", + Details: details, + } +} + +// ErrCircularDependency creates an error for circular service dependencies +func ErrCircularDependency(cycle []string) *OrkError { + return &OrkError{ + Op: "service.resolve", + Kind: ErrorService, + Message: "Circular dependency detected", + Hint: "Remove the circular dependency from your service configuration", + Details: []string{ + fmt.Sprintf("Dependency cycle: %s", strings.Join(cycle, " → ")), + }, + } +} + +// ErrInvalidConfig creates an error for invalid configuration +func ErrInvalidConfig(field, reason string) *OrkError { + return &OrkError{ + Op: "config.validate", + Kind: ErrorValidation, + Message: fmt.Sprintf("Invalid configuration: %s", field), + Hint: reason, + } +} + +// ErrServiceFailed creates an error for when a service fails to start +func ErrServiceFailed(serviceName, reason string) *OrkError { + return &OrkError{ + Op: "service.start", + Kind: ErrorService, + Message: fmt.Sprintf("Service '%s' failed to start", serviceName), + Hint: "Check service logs with: ork logs " + serviceName, + Details: []string{reason}, + } +} + +// ErrImageNotFound creates an error for missing Docker images +func ErrImageNotFound(imageName string) *OrkError { + return &OrkError{ + Op: "docker.image", + Kind: ErrorDocker, + Message: fmt.Sprintf("Docker image not found: %s", imageName), + Hint: "Pull the image with: docker pull " + imageName, + Details: []string{ + "Check if the image name is correct", + "Verify you have access to the registry", + }, + } +} + +// ============================================================================ +// Error Wrapping - Add context to existing errors +// ============================================================================ + +// Wrap adds context to an existing error +func Wrap(err error, op, message string) error { + if err == nil { + return nil + } + + // If it's already an OrkError, just add to the operation chain + if orkErr, ok := err.(*OrkError); ok { + orkErr.Op = op + "." + orkErr.Op + return orkErr + } + + // Otherwise create a new OrkError + return &OrkError{ + Op: op, + Err: err, + Message: message, + } +} + +// WrapWithHint adds context and a hint to an existing error +func WrapWithHint(err error, op, message, hint string) error { + if err == nil { + return nil + } + + if orkErr, ok := err.(*OrkError); ok { + orkErr.Op = op + "." + orkErr.Op + if orkErr.Hint == "" { + orkErr.Hint = hint + } + return orkErr + } + + return &OrkError{ + Op: op, + Err: err, + Message: message, + Hint: hint, + } +} + +// ============================================================================ +// Error Checking Helpers +// ============================================================================ + +// IsKind checks if an error is of a specific kind +func IsKind(err error, kind ErrorKind) bool { + if orkErr, ok := err.(*OrkError); ok { + return orkErr.Kind == kind + } + return false +} + +// IsDockerError checks if error is Docker-related +func IsDockerError(err error) bool { + return IsKind(err, ErrorDocker) +} + +// IsConfigError checks if the error is configuration-related +func IsConfigError(err error) bool { + return IsKind(err, ErrorConfig) +} + +// IsNetworkError checks if the error is network-related +func IsNetworkError(err error) bool { + return IsKind(err, ErrorNetwork) +} + +// ============================================================================ +// Did You Mean - Fuzzy matching for suggestions +// ============================================================================ + +// FindSuggestions returns similar strings using simple edit distance +// (basic implementation - can be improved with Levenshtein distance) +func FindSuggestions(input string, options []string, maxSuggestions int) []string { + if len(options) == 0 { + return nil + } + + var suggestions []string + input = strings.ToLower(input) + + // Check for prefix matches first + for _, option := range options { + if strings.HasPrefix(strings.ToLower(option), input) { + suggestions = append(suggestions, option) + if len(suggestions) >= maxSuggestions { + return suggestions + } + } + } + + // Check for contents matches + if len(suggestions) < maxSuggestions { + for _, option := range options { + if strings.Contains(strings.ToLower(option), input) { + // Skip if already in suggestions + found := false + for _, s := range suggestions { + if s == option { + found = true + break + } + } + if !found { + suggestions = append(suggestions, option) + if len(suggestions) >= maxSuggestions { + return suggestions + } + } + } + } + } + + return suggestions +}