Back to blog

The PATH Management Problem Nobody Talks About

the bug that started v3

so here's a fun one. I had a user report that nvm wasn't working after they set up wayu. node was installed, the nvm init script was in their config, everything looked right. but nvm use 18 would just silently fail.

took me an embarrassing amount of time to figure out. the problem was ordering. wayu v2 generated the shell config by dumping all the exports first, then the PATH entries at the bottom. but nvm's init script — which runs as part of the exports section — needs certain paths to already exist in PATH before it executes. it's looking for its own directory during initialization. if the paths come after the exports, nvm can't find itself.

this isn't just an nvm thing. pyenv does it. rbenv does it. basically any version manager that hooks into your shell has this dependency: paths need to exist before the tool initializes. and v2 just... didn't account for that.

why string concatenation doesn't work

the classic approach to PATH management is string concatenation. you've seen it:

export PATH="$PATH:/usr/local/go/bin"
export PATH="/opt/homebrew/bin:$PATH"

prepend or append, that's your two options, and the order depends entirely on which line comes first in your rc file. there's no way to express "this path needs to come before that path" without physically moving lines around.

v2 stored paths as a flat list of strings. no metadata, no ordering semantics. just strings that got concatenated into a PATH export. it worked until it didn't.

the v3 data structure

v3 treats each path entry as a struct with actual semantics:

Path_Entry :: struct {
    value:    string,
    position: enum { Prepend, Append },
    priority: int,
    exists:   bool,  // validated on load
}

sort_paths :: proc(paths: []Path_Entry) {
    slice.sort_by(paths, proc(a, b: Path_Entry) -> bool {
        if a.position != b.position do return a.position == .Prepend
        return a.priority < b.priority
    })
}

every path entry knows whether it should be prepended or appended, and has a priority within that group. prepends always come before appends. within each group, lower priority numbers come first. so if you need /opt/homebrew/bin first in your PATH, give it position: .Prepend, priority: 0. go's bin directory somewhere in the middle? position: .Prepend, priority: 50. the nvm directory that caused all this trouble? position: .Prepend, priority: 10 — early enough that it's available when the init script runs.

the sorting is stable too, so entries with the same priority keep their insertion order. no surprises.

generation order matters

the other half of the fix was changing how wayu generates the shell config file. v2 wrote sections in whatever order it felt like. v3 enforces a strict order: paths first, then environment variables, then tool initializations (like eval "$(nvm init)"), then aliases. this way, by the time any init script runs, every path it might need is already in PATH.

it sounds obvious in retrospect. it always does.

validation and deduplication

while I was reworking paths I added two things I should've had from the start. first, validation — that exists field on the struct gets set when wayu loads your config. it calls os.is_dir() on each path and flags the ones that don't exist anymore. they don't get removed automatically (that felt too aggressive) but they show up highlighted in the TUI so you can clean them up.

second, deduplication. v2 would happily let you add /usr/local/bin three times. v3 checks on add and bumps the priority if the path already exists. the dedup runs on the resolved absolute path too, so /usr/local/bin and /usr/local/bin/ don't both end up in there.

the tui view

the path management screen in the TUI ended up being one of the more useful views. you get a list of all your path entries, color-coded by status — green for active and valid, yellow for active but the directory doesn't exist, dim for disabled. you can reorder with j/k (or arrow keys if that's your thing), toggle entries on and off with space, and see the final generated PATH at the bottom updating in real time as you make changes.

there's something satisfying about seeing your entire PATH as a list you can rearrange instead of a 500-character colon-separated string. I use it probably once a week now, usually after installing something new, just to sanity-check the order.

the lesson

the real takeaway was that ordering is semantics. when you treat PATH as a string you're throwing away information about what needs to come first and why. when you treat it as a sorted array with explicit positions and priorities, the ordering becomes part of your data model instead of an accident of file layout. v3 hasn't had a single PATH ordering bug since the rewrite, which is more than I can say for my .zshrc before wayu existed.