diff --git a/Sources/DMonteCore/CleanDrive.swift b/Sources/DMonteCore/CleanDrive.swift index 75a2e1f..8b0f6d2 100644 --- a/Sources/DMonteCore/CleanDrive.swift +++ b/Sources/DMonteCore/CleanDrive.swift @@ -364,6 +364,7 @@ public struct CleanDriveWindowView: View { /// instant delete shows a visible sweep. Smaller moves scale down proportionally. private static let fullDrainSeconds: Double = 1.1 @State private var isShowingSettings = false + @State private var isShowingDevJunk = false private let layout = CleanDriveLayout.current public init(onQuit: @escaping () -> Void) { @@ -394,6 +395,12 @@ public struct CleanDriveWindowView: View { ) } } + + if isShowingDevJunk { + PreferencesOverlay(cornerRadius: 18) { + CleanDriveDevJunkPanel(onClose: { isShowingDevJunk = false }) + } + } } .frame(width: layout.windowSize.width, height: layout.windowSize.height) .frostedPanel(cornerRadius: 18) @@ -538,16 +545,37 @@ public struct CleanDriveWindowView: View { .padding(.top, layout.cleanButtonTopPadding) } + /// Both secondary actions share one row. Giving developer junk its own line would push the + /// window past its fixed height at scale 1.0, and the panel cannot grow — it is sized by + /// `CleanDriveSizing.preferredSize()`. private var storageButton: some View { - Button { - NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Disk Utility.app")) - } label: { - Text("Manage Storage...") - .font(.system(size: layout.storageFontSize, weight: .medium)) - .underline() - .foregroundStyle(Color.accentColor) + HStack(spacing: layout.footerLinkSpacing) { + Spacer() + + Button { + isShowingDevJunk = true + } label: { + Text("Developer Junk...") + .font(.system(size: layout.storageFontSize, weight: .medium)) + .underline() + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .disabled(isCleaning) + .help("Xcode, npm, Homebrew and other developer caches, listed directory by directory") + + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Disk Utility.app")) + } label: { + Text("Manage Storage...") + .font(.system(size: layout.storageFontSize, weight: .medium)) + .underline() + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + + Spacer() } - .buttonStyle(.plain) } private var selectedSize: UInt64 { @@ -785,6 +813,7 @@ private struct CleanDriveLayout { var cleanButtonTopPadding: CGFloat { 4 * scale } var buttonCornerRadius: CGFloat { 7 * scale } var storageFontSize: CGFloat { 12 * scale } + var footerLinkSpacing: CGFloat { 14 * scale } } private struct CleanDriveSettingsView: View { diff --git a/Sources/DMonteCore/CleanDriveDevJunk.swift b/Sources/DMonteCore/CleanDriveDevJunk.swift new file mode 100644 index 0000000..544342a --- /dev/null +++ b/Sources/DMonteCore/CleanDriveDevJunk.swift @@ -0,0 +1,895 @@ +import Foundation + +/// The developer-detritus categories Clean Drive can reclaim. These are deliberately kept apart +/// from `CleanDriveCategoryID`: the system categories sweep whole well-known cache roots, whereas +/// developer junk is enumerated entry by entry so the user can drill in and keep individual +/// projects. Every category here regenerates — the worst case for deleting one is a rebuild or a +/// re-download, never lost work — which is the bar a category must clear to be listed at all. +public enum DevJunkCategoryID: String, CaseIterable, Identifiable, Sendable { + case xcodeDerivedData + case xcodeArchives + case deviceSupport + case simulatorCaches + case nodeModules + case swiftPackageBuilds + case cocoaPodsCache + case jvmCaches + case scriptingCaches + case homebrewCache + case dockerLeftovers + + public var id: String { rawValue } + + public var title: String { + switch self { + case .xcodeDerivedData: "Xcode DerivedData" + case .xcodeArchives: "Xcode Archives" + case .deviceSupport: "Device Support" + case .simulatorCaches: "Simulator caches" + case .nodeModules: "node_modules" + case .swiftPackageBuilds: "SwiftPM .build" + case .cocoaPodsCache: "CocoaPods cache" + case .jvmCaches: "Gradle / Maven caches" + case .scriptingCaches: "pip / npm / yarn caches" + case .homebrewCache: "Homebrew cache" + case .dockerLeftovers: "Docker leftovers" + } + } + + /// The honest one-liner shown under the title. It always says what it costs to delete the + /// entries, because "reclaimable" is not the same as "free" — a node_modules tree costs an + /// `npm install` to get back, and on a slow connection that is a real price. + public var detail: String { + switch self { + case .xcodeDerivedData: + "Build intermediates and indexes. Xcode rebuilds them on the next build." + case .xcodeArchives: + "Shipped-build archives with their dSYMs. Deleting them means old crash reports can no longer be symbolicated." + case .deviceSupport: + "Symbols copied from devices you have attached. Re-copied the next time you plug the device in." + case .simulatorCaches: + "Simulator caches and logs. Simulator devices and their installed apps are never touched." + case .nodeModules: + "Installed npm dependencies. Restored by running npm install again in that project." + case .swiftPackageBuilds: + "SwiftPM build products and checkouts. Restored by the next swift build." + case .cocoaPodsCache: + "Downloaded pod archives. Re-fetched by the next pod install." + case .jvmCaches: + "Downloaded Gradle and Maven artifacts. Re-fetched by the next build." + case .scriptingCaches: + "Download caches for pip, npm and yarn. Installed packages are not touched." + case .homebrewCache: + "Downloaded bottles and installers. Installed formulae are not touched." + case .dockerLeftovers: + "Docker Desktop logs. Images, containers and volumes are never touched." + } + } + + /// Archives are the one category that loses something you cannot regenerate — the dSYMs that + /// make an old crash report readable — so it starts unchecked even though it is usually the + /// biggest single win on a shipping developer's disk. + public var isSelectedByDefault: Bool { + self != .xcodeArchives + } + + /// Fixed, well-known directories owned by a toolchain. Nothing is inferred here: if a path is + /// not spelled out in this table it can never be listed, let alone deleted. + var fixedTargets: [DevJunkTarget] { + switch self { + case .xcodeDerivedData: + [DevJunkTarget("~/Library/Developer/Xcode/DerivedData", granularity: .children)] + case .xcodeArchives: + [DevJunkTarget("~/Library/Developer/Xcode/Archives", granularity: .children)] + case .deviceSupport: + [ + DevJunkTarget("~/Library/Developer/Xcode/iOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/watchOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/tvOS DeviceSupport", granularity: .children), + DevJunkTarget("~/Library/Developer/Xcode/visionOS DeviceSupport", granularity: .children) + ] + case .simulatorCaches: + // Deliberately excludes ~/Library/Developer/CoreSimulator/Devices: those are the + // simulators themselves, complete with installed apps and their data, and wiping + // them is a destructive act rather than a cache eviction. + [ + DevJunkTarget("~/Library/Developer/CoreSimulator/Caches", granularity: .children), + DevJunkTarget("~/Library/Logs/CoreSimulator", granularity: .children) + ] + case .nodeModules, .swiftPackageBuilds: + [] + case .cocoaPodsCache: + // ~/.cocoapods/repos is left alone on purpose: it is a git checkout the user may have + // pointed at a private spec repo, and re-cloning it is neither cheap nor always possible. + [DevJunkTarget("~/Library/Caches/CocoaPods", granularity: .children)] + case .jvmCaches: + // ~/.gradle/wrapper holds the Gradle distributions a project pins by checksum, and + // ~/.m2 outside repository/ holds settings.xml, so both stay out of range. + [ + DevJunkTarget("~/.gradle/caches", granularity: .children), + DevJunkTarget("~/.m2/repository", granularity: .children) + ] + case .scriptingCaches: + [ + DevJunkTarget("~/Library/Caches/pip", granularity: .children), + DevJunkTarget("~/.cache/pip", granularity: .children), + DevJunkTarget("~/.npm/_cacache", granularity: .wholeDirectory), + DevJunkTarget("~/Library/Caches/Yarn", granularity: .children), + DevJunkTarget("~/.cache/yarn", granularity: .children), + DevJunkTarget("~/.yarn/berry/cache", granularity: .wholeDirectory) + ] + case .homebrewCache: + [ + DevJunkTarget("~/Library/Caches/Homebrew", granularity: .children) + ] + case .dockerLeftovers: + // Only logs. The Docker.raw disk image under Data/vms holds every image, container and + // volume the user has, so the container root is never a target — only its log folder. + [ + DevJunkTarget("~/Library/Containers/com.docker.docker/Data/log", granularity: .children), + DevJunkTarget("~/Library/Group Containers/group.com.docker/log", granularity: .children) + ] + } + } + + /// Set for the two categories that live in the user's own project folders rather than at a + /// fixed path, and therefore have to be found by walking. Nothing is matched on name alone — + /// see `DevJunkProjectScan.requiredSibling`. + var projectScan: DevJunkProjectScan? { + switch self { + case .nodeModules: + DevJunkProjectScan(directoryName: "node_modules", requiredSibling: "package.json") + case .swiftPackageBuilds: + DevJunkProjectScan(directoryName: ".build", requiredSibling: "Package.swift") + default: + nil + } + } + + /// Every directory tree this category is allowed to touch, expanded against `home`. A path + /// that is not inside one of these can never belong to the category, whatever it is named. + public func allowedRoots(home: String) -> [String] { + if projectScan != nil { + return DevJunkPaths.projectSearchRoots(home: home) + } + + return fixedTargets.map { DevJunkPaths.expand($0.path, home: home) } + } + + /// The single gate every candidate must pass before it can be listed in the UI or handed to + /// the remover. It is intentionally the *only* way a path becomes eligible, and it is applied + /// twice: once when the scanner discovers the path and again immediately before deletion, so a + /// stale entry from a previous scan cannot be used to delete something that is now a source + /// directory. + public func accepts(path: String, home: String) -> Bool { + let normalized = DevJunkPaths.normalize(DevJunkPaths.expand(path, home: home)) + let components = DevJunkPaths.components(normalized) + + guard !components.isEmpty else { + return false + } + + // `normalize` deliberately does not resolve ".." — it is a pure function and must not touch + // the filesystem — so containment here is purely textual. That makes + // "~/Documents/../.ssh/node_modules" look like it lives under a search root when it resolves + // somewhere else entirely. Rather than teach the helpers to resolve traversal, refuse the + // syntax: the scanner never produces it, so the only paths it can rule out are forged ones. + guard !components.contains(where: { $0 == "." || $0 == ".." }) else { + return false + } + + // Nothing under a denied root is eligible unless this category's own target table spells + // the path out. The Xcode and CoreSimulator caches genuinely live inside ~/Library, but a + // directory merely *discovered* by walking never may. This is the backstop, not the + // primary check: the granularity rules below still have to agree. + let isUnderOwnTarget = fixedTargets.contains { target in + DevJunkPaths.isDescendantOrSelf(normalized, of: DevJunkPaths.expand(target.path, home: home)) + } + if !isUnderOwnTarget, + DevJunkPaths.deniedRoots(home: home).contains(where: { DevJunkPaths.isDescendantOrSelf(normalized, of: $0) }) { + return false + } + + if let scan = projectScan { + guard components.last == scan.directoryName else { + return false + } + + guard let root = DevJunkPaths.projectSearchRoots(home: home).first( + where: { DevJunkPaths.isStrictDescendant(normalized, of: $0) } + ) else { + return false + } + + // A node_modules inside another node_modules (or inside a .build, or a stray copy in a + // .git object store) is already covered by its ancestor and must not be listed twice. + // A node_modules inside a bundle is worse than redundant: Electron applications ship + // their dependencies at Contents/Resources/app/node_modules with a package.json beside + // them, so the sibling test passes and trashing it breaks an installed application that + // no `npm install` can repair. Only the components below the search root are examined: + // the components of the home directory itself are not the user's project structure and + // must not veto a match. + let ancestors = components.dropLast().dropFirst(DevJunkPaths.components(root).count) + return !ancestors.contains { + DevJunkPaths.prunedDirectoryNames.contains($0) || DevJunkPaths.isBundleName($0) + } + } + + return fixedTargets.contains { target in + let root = DevJunkPaths.expand(target.path, home: home) + return switch target.granularity { + case .children: + // Only the direct children of the root are entries; the root itself stays put so + // the toolchain does not have to recreate it. + DevJunkPaths.isDirectChild(normalized, of: root) + case .wholeDirectory: + DevJunkPaths.normalize(root) == normalized + } + } + } +} + +/// A fixed directory a category owns, plus whether the entries are the directory itself or its +/// immediate children. Children give the user something to drill into; `wholeDirectory` is for +/// caches whose internals are opaque hash buckets that nobody can meaningfully choose between. +struct DevJunkTarget: Sendable, Equatable { + enum Granularity: Sendable, Equatable { + case wholeDirectory + case children + } + + var path: String + var granularity: Granularity + + init(_ path: String, granularity: Granularity) { + self.path = path + self.granularity = granularity + } +} + +/// How a category that lives in the user's own project folders is discovered. The required sibling +/// is what keeps this conservative: a directory called `node_modules` only counts when its parent +/// also contains a `package.json`, so a folder someone named `node_modules` to hold notes about npm +/// is never touched, and neither is a `.build` directory belonging to a build system that is not +/// SwiftPM. +struct DevJunkProjectScan: Sendable, Equatable { + var directoryName: String + var requiredSibling: String + /// How deep below a search root a project may sit. Four levels covers + /// ~/Developer/org/repo/package/node_modules without walking an entire home directory. + var maximumDepth: Int = 4 +} + +public enum DevJunkPaths { + /// Directory names the project walk refuses to descend into and refuses to accept as an + /// ancestor of a match. Package stores and VCS stores can contain anything, including copies + /// of the very directories being looked for. + static let prunedDirectoryNames: Set = [ + "node_modules", + ".build", + ".git", + "Pods", + "Carthage", + "DerivedData", + "Library", + ".Trash" + ] + + /// Extensions that make a directory a bundle — something macOS presents, and the user thinks + /// of, as a single opaque item rather than a folder to walk into. The list is deliberately wider + /// than "applications": an Electron app keeps its dependencies at + /// Contents/Resources/app/node_modules complete with a package.json, a plug-in or framework can + /// carry the same, and a photo or Final Cut library is user data that must never be walked at + /// all. Everything inside one of these is installed or authored content, never project state, so + /// deleting a piece of it breaks the item and no package manager can put it back. + static let bundleExtensions: Set = [ + "app", + "appex", + "framework", + "bundle", + "plugin", + "xpc", + "kext", + "dext", + "systemextension", + "prefpane", + "qlgenerator", + "mdimporter", + "component", + "vst", + "vst3", + "audiounit", + "docset", + "photoslibrary", + "photolibrary", + "musiclibrary", + "tvlibrary", + "imovielibrary", + "fcpbundle", + "logicx", + "band", + "sparsebundle", + "rtfd" + ] + + /// True when a single path component names a bundle. Matching is case-insensitive because the + /// boot volume is, so "Foo.APP" is the same directory as "Foo.app". A leading dot does not + /// count: ".app" is an ordinary hidden folder, not an application. + static func isBundleName(_ component: String) -> Bool { + guard let dot = component.lastIndex(of: "."), dot != component.startIndex else { + return false + } + + return bundleExtensions.contains(component[component.index(after: dot)...].lowercased()) + } + + /// The only places a project-scanned category is allowed to look. Home itself is not on the + /// list: walking all of ~ would be slow and would sweep in ~/Library, and a project sitting + /// loose in the home directory is rare enough not to justify either cost. + public static func projectSearchRoots(home: String) -> [String] { + [ + "Developer", + "Projects", + "Documents", + "Desktop", + "Code", + "src", + "dev", + "git", + "repos", + "workspace", + "Sites" + ].map { normalize(joined(home, $0)) } + } + + /// Trees that are off limits regardless of what a category asks for. These are either the + /// user's data, the system's own files, or somewhere a delete would be actively harmful. + public static func deniedRoots(home: String) -> [String] { + [ + normalize(joined(home, "Library")), + normalize(joined(home, ".Trash")), + "/System", + "/Library", + "/usr", + "/bin", + "/sbin", + "/opt", + "/Applications" + ] + } + + public static func expand(_ path: String, home: String) -> String { + guard path.hasPrefix("~") else { + return path + } + + if path == "~" { + return home + } + + guard path.hasPrefix("~/") else { + // A "~otheruser" style path is not ours to expand, so leave it exactly as given and + // let the containment checks reject it. + return path + } + + return joined(home, String(path.dropFirst(2))) + } + + /// Collapses repeated and trailing separators so that "/a//b/" and "/a/b" compare equal. + /// Deliberately does not resolve symlinks or "..": the scanner never produces such paths, and + /// resolving them would mean touching the filesystem from what must stay a pure function. + public static func normalize(_ path: String) -> String { + let parts = components(path) + guard !parts.isEmpty else { + return path.hasPrefix("/") ? "/" : "" + } + + let prefix = path.hasPrefix("/") ? "/" : "" + return prefix + parts.joined(separator: "/") + } + + public static func components(_ path: String) -> [String] { + path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + } + + /// Component-wise containment. A plain string prefix test would say that + /// "~/Documents/MyApp-backup" lives inside "~/Documents/MyApp", which is exactly the class of + /// bug that would make this feature delete someone's source tree. + public static func isStrictDescendant(_ path: String, of ancestor: String) -> Bool { + let pathComponents = components(path) + let ancestorComponents = components(ancestor) + + guard pathComponents.count > ancestorComponents.count else { + return false + } + + return Array(pathComponents.prefix(ancestorComponents.count)) == ancestorComponents + } + + public static func isDescendantOrSelf(_ path: String, of ancestor: String) -> Bool { + components(path) == components(ancestor) || isStrictDescendant(path, of: ancestor) + } + + public static func isDirectChild(_ path: String, of parent: String) -> Bool { + components(path).count == components(parent).count + 1 && isStrictDescendant(path, of: parent) + } + + static func joined(_ base: String, _ component: String) -> String { + base.hasSuffix("/") ? base + component : base + "/" + component + } + + /// The label shown in the drill-down list. Fixed-root entries are identified by their own + /// name; a project entry is named after the project that owns it, because a list of twenty + /// rows all reading "node_modules" tells the user nothing. + public static func displayName(forPath path: String, category: DevJunkCategoryID) -> String { + let parts = components(path) + guard let last = parts.last else { + return path + } + + guard category.projectScan != nil, parts.count >= 2 else { + return last + } + + return parts[parts.count - 2] + "/" + last + } + + /// The tilde-abbreviated path shown as the row's tooltip, so the user can always see exactly + /// which directory a row would delete. + public static func abbreviate(_ path: String, home: String) -> String { + guard isDescendantOrSelf(normalize(path), of: normalize(home)) else { + return path + } + + let suffix = components(path).dropFirst(components(home).count) + return suffix.isEmpty ? "~" : "~/" + suffix.joined(separator: "/") + } +} + +// MARK: - Scan results + +public struct DevJunkEntry: Identifiable, Sendable, Equatable, Hashable { + public var category: DevJunkCategoryID + public var path: String + public var size: UInt64 + + public var id: String { path } + + public init(category: DevJunkCategoryID, path: String, size: UInt64) { + self.category = category + self.path = path + self.size = size + } + + public var displayName: String { + DevJunkPaths.displayName(forPath: path, category: category) + } +} + +public struct DevJunkCategoryReport: Identifiable, Sendable, Equatable { + public var category: DevJunkCategoryID + public var entries: [DevJunkEntry] + + public var id: DevJunkCategoryID { category } + + public init(category: DevJunkCategoryID, entries: [DevJunkEntry]) { + self.category = category + self.entries = entries + } + + public var totalSize: UInt64 { + entries.reduce(UInt64(0)) { $0 &+ $1.size } + } +} + +// MARK: - Selection + +public enum DevJunkSelectionState: Sendable, Equatable { + case none + case partial + case all +} + +/// Tracks which individual entries are checked. Selection is stored per entry path rather than per +/// category so that drilling into a category and unchecking one project survives the category-level +/// checkbox continuing to read "partial" — and so a rescan can drop entries that no longer exist +/// without silently carrying a stale path into a delete. +public struct DevJunkSelection: Sendable, Equatable { + private var selectedPaths: Set + + public init() { + selectedPaths = [] + } + + /// Seeds the selection from the categories' defaults. Every entry of a default-on category + /// starts checked; Archives, which can lose symbolication, starts unchecked. + public init(defaultsFor reports: [DevJunkCategoryReport]) { + selectedPaths = Set( + reports + .filter(\.category.isSelectedByDefault) + .flatMap(\.entries) + .map(\.path) + ) + } + + public func isSelected(_ entry: DevJunkEntry) -> Bool { + selectedPaths.contains(entry.path) + } + + public func state(for report: DevJunkCategoryReport) -> DevJunkSelectionState { + guard !report.entries.isEmpty else { + return .none + } + + let selected = report.entries.filter { selectedPaths.contains($0.path) }.count + if selected == 0 { + return .none + } + return selected == report.entries.count ? .all : .partial + } + + public mutating func toggle(_ entry: DevJunkEntry) { + if selectedPaths.contains(entry.path) { + selectedPaths.remove(entry.path) + } else { + selectedPaths.insert(entry.path) + } + } + + /// Toggling a category checkbox is "select all" unless everything is already selected, which + /// makes the half-checked state a single click away from full rather than from empty. + public mutating func toggle(_ report: DevJunkCategoryReport) { + setCategory(report, selected: state(for: report) != .all) + } + + public mutating func setCategory(_ report: DevJunkCategoryReport, selected: Bool) { + for entry in report.entries { + if selected { + selectedPaths.insert(entry.path) + } else { + selectedPaths.remove(entry.path) + } + } + } + + /// Drops paths that the latest scan no longer reports. Without this, a path deleted in a + /// previous pass (or one that the user removed in Finder) would stay checked forever and keep + /// inflating the "will free" total. + public mutating func reconcile(with reports: [DevJunkCategoryReport]) { + let live = Set(reports.flatMap(\.entries).map(\.path)) + selectedPaths.formIntersection(live) + } + + public func selectedEntries(in reports: [DevJunkCategoryReport]) -> [DevJunkEntry] { + reports.flatMap(\.entries).filter { selectedPaths.contains($0.path) } + } + + public func selectedBytes(in reports: [DevJunkCategoryReport]) -> UInt64 { + selectedEntries(in: reports).reduce(UInt64(0)) { $0 &+ $1.size } + } +} + +// MARK: - Removal + +public struct DevJunkRemovalFailure: Sendable, Equatable { + public var path: String + public var reason: String + + public init(path: String, reason: String) { + self.path = path + self.reason = reason + } +} + +public struct DevJunkRemovalResult: Sendable, Equatable { + public var trashedBytes: UInt64 + public var trashedCount: Int + public var failures: [DevJunkRemovalFailure] + + public init(trashedBytes: UInt64, trashedCount: Int, failures: [DevJunkRemovalFailure]) { + self.trashedBytes = trashedBytes + self.trashedCount = trashedCount + self.failures = failures + } +} + +public enum DevJunkRemovalEvent: Sendable { + case progress(trashedBytes: UInt64, currentPath: String) + case finished(DevJunkRemovalResult) +} + +public enum DevJunkRemover { + /// Re-runs every entry through its category's gate. The UI already only offers what the scanner + /// found, but the entries survive across rescans and across the confirmation alert, and a delete + /// is not the place to trust that nothing moved in between. + public static func validate( + _ entries: [DevJunkEntry], + home: String + ) -> (allowed: [DevJunkEntry], rejected: [DevJunkEntry]) { + var allowed: [DevJunkEntry] = [] + var rejected: [DevJunkEntry] = [] + + for entry in entries { + if entry.category.accepts(path: entry.path, home: home) { + allowed.append(entry) + } else { + rejected.append(entry) + } + } + + return (allowed, rejected) + } + + /// The text of the confirmation the user has to accept before anything moves. It names the + /// count and the total so a mis-click on "select all" is visible before it is irreversible. + public static func confirmationMessage(for entries: [DevJunkEntry]) -> String { + let bytes = entries.reduce(UInt64(0)) { $0 &+ $1.size } + let noun = entries.count == 1 ? "item" : "items" + return "Move \(entries.count) \(noun) (\(bytes.diskBytesString)) to the Trash?" + } + + /// Moves the validated entries to the Trash one at a time, streaming progress. The Trash is + /// used rather than an outright delete because these are the user's project folders: if the + /// heuristics ever get something wrong, the mistake has to be undoable. Space is therefore not + /// reclaimed until the Trash is emptied, which the UI says out loud. + public static func trashStream(entries: [DevJunkEntry], home: String) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached(priority: .userInitiated) { + let (allowed, rejected) = validate(entries, home: home) + var trashedBytes: UInt64 = 0 + var trashedCount = 0 + var failures = rejected.map { + DevJunkRemovalFailure(path: $0.path, reason: "No longer matches a developer-junk category") + } + + for entry in allowed { + if Task.isCancelled { + break + } + + let url = URL(fileURLWithPath: entry.path) + guard FileManager.default.fileExists(atPath: entry.path) else { + // Already gone (a previous pass, or the user's own cleanup). Not a failure + // worth reporting, but it must not count toward the reclaimed total. + continue + } + + do { + try FileManager.default.trashItem(at: url, resultingItemURL: nil) + trashedBytes &+= entry.size + trashedCount += 1 + continuation.yield(.progress(trashedBytes: trashedBytes, currentPath: entry.path)) + } catch { + failures.append( + DevJunkRemovalFailure(path: entry.path, reason: (error as NSError).localizedDescription) + ) + } + } + + continuation.yield( + .finished( + DevJunkRemovalResult(trashedBytes: trashedBytes, trashedCount: trashedCount, failures: failures) + ) + ) + continuation.finish() + } + + continuation.onTermination = { _ in task.cancel() } + } + } +} + +// MARK: - Scanning + +public enum DevJunkScanEvent: Sendable { + /// Fraction of the categories finished, plus the label of the one being worked on, so the UI + /// can show honest progress without knowing anything about the filesystem. + case progress(fraction: Double, label: String) + case category(DevJunkCategoryReport) + case finished([DevJunkCategoryReport]) +} + +/// A path the scanner has found but not yet vetted. `knownSize` is only meaningful for files, +/// whose size the bulk directory read already produced. +private struct DevJunkCandidate: Sendable { + var path: String + var isDirectory: Bool + var knownSize: UInt64 +} + +public enum DevJunkScanner { + /// Walks every category off the main actor, emitting each finished category as it lands so the + /// list fills in progressively instead of staying empty until the slowest tree (invariably + /// node_modules) is done. Cancellation is cooperative and checked between directories, so + /// closing the panel mid-scan stops the walk rather than leaving it churning. + public static func scanStream(home: String = NSHomeDirectory()) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached(priority: .utility) { + var reports: [DevJunkCategoryReport] = [] + let categories = DevJunkCategoryID.allCases + + for (index, category) in categories.enumerated() { + if Task.isCancelled { + break + } + + continuation.yield( + .progress(fraction: Double(index) / Double(categories.count), label: category.title) + ) + + let report = DevJunkCategoryReport( + category: category, + entries: entries(for: category, home: home) + ) + reports.append(report) + continuation.yield(.category(report)) + } + + if !Task.isCancelled { + continuation.yield(.progress(fraction: 1, label: "")) + continuation.yield(.finished(reports)) + } + continuation.finish() + } + + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Discovers and sizes one category. Every candidate goes through `category.accepts` even + /// though the discovery already constrained it — the gate is cheap and it means the acceptance + /// rules are enforced in exactly one place. + static func entries(for category: DevJunkCategoryID, home: String) -> [DevJunkEntry] { + var entries: [DevJunkEntry] = [] + + var candidates: [DevJunkCandidate] = [] + + if let scan = category.projectScan { + candidates += projectDirectories(scan: scan, home: home).map { + DevJunkCandidate(path: $0, isDirectory: true, knownSize: 0) + } + } + + for target in category.fixedTargets { + let root = DevJunkPaths.normalize(DevJunkPaths.expand(target.path, home: home)) + switch target.granularity { + case .wholeDirectory: + candidates.append(DevJunkCandidate(path: root, isDirectory: true, knownSize: 0)) + case .children: + candidates += childCandidates(of: root) + } + } + + for candidate in candidates where category.accepts(path: candidate.path, home: home) { + if Task.isCancelled { + return entries.sorted { $0.size > $1.size } + } + + // Loose files in a cache root already carry their size from the bulk read; only + // directories need the full recursive walk. + let size = candidate.isDirectory ? allocatedSize(at: candidate.path) : candidate.knownSize + if size > 0 { + entries.append(DevJunkEntry(category: category, path: candidate.path, size: size)) + } + } + + return entries.sorted { $0.size > $1.size } + } + + /// Immediate children of a cache root. Symlinks are skipped: Homebrew's cache in particular is + /// full of links pointing back into its own downloads folder, and following them would both + /// double-count the bytes and offer the user two rows for one file. + private static func childCandidates(of root: String) -> [DevJunkCandidate] { + guard let result = BulkDirectoryReader.read(at: root) else { + return [] + } + + return result.entries + .filter { !$0.isSymlink } + .map { + DevJunkCandidate( + path: DevJunkPaths.joined(root, $0.name), + isDirectory: $0.isDirectory, + knownSize: $0.allocatedSize + ) + } + .sorted { $0.path < $1.path } + } + + /// Breadth-first walk of the project roots, depth-capped and pruned. Breadth-first matters: + /// projects sit near the top of these roots, so the interesting matches are found long before + /// the walk exhausts the deep corners of ~/Documents. + private static func projectDirectories(scan: DevJunkProjectScan, home: String) -> [String] { + var found: [String] = [] + var seen: Set = [] + var frontier = DevJunkPaths.projectSearchRoots(home: home).filter { isDirectory($0) } + var depth = 0 + + while !frontier.isEmpty, depth <= scan.maximumDepth { + if Task.isCancelled { + return found + } + + var next: [String] = [] + + for directory in frontier { + if Task.isCancelled { + return found + } + + guard let result = BulkDirectoryReader.read(at: directory) else { + continue + } + + let names = Set(result.entries.map(\.name)) + for entry in result.entries where entry.isDirectory && !entry.isSymlink { + let childPath = DevJunkPaths.joined(directory, entry.name) + + if entry.name == scan.directoryName { + // The sibling manifest is the whole safety argument: a directory only + // counts when the folder holding it is demonstrably a project of the right + // kind, not merely a folder that happens to use the same name. + // Search roots can overlap (a symlinked ~/Developer, a project reachable + // from two of them), so the same directory must not be listed twice. + if names.contains(scan.requiredSibling), seen.insert(childPath).inserted { + found.append(childPath) + } + continue + } + + // Bundles are stepped over rather than into. An application on the Desktop is + // exactly five components from the search root, so without this the walk reaches + // an Electron app's Contents/Resources/app/node_modules on its last permitted + // iteration and offers to delete the application's own dependencies. + if DevJunkPaths.prunedDirectoryNames.contains(entry.name) + || DevJunkPaths.isBundleName(entry.name) + || entry.name.hasPrefix(".") { + continue + } + + next.append(childPath) + } + } + + frontier = next + depth += 1 + } + + return found + } + + private static func isDirectory(_ path: String) -> Bool { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + /// Sums allocated size for a whole subtree using the bulk directory reader, checking for + /// cancellation between directories. A node_modules tree can hold six figures of tiny files, so + /// this is the part that must never run on the main actor and must be able to stop early. + static func allocatedSize(at path: String) -> UInt64 { + var total: UInt64 = 0 + var stack = [path] + + while let current = stack.popLast() { + if Task.isCancelled { + return total + } + + guard let result = BulkDirectoryReader.read(at: current) else { + continue + } + + for entry in result.entries where !entry.isSymlink { + if entry.isDirectory { + stack.append(DevJunkPaths.joined(current, entry.name)) + } else { + total &+= entry.allocatedSize + } + } + } + + return total + } +} diff --git a/Sources/DMonteCore/CleanDriveDevJunkView.swift b/Sources/DMonteCore/CleanDriveDevJunkView.swift new file mode 100644 index 0000000..a377ceb --- /dev/null +++ b/Sources/DMonteCore/CleanDriveDevJunkView.swift @@ -0,0 +1,454 @@ +import AppKit +import SwiftUI + +/// The developer-junk sheet: eleven categories, each expandable into the individual directories +/// that make up its total so the user can keep the one project they still care about. It is a +/// separate surface from the main Clean Drive list because the interaction is different — the +/// system categories are a checkbox each, these need drilling into. +struct CleanDriveDevJunkPanel: View { + var onClose: () -> Void + + @State private var reports: [DevJunkCategoryReport] = [] + @State private var selection = DevJunkSelection() + @State private var expanded: Set = [] + @State private var isScanning = true + @State private var scanFraction: Double = 0 + @State private var scanLabel = "" + @State private var statusMessage = "Scanning..." + @State private var isTrashing = false + @State private var didStopScan = false + @State private var scanTask: Task? + /// Bumped by every `startScan`. A cancelled scan's loop still has a tail to run, and trashing + /// starts a fresh scan, so the outgoing task has to be able to tell that the state it is about + /// to write belongs to somebody else now. + @State private var scanGeneration = 0 + + private let home = NSHomeDirectory() + private let layout = DevJunkLayout.current + + var body: some View { + VStack(spacing: 0) { + header + summary + categoryList + footer + } + .frame(width: layout.sheetSize.width, height: layout.sheetSize.height) + .task { + startScan() + } + .onDisappear { + // Closing the sheet mid-scan must stop the walk; a node_modules sweep left running in + // the background would keep spinning a core for a panel nobody is looking at. + scanTask?.cancel() + } + } + + private var header: some View { + HStack { + Text("Developer Junk") + .font(.system(size: layout.titleFontSize, weight: .bold)) + + Spacer() + + Button { + scanTask?.cancel() + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: layout.closeIconSize, weight: .bold)) + .frame(width: layout.closeButtonSize, height: layout.closeButtonSize) + } + .buttonStyle(.plain) + .disabled(isTrashing) + .help("Close") + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.top, layout.verticalPadding) + .padding(.bottom, layout.headerBottomPadding) + } + + private var summary: some View { + VStack(alignment: .leading, spacing: layout.summarySpacing) { + HStack(alignment: .firstTextBaseline) { + Text(selectedBytes.diskBytesString) + .font(.system(size: layout.summaryValueFontSize, weight: .semibold, design: .rounded)) + .monospacedDigit() + + Text("selected of \(totalBytes.diskBytesString) found") + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Spacer() + } + + if isScanning { + HStack(spacing: layout.rowSpacing) { + ProgressView(value: scanFraction) + .progressViewStyle(.linear) + + Button("Stop") { + stopScan() + } + .buttonStyle(.plain) + .font(.system(size: layout.captionFontSize, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + + Text(isScanning && !scanLabel.isEmpty ? "Scanning \(scanLabel)..." : statusMessage) + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.bottom, layout.summaryBottomPadding) + } + + private var categoryList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: layout.rowSpacing) { + ForEach(reports) { report in + categoryRow(report) + + if expanded.contains(report.category) { + detailBlock(report) + } + } + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.vertical, layout.listVerticalPadding) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func categoryRow(_ report: DevJunkCategoryReport) -> some View { + HStack(spacing: layout.rowSpacing) { + Button { + selection.toggle(report) + } label: { + DevJunkCheckbox(state: selection.state(for: report), size: layout.checkboxSize) + } + .buttonStyle(.plain) + .disabled(report.entries.isEmpty || isTrashing) + .help(report.entries.isEmpty ? "Nothing found" : "Select every entry in this category") + + Button { + toggleExpansion(report.category) + } label: { + HStack(spacing: layout.rowSpacing) { + Image(systemName: expanded.contains(report.category) ? "chevron.down" : "chevron.right") + .font(.system(size: layout.chevronFontSize, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: layout.chevronWidth, alignment: .leading) + + Text(report.category.title) + .font(.system(size: layout.rowTitleFontSize, weight: .semibold)) + .lineLimit(1) + + Spacer(minLength: layout.rowSpacing) + + Text(report.entries.isEmpty ? "—" : report.totalSize.diskBytesString) + .font(.system(size: layout.rowValueFontSize, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(report.entries.isEmpty) + } + .opacity(report.entries.isEmpty ? 0.45 : 1) + } + + private func detailBlock(_ report: DevJunkCategoryReport) -> some View { + VStack(alignment: .leading, spacing: layout.rowSpacing) { + Text(report.category.detail) + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + ForEach(report.entries) { entry in + Button { + selection.toggle(entry) + } label: { + HStack(spacing: layout.rowSpacing) { + DevJunkCheckbox( + state: selection.isSelected(entry) ? .all : .none, + size: layout.checkboxSize + ) + + Text(entry.displayName) + .font(.system(size: layout.entryFontSize, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: layout.rowSpacing) + + Text(entry.size.diskBytesString) + .font(.system(size: layout.entryFontSize, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isTrashing) + .help(DevJunkPaths.abbreviate(entry.path, home: home)) + } + } + .padding(.leading, layout.detailIndent) + .padding(.bottom, layout.detailBottomPadding) + } + + private var footer: some View { + VStack(spacing: layout.summarySpacing) { + Text("Selected items are moved to the Trash. The space is reclaimed once you empty it.") + .font(.system(size: layout.captionFontSize, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button { + confirmAndTrash() + } label: { + Text(isTrashing ? "Moving to Trash..." : "Move to Trash") + .font(.system(size: layout.buttonFontSize, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, layout.buttonHorizontalPadding) + .frame(height: layout.buttonHeight) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: layout.buttonCornerRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: layout.buttonCornerRadius, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(selectedBytes == 0 || isTrashing) + .opacity(selectedBytes == 0 || isTrashing ? 0.45 : 1) + } + .padding(.horizontal, layout.horizontalPadding) + .padding(.bottom, layout.verticalPadding) + .padding(.top, layout.footerTopPadding) + } + + private var selectedBytes: UInt64 { + selection.selectedBytes(in: reports) + } + + private var totalBytes: UInt64 { + reports.reduce(UInt64(0)) { $0 &+ $1.totalSize } + } + + private func toggleExpansion(_ category: DevJunkCategoryID) { + if expanded.contains(category) { + expanded.remove(category) + } else { + expanded.insert(category) + } + } + + private func startScan() { + scanTask?.cancel() + scanGeneration &+= 1 + let generation = scanGeneration + reports = [] + isScanning = true + didStopScan = false + scanFraction = 0 + scanLabel = "" + statusMessage = "Scanning..." + + scanTask = Task { + for await event in DevJunkScanner.scanStream(home: home) { + // Trashing restarts the scan, which cancels this task; anything still buffered in + // its stream belongs to a scan the user has moved on from and must not be written + // into the successor's state. + guard generation == scanGeneration else { + return + } + + switch event { + case let .progress(fraction, label): + scanFraction = fraction + scanLabel = label + case let .category(report): + // Categories land one at a time so the list fills in as it goes; seeding the + // selection per category keeps the "will free" total honest during the scan. + reports.append(report) + if report.category.isSelectedByDefault { + selection.setCategory(report, selected: true) + } + case let .finished(finished): + reports = finished + selection.reconcile(with: finished) + } + } + + // The tail is the dangerous part: a cancelled scan reaches it too, and clearing + // `isScanning` there would take the progress bar and the Stop button away from the scan + // that replaced it while announcing that scan's half-filled results as final. + guard generation == scanGeneration else { + return + } + + isScanning = false + scanLabel = "" + // Cancelling makes the stream finish exactly as a completed scan does, so the stopped + // case has to be remembered explicitly — otherwise a partial total would be announced + // as if it were the whole picture. + statusMessage = didStopScan ? "Scan stopped — showing what was found so far" : summaryMessage() + } + } + + private func stopScan() { + didStopScan = true + scanTask?.cancel() + } + + private func summaryMessage() -> String { + let found = reports.reduce(0) { $0 + $1.entries.count } + guard found > 0 else { + return "No developer junk found" + } + return "\(found) item\(found == 1 ? "" : "s") found" + } + + private func confirmAndTrash() { + let entries = selection.selectedEntries(in: reports) + guard !entries.isEmpty else { + return + } + + let alert = NSAlert() + alert.messageText = DevJunkRemover.confirmationMessage(for: entries) + alert.informativeText = "You can put them back from the Trash if you change your mind. " + + "Anything still in use by a running build may fail to move." + alert.alertStyle = .warning + alert.addButton(withTitle: "Move to Trash") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { + return + } + + isTrashing = true + statusMessage = "Moving to Trash..." + + Task { + var result = DevJunkRemovalResult(trashedBytes: 0, trashedCount: 0, failures: []) + + for await event in DevJunkRemover.trashStream(entries: entries, home: home) { + switch event { + case let .progress(_, currentPath): + statusMessage = "Trashing \(DevJunkPaths.abbreviate(currentPath, home: home))" + case let .finished(finished): + result = finished + } + } + + isTrashing = false + // The result is reported before the rescan, not after: `startScan` overwrites the + // status line with "Scanning...", so a report published afterwards would be the only + // trace of a bulk move and would vanish immediately. + presentResult(result) + startScan() + } + } + + private func presentResult(_ result: DevJunkRemovalResult) { + // Nothing moved and nothing failed means every entry was already gone — usually a stale + // selection from a previous pass — and there is nothing to acknowledge. + guard result.trashedCount > 0 || !result.failures.isEmpty else { + return + } + + let alert = NSAlert() + alert.messageText = "Moved \(result.trashedCount) item\(result.trashedCount == 1 ? "" : "s") " + + "(\(result.trashedBytes.diskBytesString)) to the Trash" + + if result.failures.isEmpty { + // A wholly successful bulk move used to pass in silence, which is the wrong ending for + // an action that looks irreversible: the user needs to be told it happened and that the + // space is still sitting in the Trash. + alert.informativeText = "The space is reclaimed once you empty the Trash." + } else { + let details = result.failures.prefix(6).map { + "• \(DevJunkPaths.abbreviate($0.path, home: home)) — \($0.reason)" + }.joined(separator: "\n") + let more = result.failures.count > 6 ? "\n…and \(result.failures.count - 6) more" : "" + alert.informativeText = "These were left in place:\n\n\(details)\(more)" + } + + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +/// Tri-state checkbox. The half-filled state is what makes drill-down legible: a category the user +/// has partly deselected must not look identical to one they left alone. +private struct DevJunkCheckbox: View { + var state: DevJunkSelectionState + var size: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(state == .none ? Color.secondary.opacity(0.28) : Color.accentColor) + .frame(width: size, height: size) + .overlay { + switch state { + case .none: + EmptyView() + case .partial: + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(Color.white) + .frame(width: size * 0.52, height: max(1.5, size * 0.14)) + case .all: + Image(systemName: "checkmark") + .font(.system(size: size * 0.62, weight: .black)) + .foregroundStyle(.white) + } + } + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(Color.white.opacity(0.22), lineWidth: 0.6) + } + } +} + +private struct DevJunkLayout { + let scale: CGFloat + + static var current: DevJunkLayout { + DevJunkLayout(scale: CleanDriveSizing.currentScale) + } + + var sheetSize: NSSize { CleanDriveSizing.devJunkSize() } + var horizontalPadding: CGFloat { 16 * scale } + var verticalPadding: CGFloat { 14 * scale } + var headerBottomPadding: CGFloat { 8 * scale } + var summarySpacing: CGFloat { 6 * scale } + var summaryBottomPadding: CGFloat { 8 * scale } + var summaryValueFontSize: CGFloat { 20 * scale } + var titleFontSize: CGFloat { 16 * scale } + var closeIconSize: CGFloat { 11 * scale } + var closeButtonSize: CGFloat { 24 * scale } + var captionFontSize: CGFloat { 11 * scale } + var rowSpacing: CGFloat { 8 * scale } + var listVerticalPadding: CGFloat { 4 * scale } + var checkboxSize: CGFloat { 15 * scale } + var chevronFontSize: CGFloat { 10 * scale } + var chevronWidth: CGFloat { 12 * scale } + var rowTitleFontSize: CGFloat { 13 * scale } + var rowValueFontSize: CGFloat { 12 * scale } + var entryFontSize: CGFloat { 11 * scale } + var detailIndent: CGFloat { 23 * scale } + var detailBottomPadding: CGFloat { 6 * scale } + var footerTopPadding: CGFloat { 8 * scale } + var buttonFontSize: CGFloat { 13 * scale } + var buttonHorizontalPadding: CGFloat { 18 * scale } + var buttonHeight: CGFloat { 28 * scale } + var buttonCornerRadius: CGFloat { 7 * scale } +} diff --git a/Sources/DMonteCore/CleanDriveSizing.swift b/Sources/DMonteCore/CleanDriveSizing.swift index 8f2aace..7244787 100644 --- a/Sources/DMonteCore/CleanDriveSizing.swift +++ b/Sources/DMonteCore/CleanDriveSizing.swift @@ -6,6 +6,18 @@ public enum CleanDriveSizing { return NSSize(width: (420 * scale).rounded(), height: (500 * scale).rounded()) } + /// The developer-junk sheet, which is centred *over* the panel by `PreferencesOverlay` and so + /// must never exceed it. Unlike the settings sheet this one wants every point it can get: it + /// lists eleven categories that each expand into a drill-down, so it is sized to fill the panel + /// right up to the overlay's own padding rather than to a comfortable fixed size. + public static func devJunkSize() -> NSSize { + let panel = preferredSize() + // PreferencesOverlay adds 18pt of padding on every side; a sheet sized to the full panel + // becomes panel+36 once padded and spills out of it. + let overlayChrome: CGFloat = 36 + return NSSize(width: panel.width - overlayChrome, height: panel.height - overlayChrome) + } + static var currentScale: CGFloat { let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) let screenScale = visibleFrame.height / 950 diff --git a/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift new file mode 100644 index 0000000..49a0536 --- /dev/null +++ b/Tests/DMonteCoreTests/CleanDriveDevJunkTests.swift @@ -0,0 +1,632 @@ +import XCTest +@testable import DMonteCore + +final class CleanDriveDevJunkTests: XCTestCase { + private let home = "/Users/tester" + + // MARK: - Path primitives + + func testStrictDescendantIsComponentWiseNotStringPrefix() { + // The whole feature turns on this: a plain hasPrefix test would say the backup folder + // lives inside the project, and that is how a cleaner ends up deleting someone's source. + XCTAssertFalse(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp-backup", of: "/Users/tester/Code/MyApp")) + XCTAssertTrue(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp/src", of: "/Users/tester/Code/MyApp")) + XCTAssertFalse(DevJunkPaths.isStrictDescendant("/Users/tester/Code/MyApp", of: "/Users/tester/Code/MyApp")) + } + + func testDescendantOrSelfAcceptsTheRootItself() { + XCTAssertTrue(DevJunkPaths.isDescendantOrSelf("/Users/tester/Code", of: "/Users/tester/Code")) + XCTAssertTrue(DevJunkPaths.isDescendantOrSelf("/Users/tester/Code/App", of: "/Users/tester/Code")) + XCTAssertFalse(DevJunkPaths.isDescendantOrSelf("/Users/tester", of: "/Users/tester/Code")) + } + + func testDirectChildIsExactlyOneLevelDown() { + XCTAssertTrue(DevJunkPaths.isDirectChild("/a/b/c", of: "/a/b")) + XCTAssertFalse(DevJunkPaths.isDirectChild("/a/b/c/d", of: "/a/b")) + XCTAssertFalse(DevJunkPaths.isDirectChild("/a/b", of: "/a/b")) + } + + func testNormalizeCollapsesRedundantSeparators() { + XCTAssertEqual(DevJunkPaths.normalize("/a//b/"), "/a/b") + XCTAssertEqual(DevJunkPaths.normalize("/"), "/") + } + + func testExpandOnlyHandlesTheCurrentUsersTilde() { + XCTAssertEqual(DevJunkPaths.expand("~/Library", home: home), "/Users/tester/Library") + XCTAssertEqual(DevJunkPaths.expand("~", home: home), home) + XCTAssertEqual(DevJunkPaths.expand("/absolute", home: home), "/absolute") + // "~someone" belongs to another account and must be left alone rather than mangled. + XCTAssertEqual(DevJunkPaths.expand("~other/Library", home: home), "~other/Library") + } + + func testAbbreviateOnlyShortensPathsInsideHome() { + XCTAssertEqual(DevJunkPaths.abbreviate("/Users/tester/Code/App", home: home), "~/Code/App") + XCTAssertEqual(DevJunkPaths.abbreviate("/Users/other/Code", home: home), "/Users/other/Code") + } + + func testDisplayNameNamesProjectEntriesAfterTheirProject() { + // A drill-down list of twenty rows all reading "node_modules" tells the user nothing. + XCTAssertEqual( + DevJunkPaths.displayName(forPath: "/Users/tester/Code/MyApp/node_modules", category: .nodeModules), + "MyApp/node_modules" + ) + XCTAssertEqual( + DevJunkPaths.displayName( + forPath: "/Users/tester/Library/Developer/Xcode/DerivedData/App-abc", + category: .xcodeDerivedData + ), + "App-abc" + ) + } + + // MARK: - Category acceptance: the positive cases + + func testEachFixedCategoryAcceptsAKnownEntryUnderItsOwnRoot() { + let cases: [(DevJunkCategoryID, String)] = [ + (.xcodeDerivedData, "~/Library/Developer/Xcode/DerivedData/MyApp-abcdefgh"), + (.xcodeArchives, "~/Library/Developer/Xcode/Archives/2026-07-21"), + (.deviceSupport, "~/Library/Developer/Xcode/iOS DeviceSupport/17.0 (21A342)"), + (.simulatorCaches, "~/Library/Developer/CoreSimulator/Caches/dyld"), + (.cocoaPodsCache, "~/Library/Caches/CocoaPods/Pods"), + (.jvmCaches, "~/.gradle/caches/modules-2"), + (.jvmCaches, "~/.m2/repository/org"), + (.scriptingCaches, "~/.npm/_cacache"), + (.scriptingCaches, "~/Library/Caches/pip/wheels"), + (.homebrewCache, "~/Library/Caches/Homebrew/downloads"), + (.dockerLeftovers, "~/Library/Containers/com.docker.docker/Data/log/host") + ] + + for (category, path) in cases { + XCTAssertTrue( + category.accepts(path: path, home: home), + "\(category.rawValue) should accept its own entry \(path)" + ) + } + } + + func testProjectCategoriesAcceptDirectoriesInsideTheProjectRoots() { + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/MyApp/node_modules", home: home)) + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Documents/work/site/node_modules", home: home)) + XCTAssertTrue(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Code/MyLib/.build", home: home)) + } + + // MARK: - Category acceptance: nothing outside its intended root + + /// Paths that are either the user's own work or something a toolchain cannot regenerate. If any + /// category ever claims one of these, the feature deletes something it must not. + private var sacredPaths: [String] { + [ + // Source trees that sit right next to the junk. + "~/Developer/MyApp", + "~/Developer/MyApp/src", + "~/Documents/Taxes", + "~/Documents/MyApp/Sources/main.swift", + "~/Desktop/screenshot.png", + "~/Code/MyLib/Package.swift", + // Look-alike names a substring match would happily eat. + "~/Developer/node_modules_backup", + "~/Developer/MyApp/node_modules.zip", + "~/Developer/MyApp/.buildkite", + // Xcode state that is not a cache. + "~/Library/Developer/Xcode/UserData/FontAndColorThemes/Mine.xccolortheme", + "~/Library/Developer/Xcode/Templates", + "~/Library/Developer/Xcode/DerivedData", + // Simulator devices hold installed apps and their data, not caches. + "~/Library/Developer/CoreSimulator/Devices/8F0C-DEAD-BEEF", + "~/Library/Developer/CoreSimulator/Devices", + // Gradle distributions are pinned by checksum; Maven settings are configuration. + "~/.gradle/wrapper/dists/gradle-8.5-bin", + "~/.gradle/gradle.properties", + "~/.m2/settings.xml", + // npm/pip configuration and logs, not download caches. + "~/.npmrc", + "~/.npm/_logs", + // A private CocoaPods spec repo is a git checkout that may not be re-clonable. + "~/.cocoapods/repos/private-specs", + // Installed Homebrew software, as opposed to the downloads that produced it. + "/opt/homebrew/Cellar/git/2.44.0", + "/usr/local/Cellar/git/2.44.0", + // The Docker disk image holds every image, container and volume the user owns. + "~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw", + "~/Library/Containers/com.docker.docker/Data", + // An installed application's own bundled dependencies. Electron apps ship + // Contents/Resources/app/node_modules with a package.json beside it, so the sibling test + // passes and only the bundle veto stands between the user and a broken application. + "~/Desktop/Cursor.app/Contents/Resources/app/node_modules", + "~/Documents/Visual Studio Code.app/Contents/Resources/app/node_modules", + "~/Developer/Widget.framework/Versions/A/Resources/.build", + // Traversal: textually inside a search root, resolving somewhere else entirely. + "~/Documents/../.ssh/node_modules", + "~/Documents/../../../etc/node_modules", + "~/Developer/./MyApp/node_modules", + // Unrelated user data and system files. + "~/Library/Mail", + "~/Library/Application Support/MobileSync/Backup", + "~/.ssh/id_ed25519", + "~/.Trash/MyApp/node_modules", + "/System/Library/CoreServices", + "/Applications/Xcode.app", + "/", + "" + ] + } + + func testNoCategoryAcceptsAnythingOutsideItsIntendedRoot() { + for category in DevJunkCategoryID.allCases { + for path in sacredPaths { + XCTAssertFalse( + category.accepts(path: path, home: home), + "\(category.rawValue) must not accept \(path)" + ) + } + } + } + + func testCategoriesDoNotPoachEachOthersEntries() { + // Every category's own entry must be rejected by all the others, so a mis-tagged entry + // cannot survive the pre-delete revalidation. + let owned: [(DevJunkCategoryID, String)] = [ + (.xcodeDerivedData, "~/Library/Developer/Xcode/DerivedData/MyApp-abcdefgh"), + (.xcodeArchives, "~/Library/Developer/Xcode/Archives/2026-07-21"), + (.deviceSupport, "~/Library/Developer/Xcode/iOS DeviceSupport/17.0 (21A342)"), + (.simulatorCaches, "~/Library/Developer/CoreSimulator/Caches/dyld"), + (.nodeModules, "~/Developer/MyApp/node_modules"), + (.swiftPackageBuilds, "~/Developer/MyLib/.build"), + (.cocoaPodsCache, "~/Library/Caches/CocoaPods/Pods"), + (.jvmCaches, "~/.gradle/caches/modules-2"), + (.scriptingCaches, "~/.npm/_cacache"), + (.homebrewCache, "~/Library/Caches/Homebrew/downloads"), + (.dockerLeftovers, "~/Library/Containers/com.docker.docker/Data/log/host") + ] + + for (owner, path) in owned { + for category in DevJunkCategoryID.allCases where category != owner { + XCTAssertFalse( + category.accepts(path: path, home: home), + "\(category.rawValue) must not claim \(owner.rawValue)'s entry \(path)" + ) + } + } + } + + func testFixedCategoriesOnlyAcceptDirectChildrenNotDeeperPaths() { + // Handing the remover a nested path would let it delete a fragment of a cache and leave + // the toolchain with a half-populated directory. + XCTAssertFalse( + DevJunkCategoryID.xcodeDerivedData.accepts( + path: "~/Library/Developer/Xcode/DerivedData/MyApp-abc/Build/Products", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.homebrewCache.accepts(path: "~/Library/Caches/Homebrew/downloads/abc.tar.gz", home: home) + ) + } + + func testProjectCategoriesRejectMatchesOutsideTheSearchRoots() { + // ~/Library is not a project root, and neither is an arbitrary volume. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Library/Caches/thing/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Backup/MyApp/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "/tmp/.build", home: home)) + } + + func testProjectCategoriesRejectNestedAndMisnamedDirectories() { + // A node_modules inside another one is already covered by its ancestor. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/node_modules/dep/node_modules", home: home) + ) + // ...and anything reached through a VCS or package store is off limits entirely. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/.git/node_modules", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/app/Pods/.build", home: home)) + // The last component must match exactly, not merely start or end with the name. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/app/node_modules2", home: home)) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/app/build", home: home)) + } + + func testPruneNamesOnlyVetoComponentsBelowTheSearchRoot() { + // A component of the *home directory* is not part of the user's project structure, so a + // home that happens to sit under a folder named like a package store must not make every + // project inside it unmatchable. Home directories on external volumes hit this. + let awkwardHome = "/Volumes/Pods/tester" + XCTAssertTrue( + DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Pods/tester/Developer/App/node_modules", home: awkwardHome) + ) + + // Below the root the veto still applies. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "/Volumes/Pods/tester/Developer/Pods/App/node_modules", home: awkwardHome) + ) + } + + // MARK: - Bundles + + func testBundleNamesAreRecognisedAndOrdinaryFoldersAreNot() { + for name in ["Cursor.app", "Visual Studio Code.APP", "Widget.framework", "Thing.bundle", "Trip.photoslibrary"] { + XCTAssertTrue(DevJunkPaths.isBundleName(name), "\(name) should read as a bundle") + } + + for name in ["MyApp", "node_modules", "app", ".app", "notes.txt", "release.app.zip", "Package.swift"] { + XCTAssertFalse(DevJunkPaths.isBundleName(name), "\(name) should not read as a bundle") + } + } + + func testProjectCategoriesRejectAnythingInsideABundle() { + // The application's own dependencies sit beside a real package.json, so the sibling test + // that keeps this feature conservative is no defence at all here — and unlike a project's + // node_modules, this one cannot be restored by running npm install. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: "~/Desktop/Cursor.app/Contents/Resources/app/node_modules", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: "~/Documents/Tools/Slack.APP/Contents/Resources/app/node_modules", + home: home + ) + ) + XCTAssertFalse( + DevJunkCategoryID.swiftPackageBuilds.accepts(path: "~/Developer/Thing.bundle/Contents/.build", home: home) + ) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "~/Documents/Trip.photoslibrary/private/node_modules", home: home) + ) + + // A folder that merely has a dot in its name is still an ordinary project folder. + XCTAssertTrue(DevJunkCategoryID.nodeModules.accepts(path: "~/Developer/my.site/node_modules", home: home)) + } + + func testScanLeavesAnApplicationsOwnDependenciesAlone() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + // Exactly the Electron layout, on the Desktop, where the walk's depth cap still reaches it: + // Desktop/Cursor.app/Contents/Resources/app/node_modules is five levels below the root. + let bundledApp = DevJunkPaths.joined(sandbox, "Desktop/Cursor.app/Contents/Resources/app") + try writeFile(DevJunkPaths.joined(bundledApp, "package.json")) + try writeFile(DevJunkPaths.joined(bundledApp, "node_modules/electron-log/index.js")) + + // A genuine project on the same Desktop still has to be found, or the veto is too wide. + try writeFile(DevJunkPaths.joined(sandbox, "Desktop/Site/package.json")) + try writeFile(DevJunkPaths.joined(sandbox, "Desktop/Site/node_modules/left-pad/index.js")) + + let entries = DevJunkScanner.entries(for: .nodeModules, home: sandbox) + let paths = Set(entries.map(\.path)) + + XCTAssertFalse(paths.contains(DevJunkPaths.joined(bundledApp, "node_modules"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Desktop/Site/node_modules"))) + + // The pre-delete gate is the last line of defence, so it must refuse the bundled copy too + // even if some future scanner change hands it over. + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts( + path: DevJunkPaths.joined(bundledApp, "node_modules"), + home: sandbox + ) + ) + } + + // MARK: - Path traversal + + func testAcceptsRejectsPathsContainingTraversalComponents() { + // normalize() does not resolve "..", so containment is textual: without this rule + // "~/Documents/../.ssh/node_modules" reads as living under the Documents search root while + // resolving into ~/.ssh, and "../../../etc" escapes home entirely. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Documents/../.ssh/node_modules", home: home)) + XCTAssertFalse( + DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Documents/../../../etc/node_modules", home: home) + ) + XCTAssertFalse(DevJunkCategoryID.swiftPackageBuilds.accepts(path: "/Users/tester/Code/../Library/.build", home: home)) + XCTAssertFalse( + DevJunkCategoryID.xcodeDerivedData.accepts( + path: "/Users/tester/Library/Developer/Xcode/DerivedData/../../../Mail", + home: home + ) + ) + // "." is harmless in itself but arrives from the same class of unnormalised input, and + // accepting it would mean two spellings of one path disagree about eligibility. + XCTAssertFalse(DevJunkCategoryID.nodeModules.accepts(path: "/Users/tester/Developer/./App/node_modules", home: home)) + } + + func testValidateRejectsATraversalPathBeforeItReachesTheRemover() { + let forged = DevJunkEntry( + category: .nodeModules, + path: "/Users/tester/Documents/../.ssh/node_modules", + size: 999 + ) + let genuine = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/App/node_modules", size: 10) + + let (allowed, rejected) = DevJunkRemover.validate([forged, genuine], home: home) + + XCTAssertEqual(allowed, [genuine]) + XCTAssertEqual(rejected, [forged]) + } + + func testEveryCategoryStaysInsideTheUsersHomeDirectory() { + for category in DevJunkCategoryID.allCases { + for root in category.allowedRoots(home: home) { + XCTAssertTrue( + DevJunkPaths.isStrictDescendant(root, of: home), + "\(category.rawValue) declares a root outside home: \(root)" + ) + } + } + } + + func testArchivesAreTheOnlyCategoryOffByDefault() { + // Archives carry the dSYMs that make old crash reports readable, which is the one thing + // here that does not come back. + for category in DevJunkCategoryID.allCases { + XCTAssertEqual( + category.isSelectedByDefault, + category != .xcodeArchives, + "\(category.rawValue) default selection changed" + ) + } + } + + // MARK: - Selection + + private func report(_ category: DevJunkCategoryID, _ paths: [(String, UInt64)]) -> DevJunkCategoryReport { + DevJunkCategoryReport( + category: category, + entries: paths.map { DevJunkEntry(category: category, path: $0.0, size: $0.1) } + ) + } + + private var sampleReports: [DevJunkCategoryReport] { + [ + report(.xcodeDerivedData, [ + ("/Users/tester/Library/Developer/Xcode/DerivedData/A-1", 100), + ("/Users/tester/Library/Developer/Xcode/DerivedData/B-2", 200) + ]), + report(.xcodeArchives, [ + ("/Users/tester/Library/Developer/Xcode/Archives/2026-07-21", 500) + ]) + ] + } + + func testDefaultSelectionSkipsArchives() { + let reports = sampleReports + let selection = DevJunkSelection(defaultsFor: reports) + + XCTAssertEqual(selection.state(for: reports[0]), .all) + XCTAssertEqual(selection.state(for: reports[1]), .none) + XCTAssertEqual(selection.selectedBytes(in: reports), 300) + } + + func testDeselectingOneEntryLeavesTheCategoryPartial() { + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + + selection.toggle(reports[0].entries[0]) + + XCTAssertEqual(selection.state(for: reports[0]), .partial) + XCTAssertEqual(selection.selectedBytes(in: reports), 200) + } + + func testTogglingAPartialCategorySelectsAllOfIt() { + // A half-checked box should be one click from full, not one click from empty. + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + selection.toggle(reports[0].entries[0]) + + selection.toggle(reports[0]) + + XCTAssertEqual(selection.state(for: reports[0]), .all) + + selection.toggle(reports[0]) + XCTAssertEqual(selection.state(for: reports[0]), .none) + } + + func testEmptyCategoryReadsAsUnselected() { + let empty = report(.nodeModules, []) + let selection = DevJunkSelection(defaultsFor: [empty]) + + XCTAssertEqual(selection.state(for: empty), .none) + } + + func testReconcileDropsPathsTheLatestScanNoLongerReports() { + let reports = sampleReports + var selection = DevJunkSelection(defaultsFor: reports) + + let shrunk = [report(.xcodeDerivedData, [("/Users/tester/Library/Developer/Xcode/DerivedData/A-1", 100)])] + selection.reconcile(with: shrunk) + + XCTAssertEqual(selection.selectedBytes(in: shrunk), 100) + // The vanished path must not come back if the same report is offered again. + XCTAssertEqual(selection.selectedBytes(in: reports), 100) + } + + // MARK: - Removal safety + + func testValidateRejectsAnEntryPointingOutsideItsCategory() { + // Entries survive across rescans and across the confirmation alert, so the remover + // re-checks every one rather than trusting that nothing moved in between. + let forged = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Documents/Taxes", size: 999) + let genuine = DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/App/node_modules", size: 10) + + let (allowed, rejected) = DevJunkRemover.validate([forged, genuine], home: home) + + XCTAssertEqual(allowed, [genuine]) + XCTAssertEqual(rejected, [forged]) + } + + func testConfirmationMessageNamesTheCountAndTotal() { + let entries = [ + DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/A/node_modules", size: 1_000_000), + DevJunkEntry(category: .nodeModules, path: "/Users/tester/Developer/B/node_modules", size: 2_000_000) + ] + + let message = DevJunkRemover.confirmationMessage(for: entries) + + XCTAssertTrue(message.contains("2 items"), message) + XCTAssertTrue(message.contains(UInt64(3_000_000).diskBytesString), message) + } + + func testTrashStreamLeavesARejectedPathOnDisk() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + let sourceDirectory = DevJunkPaths.joined(sandbox, "Documents/Taxes") + try FileManager.default.createDirectory(atPath: sourceDirectory, withIntermediateDirectories: true) + let forged = DevJunkEntry(category: .nodeModules, path: sourceDirectory, size: 1) + + var result = DevJunkRemovalResult(trashedBytes: 0, trashedCount: 0, failures: []) + for await event in DevJunkRemover.trashStream(entries: [forged], home: sandbox) { + if case let .finished(finished) = event { + result = finished + } + } + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: sourceDirectory)) + } + + // MARK: - Scanning a real directory tree + + private func makeSandbox() throws -> String { + let root = DevJunkPaths.joined(NSTemporaryDirectory(), "DevJunkTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + return DevJunkPaths.normalize(root) + } + + private func writeFile(_ path: String, bytes: Int = 4_096) throws { + let directory = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let data = Data(repeating: 0x41, count: bytes) + try data.write(to: URL(fileURLWithPath: path)) + } + + /// Builds a home directory holding one of everything: a real npm project, a real SwiftPM + /// package, a real DerivedData entry, and three decoys that a sloppier matcher would eat. + private func populateSandbox(_ home: String) throws { + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/package.json")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/node_modules/left-pad/index.js")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyApp/src/main.js")) + + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/Package.swift")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/.build/debug/MyLib.o")) + try writeFile(DevJunkPaths.joined(home, "Developer/MyLib/Sources/MyLib/MyLib.swift")) + + // Decoy 1: a folder named node_modules with no package.json beside it. + try writeFile(DevJunkPaths.joined(home, "Developer/Notes/node_modules/reading-list.md")) + // Decoy 2: a look-alike name. + try writeFile(DevJunkPaths.joined(home, "Developer/node_modules_backup/archive.tar")) + // Decoy 3: a .build belonging to something that is not a SwiftPM package. + try writeFile(DevJunkPaths.joined(home, "Developer/Website/.build/output.css")) + + try writeFile(DevJunkPaths.joined(home, "Library/Developer/Xcode/DerivedData/MyApp-abc/Build/MyApp.o")) + try writeFile(DevJunkPaths.joined(home, "Library/Developer/Xcode/UserData/FontAndColorThemes/Mine.xccolortheme")) + } + + private func scan(home: String) async -> [DevJunkCategoryReport] { + var reports: [DevJunkCategoryReport] = [] + for await event in DevJunkScanner.scanStream(home: home) { + if case let .finished(finished) = event { + reports = finished + } + } + return reports + } + + func testScanFindsRealProjectJunkAndIgnoresTheDecoys() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + let paths = Set(reports.flatMap(\.entries).map(\.path)) + + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Developer/MyApp/node_modules"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Developer/MyLib/.build"))) + XCTAssertTrue(paths.contains(DevJunkPaths.joined(sandbox, "Library/Developer/Xcode/DerivedData/MyApp-abc"))) + + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/Notes/node_modules"))) + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/node_modules_backup"))) + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, "Developer/Website/.build"))) + } + + func testScanNeverListsAPathOutsideSomeCategoryRoot() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + + for report in reports { + for entry in report.entries { + XCTAssertEqual(entry.category, report.category) + XCTAssertTrue( + entry.category.accepts(path: entry.path, home: sandbox), + "scan produced \(entry.path) which its own category rejects" + ) + XCTAssertTrue( + entry.category.allowedRoots(home: sandbox).contains { + DevJunkPaths.isDescendantOrSelf(entry.path, of: $0) + }, + "scan produced \(entry.path) outside \(entry.category.rawValue)'s roots" + ) + } + } + + // The source trees and Xcode's user settings must not appear anywhere in the results. + let paths = Set(reports.flatMap(\.entries).map(\.path)) + for survivor in [ + "Developer/MyApp/src", + "Developer/MyLib/Sources", + "Library/Developer/Xcode/UserData" + ] { + XCTAssertFalse(paths.contains(DevJunkPaths.joined(sandbox, survivor)), survivor) + } + } + + func testScanReportsEveryCategoryAndSizesWhatItFinds() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + let reports = await scan(home: sandbox) + + XCTAssertEqual(reports.map(\.category), DevJunkCategoryID.allCases) + + let nodeModules = try XCTUnwrap(reports.first { $0.category == .nodeModules }) + XCTAssertEqual(nodeModules.entries.count, 1) + XCTAssertGreaterThan(nodeModules.totalSize, 0) + XCTAssertEqual(nodeModules.entries[0].displayName, "MyApp/node_modules") + } + + func testScanStreamStopsWhenTheConsumerWalksAway() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + try populateSandbox(sandbox) + + // Breaking out of the loop terminates the stream, which must cancel the producing task + // rather than leaving it walking node_modules for a panel that is already closed. + var seen = 0 + for await _ in DevJunkScanner.scanStream(home: sandbox) { + seen += 1 + break + } + + XCTAssertEqual(seen, 1) + } + + func testEntriesForCategoryAreOrderedLargestFirst() async throws { + let sandbox = try makeSandbox() + defer { try? FileManager.default.removeItem(atPath: sandbox) } + + let derivedData = DevJunkPaths.joined(sandbox, "Library/Developer/Xcode/DerivedData") + try writeFile(DevJunkPaths.joined(derivedData, "Small-1/a.o"), bytes: 1_024) + try writeFile(DevJunkPaths.joined(derivedData, "Large-2/a.o"), bytes: 512 * 1_024) + + let entries = DevJunkScanner.entries(for: .xcodeDerivedData, home: sandbox) + + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries[0].displayName, "Large-2") + XCTAssertGreaterThan(entries[0].size, entries[1].size) + } +} diff --git a/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift b/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift index dfa1e24..86a2769 100644 --- a/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift +++ b/Tests/DMonteCoreTests/SettingsOverlaySizingTests.swift @@ -23,10 +23,29 @@ final class SettingsOverlaySizingTests: XCTestCase { assertSheetFits(panel: FocusTimerSizing.preferredSize(), sheet: FocusTimerSizing.settingsSize(), tool: "Focus Timer") } + /// Clean Drive's developer-junk sheet is sized to fill the panel rather than to a comfortable + /// literal, so it is the one sheet that would spill the instant the 18pt-per-side padding + /// `PreferencesOverlay` adds were forgotten. Asserted against the padded size for that reason. + func testCleanDriveDevJunkSheetFitsItsPanelOncePadded() { + let panel = CleanDriveSizing.preferredSize() + let sheet = CleanDriveSizing.devJunkSize() + let overlayChrome: CGFloat = 36 + + assertSheetFits(panel: panel, sheet: sheet, tool: "Clean Drive developer junk") + XCTAssertLessThanOrEqual( + sheet.width + overlayChrome, panel.width, + "Clean Drive: PreferencesOverlay pads the sheet by 18pt a side, so it must be 36pt narrower than the panel" + ) + XCTAssertLessThanOrEqual( + sheet.height + overlayChrome, panel.height, + "Clean Drive: PreferencesOverlay pads the sheet by 18pt a side, so it must be 36pt shorter than the panel" + ) + } + /// Both tools derive from the same clamped scale, so the guarantee has to hold across its /// whole range rather than at whatever this machine reports. func testScaleStaysWithinItsDocumentedBounds() { - for scale in [KeepAwakeSizing.currentScale, FocusTimerSizing.currentScale] { + for scale in [KeepAwakeSizing.currentScale, FocusTimerSizing.currentScale, CleanDriveSizing.currentScale] { XCTAssertGreaterThanOrEqual(scale, 0.82) XCTAssertLessThanOrEqual(scale, 1.0) }