The copy now walks the source tree item by item, checking cancellation between items, and the in-progress banner row carries its promised Cancel — a cancelled duplicate removes the partial sibling and never happened (DESIGN/03 > File menu). A sandbox permission refusal of the silent Finder-style sibling falls back to an NSSavePanel pre-filled with the parent folder and the copy name — the panel's grant is the sandbox's own answer; cancelling the panel cancels quietly, and non-permission failures keep the ordinary one-shot banner. Refusal classification is deliberately narrow (NSFileWriteNoPermissionError itself, no underlying- chain walk) so an unreadable source never masquerades as a destination refusal. Directories are created writable first with mode and timestamps restored after the subtree lands, so a read-only source folder can't strand its own copy. BoardDuplicatorTests grows from 9 to 18 tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
404 lines
19 KiB
Swift
404 lines
19 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates).
|
|
///
|
|
/// Three promises, and all three are about what the copy *doesn't* do:
|
|
///
|
|
/// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out,
|
|
/// and the thing that keeps a copied `.git` history naming paths that still exist;
|
|
/// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is
|
|
/// born exactly matching its history";
|
|
/// - it doesn't overwrite — the Finder-style `copy` ladder renames instead.
|
|
///
|
|
/// And a fourth, which the per-item walk exists for: **it doesn't leave residue** — a cancelled or
|
|
/// failed copy removes its own partial sibling, "a cancelled duplicate never happened".
|
|
///
|
|
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
|
|
/// window flow around the copy (the flush, the banner row, the save panel, the open that follows) is
|
|
/// the command's, not this type's — what belongs here is the vocabulary that flow branches on:
|
|
/// cancelled, refused, failed.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file —
|
|
/// everything a literal copy has to carry through untouched.
|
|
@MainActor
|
|
private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) {
|
|
let fixture = try WriterFixture()
|
|
let root = fixture.url(name)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
|
|
try fixture.item(name, Item.board)
|
|
try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item(
|
|
"\(name)/\(Ident.lane1)/\(Ident.card2)",
|
|
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
)
|
|
try fixture.item(
|
|
"\(name)/\(Ident.lane2)",
|
|
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
|
|
)
|
|
try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried"))
|
|
try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8))
|
|
|
|
return (fixture, root)
|
|
}
|
|
|
|
/// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy"
|
|
/// means as an assertion.
|
|
private func tree(of root: URL) throws -> Set<String> {
|
|
var paths: Set<String> = []
|
|
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
|
|
while let url = walker?.nextObject() as? URL {
|
|
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
|
|
}
|
|
return paths
|
|
}
|
|
|
|
/// `writeFailure`'s twin for the duplicate's own three-outcome vocabulary: the copy answers
|
|
/// `.cancelled`, `.refused` or `.failed`, and which one is the whole question every test below asks.
|
|
private func duplicateFailure(_ operation: () throws -> Void) -> BoardDuplicator.Failure? {
|
|
do {
|
|
try operation()
|
|
Issue.record("expected the duplicate to fail, but it succeeded")
|
|
return nil
|
|
} catch let failure as BoardDuplicator.Failure {
|
|
return failure
|
|
} catch {
|
|
Issue.record("expected a BoardDuplicator.Failure, got \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("BoardDuplicator")
|
|
struct BoardDuplicatorTests {
|
|
|
|
// MARK: The name ladder
|
|
|
|
@Test("The first duplicate is a 'copy' sibling, extension carried")
|
|
func firstCopyIsNamedFinderStyle() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let destination = BoardDuplicator.copyDestination(for: board.root)
|
|
|
|
#expect(destination.lastPathComponent == "Roadmap copy.kanban")
|
|
#expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path,
|
|
"a sibling — where the user is already looking")
|
|
}
|
|
|
|
@Test("An extension-less board has none to carry")
|
|
func extensionlessBoardCopiesWithoutOne() throws {
|
|
let board = try makeBoard(named: "Plain")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy")
|
|
}
|
|
|
|
@Test("The collision ladder counts up from 2, one collision at a time")
|
|
func collisionLadderCountsUp() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
#expect(first.lastPathComponent == "Roadmap copy.kanban")
|
|
|
|
let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
#expect(second.lastPathComponent == "Roadmap copy 2.kanban")
|
|
|
|
let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
#expect(third.lastPathComponent == "Roadmap copy 3.kanban")
|
|
|
|
// And duplicating a duplicate ladders off *its* name, exactly as Finder does.
|
|
let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy")
|
|
#expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban")
|
|
}
|
|
|
|
@Test("Anything already wearing the name blocks it, folder or file alike")
|
|
func anyExistingEntryBlocksTheName() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
// Not a board — just a file in the way. A duplicate must rename around it, never through it.
|
|
try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8))
|
|
|
|
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban")
|
|
}
|
|
|
|
// MARK: What lands in the copy
|
|
|
|
@Test("Every GUID is kept — the whole-board carve-out from the remint rule")
|
|
func guidsArePreserved() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
|
|
#expect(try tree(of: copy) == tree(of: board.root),
|
|
"same identities, same nesting — a copied history keeps naming paths that exist")
|
|
#expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path))
|
|
#expect(FileManager.default.fileExists(
|
|
atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path
|
|
))
|
|
}
|
|
|
|
@Test("Tombstoned lanes and cards are carried, trash included")
|
|
func tombstonesAreCarried() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
let loaded = try BoardLoader.load(boardRoot: copy).model
|
|
|
|
let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 })
|
|
#expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork")
|
|
#expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it")
|
|
|
|
let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 })
|
|
#expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted })
|
|
}
|
|
|
|
@Test("The copy is byte-for-byte, strays and stale attribution included")
|
|
func contentIsCopiedVerbatim() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
|
|
func bytes(_ root: URL, _ relative: String) throws -> Data {
|
|
try Data(contentsOf: root.appendingPathComponent(relative))
|
|
}
|
|
#expect(try bytes(copy, "index.md") == bytes(board.root, "index.md"))
|
|
#expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md")
|
|
== bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"),
|
|
"created, unknown keys and modified-by all survive — nothing here reads a board file")
|
|
#expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md"))
|
|
}
|
|
|
|
@Test("The original is untouched by its own duplication")
|
|
func originalSurvivesUnchanged() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
let before = try tree(of: board.root)
|
|
|
|
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
|
|
#expect(try tree(of: board.root) == before)
|
|
}
|
|
|
|
// MARK: Failure
|
|
|
|
@Test("A source that isn't there fails as a duplicate, naming the board")
|
|
func missingSourceFailsInTheDuplicateVocabulary() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
let missing = board.fixture.url("Never.kanban")
|
|
|
|
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
|
|
|
|
// An ordinary failure, not a refusal: a source that isn't there is not a question about
|
|
// where the copy should go, so no save panel could help it.
|
|
guard case let .failed(error) = failure else {
|
|
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
|
return
|
|
}
|
|
#expect(error.operation == .duplicateBoard(title: "Never"),
|
|
"the user pressed Duplicate; the banner must say so")
|
|
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't duplicate 'Never'"))
|
|
}
|
|
|
|
// MARK: - The per-item walk
|
|
|
|
@Test("Hidden folders, empty ones and nested strays all ride along — the walk is not the loader")
|
|
func theWalkCarriesEverythingATreeCanHold() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
// A `.git`-shaped hidden folder with a file in it, an empty folder inside that (the shape a
|
|
// fresh repository actually has), and a stray beside a card's index.
|
|
try board.fixture.file("Roadmap.kanban/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
|
|
try FileManager.default.createDirectory(
|
|
at: board.root.appendingPathComponent(".git/objects", isDirectory: true),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try board.fixture.file("Roadmap.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02]))
|
|
|
|
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
|
|
|
|
#expect(try tree(of: copy) == tree(of: board.root),
|
|
"an item-by-item walk carries exactly what one copyItem would have")
|
|
#expect(try Data(contentsOf: copy.appendingPathComponent(".git/HEAD"))
|
|
== Data("ref: refs/heads/main\n".utf8))
|
|
var isDirectory: ObjCBool = false
|
|
#expect(FileManager.default.fileExists(
|
|
atPath: copy.appendingPathComponent(".git/objects").path,
|
|
isDirectory: &isDirectory
|
|
) && isDirectory.boolValue, "an empty folder is a folder, not nothing")
|
|
}
|
|
|
|
// MARK: - Cancel
|
|
|
|
@Test("Cancelling between items removes the partial sibling — a cancelled duplicate never happened")
|
|
func cancellingRemovesThePartialSibling() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
let before = try tree(of: board.root)
|
|
|
|
// Trips on the fourth read: the root folder exists by then and the walk is two levels deep
|
|
// into it, so there is a genuine partial tree to remove rather than nothing to clean up.
|
|
var reads = 0
|
|
let failure = duplicateFailure {
|
|
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: {
|
|
reads += 1
|
|
return reads > 3
|
|
})
|
|
}
|
|
|
|
#expect(failure == BoardDuplicator.Failure.cancelled)
|
|
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
|
|
"no 'Roadmap copy.kanban' survives — the partial went with the cancellation")
|
|
#expect(try tree(of: board.root) == before, "and the original was never touched")
|
|
}
|
|
|
|
@Test("A duplicate cancelled before it began never creates the destination at all")
|
|
func cancellingBeforeTheFirstItemCreatesNothing() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
|
|
let failure = duplicateFailure {
|
|
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
|
|
}
|
|
|
|
#expect(failure == BoardDuplicator.Failure.cancelled)
|
|
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"])
|
|
}
|
|
|
|
@Test("A failure mid-walk removes the partial sibling too, and says what went wrong")
|
|
func aFailedWalkRemovesThePartialSibling() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
// A card folder the walk cannot list, standing in for any I/O that gives out part-way.
|
|
let unreadable = board.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path)
|
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: unreadable.path) }
|
|
|
|
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }
|
|
|
|
guard case let .failed(error) = failure else {
|
|
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
|
return
|
|
}
|
|
#expect(error.operation == .duplicateBoard(title: "Roadmap"))
|
|
#expect(error.path == unreadable.path, "fail-fast names the item it stopped on")
|
|
#expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
|
|
"a half-copied board is residue — it goes, and the banner is what remains")
|
|
}
|
|
|
|
// MARK: - Refusal and the panel's answer
|
|
|
|
@Test("A sandbox refusal of the sibling is a refusal, not a failure — the save panel's cue")
|
|
func anUnwritableParentIsARefusal() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
// The board's *parent* is unwritable while the board itself is not: exactly the shape of a
|
|
// bookmark that grants a subtree and not the folder above it.
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
|
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }
|
|
|
|
let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }
|
|
|
|
guard case let .refused(error) = failure else {
|
|
Issue.record("expected a refusal, got \(String(describing: failure))")
|
|
return
|
|
}
|
|
#expect(error.operation == .duplicateBoard(title: "Roadmap"))
|
|
}
|
|
|
|
@Test("A refusal at a destination the user chose is an ordinary failure — the panel isn't asked twice")
|
|
func aRefusalAtAnAnsweredDestinationIsAnOrdinaryFailure() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
|
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }
|
|
|
|
let chosen = board.fixture.url("Chosen.kanban")
|
|
let failure = duplicateFailure {
|
|
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)
|
|
}
|
|
|
|
guard case .failed = failure else {
|
|
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
|
return
|
|
}
|
|
}
|
|
|
|
@Test("A chosen destination is honored verbatim, name and location alike")
|
|
func aChosenDestinationIsHonoredVerbatim() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
let elsewhere = board.fixture.url("Elsewhere")
|
|
try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true)
|
|
// Not the "copy" ladder's answer at all: another folder, another name, no extension.
|
|
let chosen = elsewhere.appendingPathComponent("Fork", isDirectory: true)
|
|
|
|
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)
|
|
|
|
#expect(copy == chosen)
|
|
#expect(try tree(of: copy) == tree(of: board.root))
|
|
}
|
|
|
|
@Test("A chosen name already on disk fails, and is never overwritten or removed")
|
|
func aChosenNameAlreadyTakenFails() throws {
|
|
let board = try makeBoard(named: "Roadmap.kanban")
|
|
defer { board.fixture.tearDown() }
|
|
let occupied = try board.fixture.file("Taken.kanban", Data("not a board, and not yours to delete".utf8))
|
|
|
|
let failure = duplicateFailure {
|
|
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: occupied)
|
|
}
|
|
|
|
guard case .failed = failure else {
|
|
Issue.record("expected an ordinary failure, got \(String(describing: failure))")
|
|
return
|
|
}
|
|
#expect(try board.fixture.data("Taken.kanban") == Data("not a board, and not yours to delete".utf8),
|
|
"the destination this call did not create is the one thing cleanup never removes")
|
|
}
|
|
|
|
// MARK: - Classifying a refusal
|
|
|
|
@Test("Only a write-permission denial is the panel's question; everything else is a banner")
|
|
func permissionRefusalsAreClassifiedNarrowly() {
|
|
#expect(BoardDuplicator.isPermissionRefusal(
|
|
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError)
|
|
))
|
|
#expect(BoardDuplicator.isPermissionRefusal(
|
|
NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))
|
|
))
|
|
#expect(BoardDuplicator.isPermissionRefusal(NSError(
|
|
domain: "SomeWrapper",
|
|
code: 1,
|
|
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EPERM))]
|
|
)), "an unclassified wrapper is followed through to the refusal underneath")
|
|
|
|
#expect(!BoardDuplicator.isPermissionRefusal(
|
|
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError)
|
|
), "a full disk is a real failure — a save panel would be the app blaming the user")
|
|
#expect(!BoardDuplicator.isPermissionRefusal(
|
|
NSError(domain: NSCocoaErrorDomain, code: NSFileWriteVolumeReadOnlyError)
|
|
))
|
|
#expect(!BoardDuplicator.isPermissionRefusal(NSError(
|
|
domain: NSCocoaErrorDomain,
|
|
code: NSFileReadNoPermissionError,
|
|
userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))]
|
|
)), """
|
|
a source we cannot read is a broken board, not a question about where the copy goes — and \
|
|
Foundation hangs the same EACCES under it, which is why the chain is not searched blindly
|
|
""")
|
|
}
|
|
}
|