Repository navigation
--output resolves directory-vs-file four different ways, two of them wrong #37
Description
Four commands decide whether --output names a directory or a file, and they decide it four different ways. Two of them get it wrong for paths users plausibly type.
The help text is the same everywhere — "directory or filename" — so the behaviour difference is invisible until a write lands in the wrong place.
The four sites
| site | rule | existing dir with a dot | extensionless filename |
|---|---|---|---|
LocalMessageFile.destination |
exists-check + trailing slash | ✅ directory | ✅ file |
Fetch.isExplicitOutputFile (Fetch.swift:223) |
exists-check only | ✅ directory | ❌ becomes a directory |
Attachment.swift:44 |
pathExtension.count > 0 |
❌ write fails | ❌ becomes a directory |
PDF.swift:39 |
pathExtension == "pdf" |
❌ write fails | ❌ becomes a directory |
Fetch already carries the existence check and a test for it (testExistingDirectoryWithDotIsNotExplicitFile), so the first column was found and fixed once — just not shared. LocalMessageFile added the trailing-slash rule in #36. The other two never got either.
What goes wrong
pathExtension cannot answer the question in either direction: exports.v1 is a plausible directory name and README a plausible filename.
An existing directory with a dot in its name — Attachment and PDF treat it as a file and hand Data.write a path that is a directory:
$ post attachment 14434 --output exports.v1
Error: The file "exports.v1" couldn't be saved in the folder "…"
An extensionless filename — all three of the older sites treat it as a directory and create it, putting a generated child inside instead of writing the file that was asked for:
$ post attachment 14434 --output invoice
# creates a directory ./invoice/ containing invoice.pdf
PDF.swift has a third variant of its own: the test is pathExtension.lowercased() == "pdf", so --output report.pdf.bak is treated as a directory, while an existing directory named archive.pdf is treated as a file.
Suggested fix
One helper, used by all four, with the rule stated rather than inferred:
- an existing directory is a directory;
- a trailing slash means a directory, created if missing;
- anything else is the filename to write, with its parent created.
That is what LocalMessageFile.destination(for:named:) does now (Sources/post/Commands/LocalMessageFile.swift, added in #36) — lifting it somewhere shared and deleting the other three would settle the behaviour in one place. URL.resourceValues(forKeys: [.isDirectoryKey]) does the existence test and behaves the same on Linux.
PDF's extra rule — refusing a filename when exporting multiple UIDs — is a separate check and should survive on top of the shared resolver.
Why this was not fixed in #36
#36 added the local-file path and fixed it there. The other three are reachable only through a live IMAP server, so I could not exercise them, and changing an untested path blind seemed worse than filing this.
Activity
odrobnik commentedon Oct 5, 2026
🧭 Planner finished — implementation plan ready for approval (via
Claude Team [drobnik] · 3% used).Good — no README changes needed (it only shows an existing-directory example, which is unaffected). Here's the plan.
Implementation plan: unify
--outputdirectory-vs-file resolution (issue #37)Intended behavior
One rule, used identically by
post attachment,post pdf,post fetch --eml/--output, andLocalMessageFile's--part:/is a directory, created if missing;pathExtensionis never consulted. This already isLocalMessageFile.destination(for:named:)(Sources/post/Commands/LocalMessageFile.swift:246-254, added in #36) — the fix is to lift it to a shared location and make the other three sites call it instead of re-deriving the rule.Files/components involved
Sources/post/Commands/LocalMessageFile.swift— has the correct logic today (lines 235-254), used byextract(_:section:to:)(line 229).Sources/post/Commands/Fetch.swift—isExplicitOutputFile(lines 223-232) and theoutURL/isExplicitFile/outputDirplumbing inrun()(lines 128-143, 158-163, 191-195).Sources/post/Commands/Attachment.swift— inlinepathExtension.count > 0check (lines 43-47).Sources/post/Commands/PDF.swift— inlinepathExtension.lowercased() == "pdf"check (lines 38-58).Sources/post/OutputPath.swift(matches the existing convention of top-level helper files likeInt+ByteFormatting.swift,String+FetchHeaders.swift).Tests/PostCLITests/FetchOutputPathTests.swift,Tests/PostCLITests/LocalPartExtractionTests.swift("Output path resolution" section, lines 115-156),Tests/PostCLITests/AttachmentCommandTests.swift.Steps
Create
Sources/post/OutputPath.swiftwith the rule split into two functions so call sites that need to validate before a filename is known (Fetch's and PDF's "reject a filename with multiple UIDs" check) can do so without duplicating logic:Carry over the doc comment from
LocalMessageFile.swift:235-245explaining whypathExtensiondoesn't work.LocalMessageFile.swift: delete the staticdestination(for:named:)(lines 246-254); changeextract(line 229) to callOutputPath.destination(for: output, named: part.suggestedFilename).Fetch.swift:isExplicitOutputFile(_:fileManager:)(lines 223-232).let isExplicitFile = output.map { !OutputPath.namesDirectory($0) } ?? false.outputDir(lines 136-143); callOutputPath.destination(for: output, named: "\(uidValue).eml")directly in theemlbranch, andOutputPath.destination(for: output, named: "\(message.uid).txt")in the plain-body-to-file branch. This also fixes the extensionless-filename bug forFetch(todayinvoicewith no extension and no existing path is read as "wants directory" becausepathExtension.isEmpty).Attachment.swift: replace lines 43-47 withlet destination = try OutputPath.destination(for: output, named: attachment.filename).PDF.swift:isExplicitFile(line 39) withlet isExplicitFile = !OutputPath.namesDirectory(output), keeping it only for the existing "no filename across multiple UIDs" check (lines 42-44) — that check is a separate rule and survives unchanged.outputDir/createDirectory(lines 46-47); replace the destination computation (line 58) withtry OutputPath.destination(for: output, named: result.filename).archive.pdfas an existing directory is now correctly treated as a directory;report.pdf.bakis now correctly treated as a file;report(no extension, multiple UIDs) is now correctly rejected by the multi-UID check instead of silently becoming a directory.Help text: give all four
--outputoption declarations the same accurate wording (LocalMessageFile.swift:137,Fetch.swift:23,Attachment.swift:24,PDF.swift:22) — something like "an existing directory, or a path ending in /, takes a generated filename; anything else is the file to write" — replacing the current "directory or filename" phrasing that doesn't describe the actual rule.Tests / verification
LocalPartExtractionTests.swift(lines 117-156) and the three tests inFetchOutputPathTests.swiftinto one newTests/PostCLITests/OutputPathTests.swift, retargeted atOutputPath.namesDirectory/OutputPath.destination. This avoids testing the same logic twice under two names.archive.pdf→ treated as directory (coversPDF's old.pdf-extension check).report.pdf.bak→ treated as a file (coversPDF's old case-sensitivity/exact-match check).LocalPartExtractionTests.swiftfocused on listing/extraction; it still exercises the shared helper indirectly throughextract.AttachmentCommandTests.swiftonly tests argument parsing today (no live-client call), so it can't exercisedestinationend-to-end without a server fixture — same constraint the issue notes for why Address a single part of a local message by section #36 didn't touch these three sites. The shared-helper unit tests are the practical coverage; call this out as a residual gap rather than claiming command-level verification.swift test --scratch-path "/Volumes/SSD/SwiftPM/$(basename "$PWD")"after the change.Open questions / risks
OutputPathenum in a new top-level file) matching this repo's existingType+Feature.swiftconvention — a human reviewer may prefer a different name or an extension onURLinstead; flagging rather than deciding unilaterally.PDF: dropping the.pdf-extension-specific check for the multi-UID guard means more inputs will now correctly be rejected or accepted per the shared rule (e.g.,--output reportwith multiple UIDs is now rejected as an explicit filename, where before it silently became a directory). This is the intended fix per the issue but is a visible CLI behavior change worth a release note.Fetch/Attachment/PDF'srun(), so the fix is verified at the shared-helper unit-test level, not through the actual command path — same limitation the issue itself describes for why Address a single part of a local message by section #36 didn't touch these three sites.Approve this plan by starting an Implementor for the issue.
odrobnik commentedon Oct 8, 2026
🚀 Shipped (via
Claude Team [drobnik] · 6% used) — opened #51PR #51 is open with auto-merge and squash: #51
What changed
Sources/post/OutputPath.swift. It states the rule once: an existing directory is a directory, a trailing slash is a directory created if missing, anything else is the filename to write with its parent created. A separatenamesDirectorycheck touches nothing on disk, so commands can reject a filename before any directory exists.LocalMessageFilelost its private copy.Fetchlost its existence-plus-extension check and the precomputed output directory, and an explicit filename now also applies to the text-body export instead of being silently ignored.AttachmentandPDFlost theirpathExtensionchecks, soexports.v1andarchive.pdfas existing directories now work, andinvoiceorreport.pdf.bakare written as files. The multi-UID filename rejection inpdfandfetchsurvives on top of the shared rule.--outputoptions now describes the actual rule. The redundant "(default: .)" that ArgumentParser already appends was dropped.Tests/PostCLITests/OutputPathTests.swift, replacing the Fetch-only tests and the resolution section of the local-part tests. Two regression cases from the issue were added: an existingarchive.pdfdirectory, andreport.pdf.bakas a file.Verification. Built with
swift build --build-testsand ran the full suite on the SSD scratch. All 43 tests pass. The rewritten help text was checked by running the built binary. No screenshots, since this is a CLI change with no screen.Residual gap. As the issue and plan note,
fetch,attachmentandpdfare only reachable through a live IMAP server, so their command paths are covered by the shared-helper unit tests rather than end to end.One note for the release:
post pdf --output reportwith multiple UIDs is now rejected as a filename where it used to silently become a directory. That is the intended behaviour but is a visible change.