Skip to content
Development

The folder that stayed put

By Victor Da Luz
rusttauritestingdev-loggreenhouse

Greenhouse, my creative-project manager, has a rule baked into almost every part of it: a project’s folder lives wherever its status and stage say it should. Active project in the Explore stage? Its folder is under 20-explore/. Vault it? The folder moves to 90-vault/. Every screen that opens a folder or lists its files just computes the path from the database row. It never has to ask the filesystem “wait, where did I actually put this?”

I found the one place that assumption was false during a second full code review of the app (I’m running these periodically, treating the codebase like it deserves an outside pair of eyes even when there isn’t one). The bug was in “adopt and import” - the feature that lets you point Greenhouse at a folder you already had on disk and pull it into the pipeline instead of starting fresh.

The shortcut that made sense at the time

When I built adopt/import, I made a deliberate call: import in place. Write the database row, seed a project.md file, and leave the folder exactly where the user’s OS file browser already had it. Moving files around felt like more risk than the feature needed for a first version, and the code comment said so explicitly:

/// v1 is adopt-in-place - the folder is recorded and mirrored where it sits;
/// it is not physically moved into the stage directory.

That reads like a reasonable scope cut. It wasn’t. Every other status change in the app (promoting an idea, advancing a stage, vaulting a project) physically moves the folder as part of the transition. Import was the one entry into “active project” that didn’t. So the moment you imported a folder, its real location on disk and the location every path resolver computed from the database diverged, permanently.

What that actually broke

Once I traced it through: the in-app file previewer errored on every imported item, because it looked in the computed stage folder and the files were still sitting at the vault root. “Show in Finder” opened a folder that didn’t exist yet. The first time you touched or advanced an imported project, Greenhouse dutifully created the stage folder it expected to find, wrote a fresh project.md into it, and left the folder with your actual files stranded at the root forever - a phantom folder holding one file next to the real one holding everything you cared about. Vaulting the item flipped its status to Vaulted in the database while the files never moved an inch.

None of this showed up in existing tests, because the tests for import stopped at “the database row looks right and project.md got written.” Nothing chained import into the operations that come after it - preview a file, advance a stage, vault the thing. The end-to-end check that verified import worked never went further than seeing the imported project show up as a card.

The fix was already in the codebase

The good news about a codebase with a consistent invariant is that fixing a violation of it means copying a pattern that already exists three other places. promote_idea, the function that turns a captured idea into an active project, does exactly what import needed to do: compute the destination under the stage directory, create the parent if needed, rename the folder if it isn’t already there, then write the mirror file into the new location.

let dest = stage_dir(root, stage).join(&candidate.name);
if candidate.path != dest {
    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::rename(&candidate.path, &dest)?;
}

Move before the database write, not after. If the rename fails, the folder is still intact wherever it started; if I’d inserted the database row first and the rename failed second, I’d have reproduced the exact bug I was fixing: a database row pointing at a location the file never reached.

One side effect I liked: the fix also quietly solved a smaller, related problem. Re-scanning for importable folders used to need a database check to avoid offering an already-imported folder forever, since adopt-in-place meant the folder just kept sitting there on disk looking importable. Once import actually moves the folder, the raw filesystem scan stops seeing it at all. The database check downgrades from load-bearing to a defense-in-depth line, for free.

Writing the tests that would have caught it

The useful output of a review like this isn’t just the fix, it’s the missing test list, because each symptom maps directly to a test. I ended up with five: import then check the “show in Finder” path actually exists, import then confirm the previewer lists the seeded file, import then advance and check the folder followed the stage (nothing stranded at the old location), import then touch and confirm no phantom folder appears, and import then vault and check the files actually landed in the vault directory. Every one of those is a test that would have failed on the old code and passes now.

I also drove the fix through a real running build, not just the Rust test suite. Greenhouse has a small WebDriver setup for exactly this: it launches the actual Tauri app against a throwaway vault and clicks through it like a person would. I extended the existing import script to drop a real WAV file into the folder before importing it, then click through to preview it and advance it a stage, checking the audio element actually resolved and played through the asset-protocol URL, and that the file was still sitting next to project.md after the stage move. Watching a real click open a real audio player fetching a file that had physically moved across two directories was more convincing than any assertion in a unit test.

What I’d take from this

The lesson isn’t “test more.” It’s noticing when a system leans on an invariant so heavily that nothing checks it directly, because every existing code path happens to preserve it by construction. That’s exactly the condition under which a new code path can violate it silently. If four functions in a file all relocate a folder as a side effect and you’re about to add a fifth that doesn’t, what you’re adding is a special case that needs its own justification, however much it looks like a smaller feature. In this case there wasn’t one, so the fix was to stop being special.

Related reading

Development

Two notes nobody ever saw

Greenhouse saved a capture note and a handoff note faithfully, and showed neither: one had no field in the wire type, the other had a working command nobody called.

Read
Development

The scan that offered to import itself

An import scanner that found the app's own scaffolding, then re-offered a folder it had just imported - two versions of the same missing conversation between layers.

Read