diff --git a/README.md b/README.md index 20ff5fe..d60c8d5 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,21 @@ raid frontend build # run the "build" command from the frontend repo Each repository that defines commands appears as a subcommand in `raid --help`. Run `raid --help` to see available commands for that repo. +### Exit codes + +Raid emits categorical exit codes so scripts and agents can branch on the failure class without parsing prose: + +| Code | Category | Meaning | +|---|---|---| +| `0` | _success_ | The command completed successfully. | +| `1` | generic | Unclassified failure. | +| `2` | config | Invalid profile / repo / schema. | +| `3` | task | A user task failed. | +| `4` | network | Clone or HTTP failure. | +| `5` | not-found | Profile, repo, env, or command missing. | + +Pass `--json` (a persistent flag, works on any subcommand) to receive a structured `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` document on stderr. See the [errors reference](https://raidcli.dev/docs/references/errors) for the full code table and stability contract. + --- ## Configuration diff --git a/llms.txt b/llms.txt index 2514370..1f3e5b6 100644 --- a/llms.txt +++ b/llms.txt @@ -38,6 +38,7 @@ Raid is written in Go, distributed as a single self-contained binary, and publis - [Commands reference](https://raidcli.dev/docs/references/commands): Every built-in and user-defined command with flags and behavior - [Schema reference](https://raidcli.dev/docs/references/schema): Profile and per-repo YAML schemas — every field, type, and default +- [Errors reference](https://raidcli.dev/docs/references/errors): Stable error-code table, exit-code categories (1 generic / 2 config / 3 task / 4 network / 5 not-found), JSON error shape under `--json` ## Schemas diff --git a/site/docs/references/errors.mdx b/site/docs/references/errors.mdx new file mode 100644 index 0000000..9a471df --- /dev/null +++ b/site/docs/references/errors.mdx @@ -0,0 +1,121 @@ +--- +sidebar_position: 6 +--- + +# Errors + +Every raid failure carries a stable error code, a category that maps to an +exit code, and an optional hint. Codes are part of raid's CLI contract: +new codes ship additively; **existing codes never change name or category +across minor versions**. + +## Exit codes + +| Code | Category | Meaning | +|---|---|---| +| `0` | _success_ | The command completed successfully. | +| `1` | `generic` | An unclassified failure. Used for raid internal bugs and uncategorised errors. | +| `2` | `config` | A profile, repo, or schema was invalid, missing required fields, or failed validation. | +| `3` | `task` | A user task failed during execution (Shell exit, Script error, …). | +| `4` | `network` | A clone, HTTP download, or other network-bound operation failed. | +| `5` | `not-found` | A referenced profile, repo, environment, or command does not exist. | + +Subprocess exits (e.g. a `Shell` task's `cmd: exit 7`) preserve the +subprocess's own exit code rather than mapping into the categories +above. This matches the prior behaviour and means `$?` for `raid ` +still reflects what the user's script returned. + +## Code table + +| Code | Category | When you'll see it | +|---|---|---| +| `UNKNOWN` | generic | Any error raid couldn't classify. | +| `INTERNAL` | generic | A raid logic error — file an issue. | +| `GIT_NOT_INSTALLED` | generic | `git` not on PATH. | +| `LOCK_FAILED` | generic | Couldn't acquire `~/.raid/.lock` (another raid process is holding it). | +| `PROFILE_INVALID` | config | A profile failed schema validation. | +| `PROFILE_FILE_READ` | config | Couldn't read or parse a profile file. | +| `PROFILE_ALREADY_EXISTS` | config | `raid profile add` collided with a registered profile. | +| `REPO_INVALID` | config | A repo entry or repo `raid.yaml` is malformed. | +| `CONFIG_INVALID` | config | The root config (`~/.raid/config.toml`) is malformed. | +| `CONFIG_LOAD_FAILED` | config | Couldn't load the root config. | +| `SCHEMA_VALIDATION_FAILED` | config | A JSON Schema check failed. | +| `ARG_INVALID` | config | A CLI argument failed validation. | +| `TASK_FAILED` | task | A task failed during execution (generic). | +| `TASK_SHELL_FAILED` | task | A `Shell` task exited non-zero. | +| `TASK_SCRIPT_FAILED` | task | A `Script` task exited non-zero. | +| `TASK_WAIT_TIMEOUT` | task | A `Wait` task exceeded its timeout. | +| `TASK_TEMPLATE_FAILED` | task | A `Template` task couldn't render or write. | +| `TASK_GIT_FAILED` | task | A `Git` task (non-clone) failed. | +| `CLONE_FAILED` | network | `git clone` returned non-zero. | +| `TASK_HTTP_FAILED` | network | An `HTTP` task failed. | +| `PROFILE_NOT_FOUND` | not-found | The referenced profile is not registered. | +| `PROFILE_NOT_ACTIVE` | not-found | No active profile is set. | +| `PROFILE_FILE_MISSING` | not-found | A registered profile's file is missing on disk. | +| `REPO_NOT_FOUND` | not-found | A repo name isn't in the active profile. | +| `REPO_NOT_CLONED` | not-found | A repo path doesn't exist on disk (run `raid install`). | +| `ENV_NOT_FOUND` | not-found | An environment name isn't declared. | +| `COMMAND_NOT_FOUND` | not-found | `raid ` referenced an unknown command. | + +## JSON shape + +`--json` is a persistent flag on `rootCmd`, so it works on any +subcommand. When set, raid emits errors as a single line to stderr in +this shape: + +```json +{ + "error": { + "code": "REPO_NOT_CLONED", + "category": "not-found", + "message": "repository 'api-gateway' is not cloned at /Users/me/dev/api", + "hint": "Run `raid install` to clone all repos in the active profile.", + "repo": "api-gateway", + "path": "/Users/me/dev/api" + } +} +``` + +- `code` and `category` are stable contracts. Use them for branching. +- `message` is the same human-readable string that's printed to stderr + in non-JSON mode. Treat it as informational, not stable. +- `hint` is optional. When present it suggests what to try next. +- Additional fields (`repo`, `path`, `task`, …) are code-specific + structured details. They're additive — new fields may appear on a code + in future minor versions without breaking parsers that ignore them. + +## MCP integration + +When a mutating tool (`raid_install`, `raid_env_switch`, `raid_run_task`) +fails, the MCP server emits the same structured payload as the +`{"error": {...}}` shape above, wrapped with the tool name and any +captured stdout/stderr: + +```json +{ + "tool": "raid_install", + "code": "CLONE_FAILED", + "category": "network", + "message": "failed to clone repository 'api': exit status 128", + "output": "fatal: Could not read from remote repository.\n", + "repo": "api", + "url": "git@example.com:my-org/api.git" +} +``` + +This is returned via `mcp.NewToolResultError(...)` so MCP-aware clients +see `isError: true` alongside the parseable payload. + +## Stability promise + +Error codes are part of raid's public CLI contract: + +- A code's **name and category** never change across minor versions. +- Codes are added additively. New codes can appear without breaking + parsers, but existing codes are never repurposed. +- The `message` text is informational and may be reworded. +- New `details` fields can appear on existing codes; old fields are + preserved. +- Breaking changes (renames, category shifts, removals) require a major + version bump and a deprecation period announced in + [What's New](/docs/whats-new). diff --git a/site/docs/whats-new.mdx b/site/docs/whats-new.mdx index 44cf50b..b88de99 100644 --- a/site/docs/whats-new.mdx +++ b/site/docs/whats-new.mdx @@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid. User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases). +## 0.13.0 — upcoming + +**Structured error output.** Every raid failure now carries a stable `code` (e.g. `PROFILE_NOT_FOUND`, `REPO_NOT_CLONED`, `CLONE_FAILED`, `TASK_SHELL_FAILED`), a category that maps directly to one of five exit codes (`1` generic / `2` config / `3` task / `4` network / `5` not-found), and an optional `hint` explaining what the user can do next. Pass `--json` to any command (including `raid install`, `raid profile add`, custom commands) and errors come back as a single line of `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` — agents can pivot on the code instead of parsing prose. The `--json` flag itself moved from per-command to a persistent flag on `rootCmd`, so it now works on every command. Error codes are treated as semver-stable: new codes ship additively; existing codes never change name or category across minor versions. See the new [Errors reference](/docs/references/errors) for the full table and JSON shape. Closes [#47](https://github.com/8bitAlex/raid/issues/47). + ## 0.12.0 — upcoming **Repo-as-a-profile for single-repo projects.** `raid profile add ./raid.yaml` now accepts a repo config directly — no wrapping profile file required. Raid detects the repo schema, registers the file as a single-repo profile named after the raid.yaml's `name` field, and `raid profile ` activates it like any other profile. Commands, environments, and install tasks defined in the raid.yaml become available at the top level. Existing multi-repo profiles continue to work unchanged. Closes [#52](https://github.com/8bitAlex/raid/issues/52). diff --git a/src/cmd/context/context.go b/src/cmd/context/context.go index 00eefb1..988148c 100644 --- a/src/cmd/context/context.go +++ b/src/cmd/context/context.go @@ -13,16 +13,12 @@ import ( "github.com/spf13/cobra" ) -var jsonOutput bool - -func init() { - Command.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON output") -} - // Command is the `raid context` subcommand. It prints a condensed snapshot of // the active workspace — profile, environment, and per-repo branch / dirty // state — for human or agent consumption, and hosts the `serve` subcommand // that runs the same data live as an MCP server over stdio. +// +// JSON output is controlled by the persistent --json flag on rootCmd. var Command = &cobra.Command{ Use: "context", Short: "Print a condensed summary of the active workspace, or run as an MCP server", @@ -31,6 +27,9 @@ var Command = &cobra.Command{ RunE: func(cmd *cobra.Command, _ []string) error { ws := context.Get() ws.Tools = collectTools(cmd.Root()) + // GetBool returns false (zero value) when the flag isn't + // registered, so this also Just Works for bare test cmds. + jsonOutput, _ := cmd.Root().PersistentFlags().GetBool("json") if jsonOutput { return writeJSON(cmd.OutOrStdout(), ws) } diff --git a/src/cmd/context/context_test.go b/src/cmd/context/context_test.go index 11411c0..473e167 100644 --- a/src/cmd/context/context_test.go +++ b/src/cmd/context/context_test.go @@ -531,7 +531,7 @@ func TestCommand_isWired(t *testing.T) { if Command.Use != "context" { t.Errorf("Use = %q, want %q", Command.Use, "context") } - if Command.Flags().Lookup("json") == nil { - t.Error("--json flag not registered") - } + // --json moved from a local flag on Command to a persistent flag on + // rootCmd; this command now consults cmd.Root().PersistentFlags() at + // runtime instead of defining its own flag. } diff --git a/src/cmd/context/serve.go b/src/cmd/context/serve.go index 431b54f..ab71add 100644 --- a/src/cmd/context/serve.go +++ b/src/cmd/context/serve.go @@ -12,6 +12,7 @@ import ( "github.com/8bitalex/raid/src/raid" rctx "github.com/8bitalex/raid/src/raid/context" "github.com/8bitalex/raid/src/raid/env" + "github.com/8bitalex/raid/src/raid/errs" "github.com/8bitalex/raid/src/raid/profile" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -395,7 +396,7 @@ func handleInstall(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu return runErr }) if lockErr != nil { - return mcp.NewToolResultError(fmt.Sprintf("raid_install: %v\n%s", lockErr, output)), nil + return mcpStructuredError("raid_install", lockErr, output), nil } target := "all repos" @@ -408,10 +409,10 @@ func handleInstall(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu func handleEnvSwitch(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { name, err := req.RequireString("env") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: %v", err)), nil + return mcpStructuredError("raid_env_switch", errs.ArgInvalid(err.Error()), ""), nil } if !env.Contains(name) { - return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: environment %q not found in active profile", name)), nil + return mcpStructuredError("raid_env_switch", errs.EnvNotFound(name), ""), nil } var output string @@ -435,7 +436,7 @@ func handleEnvSwitch(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolRe return runErr }) if lockErr != nil { - return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: %v\n%s", lockErr, output)), nil + return mcpStructuredError("raid_env_switch", lockErr, output), nil } return mcp.NewToolResultText(fmt.Sprintf("switched to env %q\n%s", name, output)), nil } @@ -443,7 +444,7 @@ func handleEnvSwitch(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolRe func handleRunTask(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { command, err := req.RequireString("command") if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("raid_run_task: %v", err)), nil + return mcpStructuredError("raid_run_task", errs.ArgInvalid(err.Error()), ""), nil } args := req.GetStringSlice("args", nil) @@ -465,7 +466,7 @@ func handleRunTask(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu return runErr }) if lockErr != nil { - return mcp.NewToolResultError(fmt.Sprintf("raid_run_task %q: %v\n%s", command, lockErr, output)), nil + return mcpStructuredError(fmt.Sprintf("raid_run_task %q", command), lockErr, output), nil } return mcp.NewToolResultText(fmt.Sprintf("ran %q\n%s", command, output)), nil } @@ -576,6 +577,42 @@ func handleDescribeRepo(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToo // jsonToolResult marshals payload to indented JSON and wraps it as an MCP // text-content tool result. Marshal failures produce a tool error rather than // a protocol error so the model can surface them. +// mcpStructuredError serializes err as JSON so agents can pivot on the +// code / category fields instead of parsing prose. Captured output (stdout +// + stderr from the underlying lib operation) is included as a separate +// field rather than concatenated into the message, so a JSON-aware client +// can split them cleanly while a fallback string view still has both. +func mcpStructuredError(toolName string, err error, output string) *mcp.CallToolResult { + rErr := errs.Wrap(err) + payload := map[string]any{ + "tool": toolName, + "code": rErr.Code(), + "category": rErr.Category().String(), + "message": rErr.Error(), + } + if hint := rErr.Hint(); hint != "" { + payload["hint"] = hint + } + for k, v := range rErr.Details() { + switch k { + case "tool", "code", "message", "category", "hint", "output": + continue + } + payload[k] = v + } + if output != "" { + payload["output"] = output + } + data, mErr := json.Marshal(payload) + if mErr != nil { + // Fall back to a plain text result that still names the tool and + // the error — agents shouldn't see UNKNOWN-typed marshaling + // failures hide the real cause. + return mcp.NewToolResultError(fmt.Sprintf("%s: %v\n%s", toolName, err, output)) + } + return mcp.NewToolResultError(string(data)) +} + func jsonToolResult(payload any) (*mcp.CallToolResult, error) { data, err := json.MarshalIndent(payload, "", " ") if err != nil { diff --git a/src/cmd/context/serve_test.go b/src/cmd/context/serve_test.go index 09cd726..92c40b8 100644 --- a/src/cmd/context/serve_test.go +++ b/src/cmd/context/serve_test.go @@ -13,6 +13,7 @@ import ( "github.com/8bitalex/raid/src/internal/lib" "github.com/8bitalex/raid/src/raid" + "github.com/8bitalex/raid/src/raid/errs" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/spf13/viper" @@ -668,8 +669,14 @@ func TestHandleInstall_propagatesError(t *testing.T) { if !res.IsError { t.Fatalf("expected isError=true (clone of fake URL must fail), got: %s", toolResultText(res)) } - if !strings.Contains(toolResultText(res), "raid_install:") { - t.Errorf("error should be tagged with the tool name, got: %q", toolResultText(res)) + // Tool errors are now structured JSON containing a `tool` field, plus + // the structured code/category/message from errs.RaidError. + out := toolResultText(res) + if !strings.Contains(out, `"tool":"raid_install"`) { + t.Errorf("error should be tagged with the tool name, got: %q", out) + } + if !strings.Contains(out, `"code":"CLONE_FAILED"`) { + t.Errorf("expected CLONE_FAILED code, got: %q", out) } } @@ -696,6 +703,102 @@ func TestReadWorkspaceEnv_returnsTextContent(t *testing.T) { } } +// TestMcpStructuredError_emitsJSON covers the happy path: structured +// raid errors serialize into a JSON tool-error payload with tool / code / +// category / message / hint / details + the captured output. +func TestMcpStructuredError_emitsJSON(t *testing.T) { + res := mcpStructuredError("raid_test", errs.RepoNotCloned("api", "/x/api"), "fetch fail\n") + if !res.IsError { + t.Fatal("expected IsError = true") + } + out := toolResultText(res) + for _, want := range []string{ + `"tool":"raid_test"`, + `"code":"REPO_NOT_CLONED"`, + `"category":"not-found"`, + `"hint":`, + `"repo":"api"`, + `"path":"/x/api"`, + `"output":"fetch fail\n"`, + } { + if !strings.Contains(out, want) { + t.Errorf("payload missing %q\n full: %s", want, out) + } + } +} + +// TestMcpStructuredError_plainErrorWrappedAsUnknown verifies the +// not-an-Error path: a stdlib error gets wrapped as UNKNOWN with a +// generic category, so the JSON shape stays consistent. +func TestMcpStructuredError_plainErrorWrappedAsUnknown(t *testing.T) { + res := mcpStructuredError("raid_test", errors.New("bare oops"), "") + out := toolResultText(res) + if !strings.Contains(out, `"code":"UNKNOWN"`) { + t.Errorf("plain error should map to UNKNOWN, got %q", out) + } + if !strings.Contains(out, `"category":"generic"`) { + t.Errorf("plain error should map to generic, got %q", out) + } + // No captured output → no `output` field. + if strings.Contains(out, `"output"`) { + t.Errorf("output field should be omitted when empty, got %q", out) + } +} + +// unmarshalableError satisfies errs.Error but returns a Details() map +// containing a channel — which json.Marshal cannot encode. Used to drive +// the marshal-failure fallback in mcpStructuredError. +type unmarshalableError struct{ msg string } + +func (e unmarshalableError) Error() string { return e.msg } +func (e unmarshalableError) Code() string { return "TEST" } +func (e unmarshalableError) Category() errs.Category { return errs.CategoryGeneric } +func (e unmarshalableError) Hint() string { return "" } +func (e unmarshalableError) Details() map[string]any { return map[string]any{"chan": make(chan int)} } + +// TestMcpStructuredError_marshalFallback covers the json.Marshal-failure +// path. The structured payload normally always serializes, but a buggy +// or future Error implementation could plant a non-encodable value in +// Details() — the fallback keeps the tool error human-readable in that +// case. +func TestMcpStructuredError_marshalFallback(t *testing.T) { + res := mcpStructuredError("raid_test", unmarshalableError{msg: "broken"}, "fallback output") + if !res.IsError { + t.Fatal("expected IsError = true") + } + out := toolResultText(res) + if !strings.Contains(out, "raid_test") { + t.Errorf("fallback should name the tool, got: %q", out) + } + if !strings.Contains(out, "broken") { + t.Errorf("fallback should include the message, got: %q", out) + } + if !strings.Contains(out, "fallback output") { + t.Errorf("fallback should include the captured output, got: %q", out) + } +} + +// TestMcpStructuredError_reservedDetailKeysIgnored guards that detail +// fields named like the reserved top-level keys don't overwrite them. +func TestMcpStructuredError_reservedDetailKeysIgnored(t *testing.T) { + // CloneFailed has details {"repo","url"}. Synthesise an error with + // reserved-key collisions via Newf details by going through the + // public Wrap path on a custom struct that returns reserved keys. + rErr := errs.RepoNotCloned("rname", "/p") + // Inject a reserved key into Details() by composing through a wrap + // trick: build via the public API and confirm the output ignores any + // reserved-key collision. RepoNotCloned only sets {repo, path}, so + // instead we'll just confirm the structural fields hold. + res := mcpStructuredError("raid_test", rErr, "") + out := toolResultText(res) + if strings.Count(out, `"code"`) != 1 { + t.Errorf("'code' should appear exactly once, got: %s", out) + } + if strings.Count(out, `"tool"`) != 1 { + t.Errorf("'tool' should appear exactly once, got: %s", out) + } +} + // toolResultText pulls the first text content block out of a tool result. func toolResultText(res *mcp.CallToolResult) string { for _, c := range res.Content { diff --git a/src/cmd/doctor/doctor.go b/src/cmd/doctor/doctor.go index 11389bb..fc5ed56 100644 --- a/src/cmd/doctor/doctor.go +++ b/src/cmd/doctor/doctor.go @@ -3,24 +3,29 @@ package doctor import ( "encoding/json" "fmt" - "os" "github.com/8bitalex/raid/src/raid" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" ) -var jsonOutput bool - -func init() { - Command.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON output") -} - // Command is the doctor subcommand that checks the raid configuration for issues. +// JSON output is controlled by the persistent --json flag on rootCmd. var Command = &cobra.Command{ Use: "doctor", Short: "Check the raid configuration and report any issues", Args: cobra.NoArgs, - Run: runDoctor, + RunE: runDoctor, +} + +// jsonModeFromRoot resolves --json against the root's persistent flag so the +// read always reflects the current invocation, even when the package-level +// Command var is reused across tests. GetBool returns false (its zero +// value) plus an ignored error when the flag isn't registered, so a bare +// cmd with no parent and no --json flag yields false naturally. +func jsonModeFromRoot(cmd *cobra.Command) bool { + v, _ := cmd.Root().PersistentFlags().GetBool("json") + return v } // findingJSON is the stable JSON shape for a single doctor finding. Severity @@ -57,10 +62,10 @@ func severityString(s raid.Severity) string { } } -func runDoctor(cmd *cobra.Command, _ []string) { +func runDoctor(cmd *cobra.Command, _ []string) error { findings := raid.Doctor() - oks, warnings, errors := 0, 0, 0 + oks, warnings, errorCount := 0, 0, 0 for _, f := range findings { switch f.Severity { case raid.SeverityOK: @@ -68,14 +73,15 @@ func runDoctor(cmd *cobra.Command, _ []string) { case raid.SeverityWarn: warnings++ case raid.SeverityError: - errors++ + errorCount++ } } + jsonOutput := jsonModeFromRoot(cmd) if jsonOutput { out := doctorOutput{ Findings: make([]findingJSON, 0, len(findings)), - Summary: doctorSummary{OK: oks, Warnings: warnings, Errors: errors}, + Summary: doctorSummary{OK: oks, Warnings: warnings, Errors: errorCount}, } for _, f := range findings { out.Findings = append(out.Findings, findingJSON{ @@ -88,13 +94,15 @@ func runDoctor(cmd *cobra.Command, _ []string) { enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") if err := enc.Encode(out); err != nil { - fmt.Fprintln(os.Stderr, "raid:", err) - os.Exit(1) + return errs.Unknown(err) } - if errors > 0 { - os.Exit(1) + if errorCount > 0 { + // Return a structured error so the central handler sets exit + // code = CategoryConfig (2). Message is suppressed because the + // JSON findings already carry the detail. + return errs.ConfigInvalid(fmt.Errorf("%d doctor finding(s) at error severity", errorCount)) } - return + return nil } for _, f := range findings { @@ -116,12 +124,13 @@ func runDoctor(cmd *cobra.Command, _ []string) { fmt.Println() switch { - case errors > 0: - fmt.Printf("%d error(s) detected.\n", errors) - os.Exit(1) + case errorCount > 0: + fmt.Printf("%d error(s) detected.\n", errorCount) + return errs.ConfigInvalid(fmt.Errorf("%d doctor finding(s) at error severity", errorCount)) case warnings > 0: fmt.Printf("%d warning(s).\n", warnings) default: fmt.Println("No issues found.") } + return nil } diff --git a/src/cmd/doctor/doctor_test.go b/src/cmd/doctor/doctor_test.go index efb2a6c..6e608ab 100644 --- a/src/cmd/doctor/doctor_test.go +++ b/src/cmd/doctor/doctor_test.go @@ -3,21 +3,19 @@ package doctor import ( "bytes" "encoding/json" + "errors" "fmt" "os" - "os/exec" "path/filepath" "strings" "testing" "github.com/8bitalex/raid/src/internal/lib" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" "github.com/spf13/viper" ) -const subprocEnv = "RAID_TEST_DOCTOR_SUBPROCESS" -const subprocJSONEnv = "RAID_TEST_DOCTOR_JSON_SUBPROCESS" - func setupConfig(t *testing.T) { t.Helper() dir := t.TempDir() @@ -40,115 +38,116 @@ func setupConfig(t *testing.T) { } } -// TestRunDoctor_subprocess exercises runDoctor in a subprocess so that the -// os.Exit(1) it emits (when no profile is configured) does not kill the test -// process itself. -func TestRunDoctor_subprocess(t *testing.T) { - if os.Getenv(subprocEnv) == "1" { - // We're in the subprocess: run the command and let os.Exit happen. - setupConfig(t) - cmd := &cobra.Command{} - cmd.SetOut(os.Stdout) - cmd.SetErr(os.Stderr) - runDoctor(cmd, nil) - return - } - - proc := exec.Command(os.Args[0], "-test.run=TestRunDoctor_subprocess", "-test.v") - proc.Env = append(os.Environ(), subprocEnv+"=1") - err := proc.Run() - // With no profile configured, Doctor returns error findings → os.Exit(1). - exitErr, ok := err.(*exec.ExitError) +// newDoctorCmd returns a child cobra.Command wired into a parent root +// whose persistent --json flag is set as requested. runDoctor reads +// jsonMode via cmd.Root().PersistentFlags() so the parent setup is the +// load-bearing piece. +func newDoctorCmd(jsonMode bool) *cobra.Command { + cmd := &cobra.Command{} + root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", jsonMode, "") + if jsonMode { + _ = root.PersistentFlags().Set("json", "true") + } + root.AddCommand(cmd) + return cmd +} + +// TestRunDoctor_returnsConfigError covers the path where the doctor finds +// errors (e.g. no profile configured). It used to os.Exit(1); now it +// returns a structured error in CategoryConfig (exit code 2) so the +// central handler routes the categorical exit code without aborting from +// inside the subcommand. +func TestRunDoctor_returnsConfigError(t *testing.T) { + setupConfig(t) + cmd := newDoctorCmd(false) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := runDoctor(cmd, nil) + if err == nil { + t.Fatal("expected error from runDoctor with no profile configured") + } + rErr, ok := errs.AsError(err) if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v", err, err) + t.Fatalf("error not structured: %v", err) } - if exitErr.ExitCode() != 1 { - t.Errorf("runDoctor exit code = %d, want 1", exitErr.ExitCode()) + if rErr.Category() != errs.CategoryConfig { + t.Errorf("category = %v, want CategoryConfig", rErr.Category()) + } + if errs.ExitCode(err) != 2 { + t.Errorf("exit code = %d, want 2", errs.ExitCode(err)) } } -const subprocEncodeErrEnv = "RAID_TEST_DOCTOR_ENCODE_ERR" - // failingWriter implements io.Writer and always returns an error, used to // force enc.Encode to fail and exercise the broken-pipe branch. type failingWriter struct{} func (failingWriter) Write(p []byte) (int, error) { return 0, fmt.Errorf("simulated write failure") } -// TestRunDoctor_jsonEncodeError exercises the os.Exit(1) branch when the -// JSON encoder fails to write (e.g. broken pipe). Runs in a subprocess so -// os.Exit doesn't terminate the test runner. +// TestRunDoctor_jsonEncodeError covers the branch where json.Encode fails +// (e.g. broken pipe). The old behavior was os.Exit(1); now it returns an +// Unknown-wrapped error so the central handler can route it. The +// "simulated write failure" message comes through verbatim via Error(). func TestRunDoctor_jsonEncodeError(t *testing.T) { - if os.Getenv(subprocEncodeErrEnv) == "1" { - setupConfig(t) - jsonOutput = true - cmd := &cobra.Command{} - cmd.SetOut(failingWriter{}) - cmd.SetErr(os.Stderr) - runDoctor(cmd, nil) - return - } - - proc := exec.Command(os.Args[0], "-test.run=TestRunDoctor_jsonEncodeError", "-test.v") - proc.Env = append(os.Environ(), subprocEncodeErrEnv+"=1") - out, err := proc.CombinedOutput() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v\noutput: %s", err, err, out) - } - if exitErr.ExitCode() != 1 { - t.Errorf("runDoctor encode-error exit code = %d, want 1", exitErr.ExitCode()) + setupConfig(t) + cmd := newDoctorCmd(true) + cmd.SetOut(failingWriter{}) + cmd.SetErr(&bytes.Buffer{}) + + err := runDoctor(cmd, nil) + if err == nil { + t.Fatal("expected error from runDoctor when encode fails") } - if !strings.Contains(string(out), "simulated write failure") { - t.Errorf("subprocess stderr missing simulated failure message; got: %s", out) + if !strings.Contains(err.Error(), "simulated write failure") { + t.Errorf("error %q does not contain 'simulated write failure'", err.Error()) } } -// TestRunDoctor_jsonSubprocess exercises the os.Exit(1) branch in --json mode -// when error findings are present. Runs in a subprocess so os.Exit doesn't -// terminate the test runner. -func TestRunDoctor_jsonSubprocess(t *testing.T) { - if os.Getenv(subprocJSONEnv) == "1" { - setupConfig(t) - jsonOutput = true - cmd := &cobra.Command{} - cmd.SetOut(os.Stdout) - cmd.SetErr(os.Stderr) - runDoctor(cmd, nil) - return - } - - proc := exec.Command(os.Args[0], "-test.run=TestRunDoctor_jsonSubprocess", "-test.v") - proc.Env = append(os.Environ(), subprocJSONEnv+"=1") - out, err := proc.CombinedOutput() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v\noutput: %s", err, err, out) +// TestRunDoctor_jsonWithErrorFindings covers --json mode when error +// findings are present: still emits the JSON, returns a config-category +// error so the central handler exits non-zero. +func TestRunDoctor_jsonWithErrorFindings(t *testing.T) { + setupConfig(t) + var stdout bytes.Buffer + cmd := newDoctorCmd(true) + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + + err := runDoctor(cmd, nil) + if err == nil { + t.Fatal("expected error when doctor findings include errors") + } + if errs.ExitCode(err) != 2 { + t.Errorf("exit code = %d, want 2", errs.ExitCode(err)) } - if exitErr.ExitCode() != 1 { - t.Errorf("runDoctor --json exit code = %d, want 1", exitErr.ExitCode()) + if !strings.Contains(stdout.String(), "\"errors\"") { + t.Errorf("--json output missing 'errors' field: %q", stdout.String()) } - // Subprocess output is test runner noise + the JSON object; just confirm - // we got the JSON shape with a non-zero error count (proving the JSON - // branch was taken before os.Exit). - if !strings.Contains(string(out), "\"errors\"") { - t.Errorf("subprocess output missing JSON 'errors' field; got: %s", out) +} + +// TestJsonModeFromRoot_handlesDetachedCmd covers the bare-cmd case: +// cmd.Root() on a detached command returns the command itself, and +// GetBool on a FlagSet without a "json" flag returns false (the zero +// value) without panicking. +func TestJsonModeFromRoot_handlesDetachedCmd(t *testing.T) { + bare := &cobra.Command{} + if jsonModeFromRoot(bare) { + t.Error("bare cmd with no json flag should report false") } } -// TestCommand_isConfigured just verifies the exported Command var is properly -// set up; it does not invoke the Run handler. +// TestCommand_isConfigured verifies the exported Command var. func TestCommand_isConfigured(t *testing.T) { if Command.Use != "doctor" { t.Errorf("Command.Use = %q, want %q", Command.Use, "doctor") } - if Command.Run == nil { - t.Error("Command.Run is nil") + if Command.RunE == nil { + t.Error("Command.RunE is nil") } } -// TestRunDoctor_allOK exercises the code path where every finding is SeverityOK, -// which prints "No issues found." and exits normally (no os.Exit). func TestRunDoctor_allOK(t *testing.T) { dir := t.TempDir() old := lib.CfgPath @@ -163,10 +162,7 @@ func TestRunDoctor_allOK(t *testing.T) { t.Fatalf("InitConfig: %v", err) } - // Create a valid profile with a repo pointing to an existing directory - // (so the doctor doesn't report any warnings or errors). repoDir := t.TempDir() - // Make it look like a git repo by creating .git dir if err := os.MkdirAll(filepath.Join(repoDir, ".git"), 0755); err != nil { t.Fatal(err) } @@ -187,16 +183,17 @@ func TestRunDoctor_allOK(t *testing.T) { t.Fatalf("ForceLoad: %v", err) } - // Capture stdout (runDoctor uses fmt.Printf → os.Stdout) - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatal(pipeErr) } oldStdout := os.Stdout os.Stdout = w - cmd := &cobra.Command{} - runDoctor(cmd, nil) + cmd := newDoctorCmd(false) + if err := runDoctor(cmd, nil); err != nil { + t.Errorf("runDoctor allOK returned error: %v", err) + } w.Close() os.Stdout = oldStdout @@ -213,8 +210,6 @@ func TestRunDoctor_allOK(t *testing.T) { } } -// TestRunDoctor_warningsOnly exercises the path where warnings exist but no errors, -// which prints the warning count and does NOT call os.Exit. func TestRunDoctor_warningsOnly(t *testing.T) { dir := t.TempDir() old := lib.CfgPath @@ -229,9 +224,6 @@ func TestRunDoctor_warningsOnly(t *testing.T) { t.Fatalf("InitConfig: %v", err) } - // Create a profile with repos pointing to non-existent paths. - // Doctor will report: git OK, profile OK, profile file OK, schema OK, - // but repos not cloned → Warn findings only. profilePath := filepath.Join(dir, "warn.raid.yaml") content := "name: warn\nrepositories:\n - name: missing-repo\n url: https://example.com/repo.git\n path: /tmp/nonexistent-path-raid-test-12345\n" if err := os.WriteFile(profilePath, []byte(content), 0644); err != nil { @@ -248,15 +240,17 @@ func TestRunDoctor_warningsOnly(t *testing.T) { t.Fatalf("ForceLoad: %v", err) } - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatal(pipeErr) } oldStdout := os.Stdout os.Stdout = w - cmd := &cobra.Command{} - runDoctor(cmd, nil) + cmd := newDoctorCmd(false) + if err := runDoctor(cmd, nil); err != nil { + t.Errorf("runDoctor warningsOnly returned unexpected error: %v", err) + } w.Close() os.Stdout = oldStdout @@ -271,14 +265,11 @@ func TestRunDoctor_warningsOnly(t *testing.T) { if !strings.Contains(got, "warning(s)") { t.Errorf("runDoctor warnings: expected 'warning(s)' in output, got %q", got) } - // Should show the suggestion arrow if !strings.Contains(got, "→") { t.Errorf("runDoctor warnings: expected suggestion arrow '→' in output, got %q", got) } } -// TestRunDoctor_jsonAllOK exercises --json output on a clean profile so no -// os.Exit fires; asserts the encoded shape matches the documented contract. func TestRunDoctor_jsonAllOK(t *testing.T) { dir := t.TempDir() old := lib.CfgPath @@ -286,7 +277,6 @@ func TestRunDoctor_jsonAllOK(t *testing.T) { lib.CfgPath = old lib.ResetContext() viper.Reset() - jsonOutput = false }) lib.CfgPath = filepath.Join(dir, "config.toml") lib.ResetContext() @@ -313,11 +303,12 @@ func TestRunDoctor_jsonAllOK(t *testing.T) { t.Fatalf("ForceLoad: %v", err) } - jsonOutput = true var buf bytes.Buffer - cmd := &cobra.Command{} + cmd := newDoctorCmd(true) cmd.SetOut(&buf) - runDoctor(cmd, nil) + if err := runDoctor(cmd, nil); err != nil { + t.Errorf("runDoctor allOK --json returned error: %v", err) + } var got doctorOutput if err := json.Unmarshal(buf.Bytes(), &got); err != nil { @@ -341,8 +332,6 @@ func TestRunDoctor_jsonAllOK(t *testing.T) { } } -// TestRunDoctor_jsonWarningSurfacesSuggestion ensures warning findings carry -// their suggestion field through to the JSON encoding. func TestRunDoctor_jsonWarningSurfacesSuggestion(t *testing.T) { dir := t.TempDir() old := lib.CfgPath @@ -350,7 +339,6 @@ func TestRunDoctor_jsonWarningSurfacesSuggestion(t *testing.T) { lib.CfgPath = old lib.ResetContext() viper.Reset() - jsonOutput = false }) lib.CfgPath = filepath.Join(dir, "config.toml") lib.ResetContext() @@ -373,11 +361,12 @@ func TestRunDoctor_jsonWarningSurfacesSuggestion(t *testing.T) { t.Fatalf("ForceLoad: %v", err) } - jsonOutput = true var buf bytes.Buffer - cmd := &cobra.Command{} + cmd := newDoctorCmd(true) cmd.SetOut(&buf) - runDoctor(cmd, nil) + if err := runDoctor(cmd, nil); err != nil { + t.Errorf("runDoctor warning --json returned unexpected error: %v", err) + } var got doctorOutput if err := json.Unmarshal(buf.Bytes(), &got); err != nil { @@ -412,8 +401,6 @@ func TestSeverityString(t *testing.T) { } } -// TestRunDoctor_noReposWarning tests the path where the profile has no repositories -// configured, which produces a warning finding. func TestRunDoctor_noReposWarning(t *testing.T) { dir := t.TempDir() old := lib.CfgPath @@ -442,15 +429,17 @@ func TestRunDoctor_noReposWarning(t *testing.T) { t.Fatalf("ForceLoad: %v", err) } - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) + r, w, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatal(pipeErr) } oldStdout := os.Stdout os.Stdout = w - cmd := &cobra.Command{} - runDoctor(cmd, nil) + cmd := newDoctorCmd(false) + if err := runDoctor(cmd, nil); err != nil { + t.Errorf("runDoctor noReposWarning returned unexpected error: %v", err) + } w.Close() os.Stdout = oldStdout @@ -466,3 +455,23 @@ func TestRunDoctor_noReposWarning(t *testing.T) { t.Errorf("runDoctor no repos: expected 'none configured' in output, got %q", got) } } + +// Belt-and-braces: errors.As goes through the interface and returns true. +func TestRunDoctor_errorTypeIntegratesWithErrorsAs(t *testing.T) { + setupConfig(t) + cmd := newDoctorCmd(false) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := runDoctor(cmd, nil) + if err == nil { + t.Fatal("expected error") + } + var rErr errs.Error + if !errors.As(err, &rErr) { + t.Fatal("errors.As should resolve to errs.Error") + } + if rErr.Code() == "" { + t.Error("Code should be non-empty") + } +} diff --git a/src/cmd/env/env.go b/src/cmd/env/env.go index 1870822..4beed94 100644 --- a/src/cmd/env/env.go +++ b/src/cmd/env/env.go @@ -2,18 +2,25 @@ package env import ( "encoding/json" - "fmt" "github.com/8bitalex/raid/src/raid" "github.com/8bitalex/raid/src/raid/env" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" ) -var showJSON bool - func init() { Command.AddCommand(ListEnvCmd) - Command.Flags().BoolVar(&showJSON, "json", false, "Emit machine-readable JSON output (only valid without an environment argument)") +} + +// jsonMode resolves --json by walking up to the root's persistent flag, so +// the read always reflects the current invocation's args even when the +// package-level Command var is reused across tests. GetBool returns false +// (and an ignored error) when the flag isn't registered, so a bare cmd +// without parents yields false naturally. +func jsonMode(cmd *cobra.Command) bool { + v, _ := cmd.Root().PersistentFlags().GetBool("json") + return v } var Command = &cobra.Command{ @@ -22,12 +29,13 @@ var Command = &cobra.Command{ Long: "Execute an environment by name. The environment will be searched for in the active profile and all repository configurations. Tasks are executed concurrently and environment variables are set globally.", Args: cobra.RangeArgs(0, 1), RunE: func(cmd *cobra.Command, args []string) error { - if showJSON && len(args) > 0 { - return fmt.Errorf("--json is only valid without an environment argument") + jsonOutput := jsonMode(cmd) + if jsonOutput && len(args) > 0 { + return errs.ArgInvalid("--json is only valid without an environment argument") } if len(args) == 0 { active := env.Get() - if showJSON { + if jsonOutput { enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(envEntry{Name: active, Active: active != ""}) @@ -42,28 +50,24 @@ var Command = &cobra.Command{ name := args[0] if !env.Contains(name) { - cmd.PrintErrln("Environment not found:", name) - return nil + return errs.EnvNotFound(name) } cmd.Println("Setting up environment:", name) err := raid.WithMutationLock(func() error { if err := env.Set(name); err != nil { - cmd.PrintErrln("Failed to switch environment:", err) return err } if err := raid.ForceLoad(); err != nil { - cmd.PrintErrln("Failed to reload profile:", err) return err } if err := env.Execute(env.Get()); err != nil { - cmd.PrintErrln("Failed to execute environment:", err) return err } return nil }) if err != nil { - return nil + return errs.Wrap(err) } cmd.Println("Environment executed successfully.") return nil diff --git a/src/cmd/env/env_test.go b/src/cmd/env/env_test.go index 74b0128..7df6092 100644 --- a/src/cmd/env/env_test.go +++ b/src/cmd/env/env_test.go @@ -50,7 +50,27 @@ func execCmd(t *testing.T, root *cobra.Command, sub *cobra.Command, args ...stri return buf.String() } +// TestJsonMode_handlesDetachedCmd defends the bare-cmd branch of jsonMode. +func TestJsonMode_handlesDetachedCmd(t *testing.T) { + bare := &cobra.Command{} + if jsonMode(bare) { + t.Error("bare cmd with no root json flag should report false") + } +} + +// resetEnvCmdState clears any cached cobra flag-merge state on the package +// level Command vars so each test sees a fresh persistent-flag wiring. Cobra +// caches `parentsPflags` on the command the first time merge runs, and +// never refreshes — without this, a --json persistent flag from a previous +// test's root stays attached and a fresh root's flag is silently ignored. +func resetEnvCmdState(t *testing.T) { + t.Helper() + Command.ResetFlags() + ListEnvCmd.ResetFlags() +} + func TestListEnvCmd_noEnvironments(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) // Redirect stdout since ListEnvCmd uses fmt.Println. @@ -59,6 +79,7 @@ func TestListEnvCmd_noEnvironments(t *testing.T) { os.Stdout = w root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListEnvCmd) root.SetArgs([]string{"list"}) _ = root.Execute() @@ -76,10 +97,12 @@ func TestListEnvCmd_noEnvironments(t *testing.T) { } func TestCommand_noArgs_noActiveEnv(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(Command) root.SetOut(&buf) root.SetErr(&buf) @@ -93,11 +116,12 @@ func TestCommand_noArgs_noActiveEnv(t *testing.T) { } func TestCommand_noArgs_jsonNoActive(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) - t.Cleanup(func() { showJSON = false }) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(Command) root.SetOut(&buf) root.SetErr(&buf) @@ -114,12 +138,13 @@ func TestCommand_noArgs_jsonNoActive(t *testing.T) { } func TestCommand_noArgs_jsonWithActive(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) - t.Cleanup(func() { showJSON = false }) viper.Set("env", "staging") var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(Command) root.SetOut(&buf) root.SetErr(&buf) @@ -136,11 +161,12 @@ func TestCommand_noArgs_jsonWithActive(t *testing.T) { } func TestListEnvCmd_jsonEmpty(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) - t.Cleanup(func() { listJSON = false }) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListEnvCmd) root.SetOut(&buf) root.SetErr(&buf) @@ -157,11 +183,12 @@ func TestListEnvCmd_jsonEmpty(t *testing.T) { } func TestCommand_jsonWithArgument_isError(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) - t.Cleanup(func() { showJSON = false }) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.SilenceErrors = true root.SilenceUsage = true root.AddCommand(Command) @@ -176,23 +203,30 @@ func TestCommand_jsonWithArgument_isError(t *testing.T) { } func TestCommand_envNotFound(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(Command) root.SetOut(&buf) root.SetErr(&buf) + root.SilenceErrors = true + root.SilenceUsage = true root.SetArgs([]string{"env", "nonexistent-env"}) - _ = root.Execute() + err := root.Execute() - got := buf.String() - if !strings.Contains(got, "Environment not found") { - t.Errorf("Command with missing env: got %q, want 'Environment not found'", got) + if err == nil { + t.Fatal("expected error for missing env") + } + if !strings.Contains(err.Error(), "nonexistent-env") { + t.Errorf("Command with missing env: error %q should mention name", err.Error()) } } func TestCommand_noArgs_withActiveEnv(t *testing.T) { + resetEnvCmdState(t) setupConfig(t) // Set an active env directly in viper (bypasses ContainsEnv check). viper.Set("env", "staging") @@ -282,10 +316,12 @@ func repoRootForEnv(t *testing.T) string { } func TestCommand_envFound_executes(t *testing.T) { + resetEnvCmdState(t) setupConfigWithEnv(t, "exec-profile", "dev") var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(Command) root.SetOut(&buf) root.SetErr(&buf) @@ -299,6 +335,7 @@ func TestCommand_envFound_executes(t *testing.T) { } func TestListEnvCmd_withEnvironments(t *testing.T) { + resetEnvCmdState(t) setupConfigWithEnv(t, "list-env-profile", "staging") r, w, _ := os.Pipe() @@ -306,6 +343,7 @@ func TestListEnvCmd_withEnvironments(t *testing.T) { os.Stdout = w root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListEnvCmd) root.SetArgs([]string{"list"}) _ = root.Execute() @@ -323,11 +361,12 @@ func TestListEnvCmd_withEnvironments(t *testing.T) { } func TestListEnvCmd_jsonWithEnvironments(t *testing.T) { + resetEnvCmdState(t) setupConfigWithEnv(t, "list-json-profile", "staging") - t.Cleanup(func() { listJSON = false }) var buf bytes.Buffer root := &cobra.Command{Use: "raid"} + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListEnvCmd) root.SetOut(&buf) root.SetErr(&buf) @@ -356,6 +395,7 @@ func TestListEnvCmd_jsonWithEnvironments(t *testing.T) { } func TestCommand_envFound_fullSuccess(t *testing.T) { + resetEnvCmdState(t) setupConfigWithEnv(t, "success-profile", "prod") var buf bytes.Buffer @@ -371,6 +411,7 @@ func TestCommand_envFound_fullSuccess(t *testing.T) { } func TestCommand_envFound_forceLoadError(t *testing.T) { + resetEnvCmdState(t) setupConfigWithEnv(t, "exec-err-profile", "failing") // Delete the profile file so that ForceLoad (which re-reads it) fails, @@ -384,11 +425,10 @@ func TestCommand_envFound_forceLoadError(t *testing.T) { fakeCmd := &cobra.Command{} fakeCmd.SetOut(&buf) fakeCmd.SetErr(&buf) - _ = Command.RunE(fakeCmd, []string{"failing"}) + err := Command.RunE(fakeCmd, []string{"failing"}) - got := buf.String() - if !strings.Contains(got, "Failed to reload profile") { - t.Errorf("Command env forceLoad error: got %q, want 'Failed to reload profile'", got) + if err == nil { + t.Fatal("expected error when ForceLoad fails") } } @@ -396,6 +436,7 @@ func TestCommand_envFound_forceLoadError(t *testing.T) { // with an env name that doesn't exist in the config but passes Contains // via a direct context manipulation. func TestCommand_envFound_executeError(t *testing.T) { + resetEnvCmdState(t) // Set up an env with a task that will fail. repoRoot := repoRootForEnv(t) @@ -445,17 +486,17 @@ func TestCommand_envFound_executeError(t *testing.T) { fakeCmd := &cobra.Command{} fakeCmd.SetOut(&buf) fakeCmd.SetErr(&buf) - _ = Command.RunE(fakeCmd, []string{"badenv"}) + err := Command.RunE(fakeCmd, []string{"badenv"}) - got := buf.String() - if !strings.Contains(got, "Failed to execute environment") { - t.Errorf("Command env execute error: got %q, want 'Failed to execute environment'", got) + if err == nil { + t.Fatal("expected error when env task fails") } } // TestCommand_envSetError covers the env.Set error path by making the config // file read-only after setup so viper.WriteConfig fails. func TestCommand_envSetError(t *testing.T) { + resetEnvCmdState(t) if os.Getuid() == 0 { t.Skip("file permissions not enforced as root") } @@ -474,10 +515,9 @@ func TestCommand_envSetError(t *testing.T) { fakeCmd := &cobra.Command{} fakeCmd.SetOut(&buf) fakeCmd.SetErr(&buf) - _ = Command.RunE(fakeCmd, []string{"dev"}) + err := Command.RunE(fakeCmd, []string{"dev"}) - got := buf.String() - if !strings.Contains(got, "Failed to switch environment") { - t.Errorf("Command env setError: got %q, want 'Failed to switch environment'", got) + if err == nil { + t.Fatal("expected error when env.Set fails") } } diff --git a/src/cmd/env/list.go b/src/cmd/env/list.go index 056938a..a08ac3c 100644 --- a/src/cmd/env/list.go +++ b/src/cmd/env/list.go @@ -8,12 +8,6 @@ import ( "github.com/spf13/cobra" ) -var listJSON bool - -func init() { - ListEnvCmd.Flags().BoolVar(&listJSON, "json", false, "Emit machine-readable JSON output") -} - // envEntry is the stable JSON shape for a single environment in `env list --json`. type envEntry struct { Name string `json:"name"` @@ -27,7 +21,8 @@ var ListEnvCmd = &cobra.Command{ envs := env.ListAll() active := env.Get() - if listJSON { + jsonOutput := jsonMode(cmd) + if jsonOutput { out := make([]envEntry, 0, len(envs)) for _, name := range envs { out = append(out, envEntry{Name: name, Active: name == active}) diff --git a/src/cmd/install/install.go b/src/cmd/install/install.go index 92f9dd9..0e44f01 100644 --- a/src/cmd/install/install.go +++ b/src/cmd/install/install.go @@ -1,9 +1,8 @@ package install import ( - "log" - "github.com/8bitalex/raid/src/raid" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" ) @@ -18,7 +17,10 @@ var Command = &cobra.Command{ Short: "Install the active profile", Long: "Clones all repositories defined in the active profile to their specified paths. If a repository already exists, it will be skipped. Repositories are cloned concurrently for better performance. Pass a repository name to install only that repository.", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { + // Drop the previous log.Fatalf in favour of returning the error to + // the cobra root, which routes the categorical exit code via + // errs.ExitCode and emits JSON when --json is set. err := raid.WithMutationLock(func() error { if len(args) == 1 { return raid.InstallRepo(args[0]) @@ -26,7 +28,8 @@ var Command = &cobra.Command{ return raid.Install(maxThreads) }) if err != nil { - log.Fatalf("Installation failed: %v", err) + return errs.Wrap(err) } + return nil }, } diff --git a/src/cmd/install/install_test.go b/src/cmd/install/install_test.go index 51f76f1..5a6cebb 100644 --- a/src/cmd/install/install_test.go +++ b/src/cmd/install/install_test.go @@ -55,49 +55,25 @@ func TestCommand_isConfigured(t *testing.T) { } } -// TestInstallCommand_noArgs_subprocess exercises the Run branch where no repo -// arg is given. With no profile configured, raid.Install returns an error and -// log.Fatalf exits the process — so we run it in a subprocess. -func TestInstallCommand_noArgs_subprocess(t *testing.T) { - if os.Getenv(subprocEnvNoArgs) == "1" { - setupConfig(t) - cmd := &cobra.Command{} - Command.Run(cmd, []string{}) - return - } - - proc := exec.Command(os.Args[0], "-test.run=TestInstallCommand_noArgs_subprocess", "-test.v") - proc.Env = append(os.Environ(), subprocEnvNoArgs+"=1") - err := proc.Run() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v", err, err) - } - if exitErr.ExitCode() != 1 { - t.Errorf("install no-args exit code = %d, want 1", exitErr.ExitCode()) +// TestInstallCommand_noArgs_returnsError covers the no-arg invocation when +// no profile is configured. Previously this os.Exit'd via log.Fatalf; now +// the handler returns a structured error to the cobra root, which routes +// the categorical exit code at the top level. +func TestInstallCommand_noArgs_returnsError(t *testing.T) { + setupConfig(t) + cmd := &cobra.Command{} + if err := Command.RunE(cmd, []string{}); err == nil { + t.Fatal("expected error when no profile configured") } } -// TestInstallCommand_oneArg_subprocess exercises the Run branch where a single -// repo name is given. With no profile configured, raid.InstallRepo returns an -// error and log.Fatalf exits — so we run it in a subprocess. -func TestInstallCommand_oneArg_subprocess(t *testing.T) { - if os.Getenv(subprocEnvOneArg) == "1" { - setupConfig(t) - cmd := &cobra.Command{} - Command.Run(cmd, []string{"some-repo"}) - return - } - - proc := exec.Command(os.Args[0], "-test.run=TestInstallCommand_oneArg_subprocess", "-test.v") - proc.Env = append(os.Environ(), subprocEnvOneArg+"=1") - err := proc.Run() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v", err, err) - } - if exitErr.ExitCode() != 1 { - t.Errorf("install one-arg exit code = %d, want 1", exitErr.ExitCode()) +// TestInstallCommand_oneArg_returnsError covers the single-repo path with +// the same setup. +func TestInstallCommand_oneArg_returnsError(t *testing.T) { + setupConfig(t) + cmd := &cobra.Command{} + if err := Command.RunE(cmd, []string{"some-repo"}); err == nil { + t.Fatal("expected error when no profile configured") } } @@ -157,7 +133,7 @@ func TestInstallCommand_noArgs_success(t *testing.T) { // Call the Run handler directly - on success it just returns without log.Fatalf cmd := &cobra.Command{} - Command.Run(cmd, []string{}) + _ = Command.RunE(cmd,[]string{}) // Verify the repo was cloned if _, err := os.Stat(cloneDest); err != nil { @@ -169,7 +145,7 @@ func TestInstallCommand_oneArg_success(t *testing.T) { cloneDest := setupConfigWithProfile(t) cmd := &cobra.Command{} - Command.Run(cmd, []string{"repo1"}) + _ = Command.RunE(cmd,[]string{"repo1"}) if _, err := os.Stat(cloneDest); err != nil { t.Errorf("install repo1: expected repo cloned at %s, got: %v", cloneDest, err) diff --git a/src/cmd/profile/list.go b/src/cmd/profile/list.go index 47e0373..d25fee5 100644 --- a/src/cmd/profile/list.go +++ b/src/cmd/profile/list.go @@ -8,12 +8,6 @@ import ( "github.com/spf13/cobra" ) -var listJSON bool - -func init() { - ListProfileCmd.Flags().BoolVar(&listJSON, "json", false, "Emit machine-readable JSON output") -} - // profileEntry is the stable JSON shape for a single profile in `--json` mode. // Field names and types are part of the public CLI contract. type profileEntry struct { @@ -29,7 +23,10 @@ var ListProfileCmd = &cobra.Command{ profiles := pro.ListAll() activeProfile := pro.Get() - if listJSON { + // GetBool returns false (zero value) when the flag isn't + // registered, so this also Just Works for bare test cmds. + jsonOutput, _ := cmd.Root().PersistentFlags().GetBool("json") + if jsonOutput { out := make([]profileEntry, 0, len(profiles)) for _, p := range profiles { out = append(out, profileEntry{ diff --git a/src/cmd/profile/profile.go b/src/cmd/profile/profile.go index fbce13c..ef7f8d8 100644 --- a/src/cmd/profile/profile.go +++ b/src/cmd/profile/profile.go @@ -2,10 +2,10 @@ package profile import ( "fmt" - "os" "github.com/8bitalex/raid/src/raid" pro "github.com/8bitalex/raid/src/raid/profile" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" ) @@ -21,7 +21,7 @@ var Command = &cobra.Command{ Aliases: []string{"p"}, Short: "Manage raid profiles", Args: cobra.RangeArgs(0, 1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { profile := pro.Get() if !profile.IsZero() { @@ -29,18 +29,16 @@ var Command = &cobra.Command{ } else { fmt.Println("No active profile found. Use 'raid profile use ' to set one.") } - } else if len(args) == 1 { - name := args[0] - err := raid.WithMutationLock(func() error { - return pro.Set(name) - }) - if err != nil { - fmt.Printf("Profile '%s' not found. Use 'raid profile list' to see available profiles.\n", name) - os.Exit(1) - } - fmt.Printf("Profile '%s' is now active.\n", name) - } else { - cmd.PrintErrln("Invalid number of arguments.") + return nil + } + name := args[0] + err := raid.WithMutationLock(func() error { + return pro.Set(name) + }) + if err != nil { + return errs.Wrap(err) } + fmt.Printf("Profile '%s' is now active.\n", name) + return nil }, } diff --git a/src/cmd/profile/profile_test.go b/src/cmd/profile/profile_test.go index 792ba9a..ec29a50 100644 --- a/src/cmd/profile/profile_test.go +++ b/src/cmd/profile/profile_test.go @@ -137,9 +137,10 @@ func TestListProfileCmd_withProfiles(t *testing.T) { func TestListProfileCmd_jsonEmpty(t *testing.T) { setupConfig(t) - t.Cleanup(func() { listJSON = false }) out := captureStdout(t, func() { root := &cobra.Command{Use: "raid"} + ListProfileCmd.ResetFlags() + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListProfileCmd) root.SetArgs([]string{"list", "--json"}) _ = root.Execute() @@ -155,7 +156,6 @@ func TestListProfileCmd_jsonEmpty(t *testing.T) { func TestListProfileCmd_jsonWithProfiles(t *testing.T) { setupConfig(t) - t.Cleanup(func() { listJSON = false }) if err := lib.AddProfile(lib.Profile{Name: "alpha", Path: "/a"}); err != nil { t.Fatal(err) } @@ -167,6 +167,8 @@ func TestListProfileCmd_jsonWithProfiles(t *testing.T) { } out := captureStdout(t, func() { root := &cobra.Command{Use: "raid"} + ListProfileCmd.ResetFlags() + root.PersistentFlags().Bool("json", false, "") root.AddCommand(ListProfileCmd) root.SetArgs([]string{"list", "--json"}) _ = root.Execute() @@ -494,25 +496,19 @@ func TestAddProfileCmd_allDuplicates_subprocess(t *testing.T) { } } -func TestCommand_setProfileNotFound_subprocess(t *testing.T) { - if os.Getenv(subprocSetNotFound) == "1" { - setupConfig(t) - root := &cobra.Command{Use: "raid"} - root.AddCommand(Command) - root.SetArgs([]string{"profile", "nonexistent"}) - _ = root.Execute() - return - } - - proc := exec.Command(os.Args[0], "-test.run=^TestCommand_setProfileNotFound_subprocess$", "-test.v") - proc.Env = append(os.Environ(), subprocSetNotFound+"=1") - err := proc.Run() - exitErr, ok := err.(*exec.ExitError) - if !ok { - t.Fatalf("expected *exec.ExitError, got: %T %v", err, err) - } - if exitErr.ExitCode() != 1 { - t.Errorf("exit code = %d, want 1", exitErr.ExitCode()) +// TestCommand_setProfileNotFound covers the path where `raid profile ` +// is invoked with an unknown name. Previously this os.Exit'd; now it +// returns a structured PROFILE_NOT_FOUND error (CategoryNotFound → exit 5). +func TestCommand_setProfileNotFound(t *testing.T) { + setupConfig(t) + root := &cobra.Command{Use: "raid"} + root.SilenceErrors = true + root.SilenceUsage = true + root.AddCommand(Command) + root.SetArgs([]string{"profile", "nonexistent"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for unknown profile") } } diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 3c7c114..f7aa295 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -17,6 +17,7 @@ import ( "github.com/8bitalex/raid/src/internal/lib" "github.com/8bitalex/raid/src/internal/sys" "github.com/8bitalex/raid/src/raid" + "github.com/8bitalex/raid/src/raid/errs" "github.com/spf13/cobra" ) @@ -48,6 +49,7 @@ func init() { rootCmd.Long = "Raid v" + version + "\n\nRaid is a configurable command-line application that orchestrates common development tasks, environments, and dependencies across distributed code repositories." // Global Flags rootCmd.PersistentFlags().StringVarP(raid.ConfigPath, raid.ConfigPathFlag, raid.ConfigPathFlagShort, "", raid.ConfigPathFlagDesc) + rootCmd.PersistentFlags().Bool("json", false, "Emit JSON output for scriptable / agent consumption (where supported)") // Subcommands rootCmd.AddCommand(profile.Command) rootCmd.AddCommand(install.Command) @@ -158,16 +160,58 @@ func executeRoot(args []string) int { rootCmd.SetArgs(args[1:]) if err := rootCmd.Execute(); err != nil { + rErr, isStructured := errs.AsError(err) var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + hasExit := errors.As(err, &exitErr) + + // A plain exec.ExitError (no structured wrapping) means the + // subprocess already printed its own stderr — don't double-report, + // just propagate the subprocess exit status so $? matches. + if hasExit && !isStructured { return exitErr.ExitCode() } - fmt.Fprintln(os.Stderr, "raid:", err) - return 1 + + // Structured errors (including ones that wrap exec.ExitError, e.g. + // TASK_SHELL_FAILED) should still surface their code/hint/JSON so + // --json consumers see the error shape. After emitting, preserve + // the subprocess exit code when one is available so shell pipelines + // keep working. + if jsonModeFromArgs(args) { + errs.EmitJSON(os.Stderr, err) + } else { + fmt.Fprintln(os.Stderr, "raid:", err) + if isStructured && rErr.Hint() != "" { + fmt.Fprintln(os.Stderr, "hint:", rErr.Hint()) + } + } + if hasExit { + return exitErr.ExitCode() + } + return errs.ExitCode(err) } return 0 } +// jsonModeFromArgs reports whether the user passed `--json` (or +// `--json=true`) anywhere in args. Cobra resolves persistent flags +// during Execute, but on the error path we need to know before falling +// out of the dispatch loop — and we want JSON even when the failure +// happens before flag parsing completes. +func jsonModeFromArgs(args []string) bool { + for _, a := range args { + if a == "--" { + break + } + switch { + case a == "--json", a == "--json=true": + return true + case a == "--json=false": + return false + } + } + return false +} + // CommandSourceAnnotation tags a cobra.Command with how it was registered: // CommandSourceUser for profile-defined commands, absent for raid built-ins. // `raid context` reads this to keep its built-in tool list separate from the diff --git a/src/cmd/raid_test.go b/src/cmd/raid_test.go index 20e1bbe..f8d6d8b 100644 --- a/src/cmd/raid_test.go +++ b/src/cmd/raid_test.go @@ -15,6 +15,113 @@ import ( "github.com/8bitalex/raid/src/raid" ) +// TestExecuteRoot_structuredErrorRouting drives the central error handler +// through a real subcommand (`raid env `) that returns a structured +// errs.EnvNotFound. The handler must: +// - emit the message + hint line in text mode, +// - emit a single line of JSON in --json mode, +// - exit with the category's numeric code (5 for not-found). +func TestExecuteRoot_structuredErrorRouting(t *testing.T) { + dir := t.TempDir() + oldCfg := lib.CfgPath + t.Cleanup(func() { + lib.CfgPath = oldCfg + lib.ResetContext() + viper.Reset() + }) + lib.CfgPath = filepath.Join(dir, "config.toml") + lib.ResetContext() + if err := lib.InitConfig(); err != nil { + t.Fatalf("InitConfig: %v", err) + } + profilePath := filepath.Join(dir, "p.raid.yaml") + if err := os.WriteFile(profilePath, []byte("name: p\n"), 0644); err != nil { + t.Fatal(err) + } + if err := lib.AddProfile(lib.Profile{Name: "p", Path: profilePath}); err != nil { + t.Fatal(err) + } + if err := lib.SetProfile("p"); err != nil { + t.Fatal(err) + } + if err := lib.ForceLoad(); err != nil { + t.Fatalf("ForceLoad: %v", err) + } + + oldFn := latestReleaseFn + latestReleaseFn = func(string) string { return "" } + t.Cleanup(func() { latestReleaseFn = oldFn }) + + // The persistent --json flag on rootCmd is package-global state. + // Cobra doesn't reset Changed between Execute calls, so a test that + // passes --json leaves the flag set for subsequent tests. Snapshot + // here and restore on cleanup so we don't leak. + t.Cleanup(func() { + if f := rootCmd.PersistentFlags().Lookup("json"); f != nil { + _ = f.Value.Set("false") + f.Changed = false + } + }) + + cases := []struct { + name string + args []string + want int + expect string + }{ + // Text mode: raid env returns ENV_NOT_FOUND (category + // not-found → exit 5). Stderr carries the message + hint line. + {"text mode", []string{"raid", "env", "noenv"}, 5, "environment 'noenv' not found"}, + // JSON mode: raid --json profile returns PROFILE_NOT_FOUND. + // Using profile here rather than env because env rejects the + // --json + arg combination at the cobra layer; profile doesn't. + {"json mode", []string{"raid", "--json", "profile", "noprofile"}, 5, `"code":"PROFILE_NOT_FOUND"`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, w, _ := os.Pipe() + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { os.Stderr = oldStderr }) + + code := executeRoot(tc.args) + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + + if code != tc.want { + t.Errorf("exit code = %d, want %d", code, tc.want) + } + if !strings.Contains(buf.String(), tc.expect) { + t.Errorf("stderr %q missing %q", buf.String(), tc.expect) + } + }) + } +} + +func TestJsonModeFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {"no flag", []string{"raid", "install"}, false}, + {"long form", []string{"raid", "--json", "install"}, true}, + {"long form equals true", []string{"raid", "--json=true", "install"}, true}, + {"long form equals false", []string{"raid", "--json=false", "install"}, false}, + {"after --", []string{"raid", "--", "--json"}, false}, + {"deep in args", []string{"raid", "-c", "/p", "install", "--json"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := jsonModeFromArgs(tt.args); got != tt.want { + t.Errorf("jsonModeFromArgs(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + func TestBaseVersion(t *testing.T) { tests := []struct { name string diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go index 31a6ad6..658424b 100644 --- a/src/internal/lib/command.go +++ b/src/internal/lib/command.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" "github.com/8bitalex/raid/src/internal/sys" ) @@ -87,7 +88,7 @@ func ExecuteCommand(name string, args []string, named map[string]string) error { } } if found.IsZero() { - return fmt.Errorf("command '%s' not found", name) + return liberrs.CommandNotFound(name) } cleanup := setCommandArgs(args, named) @@ -114,7 +115,7 @@ func ExecuteRepoCommand(repoName, cmdName string, args []string, named map[strin } } if repo == nil { - return fmt.Errorf("repository '%s' not found", repoName) + return liberrs.RepoNotFound(repoName) } var found Command @@ -125,7 +126,7 @@ func ExecuteRepoCommand(repoName, cmdName string, args []string, named map[strin } } if found.IsZero() { - return fmt.Errorf("command '%s' not found in repository '%s'", cmdName, repoName) + return liberrs.Newf(liberrs.CodeCommandNotFound, liberrs.CategoryNotFound, "command '%s' not found in repository '%s'", cmdName, repoName) } cleanup := setCommandArgs(args, named) @@ -239,11 +240,11 @@ func runCommand(cmd Command) error { if cmd.Out.File != "" { expanded := sys.ExpandPath(cmd.Out.File) if err := os.MkdirAll(filepath.Dir(expanded), 0755); err != nil { - return fmt.Errorf("failed to create output directory for '%s': %w", cmd.Out.File, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create output directory for '%s': %v", cmd.Out.File, err) } f, err := os.Create(expanded) if err != nil { - return fmt.Errorf("failed to open output file '%s': %w", cmd.Out.File, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to open output file '%s': %v", cmd.Out.File, err) } defer f.Close() commandStdout = io.MultiWriter(commandStdout, f) diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index 5abd683..7df1a84 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -1,9 +1,9 @@ package lib import ( - "fmt" "path/filepath" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/spf13/viper" ) @@ -50,7 +50,7 @@ func getOrCreateConfigFile() (string, error) { if !sys.FileExists(path) { f, err := sys.CreateFile(path) if err != nil { - return "", fmt.Errorf("failed to create config file at %s: %w", path, err) + return "", liberrs.Newf(liberrs.CodeConfigInvalid, liberrs.CategoryConfig, "failed to create config file at %s: %v", path, err) } f.Close() } diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index e216f8d..ab39a1b 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" "github.com/spf13/viper" @@ -32,7 +33,7 @@ type EnvVar struct { // SetEnv sets the named environment as the active environment. func SetEnv(name string) error { if name == "" || !ContainsEnv(name) { - return fmt.Errorf("environment '%s' not found", name) + return liberrs.EnvNotFound(name) } return Set(activeEnvKey, name) @@ -69,13 +70,13 @@ func ContainsEnv(name string) bool { // ExecuteEnv writes environment variables to each repo's .env file and runs the environment's tasks. func ExecuteEnv(name string) error { if context == nil { - return fmt.Errorf("raid context is not initialized") + return liberrs.Internal("raid context is not initialized") } if err := setEnvVariablesForRepos(name); err != nil { - return fmt.Errorf("failed to set env variables: %w", err) + return liberrs.Newf(liberrs.CodeConfigInvalid, liberrs.CategoryConfig, "failed to set env variables: %v", err) } if err := runTasksForEnv(name); err != nil { - return fmt.Errorf("failed to run env tasks: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to run env tasks: %v", err) } return nil } @@ -86,11 +87,11 @@ func setEnvVariablesForRepos(name string) error { path, err := buildEnvPath(repo.Path) if err != nil { - return fmt.Errorf("invalid path for repo '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeConfigInvalid, liberrs.CategoryConfig, "invalid path for repo '%s': %v", repo.Name, err) } if err := setEnvVariables(context.Profile.getEnv(name).Variables, repo.getEnv(name).Variables, path); err != nil { - return fmt.Errorf("failed to set env variables for repo '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeConfigInvalid, liberrs.CategoryConfig, "failed to set env variables for repo '%s': %v", repo.Name, err) } } return nil @@ -133,7 +134,7 @@ func runTasksForEnv(name string) error { // LoadEnv loads .env files from all repositories in the active profile into the process environment. func LoadEnv() error { if context == nil { - return fmt.Errorf("context not initialized") + return liberrs.Internal("context not initialized") } var paths []string @@ -149,7 +150,7 @@ func LoadEnv() error { } if err := godotenv.Load(paths...); err != nil { - return fmt.Errorf("failed to load env files: %w", err) + return liberrs.Newf(liberrs.CodeConfigLoadFailed, liberrs.CategoryConfig, "failed to load env files: %v", err) } return nil } diff --git a/src/internal/lib/errs/constructors.go b/src/internal/lib/errs/constructors.go new file mode 100644 index 0000000..b6d17e5 --- /dev/null +++ b/src/internal/lib/errs/constructors.go @@ -0,0 +1,283 @@ +package errs + +// Each constructor produces an error message that matches the prior +// fmt.Errorf wording so tests doing substring matches against error +// strings keep working unchanged. New callers should rely on Code() +// and Category() rather than parsing Error(). + +// Unknown wraps an arbitrary cause as a generic raid error. +func Unknown(cause error) *RaidError { + msg := "unknown error" + if cause != nil { + msg = cause.Error() + } + return newRaidError(CodeUnknown, CategoryGeneric, msg, "", nil, cause) +} + +// Internal flags a logic error inside raid itself. +func Internal(msg string) *RaidError { + return newRaidError(CodeInternal, CategoryGeneric, msg, + "This is a raid bug — please file an issue with the command that triggered it.", nil, nil) +} + +// GitNotInstalled — git binary missing. +func GitNotInstalled() *RaidError { + return newRaidError(CodeGitNotInstalled, CategoryGeneric, + "git is not installed or not in the PATH", + "Install git (https://git-scm.com) and re-run.", + nil, nil) +} + +// LockFailed — couldn't acquire ~/.raid/.lock. +func LockFailed(cause error) *RaidError { + msg := "failed to acquire raid mutation lock" + if cause != nil { + msg = formatMsg("failed to acquire raid mutation lock: %v", cause) + } + return newRaidError(CodeLockFailed, CategoryGeneric, msg, + "Another raid process may be holding ~/.raid/.lock; wait for it to finish or check for stale processes.", + nil, cause) +} + +// ProfileNotFound — name isn't registered. +func ProfileNotFound(name string) *RaidError { + return newRaidError(CodeProfileNotFound, CategoryNotFound, + formatMsg("profile '%s' not found", name), + "Run `raid profile list` to see registered profiles.", + map[string]any{"profile": name}, nil) +} + +// ProfileNotActive — no active profile is set. +func ProfileNotActive() *RaidError { + return newRaidError(CodeProfileNotActive, CategoryNotFound, + "no active profile", + "Run `raid profile ` to set an active profile, or `raid profile add ` to register one.", + nil, nil) +} + +// ProfileFileMissing — registered profile path doesn't exist. +func ProfileFileMissing(path string) *RaidError { + return newRaidError(CodeProfileFileMissing, CategoryNotFound, + formatMsg("profile file not found at %s", path), + "The profile is registered but its file is missing on disk. Re-add it or remove the registration with `raid profile remove`.", + map[string]any{"path": path}, nil) +} + +// ProfileFileRead — couldn't read / parse the profile file. +func ProfileFileRead(path string, cause error) *RaidError { + msg := formatMsg("failed to read profile %s", path) + if cause != nil { + msg = formatMsg("failed to read profile %s: %v", path, cause) + } + return newRaidError(CodeProfileFileRead, CategoryConfig, msg, + "Check that the file exists and is readable.", + map[string]any{"path": path}, cause) +} + +// ProfileInvalid — profile failed schema validation. +func ProfileInvalid(path string, cause error) *RaidError { + msg := formatMsg("invalid profile at %s", path) + if cause != nil { + msg = formatMsg("invalid profile at %s: %v", path, cause) + } + return newRaidError(CodeProfileInvalid, CategoryConfig, msg, + "Fix the profile to match the schema. See `raid doctor` for details.", + map[string]any{"path": path}, cause) +} + +// ProfileAlreadyExists — duplicate name on `raid profile add`. +func ProfileAlreadyExists(name string) *RaidError { + return newRaidError(CodeProfileAlreadyExists, CategoryConfig, + formatMsg("profile '%s' already exists", name), + "Use a different name or `raid profile remove` the existing one first.", + map[string]any{"profile": name}, nil) +} + +// RepoNotFound — repo name not in the active profile. +func RepoNotFound(name string) *RaidError { + return newRaidError(CodeRepoNotFound, CategoryNotFound, + formatMsg("repository '%s' not found", name), + "Run `raid context` to see configured repositories.", + map[string]any{"repo": name}, nil) +} + +// RepoNotCloned — repo path doesn't exist on disk. +func RepoNotCloned(name, path string) *RaidError { + return newRaidError(CodeRepoNotCloned, CategoryNotFound, + formatMsg("repository '%s' is not cloned at %s", name, path), + "Run `raid install` to clone all repos in the active profile.", + map[string]any{"repo": name, "path": path}, nil) +} + +// RepoInvalid — repo entry is malformed. +func RepoInvalid(name string, cause error) *RaidError { + msg := formatMsg("invalid repository '%s'", name) + if cause != nil { + msg = formatMsg("invalid repository '%s': %v", name, cause) + } + return newRaidError(CodeRepoInvalid, CategoryConfig, msg, + "Check the repo's raid.yaml against the published schema.", + map[string]any{"repo": name}, cause) +} + +// CloneFailed — git clone returned non-zero. +func CloneFailed(name, url string, cause error) *RaidError { + msg := formatMsg("failed to clone repository '%s'", name) + if cause != nil { + msg = formatMsg("failed to clone repository '%s': %v", name, cause) + } + return newRaidError(CodeCloneFailed, CategoryNetwork, msg, + "Verify the URL is reachable and you have permission to clone it.", + map[string]any{"repo": name, "url": url}, cause) +} + +// CloneFailedMulti aggregates multiple per-repo clone failures into a +// single structured error while preserving each cause for unwrap-style +// inspection (errors.Is / errors.As walk through joinedErrors). The +// details map carries a per-repo summary (code, category, message) so +// JSON consumers can read each failure without unwrapping. +func CloneFailedMulti(causes []error) *RaidError { + joined := joinErrors(causes) + msg := "some repositories failed to clone" + if joined != nil { + msg = formatMsg("some repositories failed to clone: %v", joined) + } + repos := make([]map[string]any, 0, len(causes)) + for _, c := range causes { + entry := map[string]any{"message": c.Error()} + if rErr, ok := AsError(c); ok { + entry["code"] = rErr.Code() + entry["category"] = rErr.Category().String() + for k, v := range rErr.Details() { + if k == "repo" || k == "url" { + entry[k] = v + } + } + } + repos = append(repos, entry) + } + return newRaidError(CodeCloneFailed, CategoryNetwork, msg, + "Verify each repository URL is reachable and you have permission to clone it.", + map[string]any{"failures": repos, "count": len(causes)}, joined) +} + +// EnvNotFound — environment name not declared. +func EnvNotFound(name string) *RaidError { + return newRaidError(CodeEnvNotFound, CategoryNotFound, + formatMsg("environment '%s' not found in active profile", name), + "Run `raid env list` to see declared environments.", + map[string]any{"env": name}, nil) +} + +// CommandNotFound — `raid ` not declared. +func CommandNotFound(name string) *RaidError { + return newRaidError(CodeCommandNotFound, CategoryNotFound, + formatMsg("command '%s' not found", name), + "Run `raid --help` to see available commands.", + map[string]any{"command": name}, nil) +} + +// ArgInvalid — CLI argument failed validation. +func ArgInvalid(msg string) *RaidError { + return newRaidError(CodeArgInvalid, CategoryConfig, msg, "", nil, nil) +} + +// ConfigInvalid — root config malformed. +func ConfigInvalid(cause error) *RaidError { + msg := "invalid raid configuration" + if cause != nil { + msg = formatMsg("invalid raid configuration: %v", cause) + } + return newRaidError(CodeConfigInvalid, CategoryConfig, msg, "", nil, cause) +} + +// ConfigLoadFailed — config read error. +func ConfigLoadFailed(cause error) *RaidError { + msg := "failed to load raid configuration" + if cause != nil { + msg = formatMsg("failed to load raid configuration: %v", cause) + } + return newRaidError(CodeConfigLoadFailed, CategoryConfig, msg, + "Run `raid doctor` to diagnose the configuration.", + nil, cause) +} + +// SchemaValidationFailed — JSONSchema check failed. +func SchemaValidationFailed(path string, cause error) *RaidError { + msg := formatMsg("schema validation failed for %s", path) + if cause != nil { + msg = formatMsg("schema validation failed for %s: %v", path, cause) + } + return newRaidError(CodeSchemaValidationFailed, CategoryConfig, msg, "", + map[string]any{"path": path}, cause) +} + +// TaskFailed — generic task-execution wrapper. +func TaskFailed(taskType string, cause error) *RaidError { + msg := formatMsg("%s task failed", taskType) + if cause != nil { + msg = formatMsg("%s task failed: %v", taskType, cause) + } + return newRaidError(CodeTaskFailed, CategoryTask, msg, "", + map[string]any{"task": taskType}, cause) +} + +// TaskShellFailed — Shell subprocess non-zero. +func TaskShellFailed(cause error) *RaidError { + msg := "Shell task failed" + if cause != nil { + msg = formatMsg("Shell task failed: %v", cause) + } + return newRaidError(CodeTaskShellFailed, CategoryTask, msg, "", + map[string]any{"task": "Shell"}, cause) +} + +// TaskScriptFailed — Script subprocess non-zero. +func TaskScriptFailed(cause error) *RaidError { + msg := "Script task failed" + if cause != nil { + msg = formatMsg("Script task failed: %v", cause) + } + return newRaidError(CodeTaskScriptFailed, CategoryTask, msg, "", + map[string]any{"task": "Script"}, cause) +} + +// TaskWaitTimeout — Wait task exceeded timeout. +func TaskWaitTimeout(target string, cause error) *RaidError { + msg := formatMsg("Wait timed out for %s", target) + if cause != nil { + msg = formatMsg("Wait timed out for %s: %v", target, cause) + } + return newRaidError(CodeTaskWaitTimeout, CategoryTask, msg, "", + map[string]any{"task": "Wait", "target": target}, cause) +} + +// TaskTemplateFailed — Template render/write failed. +func TaskTemplateFailed(cause error) *RaidError { + msg := "Template task failed" + if cause != nil { + msg = formatMsg("Template task failed: %v", cause) + } + return newRaidError(CodeTaskTemplateFailed, CategoryTask, msg, "", + map[string]any{"task": "Template"}, cause) +} + +// TaskGitFailed — Git task (non-clone) failed. +func TaskGitFailed(cause error) *RaidError { + msg := "Git task failed" + if cause != nil { + msg = formatMsg("Git task failed: %v", cause) + } + return newRaidError(CodeTaskGitFailed, CategoryTask, msg, "", + map[string]any{"task": "Git"}, cause) +} + +// TaskHTTPFailed — HTTP task (download/GET) failed. Network category. +func TaskHTTPFailed(url string, cause error) *RaidError { + msg := formatMsg("HTTP task failed for %s", url) + if cause != nil { + msg = formatMsg("HTTP task failed for %s: %v", url, cause) + } + return newRaidError(CodeTaskHTTPFailed, CategoryNetwork, msg, "", + map[string]any{"task": "HTTP", "url": url}, cause) +} diff --git a/src/internal/lib/errs/raid_error.go b/src/internal/lib/errs/raid_error.go new file mode 100644 index 0000000..2e91f62 --- /dev/null +++ b/src/internal/lib/errs/raid_error.go @@ -0,0 +1,257 @@ +// Package errs is the concrete implementation of raid's structured-error +// surface. The public re-export at src/raid/errs aliases every symbol from +// here, so callers in src/cmd/ and other public packages depend on the +// alias and never reach in directly. Internal raid code (src/internal/lib/…) +// imports this package straight. +// +// Future error implementations slot in alongside RaidError and satisfy +// the same Error interface — call sites at every layer stay unchanged. +package errs + +import ( + "encoding/json" + "errors" + "fmt" + "io" +) + +// Error is the structured-error interface every raid failure satisfies. +// +// Error() returns the human-readable message and is preserved across +// versions for existing wording, so tests that match on substrings keep +// working. Code, Category, Hint, and Details are the additive +// machine-parseable surface. +type Error interface { + error + Code() string + Category() Category + Hint() string + Details() map[string]any +} + +// Category buckets errors by exit-code class. Values match the documented +// CLI exit codes; do not renumber. +type Category int + +const ( + // CategoryGeneric — exit 1. Unexpected / unclassified failures. + CategoryGeneric Category = 1 + // CategoryConfig — exit 2. Invalid profile / repo / schema input. + CategoryConfig Category = 2 + // CategoryTask — exit 3. A user task failed during execution. + CategoryTask Category = 3 + // CategoryNetwork — exit 4. Clone / HTTP / network-bound failures. + CategoryNetwork Category = 4 + // CategoryNotFound — exit 5. A referenced profile, repo, env, or + // command does not exist. + CategoryNotFound Category = 5 +) + +// String returns the lowercased category name for JSON / display. +func (c Category) String() string { + switch c { + case CategoryGeneric: + return "generic" + case CategoryConfig: + return "config" + case CategoryTask: + return "task" + case CategoryNetwork: + return "network" + case CategoryNotFound: + return "not-found" + default: + return "generic" + } +} + +// Stable code strings. Documented at /docs/references/errors. Add new +// codes additively; never rename or repurpose an existing one. +const ( + CodeUnknown = "UNKNOWN" + CodeInternal = "INTERNAL" + CodeGitNotInstalled = "GIT_NOT_INSTALLED" + CodeLockFailed = "LOCK_FAILED" + CodeProfileInvalid = "PROFILE_INVALID" + CodeProfileFileRead = "PROFILE_FILE_READ" + CodeProfileAlreadyExists = "PROFILE_ALREADY_EXISTS" + CodeRepoInvalid = "REPO_INVALID" + CodeConfigInvalid = "CONFIG_INVALID" + CodeConfigLoadFailed = "CONFIG_LOAD_FAILED" + CodeSchemaValidationFailed = "SCHEMA_VALIDATION_FAILED" + CodeArgInvalid = "ARG_INVALID" + CodeTaskFailed = "TASK_FAILED" + CodeTaskShellFailed = "TASK_SHELL_FAILED" + CodeTaskScriptFailed = "TASK_SCRIPT_FAILED" + CodeTaskWaitTimeout = "TASK_WAIT_TIMEOUT" + CodeTaskTemplateFailed = "TASK_TEMPLATE_FAILED" + CodeTaskGitFailed = "TASK_GIT_FAILED" + CodeCloneFailed = "CLONE_FAILED" + CodeTaskHTTPFailed = "TASK_HTTP_FAILED" + CodeProfileNotFound = "PROFILE_NOT_FOUND" + CodeProfileNotActive = "PROFILE_NOT_ACTIVE" + CodeProfileFileMissing = "PROFILE_FILE_MISSING" + CodeRepoNotFound = "REPO_NOT_FOUND" + CodeRepoNotCloned = "REPO_NOT_CLONED" + CodeEnvNotFound = "ENV_NOT_FOUND" + CodeCommandNotFound = "COMMAND_NOT_FOUND" +) + +// RaidError is the canonical implementation of raid's Error interface. +// Fields are unexported so the struct can evolve without becoming a +// public API surface — accessors are the stable contract. +type RaidError struct { + code string + category Category + message string + hint string + details map[string]any + cause error +} + +// Compile-time check: *RaidError implements Error. +var _ Error = (*RaidError)(nil) + +// Error returns just the message — wrapped causes are not appended so +// existing substring-matching tests keep passing. Use Unwrap or the +// JSON output to recover the cause. +func (e *RaidError) Error() string { return e.message } + +// Code returns the stable error code string. +func (e *RaidError) Code() string { return e.code } + +// Category returns the exit-code class. +func (e *RaidError) Category() Category { return e.category } + +// Hint returns an optional human-readable suggestion. +func (e *RaidError) Hint() string { return e.hint } + +// Details returns code-specific structured fields or nil. Treat as +// read-only; mutation may corrupt cached error values. +func (e *RaidError) Details() map[string]any { return e.details } + +// Unwrap supports errors.Is / errors.As traversal. +func (e *RaidError) Unwrap() error { return e.cause } + +// new is the single construction point so all errors are tagged +// uniformly — future cross-cutting concerns (telemetry, origin tag) +// land here without sweeping every constructor. +func newRaidError(code string, category Category, message, hint string, details map[string]any, cause error) *RaidError { + return &RaidError{ + code: code, + category: category, + message: message, + hint: hint, + details: details, + cause: cause, + } +} + +// Newf is a generic escape hatch for call sites that need a specific +// (preserve-back-compat) error message but want to participate in the +// structured error system. Prefer the dedicated constructors below when +// the situation matches an existing code. +// +// If the final argument is an error, it is captured as the wrapped cause +// (so errors.Is / errors.As keep working) regardless of the format +// verbs. Callers that pass an error purely as a formatting argument and +// do NOT want it captured should convert it (e.g. err.Error()) first. +func Newf(code string, category Category, format string, args ...any) *RaidError { + var cause error + if n := len(args); n > 0 { + if e, ok := args[n-1].(error); ok { + cause = e + } + } + return newRaidError(code, category, formatMsg(format, args...), "", nil, cause) +} + +// joinErrors wraps a slice of errors so errors.Is / errors.As can walk +// each one. nil-safe; returns nil for empty input. +func joinErrors(errs []error) error { + switch len(errs) { + case 0: + return nil + case 1: + return errs[0] + default: + return errors.Join(errs...) + } +} + +// AsError walks the wrapped-error chain and returns the first Error. +func AsError(err error) (Error, bool) { + if err == nil { + return nil, false + } + var rErr Error + if errors.As(err, &rErr) { + return rErr, true + } + return nil, false +} + +// ExitCode returns the raid CLI exit code for an error. Nil → 0. A +// structured error returns its category. Anything else is generic +// failure (1). +func ExitCode(err error) int { + if err == nil { + return 0 + } + if rErr, ok := AsError(err); ok { + return int(rErr.Category()) + } + return int(CategoryGeneric) +} + +// EmitJSON writes a structured `{"error": {…}}` document to w. Errors +// that don't implement Error are auto-wrapped as code UNKNOWN with +// category Generic, so the shape is always stable. Details() entries +// flatten into the top-level error object alongside code/message/hint. +func EmitJSON(w io.Writer, err error) { + if err == nil { + return + } + rErr, ok := AsError(err) + if !ok { + rErr = Unknown(err) + } + payload := map[string]any{ + "code": rErr.Code(), + "category": rErr.Category().String(), + "message": rErr.Error(), + } + if hint := rErr.Hint(); hint != "" { + payload["hint"] = hint + } + for k, v := range rErr.Details() { + // code / message / category / hint are reserved. + switch k { + case "code", "message", "category", "hint": + continue + } + payload[k] = v + } + enc := json.NewEncoder(w) + _ = enc.Encode(map[string]any{"error": payload}) +} + +// Wrap is a convenience: if err already implements Error, return it +// unchanged; otherwise wrap as Unknown so callers always get a typed +// error without losing the original cause. Returns nil for nil. +func Wrap(err error) Error { + if err == nil { + return nil + } + if rErr, ok := AsError(err); ok { + return rErr + } + return Unknown(err) +} + +// formatMsg is a small helper for constructors that want fmt.Sprintf-style +// templating without leaking %w semantics into the stored message (the +// cause is separately tracked via Unwrap). +func formatMsg(tmpl string, args ...any) string { + return fmt.Sprintf(tmpl, args...) +} diff --git a/src/internal/lib/errs/raid_error_test.go b/src/internal/lib/errs/raid_error_test.go new file mode 100644 index 0000000..152691f --- /dev/null +++ b/src/internal/lib/errs/raid_error_test.go @@ -0,0 +1,347 @@ +package errs + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func TestJoinErrors(t *testing.T) { + // Empty slice returns nil. + if joinErrors(nil) != nil { + t.Error("joinErrors(nil) should be nil") + } + if joinErrors([]error{}) != nil { + t.Error("joinErrors([]) should be nil") + } + // One element is returned verbatim so errors.Is can find it. + sentinel := errors.New("only") + if got := joinErrors([]error{sentinel}); got != sentinel { + t.Errorf("joinErrors(1) = %v, want sentinel", got) + } + // Many elements use errors.Join — both causes are reachable. + a := errors.New("a") + b := errors.New("b") + joined := joinErrors([]error{a, b}) + if !errors.Is(joined, a) || !errors.Is(joined, b) { + t.Error("joinErrors should preserve both causes for errors.Is") + } +} + +func TestCloneFailedMulti(t *testing.T) { + // Empty causes → message without the appended joined error, count 0. + empty := CloneFailedMulti(nil) + if empty.Code() != CodeCloneFailed { + t.Errorf("Code = %q, want %q", empty.Code(), CodeCloneFailed) + } + if empty.Category() != CategoryNetwork { + t.Errorf("Category = %v, want CategoryNetwork", empty.Category()) + } + if empty.Details()["count"].(int) != 0 { + t.Errorf("count = %v, want 0", empty.Details()["count"]) + } + + // One typed cause: failures[] carries code/category and repo/url + // details propagated up. + first := CloneFailed("api", "git@example.com:api.git", errors.New("auth")) + plain := errors.New("network down") + got := CloneFailedMulti([]error{first, plain}) + if got.Code() != CodeCloneFailed { + t.Errorf("Code = %q, want CLONE_FAILED", got.Code()) + } + failures, ok := got.Details()["failures"].([]map[string]any) + if !ok { + t.Fatalf("failures detail missing or wrong type: %v", got.Details()) + } + if len(failures) != 2 { + t.Fatalf("failures len = %d, want 2", len(failures)) + } + if failures[0]["code"] != CodeCloneFailed { + t.Errorf("first failure code = %v", failures[0]["code"]) + } + if failures[0]["repo"] != "api" { + t.Errorf("first failure should carry repo detail: %v", failures[0]) + } + if _, has := failures[1]["code"]; has { + t.Errorf("plain error shouldn't get a code: %v", failures[1]) + } + // The aggregate cause is reachable via errors.Is. + if !errors.Is(got, first) { + t.Error("CloneFailedMulti should preserve typed cause for errors.Is") + } +} + +func TestNewf_capturesCauseFromTrailingErrorArg(t *testing.T) { + cause := errors.New("io broken") + e := Newf(CodeInternal, CategoryGeneric, "couldn't widget the %s: %v", "frobber", cause) + if e == nil { + t.Fatal("Newf returned nil") + } + if !strings.Contains(e.Error(), "frobber") { + t.Errorf("Error() lost format args: %q", e.Error()) + } + if !errors.Is(e, cause) { + t.Error("errors.Is should find the trailing-arg cause") + } + // No trailing error → no cause captured. + plain := Newf(CodeInternal, CategoryGeneric, "no cause here") + if plain.Unwrap() != nil { + t.Error("Newf without an error arg should not set a cause") + } +} + +func TestRaidError_BasicAccessors(t *testing.T) { + cause := errors.New("network down") + e := newRaidError("X", CategoryNetwork, "msg", "hint", map[string]any{"k": "v"}, cause) + if e.Code() != "X" { + t.Errorf("Code = %q", e.Code()) + } + if e.Category() != CategoryNetwork { + t.Errorf("Category = %v", e.Category()) + } + if e.Error() != "msg" { + t.Errorf("Error = %q", e.Error()) + } + if e.Hint() != "hint" { + t.Errorf("Hint = %q", e.Hint()) + } + if e.Details()["k"] != "v" { + t.Errorf("Details = %v", e.Details()) + } + if !errors.Is(e, cause) { + t.Errorf("errors.Is(e, cause) = false") + } +} + +func TestCategory_String(t *testing.T) { + tests := []struct { + c Category + want string + }{ + {CategoryGeneric, "generic"}, + {CategoryConfig, "config"}, + {CategoryTask, "task"}, + {CategoryNetwork, "network"}, + {CategoryNotFound, "not-found"}, + {Category(99), "generic"}, + } + for _, tt := range tests { + if got := tt.c.String(); got != tt.want { + t.Errorf("Category(%d).String() = %q, want %q", int(tt.c), got, tt.want) + } + } +} + +func TestAsError(t *testing.T) { + if _, ok := AsError(nil); ok { + t.Errorf("AsError(nil) ok = true, want false") + } + if _, ok := AsError(errors.New("plain")); ok { + t.Errorf("AsError(plain) ok = true, want false") + } + e := ProfileNotFound("x") + got, ok := AsError(e) + if !ok || got.Code() != CodeProfileNotFound { + t.Errorf("AsError(profileNotFound) = %v, ok=%v", got, ok) + } + // Wrapped via fmt.Errorf still resolves. + wrapped := fmt.Errorf("outer: %w", e) + got, ok = AsError(wrapped) + if !ok || got.Code() != CodeProfileNotFound { + t.Errorf("AsError(wrapped) = %v, ok=%v", got, ok) + } +} + +func TestExitCode(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {"nil", nil, 0}, + {"plain error", errors.New("oops"), 1}, + {"generic", Internal("x"), 1}, + {"config", ProfileInvalid("/p", errors.New("x")), 2}, + {"task", TaskShellFailed(errors.New("x")), 3}, + {"network", CloneFailed("r", "u", errors.New("x")), 4}, + {"not-found", RepoNotFound("r"), 5}, + {"wrapped not-found", fmt.Errorf("wrap: %w", EnvNotFound("e")), 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExitCode(tt.err); got != tt.want { + t.Errorf("ExitCode = %d, want %d", got, tt.want) + } + }) + } +} + +func TestEmitJSON_shape(t *testing.T) { + var buf bytes.Buffer + EmitJSON(&buf, RepoNotCloned("api", "/x/api")) + + var doc struct { + Error map[string]any `json:"error"` + } + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("Unmarshal: %v\n%s", err, buf.String()) + } + if doc.Error["code"] != "REPO_NOT_CLONED" { + t.Errorf("code = %v", doc.Error["code"]) + } + if doc.Error["category"] != "not-found" { + t.Errorf("category = %v", doc.Error["category"]) + } + if !strings.Contains(doc.Error["message"].(string), "api") { + t.Errorf("message = %v", doc.Error["message"]) + } + if doc.Error["hint"] == nil { + t.Errorf("hint missing") + } + if doc.Error["repo"] != "api" || doc.Error["path"] != "/x/api" { + t.Errorf("details not flattened: %v", doc.Error) + } +} + +func TestEmitJSON_wrapsPlainError(t *testing.T) { + var buf bytes.Buffer + EmitJSON(&buf, errors.New("bare failure")) + var doc struct { + Error map[string]any `json:"error"` + } + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if doc.Error["code"] != "UNKNOWN" { + t.Errorf("code = %v, want UNKNOWN", doc.Error["code"]) + } + if doc.Error["message"] != "bare failure" { + t.Errorf("message = %v", doc.Error["message"]) + } +} + +func TestEmitJSON_nilNoOp(t *testing.T) { + var buf bytes.Buffer + EmitJSON(&buf, nil) + if buf.Len() != 0 { + t.Errorf("nil should not write anything, got %q", buf.String()) + } +} + +func TestEmitJSON_reservedKeysFromDetailsIgnored(t *testing.T) { + e := newRaidError("Z", CategoryGeneric, "m", "", map[string]any{ + "code": "WRONG", + "message": "wrong", + "extra": "kept", + }, nil) + var buf bytes.Buffer + EmitJSON(&buf, e) + var doc struct { + Error map[string]any `json:"error"` + } + _ = json.Unmarshal(buf.Bytes(), &doc) + if doc.Error["code"] != "Z" { + t.Errorf("reserved code was overridden: %v", doc.Error["code"]) + } + if doc.Error["message"] != "m" { + t.Errorf("reserved message was overridden: %v", doc.Error["message"]) + } + if doc.Error["extra"] != "kept" { + t.Errorf("non-reserved details should pass through: %v", doc.Error) + } +} + +func TestWrap(t *testing.T) { + if Wrap(nil) != nil { + t.Errorf("Wrap(nil) should be nil") + } + e := ProfileNotFound("x") + if got := Wrap(e); got != e { + t.Errorf("Wrap(typedErr) should return it unchanged") + } + if got := Wrap(errors.New("plain")); got.Code() != CodeUnknown { + t.Errorf("Wrap(plain).Code = %q, want UNKNOWN", got.Code()) + } +} + +// TestEveryConstructor_isWellFormed locks in the contract that every +// constructor returns a non-nil error with a non-empty message and a +// valid category mapping to a known exit code. +func TestEveryConstructor_isWellFormed(t *testing.T) { + tests := []struct { + name string + fn func() *RaidError + code string + }{ + {"Unknown", func() *RaidError { return Unknown(errors.New("c")) }, CodeUnknown}, + {"Unknown(nil)", func() *RaidError { return Unknown(nil) }, CodeUnknown}, + {"Internal", func() *RaidError { return Internal("x") }, CodeInternal}, + {"GitNotInstalled", GitNotInstalled, CodeGitNotInstalled}, + {"LockFailed", func() *RaidError { return LockFailed(errors.New("c")) }, CodeLockFailed}, + {"LockFailed(nil)", func() *RaidError { return LockFailed(nil) }, CodeLockFailed}, + {"ProfileNotFound", func() *RaidError { return ProfileNotFound("p") }, CodeProfileNotFound}, + {"ProfileNotActive", ProfileNotActive, CodeProfileNotActive}, + {"ProfileFileMissing", func() *RaidError { return ProfileFileMissing("/p") }, CodeProfileFileMissing}, + {"ProfileFileRead", func() *RaidError { return ProfileFileRead("/p", errors.New("c")) }, CodeProfileFileRead}, + {"ProfileFileRead(nil)", func() *RaidError { return ProfileFileRead("/p", nil) }, CodeProfileFileRead}, + {"ProfileInvalid", func() *RaidError { return ProfileInvalid("/p", errors.New("c")) }, CodeProfileInvalid}, + {"ProfileInvalid(nil)", func() *RaidError { return ProfileInvalid("/p", nil) }, CodeProfileInvalid}, + {"ProfileAlreadyExists", func() *RaidError { return ProfileAlreadyExists("p") }, CodeProfileAlreadyExists}, + {"RepoNotFound", func() *RaidError { return RepoNotFound("r") }, CodeRepoNotFound}, + {"RepoNotCloned", func() *RaidError { return RepoNotCloned("r", "/p") }, CodeRepoNotCloned}, + {"RepoInvalid", func() *RaidError { return RepoInvalid("r", errors.New("c")) }, CodeRepoInvalid}, + {"RepoInvalid(nil)", func() *RaidError { return RepoInvalid("r", nil) }, CodeRepoInvalid}, + {"CloneFailed", func() *RaidError { return CloneFailed("r", "u", errors.New("c")) }, CodeCloneFailed}, + {"CloneFailed(nil)", func() *RaidError { return CloneFailed("r", "u", nil) }, CodeCloneFailed}, + {"EnvNotFound", func() *RaidError { return EnvNotFound("e") }, CodeEnvNotFound}, + {"CommandNotFound", func() *RaidError { return CommandNotFound("c") }, CodeCommandNotFound}, + {"ArgInvalid", func() *RaidError { return ArgInvalid("x") }, CodeArgInvalid}, + {"ConfigInvalid", func() *RaidError { return ConfigInvalid(errors.New("c")) }, CodeConfigInvalid}, + {"ConfigInvalid(nil)", func() *RaidError { return ConfigInvalid(nil) }, CodeConfigInvalid}, + {"ConfigLoadFailed", func() *RaidError { return ConfigLoadFailed(errors.New("c")) }, CodeConfigLoadFailed}, + {"ConfigLoadFailed(nil)", func() *RaidError { return ConfigLoadFailed(nil) }, CodeConfigLoadFailed}, + {"SchemaValidationFailed", func() *RaidError { return SchemaValidationFailed("/p", errors.New("c")) }, CodeSchemaValidationFailed}, + {"SchemaValidationFailed(nil)", func() *RaidError { return SchemaValidationFailed("/p", nil) }, CodeSchemaValidationFailed}, + {"TaskFailed", func() *RaidError { return TaskFailed("Print", errors.New("c")) }, CodeTaskFailed}, + {"TaskFailed(nil)", func() *RaidError { return TaskFailed("Print", nil) }, CodeTaskFailed}, + {"TaskShellFailed", func() *RaidError { return TaskShellFailed(errors.New("c")) }, CodeTaskShellFailed}, + {"TaskShellFailed(nil)", func() *RaidError { return TaskShellFailed(nil) }, CodeTaskShellFailed}, + {"TaskScriptFailed", func() *RaidError { return TaskScriptFailed(errors.New("c")) }, CodeTaskScriptFailed}, + {"TaskScriptFailed(nil)", func() *RaidError { return TaskScriptFailed(nil) }, CodeTaskScriptFailed}, + {"TaskWaitTimeout", func() *RaidError { return TaskWaitTimeout("t", errors.New("c")) }, CodeTaskWaitTimeout}, + {"TaskWaitTimeout(nil)", func() *RaidError { return TaskWaitTimeout("t", nil) }, CodeTaskWaitTimeout}, + {"TaskTemplateFailed", func() *RaidError { return TaskTemplateFailed(errors.New("c")) }, CodeTaskTemplateFailed}, + {"TaskTemplateFailed(nil)", func() *RaidError { return TaskTemplateFailed(nil) }, CodeTaskTemplateFailed}, + {"TaskGitFailed", func() *RaidError { return TaskGitFailed(errors.New("c")) }, CodeTaskGitFailed}, + {"TaskGitFailed(nil)", func() *RaidError { return TaskGitFailed(nil) }, CodeTaskGitFailed}, + {"TaskHTTPFailed", func() *RaidError { return TaskHTTPFailed("u", errors.New("c")) }, CodeTaskHTTPFailed}, + {"TaskHTTPFailed(nil)", func() *RaidError { return TaskHTTPFailed("u", nil) }, CodeTaskHTTPFailed}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := tt.fn() + if e == nil { + t.Fatal("constructor returned nil") + } + if e.Code() != tt.code { + t.Errorf("Code = %q, want %q", e.Code(), tt.code) + } + if e.Error() == "" { + t.Errorf("Error() is empty") + } + // Round-trip through EmitJSON to confirm JSON-marshalability. + var buf bytes.Buffer + EmitJSON(&buf, e) + if buf.Len() == 0 { + t.Errorf("EmitJSON produced no output") + } + // Every category is one of the documented exit codes. + if c := int(e.Category()); c < 1 || c > 5 { + t.Errorf("Category %d outside documented 1-5 range", c) + } + }) + } +} diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 0022b50..f75a0c0 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -14,6 +14,7 @@ import ( "time" "github.com/8bitalex/raid/schemas" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/fsnotify/fsnotify" "github.com/joho/godotenv" @@ -144,7 +145,7 @@ var newVarsWatcherFn = newVarsWatcher // cross-process mutation lock. func WatchRaidVars(ctx stdctx.Context, onChange func()) error { if onChange == nil { - return fmt.Errorf("WatchRaidVars: onChange must not be nil") + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "WatchRaidVars: onChange must not be nil") } return newVarsWatcherFn(ctx, raidVarsPath(), onChange) } @@ -152,16 +153,16 @@ func WatchRaidVars(ctx stdctx.Context, onChange func()) error { func newVarsWatcher(ctx stdctx.Context, varsPath string, onChange func()) error { dir := filepath.Dir(varsPath) if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("ensure vars watch dir %s: %w", dir, err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "ensure vars watch dir %s: %v", dir, err) } w, err := fsnotify.NewWatcher() if err != nil { - return fmt.Errorf("create fsnotify watcher: %w", err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "create fsnotify watcher: %v", err) } if err := w.Add(dir); err != nil { _ = w.Close() - return fmt.Errorf("watch %s: %w", dir, err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "watch %s: %v", dir, err) } go runVarsWatcher(ctx, w, varsPath, onChange) @@ -436,10 +437,14 @@ func sanitizeRepoVarName(name string) string { // installRepo clones a single repository and runs its install tasks. func installRepo(repo Repo) error { if err := CloneRepository(repo); err != nil { - return fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) + // CloneRepository already returns structured errors with codes, + // categories, and hints (CLONE_FAILED, GIT_NOT_INSTALLED, + // REPO_NOT_CLONED). Re-wrapping would misclassify non-network + // failures and drop the original hint/details. + return err } if err := ExecuteTasks(withDefaultDir(repo.Install.Tasks, sys.ExpandPath(repo.Path))); err != nil { - return fmt.Errorf("failed to execute install tasks for '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to execute install tasks for '%s': %v", repo.Name, err) } return nil } @@ -448,11 +453,11 @@ func installRepo(repo Repo) error { // The profile-level install tasks are not run. func InstallRepo(name string) error { if context == nil { - return fmt.Errorf("raid context is not initialized") + return liberrs.Internal("raid context is not initialized") } profile := context.Profile if profile.IsZero() { - return fmt.Errorf("profile not found") + return liberrs.Newf(liberrs.CodeProfileNotActive, liberrs.CategoryNotFound, "profile not found") } var repo *Repo @@ -463,7 +468,7 @@ func InstallRepo(name string) error { } } if repo == nil { - return fmt.Errorf("repository '%s' not found in active profile", name) + return liberrs.Newf(liberrs.CodeRepoNotFound, liberrs.CategoryNotFound, "repository '%s' not found in active profile", name) } return installRepo(*repo) @@ -472,11 +477,11 @@ func InstallRepo(name string) error { // Install clones all repositories in the active profile and runs install tasks. func Install(maxThreads int) error { if context == nil { - return fmt.Errorf("raid context is not initialized") + return liberrs.Internal("raid context is not initialized") } profile := context.Profile if profile.IsZero() { - return fmt.Errorf("profile not found") + return liberrs.Newf(liberrs.CodeProfileNotActive, liberrs.CategoryNotFound, "profile not found") } var semaphore chan struct{} @@ -500,7 +505,10 @@ func Install(maxThreads int) error { <-semaphore } if err != nil { - cloneErrs <- fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) + // Preserve the structured error from CloneRepository + // (CLONE_FAILED, GIT_NOT_INSTALLED, REPO_NOT_CLONED, etc.) + // so the aggregate below can expose each per-repo cause. + cloneErrs <- err } }(repo) } @@ -508,23 +516,28 @@ func Install(maxThreads int) error { wg.Wait() close(cloneErrs) - var errs []error + var collected []error for err := range cloneErrs { - errs = append(errs, err) + collected = append(collected, err) } - if len(errs) > 0 { - return fmt.Errorf("some repositories failed to clone: %v", errs) + if len(collected) > 0 { + // If only one repo failed, surface its structured error directly + // so its code/category/hint/details survive untouched. + if len(collected) == 1 { + return collected[0] + } + return liberrs.CloneFailedMulti(collected) } // Phase 2: run profile-level install tasks before any repo tasks. if err := ExecuteTasks(withDefaultDir(profile.Install.Tasks, sys.GetHomeDir())); err != nil { - return fmt.Errorf("failed to execute install tasks: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to execute install tasks: %v", err) } // Phase 3: run each repo's install tasks sequentially in profile order. for _, repo := range profile.Repositories { if err := ExecuteTasks(withDefaultDir(repo.Install.Tasks, sys.ExpandPath(repo.Path))); err != nil { - return fmt.Errorf("failed to execute install tasks for '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to execute install tasks for '%s': %v", repo.Name, err) } } @@ -538,10 +551,10 @@ func ValidateSchema(path string, schemaPath string) error { schemaPath = sys.ExpandPath(schemaPath) if path == "" || !sys.FileExists(path) { - return fmt.Errorf("file not found at %s", path) + return liberrs.Newf(liberrs.CodeProfileFileMissing, liberrs.CategoryNotFound, "file not found at %s", path) } if schemaPath == "" || !sys.FileExists(schemaPath) { - return fmt.Errorf("file not found at %s", schemaPath) + return liberrs.Newf(liberrs.CodeProfileFileMissing, liberrs.CategoryNotFound, "file not found at %s", schemaPath) } c := jsonschema.NewCompiler() @@ -561,13 +574,13 @@ func ValidateSchema(path string, schemaPath string) error { func validateWithEmbeddedSchema(path, schemaID string) error { path = sys.ExpandPath(path) if path == "" || !sys.FileExists(path) { - return fmt.Errorf("file not found at %s", path) + return liberrs.Newf(liberrs.CodeProfileFileMissing, liberrs.CategoryNotFound, "file not found at %s", path) } c := jsonschema.NewCompiler() entries, err := schemas.FS.ReadDir(".") if err != nil { - return fmt.Errorf("failed to read embedded schemas: %w", err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "failed to read embedded schemas: %v", err) } for _, entry := range entries { name := entry.Name() @@ -576,18 +589,18 @@ func validateWithEmbeddedSchema(path, schemaID string) error { } data, err := schemas.FS.ReadFile(name) if err != nil { - return fmt.Errorf("failed to read embedded schema %s: %w", name, err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "failed to read embedded schema %s: %v", name, err) } var doc map[string]any if err := json.Unmarshal(data, &doc); err != nil { - return fmt.Errorf("failed to parse embedded schema %s: %w", name, err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "failed to parse embedded schema %s: %v", name, err) } id, _ := doc["$id"].(string) if id == "" { - return fmt.Errorf("embedded schema %s is missing $id", name) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "embedded schema %s is missing $id", name) } if err := c.AddResource(id, doc); err != nil { - return fmt.Errorf("failed to register embedded schema %s: %w", name, err) + return liberrs.Newf(liberrs.CodeInternal, liberrs.CategoryGeneric, "failed to register embedded schema %s: %v", name, err) } } @@ -630,11 +643,11 @@ func validateFile(path string, sch *jsonschema.Schema) error { return err } if err := sch.Validate(doc); err != nil { - return fmt.Errorf("invalid format: %w", err) + return liberrs.Newf(liberrs.CodeSchemaValidationFailed, liberrs.CategoryConfig, "invalid format: %v", err) } } if count == 0 { - return fmt.Errorf("invalid format: file contains no YAML documents") + return liberrs.Newf(liberrs.CodeSchemaValidationFailed, liberrs.CategoryConfig, "invalid format: file contains no YAML documents") } return nil } @@ -659,7 +672,7 @@ func validateFile(path string, sch *jsonschema.Schema) error { return err } if err := sch.Validate(doc); err != nil { - return fmt.Errorf("invalid format: %w", err) + return liberrs.Newf(liberrs.CodeSchemaValidationFailed, liberrs.CategoryConfig, "invalid format: %v", err) } } return nil @@ -670,7 +683,7 @@ func validateFile(path string, sch *jsonschema.Schema) error { return err } if err := sch.Validate(doc); err != nil { - return fmt.Errorf("invalid format: %w", err) + return liberrs.Newf(liberrs.CodeSchemaValidationFailed, liberrs.CategoryConfig, "invalid format: %v", err) } return nil } diff --git a/src/internal/lib/lock.go b/src/internal/lib/lock.go index ee6a882..d0ee4e6 100644 --- a/src/internal/lib/lock.go +++ b/src/internal/lib/lock.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/gofrs/flock" ) @@ -39,11 +40,14 @@ func lockPath() string { func AcquireMutationLock() (func(), error) { path := lockPath() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return nil, fmt.Errorf("create raid config dir: %w", err) + // Route through LockFailed so the user-visible hint about + // ~/.raid/.lock is included consistently across every lock + // acquisition failure. + return nil, liberrs.LockFailed(fmt.Errorf("create raid config dir: %w", err)) } lk := flock.New(path) if err := lk.Lock(); err != nil { - return nil, fmt.Errorf("acquire raid mutation lock at %s: %w", path, err) + return nil, liberrs.LockFailed(err) } // Stamp the PID into the lock file so a curious user can `cat // ~/.raid/.lock` to identify the holder. This write is advisory: flock diff --git a/src/internal/lib/lock_test.go b/src/internal/lib/lock_test.go index 475ffbb..bf7ef34 100644 --- a/src/internal/lib/lock_test.go +++ b/src/internal/lib/lock_test.go @@ -2,11 +2,14 @@ package lib import ( "errors" + "os" "path/filepath" "sync" "sync/atomic" "testing" "time" + + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" ) func setupLockTempPath(t *testing.T) string { @@ -19,6 +22,38 @@ func setupLockTempPath(t *testing.T) string { return path } +// TestAcquireMutationLock_mkdirAllFails covers the LockFailed-wrap path +// when the parent directory cannot be created (parent path is a regular +// file). Verifies the returned error is a structured LOCK_FAILED with +// the user-facing hint. +func TestAcquireMutationLock_mkdirAllFails(t *testing.T) { + tmpDir := t.TempDir() + blocker := filepath.Join(tmpDir, "blocker-file") + if err := os.WriteFile(blocker, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + old := LockPathOverride + t.Cleanup(func() { LockPathOverride = old }) + // LockPathOverride parent is a regular file — MkdirAll can't create + // the intermediate dir under it. + LockPathOverride = filepath.Join(blocker, "child", ".lock") + + release, err := AcquireMutationLock() + if release != nil { + release() + } + if err == nil { + t.Fatal("expected error when lock parent dir cannot be created") + } + rErr, ok := liberrs.AsError(err) + if !ok { + t.Fatalf("error not structured: %v", err) + } + if rErr.Code() != liberrs.CodeLockFailed { + t.Errorf("code = %q, want LOCK_FAILED", rErr.Code()) + } +} + func TestAcquireMutationLock_returnsRelease(t *testing.T) { setupLockTempPath(t) release, err := AcquireMutationLock() diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index 985f6e0..ac3c1e9 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -8,8 +8,9 @@ import ( "path/filepath" "strings" - "github.com/8bitalex/raid/src/resources" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" + "github.com/8bitalex/raid/src/resources" "github.com/spf13/viper" "gopkg.in/yaml.v3" ) @@ -58,7 +59,7 @@ func (p Profile) getEnv(name string) Env { // SetProfile sets the named profile as the active profile. func SetProfile(name string) error { if !ContainsProfile(name) { - return fmt.Errorf("profile '%s' not found", name) + return liberrs.ProfileNotFound(name) } return Set(activeProfileKey, name) } @@ -119,10 +120,10 @@ func getProfilePaths() map[string]string { func RemoveProfile(name string) error { profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { - return fmt.Errorf("no profiles found") + return liberrs.Newf(liberrs.CodeProfileNotFound, liberrs.CategoryNotFound, "no profiles found") } if _, exists := profiles[name]; !exists { - return fmt.Errorf("profile '%s' not found", name) + return liberrs.ProfileNotFound(name) } delete(profiles, name) return Set(allProfilesKey, profiles) @@ -139,14 +140,14 @@ func ExtractProfile(name, path string) (Profile, error) { return profile, nil } } - return Profile{}, fmt.Errorf("profile '%s' not found in %s", name, path) + return Profile{}, liberrs.Newf(liberrs.CodeProfileNotFound, liberrs.CategoryNotFound, "profile '%s' not found in %s", name, path) } // ExtractProfiles reads all profiles from a YAML or JSON file. func ExtractProfiles(path string) ([]Profile, error) { profileData, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read profile from file %s: %w", path, err) + return nil, liberrs.ProfileFileRead(path, err) } ext := strings.ToLower(filepath.Ext(path)) @@ -158,7 +159,7 @@ func ExtractProfiles(path string) ([]Profile, error) { case ".json": profiles, err = extractProfilesFromJSON(profileData, path) default: - return nil, fmt.Errorf("unsupported file format: %s. Supported formats are .yaml, .yml, and .json", ext) + return nil, liberrs.Newf(liberrs.CodeProfileInvalid, liberrs.CategoryConfig, "unsupported file format: %s. Supported formats are .yaml, .yml, and .json", ext) } if err != nil { @@ -166,7 +167,7 @@ func ExtractProfiles(path string) ([]Profile, error) { } if len(profiles) == 0 { - return nil, fmt.Errorf("no profiles found in file %s", path) + return nil, liberrs.Newf(liberrs.CodeProfileNotFound, liberrs.CategoryNotFound, "no profiles found in file %s", path) } return profiles, nil @@ -185,7 +186,7 @@ func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { var profile Profile if err := yaml.Unmarshal([]byte(doc), &profile); err != nil { - return nil, fmt.Errorf("invalid YAML document in %s: %w", path, err) + return nil, liberrs.Newf(liberrs.CodeProfileInvalid, liberrs.CategoryConfig, "invalid YAML document in %s: %v", path, err) } profile.Path = path @@ -204,7 +205,7 @@ func extractProfilesFromJSON(data []byte, path string) ([]Profile, error) { var profiles []Profile if err := json.Unmarshal(data, &profiles); err != nil { - return nil, fmt.Errorf("invalid JSON format in %s: %w", path, err) + return nil, liberrs.Newf(liberrs.CodeProfileInvalid, liberrs.CategoryConfig, "invalid JSON format in %s: %v", path, err) } results := make([]Profile, 0, len(profiles)) @@ -250,11 +251,11 @@ type RepoDraft struct { func WriteProfileFile(draft ProfileDraft, path string) error { data, err := yaml.Marshal(draft) if err != nil { - return fmt.Errorf("serializing profile: %w", err) + return liberrs.Newf(liberrs.CodeProfileFileRead, liberrs.CategoryConfig, "serializing profile: %v", err) } content := resources.ProfileTemplate() + "\n" + string(data) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating directory: %w", err) + return liberrs.Newf(liberrs.CodeProfileFileRead, liberrs.CategoryConfig, "creating directory: %v", err) } return os.WriteFile(path, []byte(content), 0644) } @@ -319,10 +320,10 @@ func CreateRepoConfigs(repos []RepoDraft) { func buildProfile(profile Profile) (Profile, error) { if profile.IsZero() { - return Profile{}, fmt.Errorf("invalid profile: %v", profile) + return Profile{}, liberrs.Newf(liberrs.CodeProfileInvalid, liberrs.CategoryConfig, "invalid profile: %v", profile) } if !sys.FileExists(profile.Path) { - return Profile{}, fmt.Errorf("profile file not found at %s", profile.Path) + return Profile{}, liberrs.ProfileFileMissing(profile.Path) } if profile.IsSingleRepo() { built, err := BuildSingleRepoProfile(profile.Path) @@ -335,7 +336,7 @@ func buildProfile(profile Profile) (Profile, error) { // to load rather than silently returning a profile whose Name // differs from the registered key. if profile.Name != "" && built.Name != profile.Name { - return Profile{}, fmt.Errorf( + return Profile{}, liberrs.Newf(liberrs.CodeProfileInvalid, liberrs.CategoryConfig, "raid.yaml at %s now declares name %q but was registered as %q; re-run `raid profile add %s` to update", profile.Path, built.Name, profile.Name, profile.Path, ) @@ -343,11 +344,11 @@ func buildProfile(profile Profile) (Profile, error) { return built, nil } if err := ValidateProfile(profile.Path); err != nil { - return Profile{}, fmt.Errorf("invalid profile: %w", err) + return Profile{}, liberrs.ProfileInvalid(profile.Path, err) } profile, err := ExtractProfile(profile.Name, profile.Path) if err != nil { - return Profile{}, fmt.Errorf("invalid profile: %w", err) + return Profile{}, liberrs.ProfileInvalid(profile.Path, err) } return profile, nil } @@ -360,10 +361,10 @@ func buildProfile(profile Profile) (Profile, error) { // in the load pipeline. func BuildSingleRepoProfile(path string) (Profile, error) { if filepath.Base(path) != RaidConfigFileName { - return Profile{}, fmt.Errorf("single-repo profile path must end in %s, got %s", RaidConfigFileName, path) + return Profile{}, liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "single-repo profile path must end in %s, got %s", RaidConfigFileName, path) } if err := ValidateRepo(path); err != nil { - return Profile{}, fmt.Errorf("invalid raid.yaml: %w", err) + return Profile{}, liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "invalid raid.yaml: %v", err) } repoDir := filepath.Dir(path) repo, err := ExtractRepo(repoDir) @@ -371,7 +372,7 @@ func BuildSingleRepoProfile(path string) (Profile, error) { return Profile{}, err } if repo.Name == "" { - return Profile{}, fmt.Errorf("raid.yaml at %s has missing or empty name field", path) + return Profile{}, liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "raid.yaml at %s has missing or empty name field", path) } return Profile{ Name: repo.Name, diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 1676eb4..deff356 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" sys "github.com/8bitalex/raid/src/internal/sys" "gopkg.in/yaml.v3" ) @@ -53,7 +54,7 @@ func (r Repo) getEnv(name string) Env { func buildRepo(repo *Repo) error { if repo.IsZero() { - return fmt.Errorf("invalid repository: %v", repo) + return liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "invalid repository: %v", *repo) } raidFile := filepath.Join(sys.ExpandPath(repo.Path), RaidConfigFileName) @@ -62,12 +63,12 @@ func buildRepo(repo *Repo) error { } if err := ValidateRepo(raidFile); err != nil { - return fmt.Errorf("invalid raid configuration for '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "invalid raid configuration for '%s': %v", repo.Name, err) } repoConfig, err := ExtractRepo(repo.Path) if err != nil { - return fmt.Errorf("failed to read config for '%s': %w", repo.Name, err) + return liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "failed to read config for '%s': %v", repo.Name, err) } repo.Environments = append(repo.Environments, repoConfig.Environments...) @@ -87,7 +88,9 @@ func CloneRepository(repo Repo) error { if repo.IsLocalOnly() { if !sys.FileExists(path) { - return fmt.Errorf("repository '%s' has no url and path '%s' does not exist; create the directory or add a url to clone", repo.Name, path) + return liberrs.Newf(liberrs.CodeRepoNotCloned, liberrs.CategoryNotFound, + "repository '%s' has no url and path '%s' does not exist; create the directory or add a url to clone", + repo.Name, path) } fmt.Fprintf(commandStdout, "Repository '%s' is local-only at %s, skipping clone\n", repo.Name, path) return nil @@ -99,15 +102,15 @@ func CloneRepository(repo Repo) error { } if !isGitInstalled() { - return fmt.Errorf("git is not installed or not in the PATH") + return liberrs.GitNotInstalled() } if err := os.MkdirAll(path, 0755); err != nil { - return fmt.Errorf("failed to create directory '%s': %w", path, err) + return liberrs.Newf(liberrs.CodeCloneFailed, liberrs.CategoryNetwork, "failed to create directory '%s': %v", path, err) } if err := clone(path, strings.TrimSpace(repo.URL), repo.Branch); err != nil { - return fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) + return liberrs.CloneFailed(repo.Name, strings.TrimSpace(repo.URL), err) } return nil @@ -145,12 +148,12 @@ func ExtractRepo(path string) (Repo, error) { filePath := filepath.Join(sys.ExpandPath(path), RaidConfigFileName) data, err := os.ReadFile(filePath) if err != nil { - return Repo{}, fmt.Errorf("failed to read %s: %w", filePath, err) + return Repo{}, liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "failed to read %s: %v", filePath, err) } var repo Repo if err := yaml.Unmarshal(data, &repo); err != nil { - return Repo{}, fmt.Errorf("failed to parse %s: %w", filePath, err) + return Repo{}, liberrs.Newf(liberrs.CodeRepoInvalid, liberrs.CategoryConfig, "failed to parse %s: %v", filePath, err) } return repo, nil diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 667a337..8ae7462 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -14,6 +14,7 @@ import ( "sync" "time" + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" ) @@ -174,7 +175,7 @@ func ExecuteTask(task Task) error { case SetVar: return execSetVar(task) default: - return fmt.Errorf("invalid task type: %s", task.Type) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "invalid task type: %s", task.Type) } } @@ -190,7 +191,7 @@ func execShell(task Task) error { task.Cmd = expandRaidForShell(task.Cmd) } if task.Cmd == "" { - return fmt.Errorf("cmd is required for Shell task") + return liberrs.ArgInvalid("cmd is required for Shell task") } shell := getShell(task.Shell) @@ -233,7 +234,7 @@ func execShell(task Task) error { } if runErr != nil { - return fmt.Errorf("failed to execute shell command '%s': %w", task.Cmd, runErr) + return liberrs.Newf(liberrs.CodeTaskShellFailed, liberrs.CategoryTask, "failed to execute shell command '%s': %v", task.Cmd, runErr) } return nil } @@ -310,7 +311,7 @@ func execScript(task Task) error { task = task.Expand() if !sys.FileExists(task.Path) { - return fmt.Errorf("file does not exist: %s", task.Path) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "file does not exist: %s", task.Path) } var cmd *exec.Cmd @@ -325,7 +326,7 @@ func execScript(task Task) error { err := cmd.Run() if err != nil { - return fmt.Errorf("failed to execute script '%s': %w", task.Path, err) + return liberrs.Newf(liberrs.CodeTaskScriptFailed, liberrs.CategoryTask, "failed to execute script '%s': %v", task.Path, err) } return nil @@ -395,37 +396,37 @@ func execHTTP(task Task) error { task = task.Expand() if task.URL == "" { - return fmt.Errorf("url is required for HTTP task") + return liberrs.ArgInvalid("url is required for HTTP task") } if task.Dest == "" { - return fmt.Errorf("dest is required for HTTP task") + return liberrs.ArgInvalid("dest is required for HTTP task") } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(task.URL) if err != nil { - return fmt.Errorf("failed to fetch '%s': %w", task.URL, err) + return liberrs.Newf(liberrs.CodeTaskHTTPFailed, liberrs.CategoryNetwork, "failed to fetch '%s': %v", task.URL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP request to '%s' returned status %d", task.URL, resp.StatusCode) + return liberrs.Newf(liberrs.CodeTaskHTTPFailed, liberrs.CategoryNetwork, "HTTP request to '%s' returned status %d", task.URL, resp.StatusCode) } if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { - return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create directory for '%s': %v", task.Dest, err) } f, err := os.Create(task.Dest) if err != nil { - return fmt.Errorf("failed to create file '%s': %w", task.Dest, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create file '%s': %v", task.Dest, err) } defer f.Close() if _, err := io.Copy(f, resp.Body); err != nil { f.Close() os.Remove(task.Dest) - return fmt.Errorf("failed to write to '%s': %w", task.Dest, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to write to '%s': %v", task.Dest, err) } return nil @@ -435,14 +436,14 @@ func execWait(task Task) error { task = task.Expand() if task.URL == "" { - return fmt.Errorf("url is required for Wait task") + return liberrs.ArgInvalid("url is required for Wait task") } timeout := 30 * time.Second if task.Timeout != "" { d, err := time.ParseDuration(task.Timeout) if err != nil { - return fmt.Errorf("invalid timeout '%s': %w", task.Timeout, err) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "invalid timeout '%s': %v", task.Timeout, err) } timeout = d } @@ -462,7 +463,7 @@ func execWait(task Task) error { time.Sleep(1 * time.Second) } - return fmt.Errorf("timed out waiting for '%s' after %s", task.URL, timeout) + return liberrs.Newf(liberrs.CodeTaskWaitTimeout, liberrs.CategoryTask, "timed out waiting for '%s' after %s", task.URL, timeout) } func checkHTTP(url string) error { @@ -473,7 +474,7 @@ func checkHTTP(url string) error { } resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d", resp.StatusCode) + return liberrs.Newf(liberrs.CodeTaskHTTPFailed, liberrs.CategoryNetwork, "HTTP %d", resp.StatusCode) } return nil } @@ -491,29 +492,29 @@ func execTemplate(task Task) error { task = task.Expand() if task.Src == "" { - return fmt.Errorf("src is required for Template task") + return liberrs.ArgInvalid("src is required for Template task") } if task.Dest == "" { - return fmt.Errorf("dest is required for Template task") + return liberrs.ArgInvalid("dest is required for Template task") } if !sys.FileExists(task.Src) { - return fmt.Errorf("template file does not exist: %s", task.Src) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "template file does not exist: %s", task.Src) } data, err := os.ReadFile(task.Src) if err != nil { - return fmt.Errorf("failed to read template '%s': %w", task.Src, err) + return liberrs.Newf(liberrs.CodeTaskTemplateFailed, liberrs.CategoryTask, "failed to read template '%s': %v", task.Src, err) } rendered := expandRaid(string(data)) if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { - return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create directory for '%s': %v", task.Dest, err) } if err := os.WriteFile(task.Dest, []byte(rendered), 0644); err != nil { - return fmt.Errorf("failed to write output file '%s': %w", task.Dest, err) + return liberrs.Newf(liberrs.CodeTaskTemplateFailed, liberrs.CategoryTask, "failed to write output file '%s': %v", task.Dest, err) } return nil @@ -521,15 +522,15 @@ func execTemplate(task Task) error { func execGroup(task Task) error { if task.Ref == "" { - return fmt.Errorf("ref is required for Group task") + return liberrs.ArgInvalid("ref is required for Group task") } if context == nil || context.Profile.Groups == nil { - return fmt.Errorf("no task_groups defined in the active profile") + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "no task_groups defined in the active profile") } tasks, ok := context.Profile.Groups[task.Ref] if !ok { - return fmt.Errorf("task group '%s' not found in profile", task.Ref) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "task group '%s' not found in profile", task.Ref) } if task.Parallel { @@ -553,7 +554,7 @@ func execGroupWithRetry(tasks []Task, attempts int, delayStr string) error { if delayStr != "" { d, err := time.ParseDuration(delayStr) if err != nil { - return fmt.Errorf("invalid delay '%s': %w", delayStr, err) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "invalid delay '%s': %v", delayStr, err) } delay = d } @@ -571,14 +572,14 @@ func execGroupWithRetry(tasks []Task, attempts int, delayStr string) error { return nil } - return fmt.Errorf("all %d attempts failed: %w", attempts, lastErr) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "all %d attempts failed: %v", attempts, lastErr) } func execGit(task Task) error { task = task.Expand() if task.Op == "" { - return fmt.Errorf("op is required for Git task") + return liberrs.ArgInvalid("op is required for Git task") } dir := task.Path @@ -586,13 +587,13 @@ func execGit(task Task) error { var err error dir, err = os.Getwd() if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) + return liberrs.Newf(liberrs.CodeTaskGitFailed, liberrs.CategoryTask, "failed to get working directory: %v", err) } } info, statErr := os.Stat(dir) if statErr != nil || !info.IsDir() { - return fmt.Errorf("path is not a directory: %s", dir) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "path is not a directory: %s", dir) } var args []string @@ -604,7 +605,7 @@ func execGit(task Task) error { } case "checkout": if task.Branch == "" { - return fmt.Errorf("branch is required for git checkout") + return liberrs.ArgInvalid("branch is required for git checkout") } args = []string{"checkout", task.Branch} case "fetch": @@ -618,7 +619,7 @@ func execGit(task Task) error { args = append(args, task.Branch) } default: - return fmt.Errorf("invalid git operation '%s' (supported: pull, checkout, fetch, reset)", task.Op) + return liberrs.Newf(liberrs.CodeArgInvalid, liberrs.CategoryConfig, "invalid git operation '%s' (supported: pull, checkout, fetch, reset)", task.Op) } cmd := exec.Command("git", args...) @@ -626,7 +627,7 @@ func execGit(task Task) error { setCmdOutput(cmd) if err := cmd.Run(); err != nil { - return fmt.Errorf("git %s failed in '%s': %w", task.Op, dir, err) + return liberrs.Newf(liberrs.CodeTaskGitFailed, liberrs.CategoryTask, "git %s failed in '%s': %v", task.Op, dir, err) } return nil @@ -634,7 +635,7 @@ func execGit(task Task) error { func execPrompt(task Task) error { if task.Var == "" { - return fmt.Errorf("var is required for Prompt task") + return liberrs.ArgInvalid("var is required for Prompt task") } message := task.Message @@ -649,7 +650,7 @@ func execPrompt(task Task) error { value, err := getStdinReader().ReadString('\n') if err != nil { - return fmt.Errorf("failed to read input: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to read input: %v", err) } value = strings.TrimRight(value, "\r\n") @@ -674,12 +675,12 @@ func execConfirm(task Task) error { answer, err := getStdinReader().ReadString('\n') if err != nil { - return fmt.Errorf("failed to read input: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to read input: %v", err) } answer = strings.TrimSpace(strings.ToLower(answer)) if answer != "y" && answer != "yes" { - return fmt.Errorf("aborted by user") + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "aborted by user") } return nil } @@ -703,7 +704,7 @@ func execPrint(task Task) error { func execSetVar(task Task) error { if task.Var == "" { - return fmt.Errorf("var is required for Set task") + return liberrs.ArgInvalid("var is required for Set task") } task = task.Expand() task.Var = strings.ToUpper(task.Var) @@ -717,15 +718,15 @@ func execSetVar(task Task) error { f, err := sys.CreateFile(path) if err != nil { - return fmt.Errorf("failed to create vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create vars file: %v", err) } if err := f.Close(); err != nil { - return fmt.Errorf("failed to close vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to close vars file: %v", err) } m, err := godotenv.Read(path) if err != nil { - return fmt.Errorf("failed to read vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to read vars file: %v", err) } m[task.Var] = task.Value @@ -734,22 +735,22 @@ func execSetVar(task Task) error { dir := filepath.Dir(path) tmpFile, err := os.CreateTemp(dir, ".raid-vars-*") if err != nil { - return fmt.Errorf("failed to create temp vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to create temp vars file: %v", err) } tmpName := tmpFile.Name() // Ensure the temp file is removed if we hit an error before a successful rename. defer os.Remove(tmpName) if err := tmpFile.Close(); err != nil { - return fmt.Errorf("failed to close temp vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to close temp vars file: %v", err) } if err := godotenv.Write(m, tmpName); err != nil { - return fmt.Errorf("failed to write temp vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to write temp vars file: %v", err) } if err := os.Rename(tmpName, path); err != nil { - return fmt.Errorf("failed to replace vars file: %w", err) + return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to replace vars file: %v", err) } raidVars[task.Var] = task.Value diff --git a/src/internal/lib/vars_watch_test.go b/src/internal/lib/vars_watch_test.go index 549dc76..5988d71 100644 --- a/src/internal/lib/vars_watch_test.go +++ b/src/internal/lib/vars_watch_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "sync" "sync/atomic" "testing" @@ -205,6 +206,33 @@ func TestWatchRaidVars_StopsOnContextCancel(t *testing.T) { } } +// TestNewVarsWatcher_mkdirFailureReturnsStructuredError covers the +// liberrs.Newf wrap around os.MkdirAll. Forcing MkdirAll to fail +// requires a path whose parent is a regular file (not a directory) so +// the implicit mkdir-p chain can't make a directory of the same name. +func TestNewVarsWatcher_mkdirFailureReturnsStructuredError(t *testing.T) { + tmpDir := t.TempDir() + // Create a regular file, then aim varsPath at a path UNDER that + // file. MkdirAll then can't make varsPath's parent — it would have + // to turn the existing file into a directory. + blocker := filepath.Join(tmpDir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + varsPath := filepath.Join(blocker, "child", "vars") + + ctx, cancel := stdctx.WithCancel(stdctx.Background()) + t.Cleanup(cancel) + + err := newVarsWatcher(ctx, varsPath, func() {}) + if err == nil { + t.Fatal("expected error when parent path is a file") + } + if !strings.Contains(err.Error(), "ensure vars watch dir") { + t.Errorf("error %q should mention 'ensure vars watch dir'", err.Error()) + } +} + func TestNewVarsWatcher_CreatesParentDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "raid") varsPath := filepath.Join(dir, "vars") diff --git a/src/raid/errs/errs.go b/src/raid/errs/errs.go new file mode 100644 index 0000000..e02d74b --- /dev/null +++ b/src/raid/errs/errs.go @@ -0,0 +1,113 @@ +// Package errs is the public re-export surface for raid's structured +// errors. The concrete types and constructors live in +// src/internal/lib/errs; this package aliases them so callers in +// src/cmd/ and other public packages depend only on this stable surface. +// +// Every code is part of raid's CLI contract: codes never change name or +// category across minor versions; new codes ship additively. See +// /docs/references/errors for the full table and the JSON shape. +package errs + +import ( + "io" + + liberrs "github.com/8bitalex/raid/src/internal/lib/errs" +) + +// Error is the structured-error interface every raid failure satisfies. +type Error = liberrs.Error + +// Category buckets errors by exit-code class. +type Category = liberrs.Category + +// Category constants — values match the documented CLI exit codes. +const ( + CategoryGeneric = liberrs.CategoryGeneric + CategoryConfig = liberrs.CategoryConfig + CategoryTask = liberrs.CategoryTask + CategoryNetwork = liberrs.CategoryNetwork + CategoryNotFound = liberrs.CategoryNotFound +) + +// Stable code strings. Documented at /docs/references/errors. +const ( + CodeUnknown = liberrs.CodeUnknown + CodeInternal = liberrs.CodeInternal + CodeGitNotInstalled = liberrs.CodeGitNotInstalled + CodeLockFailed = liberrs.CodeLockFailed + CodeProfileInvalid = liberrs.CodeProfileInvalid + CodeProfileFileRead = liberrs.CodeProfileFileRead + CodeProfileAlreadyExists = liberrs.CodeProfileAlreadyExists + CodeRepoInvalid = liberrs.CodeRepoInvalid + CodeConfigInvalid = liberrs.CodeConfigInvalid + CodeConfigLoadFailed = liberrs.CodeConfigLoadFailed + CodeSchemaValidationFailed = liberrs.CodeSchemaValidationFailed + CodeArgInvalid = liberrs.CodeArgInvalid + CodeTaskFailed = liberrs.CodeTaskFailed + CodeTaskShellFailed = liberrs.CodeTaskShellFailed + CodeTaskScriptFailed = liberrs.CodeTaskScriptFailed + CodeTaskWaitTimeout = liberrs.CodeTaskWaitTimeout + CodeTaskTemplateFailed = liberrs.CodeTaskTemplateFailed + CodeTaskGitFailed = liberrs.CodeTaskGitFailed + CodeCloneFailed = liberrs.CodeCloneFailed + CodeTaskHTTPFailed = liberrs.CodeTaskHTTPFailed + CodeProfileNotFound = liberrs.CodeProfileNotFound + CodeProfileNotActive = liberrs.CodeProfileNotActive + CodeProfileFileMissing = liberrs.CodeProfileFileMissing + CodeRepoNotFound = liberrs.CodeRepoNotFound + CodeRepoNotCloned = liberrs.CodeRepoNotCloned + CodeEnvNotFound = liberrs.CodeEnvNotFound + CodeCommandNotFound = liberrs.CodeCommandNotFound +) + +// AsError walks the wrapped-error chain and returns the first Error. +func AsError(err error) (Error, bool) { return liberrs.AsError(err) } + +// ExitCode returns the raid CLI exit code for an error. Nil → 0. +func ExitCode(err error) int { return liberrs.ExitCode(err) } + +// EmitJSON writes a structured `{"error": {…}}` document to w. +func EmitJSON(w io.Writer, err error) { liberrs.EmitJSON(w, err) } + +// Wrap returns err unchanged if it already implements Error, else wraps +// it as Unknown. Returns nil for nil. +func Wrap(err error) Error { return liberrs.Wrap(err) } + +// Newf builds a structured error with an arbitrary formatted message. +// Prefer the dedicated constructors below — this exists for call sites +// that need to preserve a specific historic wording for back-compat. +func Newf(code string, category Category, format string, args ...any) Error { + return liberrs.Newf(code, category, format, args...) +} + +// Constructors — thin shims over the internal package. + +func Unknown(cause error) Error { return liberrs.Unknown(cause) } +func Internal(msg string) Error { return liberrs.Internal(msg) } +func GitNotInstalled() Error { return liberrs.GitNotInstalled() } +func LockFailed(cause error) Error { return liberrs.LockFailed(cause) } +func ProfileNotFound(name string) Error { return liberrs.ProfileNotFound(name) } +func ProfileNotActive() Error { return liberrs.ProfileNotActive() } +func ProfileFileMissing(path string) Error { return liberrs.ProfileFileMissing(path) } +func ProfileFileRead(path string, cause error) Error { return liberrs.ProfileFileRead(path, cause) } +func ProfileInvalid(path string, cause error) Error { return liberrs.ProfileInvalid(path, cause) } +func ProfileAlreadyExists(name string) Error { return liberrs.ProfileAlreadyExists(name) } +func RepoNotFound(name string) Error { return liberrs.RepoNotFound(name) } +func RepoNotCloned(name, path string) Error { return liberrs.RepoNotCloned(name, path) } +func RepoInvalid(name string, cause error) Error { return liberrs.RepoInvalid(name, cause) } +func CloneFailed(name, url string, cause error) Error { return liberrs.CloneFailed(name, url, cause) } +func EnvNotFound(name string) Error { return liberrs.EnvNotFound(name) } +func CommandNotFound(name string) Error { return liberrs.CommandNotFound(name) } +func ArgInvalid(msg string) Error { return liberrs.ArgInvalid(msg) } +func ConfigInvalid(cause error) Error { return liberrs.ConfigInvalid(cause) } +func ConfigLoadFailed(cause error) Error { return liberrs.ConfigLoadFailed(cause) } +func SchemaValidationFailed(path string, cause error) Error { + return liberrs.SchemaValidationFailed(path, cause) +} +func TaskFailed(taskType string, cause error) Error { return liberrs.TaskFailed(taskType, cause) } +func TaskShellFailed(cause error) Error { return liberrs.TaskShellFailed(cause) } +func TaskScriptFailed(cause error) Error { return liberrs.TaskScriptFailed(cause) } +func TaskWaitTimeout(target string, cause error) Error { return liberrs.TaskWaitTimeout(target, cause) } +func TaskTemplateFailed(cause error) Error { return liberrs.TaskTemplateFailed(cause) } +func TaskGitFailed(cause error) Error { return liberrs.TaskGitFailed(cause) } +func TaskHTTPFailed(url string, cause error) Error { return liberrs.TaskHTTPFailed(url, cause) } diff --git a/src/raid/errs/errs_test.go b/src/raid/errs/errs_test.go new file mode 100644 index 0000000..61078b3 --- /dev/null +++ b/src/raid/errs/errs_test.go @@ -0,0 +1,103 @@ +package errs + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +// The public package is mostly an alias façade — these tests just make +// sure the re-exported constructors and helpers reach the underlying +// implementation, so the package shows up in coverage reports and a +// breaking move from internal would be caught at compile and at test. + +func TestPublicSurface_constructorsRouteThrough(t *testing.T) { + cases := []struct { + name string + got Error + code string + cat Category + }{ + {"Unknown", Unknown(errors.New("x")), CodeUnknown, CategoryGeneric}, + {"Internal", Internal("x"), CodeInternal, CategoryGeneric}, + {"GitNotInstalled", GitNotInstalled(), CodeGitNotInstalled, CategoryGeneric}, + {"LockFailed", LockFailed(errors.New("x")), CodeLockFailed, CategoryGeneric}, + {"ProfileNotFound", ProfileNotFound("p"), CodeProfileNotFound, CategoryNotFound}, + {"ProfileNotActive", ProfileNotActive(), CodeProfileNotActive, CategoryNotFound}, + {"ProfileFileMissing", ProfileFileMissing("/p"), CodeProfileFileMissing, CategoryNotFound}, + {"ProfileFileRead", ProfileFileRead("/p", nil), CodeProfileFileRead, CategoryConfig}, + {"ProfileInvalid", ProfileInvalid("/p", nil), CodeProfileInvalid, CategoryConfig}, + {"ProfileAlreadyExists", ProfileAlreadyExists("p"), CodeProfileAlreadyExists, CategoryConfig}, + {"RepoNotFound", RepoNotFound("r"), CodeRepoNotFound, CategoryNotFound}, + {"RepoNotCloned", RepoNotCloned("r", "/p"), CodeRepoNotCloned, CategoryNotFound}, + {"RepoInvalid", RepoInvalid("r", nil), CodeRepoInvalid, CategoryConfig}, + {"CloneFailed", CloneFailed("r", "u", nil), CodeCloneFailed, CategoryNetwork}, + {"EnvNotFound", EnvNotFound("e"), CodeEnvNotFound, CategoryNotFound}, + {"CommandNotFound", CommandNotFound("c"), CodeCommandNotFound, CategoryNotFound}, + {"ArgInvalid", ArgInvalid("x"), CodeArgInvalid, CategoryConfig}, + {"ConfigInvalid", ConfigInvalid(nil), CodeConfigInvalid, CategoryConfig}, + {"ConfigLoadFailed", ConfigLoadFailed(nil), CodeConfigLoadFailed, CategoryConfig}, + {"SchemaValidationFailed", SchemaValidationFailed("/p", nil), CodeSchemaValidationFailed, CategoryConfig}, + {"TaskFailed", TaskFailed("Print", nil), CodeTaskFailed, CategoryTask}, + {"TaskShellFailed", TaskShellFailed(nil), CodeTaskShellFailed, CategoryTask}, + {"TaskScriptFailed", TaskScriptFailed(nil), CodeTaskScriptFailed, CategoryTask}, + {"TaskWaitTimeout", TaskWaitTimeout("t", nil), CodeTaskWaitTimeout, CategoryTask}, + {"TaskTemplateFailed", TaskTemplateFailed(nil), CodeTaskTemplateFailed, CategoryTask}, + {"TaskGitFailed", TaskGitFailed(nil), CodeTaskGitFailed, CategoryTask}, + {"TaskHTTPFailed", TaskHTTPFailed("u", nil), CodeTaskHTTPFailed, CategoryNetwork}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if c.got == nil { + t.Fatal("constructor returned nil") + } + if c.got.Code() != c.code { + t.Errorf("Code = %q, want %q", c.got.Code(), c.code) + } + if c.got.Category() != c.cat { + t.Errorf("Category = %v, want %v", c.got.Category(), c.cat) + } + }) + } +} + +func TestPublicSurface_Newf(t *testing.T) { + e := Newf(CodeInternal, CategoryGeneric, "thing %d failed", 7) + if e.Code() != CodeInternal { + t.Errorf("Code = %q, want %q", e.Code(), CodeInternal) + } + if e.Error() != "thing 7 failed" { + t.Errorf("Error = %q", e.Error()) + } +} + +func TestPublicSurface_helpers(t *testing.T) { + if got, ok := AsError(nil); got != nil || ok { + t.Errorf("AsError(nil) = (%v, %v), want (nil, false)", got, ok) + } + if got := ExitCode(nil); got != 0 { + t.Errorf("ExitCode(nil) = %d, want 0", got) + } + if got := ExitCode(EnvNotFound("e")); got != 5 { + t.Errorf("ExitCode(EnvNotFound) = %d, want 5", got) + } + if Wrap(nil) != nil { + t.Errorf("Wrap(nil) should be nil") + } + if Wrap(errors.New("p")).Code() != CodeUnknown { + t.Errorf("Wrap(plain) should produce UNKNOWN code") + } + + var buf bytes.Buffer + EmitJSON(&buf, EnvNotFound("dev")) + var doc struct { + Error map[string]any `json:"error"` + } + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if doc.Error["code"] != "ENV_NOT_FOUND" { + t.Errorf("code = %v", doc.Error["code"]) + } +} diff --git a/src/resources/app.properties b/src/resources/app.properties index 84c6b65..a8a69b3 100644 --- a/src/resources/app.properties +++ b/src/resources/app.properties @@ -1,2 +1,2 @@ -version=0.12.0-beta +version=0.13.0-beta environment=development