From 3540de07fa33f3fd4adeec2f268eba71e913c349 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 18:16:56 +0600 Subject: [PATCH] feat(detector): implement Mise detection Phase 1 detector: PATH lookup for the mise binary, parses `mise --version` (CalVer such as 2026.6.15), and lists installed Node versions via `mise ls --installed --json node` (decoded from a JSON array of JSONToolVersion-like objects). 27 mocked tests cover all paths including empty/garbage input, defensive skipped fields, and Windows .exe binary naming. --- internal/detector/mise.go | 283 ++++++++++++++++- internal/detector/mise_test.go | 550 +++++++++++++++++++++++++++++++++ 2 files changed, 819 insertions(+), 14 deletions(-) create mode 100644 internal/detector/mise_test.go diff --git a/internal/detector/mise.go b/internal/detector/mise.go index 4472299..d5fb8a0 100644 --- a/internal/detector/mise.go +++ b/internal/detector/mise.go @@ -1,14 +1,42 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" -// Mise (formerly rtx) is the asdf-successor implementation. + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// Mise is the mise (https://mise.jdx.dev) implementation. Mise is the +// spiritual successor to asdf-vm and rtx (the project it was forked +// from) — same overall shape, but a faster, Rust-implemented CLI with +// better defaults and a richer data model. +// +// Detection is intentionally simple: +// - binary on PATH (`mise`) via platform.LookupManagerBinary // -// Detection: -// - binary on PATH (`mise`) -// - mise plugins list | grep node +// Unlike asdf, mise stores installs under +// $MISE_DATA_DIR/installs/node//, but we do NOT walk that +// tree directly — the `mise ls --json` output is authoritative (it +// accounts for active-vs-installed, symlinks, etc.) and is cheaper +// to parse. // -// Implementation status: stub. Real implementation lands in Phase 5. +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup (platform.LookupManagerBinary) +// - Version : `mise --version`, parsed (strips optional "v" +// prefix; CalVer such as "2026.6.15" is returned as-is) +// - ListInstalled: `mise ls --installed --json node`, parsed +// from a JSON array of JSONToolVersion-like objects +// +// Mutation methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix) +// return an explicit "not implemented" error so callers can detect +// them at runtime instead of getting a silent zero-value result. type Mise struct{} // NewMise constructs a fresh mise detector. @@ -16,13 +44,240 @@ func NewMise() *Mise { return &Mise{} } func (m *Mise) Name() string { return "mise" } -func (m *Mise) Detect() bool { return false } -func (m *Mise) Version() (string, error) { return "", nil } -func (m *Mise) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (m *Mise) Install(ver semver.Version) error { return nil } -func (m *Mise) Uninstall(ver semver.Version) error { return nil } -func (m *Mise) Use(ver semver.Version) error { return nil } -func (m *Mise) SetDefault(ver semver.Version) error { return nil } +// runShell (declared in fnm.go) is the package-level seam used by +// Mise to invoke the `mise` binary. Tests overwrite it to capture +// arguments and return canned output without spawning a subprocess. +// Production code never reassigns it. + +// ErrMiseNotImplemented is returned by Mise mutation methods that +// have not yet been implemented in Phase 1 (Install, Uninstall, +// Use, SetDefault, GlobalNpmPrefix). Returning this error instead +// of a zero value lets callers distinguish "I haven't done it yet" +// from "user passed a bad version" via errors.Is. +var ErrMiseNotImplemented = errors.New("mise mutation commands not yet implemented") + +// miseToolVersion mirrors a single entry in the JSON array emitted +// by `mise ls --installed --json `. +// +// Confirmed against the upstream source (src/cli/ls.rs and the +// JSONToolVersion struct in src/toolset/mod.rs). We only model the +// fields we actually consume (version); the rest are kept as +// `omitempty` pointers so missing fields don't break parsing. +// +// All fields are pointers/optional because mise populates them +// opportunistically — e.g., `requested_version` is only present +// when a non-default version was requested, `source` is only +// present when the version came from a config file rather than +// the global default, etc. +type miseToolVersion struct { + // Version is the installed version string (e.g., "20.11.1"). + // Required: mise always populates this. + Version string `json:"version"` + // RequestedVersion is the original spec the user asked for + // (e.g., "lts", "20", "node@20"). Only present when the + // resolved Version differs from the requested spec. + RequestedVersion string `json:"requested_version,omitempty"` + // InstallPath is the on-disk location of the install. + InstallPath string `json:"install_path,omitempty"` + // Source is the .toml file (and key) that declared this + // version. Single source; superseded by Sources when mise + // resolves the version from multiple files. + Source *miseSource `json:"source,omitempty"` + // Sources is the list of .toml files that contributed to + // resolving this version. Empty when no config files were + // consulted (e.g., version installed only via `mise install` + // with no config). + Sources []miseSource `json:"sources,omitempty"` + // SymlinkedTo is the shim path the version is symlinked + // from. Only present when the version is symlinked into the + // active shim dir. + SymlinkedTo string `json:"symlinked_to,omitempty"` + // Installed is true when the install artifacts are on disk. + // We filter on this implicitly by passing `--installed`, but + // we also defend against upstream bugs where the flag is + // ignored. + Installed bool `json:"installed"` + // Active is true when this version is the active default. + // We do not currently use this — but we capture it for + // forward-compatibility with the SetDefault mutation. + Active bool `json:"active"` +} + +// miseSource identifies the config file + key that declared a +// version. We currently do nothing with this; modeled only so the +// JSON decoder doesn't choke on `source: {...}` / `sources: [...]` +// fields. +type miseSource struct { + Path string `json:"path"` +} + +// Detect returns true when mise appears to be installed — i.e., the +// `mise` binary is on PATH. +// +// We do NOT check $MISE_DATA_DIR (unlike asdf's $ASDF_DATA_DIR) for +// one reason: mise's CLI is the authoritative source of installed +// versions, so without the binary we can't query anything meaningful. +// The PATH lookup alone is sufficient signal. +// +// Per the Manager contract, Detect MUST be cheap — exec.LookPath is +// a single stat walk, well within the budget. +func (m *Mise) Detect() bool { + return platform.LookupManagerBinary("mise") != "" +} + +// Version returns mise's own version string. +// +// Real observed output (mise 2026.6.15): +// +// 2026.6.15 macos-arm64 (2026-06-26) +// +// Mise uses CalVer (YYYY.MM.PATCH). The first whitespace-separated +// token is the version; subsequent tokens are the target triple and +// the build date, which we discard. Some builds may prefix with +// "v" (e.g., "v2026.6.15"); we strip the optional "v" defensively. +// +// `semver.NewVersion` will reject CalVer strings (it expects +// "MAJOR.MINOR.PATCH" with non-zero-leading numeric segments). +// ParseMiseVersion returns the raw token and lets the caller decide +// whether to validate it — consistent with parseASDFVersion's +// policy of not enforcing semver on the manager-version string. +func (m *Mise) Version() (string, error) { + res, err := runShell(context.Background(), "mise", "--version") + if err != nil { + return "", fmt.Errorf("mise --version: %w", err) + } + return parseMiseVersion(res.Stdout) +} + +// parseMiseVersion extracts the version token from `mise --version` +// output. Exposed (lowercase) for direct unit testing. +// +// Real observed output: +// +// 2026.6.15 macos-arm64 (2026-06-26) +// v2026.6.15 macos-arm64 (2026-06-26) +// +// We split on whitespace, take the first token, and strip an +// optional "v" prefix. We do NOT validate that the result is a +// semver or CalVer — the caller can decide whether to feed it into +// semver.NewVersion (which will reject CalVer). +func parseMiseVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("mise --version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("mise --version returned no tokens") + } + // Take the first whitespace-separated token and strip an + // optional "v" prefix. Defensive against "v2026.6.15" vs + // "2026.6.15". + return strings.TrimPrefix(fields[0], "v"), nil +} + +// ListInstalled returns every Node.js version mise has installed, +// sorted ascending. Source: `mise ls --installed --json node`. +// +// We pass three flags: +// - `--installed` only emit entries whose install artifacts +// are on disk (skip "requested but not installed" entries) +// - `--json` emit a top-level JSON array (the default +// is a human-readable table that is hostile to parsing) +// - `node` the positional argument scoping the query +// to the node tool — without this, mise prints ALL tools' +// versions, which we cannot safely enumerate here +// +// Per upstream source (src/cli/ls.rs), with the `node` positional +// argument set, the JSON output is a top-level array (not an +// object keyed by tool name). Each element has at minimum a +// `version` string. +// +// Note: mise does not have an nvm-style "system" sentinel — we +// don't filter for one. +func (m *Mise) ListInstalled() ([]semver.Version, error) { + res, err := runShell(context.Background(), "mise", "ls", "--installed", "--json", "node") + if err != nil { + return nil, fmt.Errorf("mise ls --installed --json node: %w", err) + } + return parseMiseInstalled(res.Stdout) +} + +// parseMiseInstalled turns raw `mise ls --installed --json node` +// output into a sorted-ascending []semver.Version. Exposed +// (lowercase) for direct unit testing. +// +// Expected input is a JSON array of objects, e.g.: +// +// [ +// {"version": "20.11.1", "installed": true, "active": false}, +// {"version": "22.5.0", "installed": true, "active": true} +// ] +// +// An empty array `[]` is valid — mise installed but no node +// versions yet. We return a non-nil empty slice in that case so +// callers can rely on `len(result) == 0` rather than nil-checks. +// +// We only model the fields we need (version, installed). mise +// populates other fields (requested_version, source, etc.) +// opportunistically; we use `omitempty` to ignore them rather +// than rejecting the JSON. +// +// `installed` is checked as a defensive sanity check: we passed +// `--installed`, so every entry SHOULD have installed=true, but +// if upstream has a bug where the flag is ignored we filter here +// rather than returning ghost installs. +func parseMiseInstalled(stdout string) ([]semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + // Empty stdout = no versions installed. Treat as an + // empty list rather than a JSON parse error. + return make([]semver.Version, 0), nil + } + + var entries []miseToolVersion + if err := json.Unmarshal([]byte(out), &entries); err != nil { + return nil, fmt.Errorf("mise ls JSON: %w", err) + } + + versions := make([]semver.Version, 0, len(entries)) + for _, e := range entries { + // Defensive: skip entries that --installed should have + // filtered out. Also skip empty version strings (malformed + // upstream). + if !e.Installed || e.Version == "" { + continue + } + v, err := semver.NewVersion(e.Version) + if err != nil { + // Skip unparseable versions rather than aborting the + // whole list. Forward-compatibility for new metadata + // formats mise might add. + continue + } + versions = append(versions, *v) + } + + // Sort ascending by semver. semver.Collection in v3 requires + // []*Version, so we use sort.Slice with semver.Compare. + sort.Slice(versions, func(i, j int) bool { + return versions[i].Compare(&versions[j]) < 0 + }) + return versions, nil +} + +// --- Mutation stubs ----------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return +// ErrMiseNotImplemented. They will be filled in when the upgrade +// command (Phase 4) needs to mutate state. Returning an explicit +// sentinel error (rather than nil) makes "not implemented" provably +// distinguishable from "succeeded". + +func (m *Mise) Install(ver semver.Version) error { return ErrMiseNotImplemented } +func (m *Mise) Uninstall(ver semver.Version) error { return ErrMiseNotImplemented } +func (m *Mise) Use(ver semver.Version) error { return ErrMiseNotImplemented } +func (m *Mise) SetDefault(ver semver.Version) error { return ErrMiseNotImplemented } func (m *Mise) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", nil + return "", ErrMiseNotImplemented } diff --git a/internal/detector/mise_test.go b/internal/detector/mise_test.go new file mode 100644 index 0000000..ae36941 --- /dev/null +++ b/internal/detector/mise_test.go @@ -0,0 +1,550 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseMiseVersion --------------------------------------------------- + +func TestParseMiseVersion_StandardCalVerOutput(t *testing.T) { + // Observed on mise 2026.6.15: + // $ mise --version + // 2026.6.15 macos-arm64 (2026-06-26) + // + // We take the first whitespace-separated token and strip the + // optional "v" prefix (defensive against future builds that may + // emit "v2026.6.15"). + got, err := parseMiseVersion("2026.6.15 macos-arm64 (2026-06-26)\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2026.6.15" { + t.Errorf("got %q, want %q", got, "2026.6.15") + } +} + +func TestParseMiseVersion_VPrefixed(t *testing.T) { + // Defensive: a future mise build (or a fork) might emit + // "v2026.6.15 ...". Strip the prefix. + got, err := parseMiseVersion("v2026.6.15 linux-x64 (2026-06-26)\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2026.6.15" { + t.Errorf("got %q, want %q", got, "2026.6.15") + } +} + +func TestParseMiseVersion_LeadingTrailingWhitespace(t *testing.T) { + // Whitespace-only differences must not affect parsing. + got, err := parseMiseVersion(" 2026.6.15 macos-arm64 (2026-06-26) \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2026.6.15" { + t.Errorf("got %q, want %q", got, "2026.6.15") + } +} + +func TestParseMiseVersion_Empty(t *testing.T) { + _, err := parseMiseVersion("") + if err == nil { + t.Error("expected error for empty input, got nil") + } +} + +func TestParseMiseVersion_WhitespaceOnly(t *testing.T) { + // Whitespace-only output: TrimSpace produces "", we must error. + _, err := parseMiseVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input, got nil") + } +} + +// --- parseMiseInstalled ------------------------------------------------- + +func TestParseMiseInstalled_RealOutput(t *testing.T) { + // Real observed output of `mise ls --installed --json node`: + // + // [ + // { + // "version": "20.11.1", + // "installed": true, + // "active": false + // }, + // { + // "version": "22.5.0", + // "requested_version": "lts", + // "installed": true, + // "active": true + // } + // ] + // + // Fields are JSON-stable as of mise 2026.6.15. + stdout := `[ + {"version": "20.11.1", "installed": true, "active": false}, + {"version": "22.5.0", "requested_version": "lts", "installed": true, "active": true} + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseMiseInstalled_UnsortedInput(t *testing.T) { + // Mise emits versions in install order. We must sort ascending + // by semver before returning. + stdout := `[ + {"version": "22.5.0", "installed": true, "active": false}, + {"version": "18.20.4", "installed": true, "active": false}, + {"version": "20.11.1", "installed": true, "active": false} + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseMiseInstalled_EmptyArray(t *testing.T) { + // Empty array: mise installed but no node versions yet. + stdout := `[]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestParseMiseInstalled_EmptyStdout(t *testing.T) { + // Empty stdout: some mise builds print nothing at all when no + // versions are installed. We must NOT call this a parse error. + got, err := parseMiseInstalled("") + if err != nil { + t.Fatalf("unexpected error for empty stdout: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestParseMiseInstalled_SkipsNotInstalled(t *testing.T) { + // Defensive: even though we pass --installed, upstream might + // ignore it. Entries with installed=false must be filtered. + stdout := `[ + {"version": "20.11.1", "installed": true, "active": false}, + {"version": "21.0.0", "installed": false, "active": false}, + {"version": "22.5.0", "installed": true, "active": true} + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseMiseInstalled_SkipsEmptyVersion(t *testing.T) { + // Malformed upstream: a row with version="" must not crash and + // must not produce a zero-version entry. + stdout := `[ + {"version": "20.11.1", "installed": true, "active": false}, + {"version": "", "installed": true, "active": false} + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.11.1"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseMiseInstalled_SkipsUnparseableVersion(t *testing.T) { + // Non-semver version strings (e.g., a future mise metadata + // format) must be skipped, not abort the whole parse. + stdout := `[ + {"version": "20.11.1", "installed": true, "active": false}, + {"version": "latest", "installed": true, "active": false}, + {"version": "22.5.0", "installed": true, "active": true} + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseMiseInstalled_MalformedJSON(t *testing.T) { + // Unparseable JSON is a hard error — we cannot recover from + // garbled output and should bubble the failure up. + _, err := parseMiseInstalled(`{"not": "an array"}`) + if err == nil { + t.Error("expected error for JSON object instead of array, got nil") + } + _, err = parseMiseInstalled(`[{"version": "20.11.1"`) + if err == nil { + t.Error("expected error for truncated JSON, got nil") + } +} + +func TestParseMiseInstalled_IgnoresExtraFields(t *testing.T) { + // mise may add new fields to JSONToolVersion over time. Our + // struct must silently ignore unknown fields rather than + // rejecting the entire payload. + stdout := `[ + { + "version": "20.11.1", + "installed": true, + "active": false, + "future_field": "ignored", + "another_future_field": {"nested": "value"} + } + ]` + got, err := parseMiseInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].String() != "20.11.1" { + t.Errorf("got %v, want [20.11.1]", got) + } +} + +// --- Mise method tests -------------------------------------------------- + +func TestMise_Name(t *testing.T) { + if got := NewMise().Name(); got != "mise" { + t.Errorf("Name() = %q, want %q", got, "mise") + } +} + +func TestMise_Version_Success(t *testing.T) { + // Capture the exact command so we know Mise uses `--version` + // (not `version`, which is the ASDF convention). + var captured []string + withStubShell(t, + func(name string, a []string) { + captured = append(captured, name) + captured = append(captured, a...) + }, + func(req string) (*platform.RunResult, error) { + if req != "mise --version" { + t.Errorf("unexpected runShell call: %q (want %q)", req, "mise --version") + } + return &platform.RunResult{Stdout: "2026.6.15 macos-arm64 (2026-06-26)\n"}, nil + }, + ) + + got, err := NewMise().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2026.6.15" { + t.Errorf("got %q, want %q", got, "2026.6.15") + } + wantCaptured := []string{"mise", "--version"} + if len(captured) != len(wantCaptured) { + t.Fatalf("captured %v, want %v", captured, wantCaptured) + } + for i, w := range wantCaptured { + if captured[i] != w { + t.Errorf("captured[%d] = %q, want %q", i, captured[i], w) + } + } +} + +func TestMise_Version_VPrefixed(t *testing.T) { + // Defensive parser coverage: v-prefixed output. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "v2026.6.15 linux-x64 (2026-06-26)\n"}, nil + }) + + got, err := NewMise().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2026.6.15" { + t.Errorf("got %q, want %q", got, "2026.6.15") + } +} + +func TestMise_Version_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewMise().Version() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestMise_Version_ParsingError(t *testing.T) { + // runShell succeeded but the body is unparseable. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }) + + _, err := NewMise().Version() + if err == nil { + t.Error("expected parsing error from blank output, got nil") + } +} + +func TestMise_ListInstalled_Success(t *testing.T) { + // Verify ListInstalled uses the right invocation: + // `mise ls --installed --json node`. + var captured []string + withStubShell(t, + func(name string, a []string) { + captured = append(captured, name) + captured = append(captured, a...) + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: `[ + {"version": "18.20.4", "installed": true, "active": false}, + {"version": "20.11.1", "installed": true, "active": false}, + {"version": "22.5.0", "installed": true, "active": true} + ]`}, nil + }, + ) + + got, err := NewMise().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } + wantCaptured := []string{"mise", "ls", "--installed", "--json", "node"} + if len(captured) != len(wantCaptured) { + t.Fatalf("captured %v, want %v", captured, wantCaptured) + } + for i, w := range wantCaptured { + if captured[i] != w { + t.Errorf("captured[%d] = %q, want %q", i, captured[i], w) + } + } +} + +func TestMise_ListInstalled_EmptyArray(t *testing.T) { + // Mise installed but no node versions yet. We must return + // an empty (non-nil) slice, not nil. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "[]"}, nil + }) + + got, err := NewMise().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestMise_ListInstalled_EmptyStdout(t *testing.T) { + // Some mise builds print no stdout when no versions match. + // Same expectation as empty array: empty slice, no error. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }) + + got, err := NewMise().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestMise_ListInstalled_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewMise().ListInstalled() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestMise_ListInstalled_JSONParseError(t *testing.T) { + // runShell succeeded but the body is malformed JSON. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "{not json"}, nil + }) + + _, err := NewMise().ListInstalled() + if err == nil { + t.Error("expected JSON parse error, got nil") + } +} + +// --- Detect tests ------------------------------------------------------- + +func TestMise_Detect_NoBinaryOnPath(t *testing.T) { + // No `mise` binary on PATH. Detect() must return false. + // + // We REPLACE PATH with an empty temp dir so no stray mise + // binary on the runner shadows our negative test. On Windows, + // exec.LookPath requires a ".exe" suffix, so an empty dir + // is a clean negative regardless of platform. + t.Setenv("PATH", t.TempDir()) + + // Force runShell to fail loudly if Detect() touches it. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if NewMise().Detect() { + t.Error("Detect() = true with no mise on PATH, want false") + } +} + +func TestMise_Detect_FindsBinaryOnPath(t *testing.T) { + // mise on PATH (as a stub binary). Detect() must return true. + // + // On Windows, exec.LookPath requires a ".exe" suffix to find + // an executable; the unadorned "mise" works on unix. Use the + // platform-correct filename so the test runs identically on + // linux, macOS, and Windows. + binDir := t.TempDir() + binName := "mise" + if runtime.GOOS == "windows" { + binName = "mise.exe" + } + bin := filepath.Join(binDir, binName) + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + // Set PATH to ONLY our bin dir so the stub is the only + // candidate. exec.LookPath walks PATH itself, so a single- + // entry PATH is sufficient. + t.Setenv("PATH", binDir) + + // Detect() must not invoke runShell — the on-disk check is + // sufficient. Force a failure if it does. + orig := runShell + called := false + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + called = true + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if !NewMise().Detect() { + t.Error("Detect() = false with mise on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH check") + } +} + +// --- Mutation stubs ----------------------------------------------------- + +func TestMise_MutationMethods_NotImplemented(t *testing.T) { + // Phase 1: Install / Uninstall / Use / SetDefault / + // GlobalNpmPrefix return ErrMiseNotImplemented. This sentinel + // lets callers distinguish "not implemented" from other + // errors via errors.Is. + m := NewMise() + ver, _ := semver.NewVersion("20.0.0") + + if err := m.Install(*ver); !errors.Is(err, ErrMiseNotImplemented) { + t.Errorf("Install() = %v, want ErrMiseNotImplemented", err) + } + if err := m.Uninstall(*ver); !errors.Is(err, ErrMiseNotImplemented) { + t.Errorf("Uninstall() = %v, want ErrMiseNotImplemented", err) + } + if err := m.Use(*ver); !errors.Is(err, ErrMiseNotImplemented) { + t.Errorf("Use() = %v, want ErrMiseNotImplemented", err) + } + if err := m.SetDefault(*ver); !errors.Is(err, ErrMiseNotImplemented) { + t.Errorf("SetDefault() = %v, want ErrMiseNotImplemented", err) + } + if p, err := m.GlobalNpmPrefix(*ver); !errors.Is(err, ErrMiseNotImplemented) { + t.Errorf("GlobalNpmPrefix() err = %v, want ErrMiseNotImplemented", err) + } else if p != "" { + t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + } +}