Implement attachment import and listing

importAttachments lands files into a card's attachments/ (created on
first import — the one folder the app ever makes there), never
overwriting and never refusing a name: collisions auto-rename
Finder-style, counting up from 2, with a same-named subfolder
blocking a name exactly like a file. Sources are validated before any
copy; a failed copy removes the partial destination and throws naming
the source file; a multi-file batch stops at the first failure with
earlier files staying landed. listAttachments is the flat surface:
top-level regular files only, Finder-sorted, subfolders and hidden
entries excluded and untouched; a missing attachments/ lists empty.

11 new unit tests; 245 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 17:48:46 -04:00
parent fa834501b5
commit 840528d14c
2 changed files with 396 additions and 0 deletions
+225
View File
@@ -1909,3 +1909,228 @@ struct BoardWriterSameParentMoveTests {
#expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").contains(Ident.card1))
}
}
// MARK: - Import attachments
/// `BoardWriter.importAttachments`: the write side of 01-storage-format.md § Attachments
/// never refuses a drop, auto-renames Finder-style on collision, and stops a multi-file batch
/// at the first failure without undoing what already landed.
struct BoardWriterImportAttachmentsTests {
/// A bare UUID-shaped card folder `importAttachments` never reads its `index.md`, so a
/// minimal one (like every other card these suites mint) is as good a target as a fully
/// loaded one.
private func card(_ fixture: WriterFixture) throws -> URL {
try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card"))
}
@Test func firstImportCreatesAttachmentsAndLandsTheOriginalName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let bytes = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
let source = try fixture.file("sources/shot.png", bytes)
let landed = try BoardWriter.importAttachments([source], intoCard: card)
#expect(landed == [ImportedAttachment(sourceURL: source, fileName: "shot.png")])
#expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == bytes)
// The source is a copy's origin, never its casualty.
#expect(try fixture.data("sources/shot.png") == bytes)
}
/// Three separate drops of a file named `shot.png`: never overwritten, never refused each
/// lands under the next free Finder-style name, and all three coexist afterwards.
@Test func collidingImportsAutoRenameFinderStyle() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let first = try fixture.file("sources/1/shot.png", Data([0x01]))
let second = try fixture.file("sources/2/shot.png", Data([0x02]))
let third = try fixture.file("sources/3/shot.png", Data([0x03]))
let landed1 = try BoardWriter.importAttachments([first], intoCard: card)
let landed2 = try BoardWriter.importAttachments([second], intoCard: card)
let landed3 = try BoardWriter.importAttachments([third], intoCard: card)
#expect(landed1.map(\.fileName) == ["shot.png"])
#expect(landed2.map(\.fileName) == ["shot 2.png"])
#expect(landed3.map(\.fileName) == ["shot 3.png"])
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot 2.png", "shot 3.png", "shot.png"])
#expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == Data([0x01]))
#expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x02]))
#expect(try fixture.data("\(Ident.card1)/attachments/shot 3.png") == Data([0x03]))
}
/// A name with no extension at all suffixes directly `"notes"` `"notes 2"`, not
/// `"notes 2."` or some other extension-shaped artifact.
@Test func anExtensionLessCollisionRenamesToNotes2() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let first = try fixture.file("sources/1/notes", Data("first".utf8))
let second = try fixture.file("sources/2/notes", Data("second".utf8))
_ = try BoardWriter.importAttachments([first], intoCard: card)
let landed = try BoardWriter.importAttachments([second], intoCard: card)
#expect(landed.map(\.fileName) == ["notes 2"])
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["notes", "notes 2"])
}
/// `fileExists` is true for a directory as much as a file: a subfolder that happens to share
/// the incoming name blocks it exactly like a file would, and is never touched by the import.
@Test func aFileNamedLikeAnExistingSubfolderAlsoRenames() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
try fixture.file("\(Ident.card1)/attachments/shot.png/inner.txt", Data("hand-made".utf8))
let source = try fixture.file("sources/shot.png", Data([0x09]))
let landed = try BoardWriter.importAttachments([source], intoCard: card)
#expect(landed.map(\.fileName) == ["shot 2.png"])
#expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x09]))
#expect(try fixture.entryNames("\(Ident.card1)/attachments/shot.png") == ["inner.txt"])
}
/// One call, three sources, two of which collide with what is already there and with each
/// other in turn the returned names track input order, not landing order.
@Test func multiFileImportInOneCallReturnsLandedNamesInInputOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
try fixture.file("\(Ident.card1)/attachments/shot.png", Data([0x00]))
let shot = try fixture.file("sources/1/shot.png", Data([0x01]))
let notes = try fixture.file("sources/2/notes.txt", Data([0x02]))
let shotAgain = try fixture.file("sources/3/shot.png", Data([0x03]))
let landed = try BoardWriter.importAttachments([shot, notes, shotAgain], intoCard: card)
#expect(landed.map(\.fileName) == ["shot 2.png", "notes.txt", "shot 3.png"])
#expect(landed.map(\.sourceURL) == [shot, notes, shotAgain])
#expect(try fixture.entryNames("\(Ident.card1)/attachments")
== ["notes.txt", "shot 2.png", "shot 3.png", "shot.png"])
}
/// Refused before any copy for that file: the failing source is named, nothing of its lands,
/// and because each import is its own completed write the file that landed *before* it
/// in the same batch stays landed rather than being rolled back.
@Test func anUnreadableSourceRefusesNamingItAndStopsTheBatch() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let first = try fixture.file("sources/shot.png", Data([0x01]))
let missing = fixture.url("sources").appendingPathComponent("missing.png")
let third = try fixture.file("sources/third.png", Data([0x03]))
let error = writeFailure {
_ = try BoardWriter.importAttachments([first, missing, third], intoCard: card)
}
guard case .unreadable = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(error?.path == missing.path)
#expect(error?.operation == "import attachment")
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
}
/// Attachments belong to cards: the shape guard `deleteItem`/`restoreItem`/`purgeItem` share
/// refuses a board root (or any non-UUID-shaped folder) before `attachments/` is even
/// considered.
@Test func importIntoANonUUIDShapedFolderIsRefused() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let root = try fixture.item("A.kanban", Item.board)
let source = try fixture.file("sources/shot.png", Data([0x01]))
let error = writeFailure {
_ = try BoardWriter.importAttachments([source], intoCard: root)
}
guard case let .unreadable(message) = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
#expect(message.contains("UUID-shaped"))
#expect(!fixture.exists("A.kanban/attachments"))
}
/// "Never refuses the drop" is policy, not an I/O guarantee: a copy that genuinely cannot
/// land `attachments/` made unwritable here, standing in for a full disk or a permissions
/// error surfaces as `.io` naming the source file, and no partial file is left behind.
@Test func aFailedCopyLeavesNoPartialAttachment() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let existing = try fixture.file("sources/existing.png", Data([0x00]))
_ = try BoardWriter.importAttachments([existing], intoCard: card)
let attachmentsFolder = fixture.url(Ident.card1).appendingPathComponent("attachments")
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: attachmentsFolder.path)
let source = try fixture.file("sources/shot.png", Data([0x01]))
let error = writeFailure {
_ = try BoardWriter.importAttachments([source], intoCard: card)
}
guard case .io = error?.reason else {
Issue.record("expected .io, got \(String(describing: error?.reason))")
return
}
#expect(error?.path == source.path)
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["existing.png"])
}
}
// MARK: - List attachments
/// `BoardWriter.listAttachments`: the flat, top-level-files-only read (01-storage-format.md §
/// Attachments) that never writes anything, not even `attachments/` itself.
struct BoardWriterListAttachmentsTests {
private func card(_ fixture: WriterFixture) throws -> URL {
try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card"))
}
/// Subfolders and hidden files never surface, and survive both the import and the listing
/// untouched; the sort is Finder's numeric order, not plain string order (`"shot 2.png"`
/// before `"shot 10.png"`, which a byte-wise sort would put the other way).
@Test func listingIsFlatExcludesHiddenFilesAndSortsFinderStyle() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
let shot2 = try fixture.file("sources/1/shot 2.png", Data([0x01]))
let shot10 = try fixture.file("sources/2/shot 10.png", Data([0x02]))
_ = try BoardWriter.importAttachments([shot2, shot10], intoCard: card)
try fixture.file("\(Ident.card1)/attachments/sub/inside.txt", Data("hand-made".utf8))
try fixture.file("\(Ident.card1)/attachments/.DS_Store", Data("hidden".utf8))
let listing = try BoardWriter.listAttachments(ofCard: card)
#expect(listing == ["shot 2.png", "shot 10.png"])
#expect(try fixture.entryNames("\(Ident.card1)/attachments/sub") == ["inside.txt"])
#expect(fixture.exists("\(Ident.card1)/attachments/.DS_Store"))
}
/// Nothing imported yet is an ordinary state, not an error `attachments/` need not exist
/// for a card to list cleanly as empty.
@Test func aMissingAttachmentsFolderListsAsEmpty() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try card(fixture)
#expect(try BoardWriter.listAttachments(ofCard: card) == [])
#expect(!fixture.exists("\(Ident.card1)/attachments"))
}
/// Unlike a missing `attachments/`, a missing *card* is a loud error the caller asked
/// about something that is not there.
@Test func aMissingCardFolderIsALoudUnreadableError() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = fixture.url(Ident.card1)
let error = writeFailure { _ = try BoardWriter.listAttachments(ofCard: missing) }
guard case .unreadable = error?.reason else {
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
return
}
}
}