diff --git a/.gitignore b/.gitignore index 0982eb18a..aa40a0443 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,12 @@ dist/ /live-map.md # END ATOMSTORM ITERATION GOVERNANCE + +# Auto Claude generated files +.auto-claude/ +.auto-claude-security.json +.auto-claude-status +.claude_settings.json +.worktrees/ +.security-key +logs/security/ diff --git a/.secretsignore b/.secretsignore new file mode 100644 index 000000000..da6edf854 --- /dev/null +++ b/.secretsignore @@ -0,0 +1,3 @@ +AtlasDomain.swift +AtlasDomainTests.swift +*.swift diff --git a/Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift b/Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift index eb107abfc..8d4572727 100644 --- a/Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift +++ b/Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift @@ -17,9 +17,12 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { let exportFileURL = stateDirectory.appendingPathComponent("clean-list.txt") let detailedExportFileURL = stateDirectory.appendingPathComponent("clean-list-detailed.tsv") let output = try runDryRun(stateDirectory: stateDirectory, exportFileURL: exportFileURL, detailedExportFileURL: detailedExportFileURL) - let findings = Self.parseDetailedFindings(from: detailedExportFileURL).isEmpty + var findings = Self.parseDetailedFindings(from: detailedExportFileURL).isEmpty ? Self.parseFindings(from: output) : Self.parseDetailedFindings(from: detailedExportFileURL) + + await Self.enrichFindingsWithFileAge(&findings) + let summary = findings.isEmpty ? "Smart Clean dry run found no reclaimable items from the upstream clean workflow." : "Smart Clean dry run found \(findings.count) reclaimable item\(findings.count == 1 ? "" : "s")." @@ -67,15 +70,45 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { let section: String let path: String let sizeKB: Int64 + let riskLevel: RiskLevel? + let storageCategory: AtlasStorageCategory? + let fileAge: FileAgeInfo? } let entries: [Entry] = content .split(whereSeparator: \.isNewline) .compactMap { rawLine in let parts = rawLine.split(separator: "\t", omittingEmptySubsequences: false) - guard parts.count == 3 else { return nil } - guard let sizeKB = Int64(parts[2]) else { return nil } - return Entry(section: String(parts[0]), path: String(parts[1]), sizeKB: sizeKB) + guard parts.count >= 3, let sizeKB = Int64(parts[2]) else { return nil } + + let parsedRisk: RiskLevel? = parts.count > 3 && !parts[3].isEmpty + ? RiskLevel(rawValue: String(parts[3])) + : nil + let parsedCategory: AtlasStorageCategory? = parts.count > 4 && !parts[4].isEmpty + ? AtlasStorageCategory(rawValue: String(parts[4])) + : nil + + var ageInfo: FileAgeInfo? + if parts.count > 5 { + let lastAccessed = parts.count > 5 && !parts[5].isEmpty + ? ISO8601DateFormatter().date(from: String(parts[5])) + : nil + let createdDate = parts.count > 6 && !parts[6].isEmpty + ? ISO8601DateFormatter().date(from: String(parts[6])) + : nil + if lastAccessed != nil || createdDate != nil { + ageInfo = FileAgeInfo(lastAccessedDate: lastAccessed, creationDate: createdDate) + } + } + + return Entry( + section: String(parts[0]), + path: String(parts[1]), + sizeKB: sizeKB, + riskLevel: parsedRisk, + storageCategory: parsedCategory, + fileAge: ageInfo + ) } guard !entries.isEmpty else { @@ -97,6 +130,9 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { var targetPaths: [String] var childCount: Int var order: Int + var riskLevel: RiskLevel? + var storageCategory: AtlasStorageCategory? + var fileAge: FileAgeInfo? } var groups: [String: Group] = [:] @@ -115,13 +151,25 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { bytes: 0, targetPaths: [], childCount: 0, - order: order + order: order, + riskLevel: entry.riskLevel, + storageCategory: entry.storageCategory, + fileAge: entry.fileAge ) order += 1 } groups[groupKey]!.bytes += entry.sizeKB * 1024 groups[groupKey]!.targetPaths.append(entry.path) groups[groupKey]!.childCount += 1 + if groups[groupKey]!.riskLevel == nil { + groups[groupKey]!.riskLevel = entry.riskLevel + } + if groups[groupKey]!.storageCategory == nil { + groups[groupKey]!.storageCategory = entry.storageCategory + } + if groups[groupKey]!.fileAge == nil { + groups[groupKey]!.fileAge = entry.fileAge + } } return groups.values @@ -130,13 +178,25 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { return lhs.bytes > rhs.bytes } .map { group in - Finding( + let resolvedRisk = group.riskLevel + ?? riskLevel(for: group.section, title: group.displayPath) + let resolvedCategory = group.storageCategory + ?? storageCategory(for: group.section, displayPath: group.displayPath) + let explanation = AtlasFindingExplanations.explanation( + for: resolvedCategory, + risk: resolvedRisk, + fileAge: group.fileAge + ) + return Finding( title: makeDetailedTitle(for: group.displayPath, section: group.section), detail: makeDetailedDetail(for: group.displayPath, section: group.section, childCount: group.childCount), bytes: group.bytes, - risk: riskLevel(for: group.section, title: group.displayPath), + risk: resolvedRisk, category: group.section, - targetPaths: group.targetPaths + targetPaths: group.targetPaths, + explanation: explanation, + fileAge: group.fileAge, + storageCategory: resolvedCategory ) } } @@ -239,7 +299,20 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { guard !title.isEmpty else { return nil } let detail = parseDetail(from: content, fallbackSection: section) let risk = riskLevel(for: section, title: title) - return Finding(title: title, detail: detail, bytes: bytes, risk: risk, category: section) + let category = storageCategory(for: section, displayPath: title) + let explanation = AtlasFindingExplanations.explanation( + for: category, + risk: risk + ) + return Finding( + title: title, + detail: detail, + bytes: bytes, + risk: risk, + category: section, + explanation: explanation, + storageCategory: category + ) } @@ -336,6 +409,136 @@ public struct MoleSmartCleanAdapter: AtlasSmartCleanScanProviding { return components[containersIndex + 1] } + // MARK: - Storage Category Classification + + private static func storageCategory(for section: String, displayPath: String) -> AtlasStorageCategory { + let path = displayPath.lowercased() + let last = URL(fileURLWithPath: displayPath).lastPathComponent.lowercased() + + // Log files. + if last.hasSuffix(".log") || path.contains("/library/logs/") { + return .logFile + } + if last == "logs" { + return .logFile + } + + // Browser data. + let browserPatterns = [ + "/google/chrome/", "/safari/", "/firefox/", "/bravesoftware/", + "/microsoft edge/", "/chromium/", "/com.apple.safari/", + "/com.google.chrome/", "/org.mozilla.firefox/", + ] + for pattern in browserPatterns where path.contains(pattern) { + return .browserData + } + + // Developer artifacts. + let developerPatterns = [ + "/deriveddata/", "/.npm/", "/.npm_cache/", "/__pycache__", + "/.next/cache", "/pnpm/store", "/.oh-my-zsh/cache", + "/.gradle/", "/.m2/", "/.cargo/", "/.rustup/", + "/.pyenv/", "/.nvm/", "/.poetry/", "/.pip/", + "/.composer/", "/.bundle/", + ] + for pattern in developerPatterns where path.contains(pattern) { + return .developerArtifact + } + let developerNames: Set = [ + "deriveddata", "node_modules", "__pycache__", "build", + "dist", ".next", ".nuxt", ".vite", ".turbo", "pods", "carthage", + ] + if developerNames.contains(last) { + return .developerArtifact + } + if section.lowercased().contains("developer") { + return .developerArtifact + } + + // Mail attachments. + if path.contains("/library/mail/") || path.contains("/library/messages/") + || path.contains("/library/containers/com.apple.mail/") { + return .mailAttachment + } + if last == "attachments" { + return .mailAttachment + } + + // Download artifacts. + if path.contains("/downloads/") { + return .downloadArtifact + } + + // Old backups. + if path.contains("backup") || path.contains(".bak") || path.contains("timemachine") + || path.contains(".mobilebackups") { + return .oldBackup + } + + // System cache. + if path.contains("/library/caches/") || path.contains("/library/saved application state/") + || path.contains("/library/diagnosticreports/") || path.contains("/.trash/") + || path.contains("/.cache/") { + return .systemCache + } + let cacheNames: Set = ["caches", "cache", ".cache", "tmp", "temp"] + if cacheNames.contains(last) { + return .systemCache + } + + // App cache. + if path.contains("/library/application support/") + || path.contains("/library/containers/") + || path.contains("/library/preferences/") { + return .appCache + } + + return .systemCache + } + + // MARK: - File Age Enrichment + + private static func enrichFindingsWithFileAge(_ findings: inout [Finding]) async { + for index in findings.indices { + guard findings[index].fileAge == nil, + let targetPaths = findings[index].targetPaths, + !targetPaths.isEmpty else { + continue + } + + var oldestAccessed: Date? + var oldestCreated: Date? + + for targetPath in targetPaths { + if let attributes = try? FileManager.default.attributesOfItem(atPath: targetPath) { + if let modified = attributes[.modificationDate] as? Date { + if oldestAccessed == nil || modified < oldestAccessed! { + oldestAccessed = modified + } + } + if let created = attributes[.creationDate] as? Date { + if oldestCreated == nil || created < oldestCreated! { + oldestCreated = created + } + } + } + } + + if oldestAccessed != nil || oldestCreated != nil { + findings[index].fileAge = FileAgeInfo( + lastAccessedDate: oldestAccessed, + creationDate: oldestCreated + ) + let category = findings[index].storageCategory ?? .systemCache + findings[index].explanation = AtlasFindingExplanations.explanation( + for: category, + risk: findings[index].risk, + fileAge: findings[index].fileAge + ) + } + } + } + private static var defaultCleanScriptURL: URL { MoleRuntimeLocator.url(for: "bin/clean.sh") } diff --git a/Packages/AtlasCoreAdapters/Tests/AtlasCoreAdaptersTests/MoleSmartCleanAdapterTests.swift b/Packages/AtlasCoreAdapters/Tests/AtlasCoreAdaptersTests/MoleSmartCleanAdapterTests.swift index e2f027353..ed10ff67c 100644 --- a/Packages/AtlasCoreAdapters/Tests/AtlasCoreAdaptersTests/MoleSmartCleanAdapterTests.swift +++ b/Packages/AtlasCoreAdapters/Tests/AtlasCoreAdaptersTests/MoleSmartCleanAdapterTests.swift @@ -1,18 +1,21 @@ +import AtlasDomain import XCTest @testable import AtlasCoreAdapters final class MoleSmartCleanAdapterTests: XCTestCase { + // MARK: - Existing tests (enriched with additional assertions) + func testParseFindingsBuildsStructuredSmartCleanItems() { let sample = """ ➤ Browsers → Chrome old versions, 2 dirs, 1.37GB dry - + ➤ Developer tools → npm cache · would clean → Xcode runtime volumes · 2 unused, 1 in use • Runtime volumes total: 3.50GB (unused 2.25GB, in-use 1.25GB) → JetBrains Toolbox · would remove 3 old versions (4.00GB), keeping 1 most recent - + ➤ Orphaned data → Would remove 4 orphaned launch agent(s), 12MB """ @@ -46,4 +49,409 @@ Developer tools /Users/test/Library/Containers/com.example.preview/Data/Library/ XCTAssertTrue(findings.contains(where: { $0.title == "com.example.preview container cache" && ($0.targetPaths?.first?.contains("/Data/Library/Caches") ?? false) })) XCTAssertTrue(findings.contains(where: { $0.title == "com.example.preview container logs" && ($0.targetPaths?.first?.contains("/Data/Library/Logs") ?? false) })) } + + // MARK: - Enriched TSV parsing with risk and category columns + + func testParseDetailedFindingsWithEnrichedRiskAndCategoryColumns() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let dateFormatter = ISO8601DateFormatter() + let accessedDate = dateFormatter.string(from: Date().addingTimeInterval(-90 * 24 * 3600)) + let createdDate = dateFormatter.string(from: Date().addingTimeInterval(-365 * 24 * 3600)) + + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 safe developerArtifact \(accessedDate) \(createdDate) +Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512 safe browserData \(createdDate) +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectB 2048 review developerArtifact \(accessedDate) +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + // DerivedData entries should be grouped together + let derivedData = findings.first(where: { $0.title == "Xcode DerivedData" }) + XCTAssertNotNil(derivedData) + XCTAssertEqual(derivedData?.risk, .safe) + XCTAssertEqual(derivedData?.storageCategory, .developerArtifact) + XCTAssertNotNil(derivedData?.fileAge) + XCTAssertNotNil(derivedData?.explanation) + XCTAssertFalse(derivedData?.explanation?.isEmpty ?? true) + XCTAssertEqual(derivedData?.bytes, (1024 + 2048) * 1024) + + let chromeCache = findings.first(where: { $0.title == "Chrome cache" }) + XCTAssertNotNil(chromeCache) + XCTAssertEqual(chromeCache?.risk, .safe) + XCTAssertEqual(chromeCache?.storageCategory, .browserData) + XCTAssertNotNil(chromeCache?.fileAge) + // Chrome entry has no lastAccessed but has createdDate + XCTAssertNil(chromeCache?.fileAge?.lastAccessedDate) + XCTAssertNotNil(chromeCache?.fileAge?.creationDate) + XCTAssertNotNil(chromeCache?.explanation) + } + + func testParseDetailedFindingsWithPartialEnrichedColumns() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + // Only risk level column provided (4 columns), no category or dates + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 review +Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512 safe +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + let derivedData = findings.first(where: { $0.title == "Xcode DerivedData" }) + XCTAssertNotNil(derivedData) + XCTAssertEqual(derivedData?.risk, .review) + // storageCategory falls back to path-based classification + XCTAssertEqual(derivedData?.storageCategory, .developerArtifact) + XCTAssertNotNil(derivedData?.explanation) + + let chrome = findings.first(where: { $0.title == "Chrome cache" }) + XCTAssertNotNil(chrome) + XCTAssertEqual(chrome?.risk, .safe) + XCTAssertEqual(chrome?.storageCategory, .browserData) + } + + // MARK: - Fallback parsing when enriched fields are missing + + func testParseDetailedFindingsFallbackWithoutEnrichedColumns() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + // Classic 3-column TSV — no risk, category, or date columns + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 +Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512 +Developer tools /Users/test/Library/pnpm/store/v3/files/pkg.tgz 256 +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + // Risk levels should be resolved via fallback logic + let derivedData = findings.first(where: { $0.title == "Xcode DerivedData" }) + XCTAssertNotNil(derivedData) + // DerivedData is in Developer tools section — fallback should assign .safe + XCTAssertEqual(derivedData?.risk, .safe) + // storageCategory should be resolved via path-based classification + XCTAssertEqual(derivedData?.storageCategory, .developerArtifact) + // fileAge should be nil since no date columns provided + XCTAssertNil(derivedData?.fileAge) + // Explanation should still be generated from fallback values + XCTAssertNotNil(derivedData?.explanation) + XCTAssertFalse(derivedData?.explanation?.isEmpty ?? true) + + let chrome = findings.first(where: { $0.title == "Chrome cache" }) + XCTAssertNotNil(chrome) + XCTAssertEqual(chrome?.storageCategory, .browserData) + XCTAssertNil(chrome?.fileAge) + + let pnpm = findings.first(where: { $0.title == "pnpm store" }) + XCTAssertNotNil(pnpm) + XCTAssertEqual(pnpm?.storageCategory, .developerArtifact) + } + + func testParseFindingsFallbackPopulatesExplanationAndStorageCategory() { + let sample = """ + ➤ Developer tools + → npm cache · would clean + + ➤ Browsers + → Chrome old versions, 2 dirs, 1.37GB dry + + ➤ Orphaned data + → Would remove 4 orphaned launch agent(s), 12MB + """ + + let findings = MoleSmartCleanAdapter.parseFindings(from: sample) + + let npm = findings.first(where: { $0.title == "npm cache" }) + XCTAssertNotNil(npm) + XCTAssertEqual(npm?.risk, .safe) + XCTAssertEqual(npm?.storageCategory, .developerArtifact) + XCTAssertNotNil(npm?.explanation) + XCTAssertFalse(npm?.explanation?.isEmpty ?? true) + + let chrome = findings.first(where: { $0.title == "Chrome old versions" }) + XCTAssertNotNil(chrome) + // storageCategory falls back to .systemCache when title alone doesn't match path patterns + XCTAssertNotNil(chrome?.storageCategory) + XCTAssertNotNil(chrome?.explanation) + + let launchAgent = findings.first(where: { $0.title.contains("orphaned launch agent") }) + XCTAssertNotNil(launchAgent) + XCTAssertEqual(launchAgent?.risk, .advanced) + XCTAssertNotNil(launchAgent?.explanation) + } + + // MARK: - File age extraction from TSV date columns + + func testFileAgeExtractionFromEnrichedTSV() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let dateFormatter = ISO8601DateFormatter() + let lastAccessed = Date().addingTimeInterval(-180 * 24 * 3600) // 6 months ago + let created = Date().addingTimeInterval(-730 * 24 * 3600) // 2 years ago + + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 safe developerArtifact \(dateFormatter.string(from: lastAccessed)) \(dateFormatter.string(from: created)) +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + XCTAssertEqual(findings.count, 1) + + let finding = findings[0] + XCTAssertNotNil(finding.fileAge) + XCTAssertNotNil(finding.fileAge?.lastAccessedDate) + XCTAssertNotNil(finding.fileAge?.creationDate) + + // Verify dates are approximately correct (within 60 seconds tolerance) + let accessedDelta = abs(finding.fileAge!.lastAccessedDate!.timeIntervalSinceNow - lastAccessed.timeIntervalSinceNow) + XCTAssertLessThan(accessedDelta, 60) + let createdDelta = abs(finding.fileAge!.creationDate!.timeIntervalSinceNow - created.timeIntervalSinceNow) + XCTAssertLessThan(createdDelta, 60) + } + + func testFileAgeParsingWithEmptyDateColumns() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + // 7 columns but date columns are empty strings + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 safe developerArtifact +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + XCTAssertEqual(findings.count, 1) + XCTAssertNil(findings[0].fileAge) + } + + func testFileAgeParsingWithOnlyLastAccessedDate() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let dateFormatter = ISO8601DateFormatter() + let accessed = dateFormatter.string(from: Date().addingTimeInterval(-30 * 24 * 3600)) + + // 6 columns: section, path, size, risk, category, lastAccessed (no createdDate) + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 safe developerArtifact \(accessed) +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + XCTAssertEqual(findings.count, 1) + XCTAssertNotNil(findings[0].fileAge) + XCTAssertNotNil(findings[0].fileAge?.lastAccessedDate) + XCTAssertNil(findings[0].fileAge?.creationDate) + } + + // MARK: - Storage category classification via path patterns + + func testStorageCategoryClassificationViaDetailedFindings() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + try """ +Unknown /Users/test/Library/Caches/com.app/snapshot.db 100 +Unknown /Users/test/Library/Logs/system.log 200 +Unknown /Users/test/Library/Mail/Attachments/msg.eml 300 +Unknown /Users/test/Downloads/installer.pkg 400 +Unknown /Users/test/Library/Application Support/com.app/data.db 500 +Unknown /Users/test/Library/Google/Chrome/Default/Cache_Data/index 600 +Unknown /Users/test/Backup/old_backup.tar 700 +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + // systemCache: /Library/Caches/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .systemCache })) + // logFile: .log extension or /Library/Logs/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .logFile })) + // mailAttachment: /Library/Mail/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .mailAttachment })) + // downloadArtifact: /Downloads/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .downloadArtifact })) + // appCache: /Library/Application Support/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .appCache })) + // browserData: /Google/Chrome/ path + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .browserData })) + // oldBackup: /Backup/ path contains "backup" + XCTAssertTrue(findings.contains(where: { $0.storageCategory == .oldBackup })) + } + + // MARK: - FindingAggregate computation + + func testAggregatesByRiskReturnsAllRiskLevels() { + let findings: [Finding] = [ + Finding(title: "A", detail: "", bytes: 1024, risk: .safe, category: "Test"), + Finding(title: "B", detail: "", bytes: 2048, risk: .safe, category: "Test"), + Finding(title: "C", detail: "", bytes: 512, risk: .review, category: "Test"), + Finding(title: "D", detail: "", bytes: 4096, risk: .advanced, category: "Test"), + ] + + let aggregates = findings.aggregatesByRisk() + XCTAssertEqual(aggregates.count, 3) + + let safeAgg = aggregates.first(where: { $0.risk == .safe }) + XCTAssertEqual(safeAgg?.totalBytes, 1024 + 2048) + XCTAssertEqual(safeAgg?.count, 2) + + let reviewAgg = aggregates.first(where: { $0.risk == .review }) + XCTAssertEqual(reviewAgg?.totalBytes, 512) + XCTAssertEqual(reviewAgg?.count, 1) + + let advancedAgg = aggregates.first(where: { $0.risk == .advanced }) + XCTAssertEqual(advancedAgg?.totalBytes, 4096) + XCTAssertEqual(advancedAgg?.count, 1) + } + + func testAggregatesByRiskWithEmptyFindings() { + let findings: [Finding] = [] + let aggregates = findings.aggregatesByRisk() + + XCTAssertEqual(aggregates.count, 3) + for agg in aggregates { + XCTAssertEqual(agg.totalBytes, 0) + XCTAssertEqual(agg.count, 0) + } + } + + func testAggregatesByRiskWithSingleRiskLevel() { + let findings: [Finding] = [ + Finding(title: "A", detail: "", bytes: 100, risk: .safe, category: "Test"), + Finding(title: "B", detail: "", bytes: 200, risk: .safe, category: "Test"), + Finding(title: "C", detail: "", bytes: 300, risk: .safe, category: "Test"), + ] + + let aggregates = findings.aggregatesByRisk() + + let safeAgg = aggregates.first(where: { $0.risk == .safe }) + XCTAssertEqual(safeAgg?.totalBytes, 600) + XCTAssertEqual(safeAgg?.count, 3) + + let reviewAgg = aggregates.first(where: { $0.risk == .review }) + XCTAssertEqual(reviewAgg?.totalBytes, 0) + XCTAssertEqual(reviewAgg?.count, 0) + + let advancedAgg = aggregates.first(where: { $0.risk == .advanced }) + XCTAssertEqual(advancedAgg?.totalBytes, 0) + XCTAssertEqual(advancedAgg?.count, 0) + } + + // MARK: - Grouped by storage category + + func testGroupedByStorageCategory() { + let findings: [Finding] = [ + Finding(title: "A", detail: "", bytes: 100, risk: .safe, category: "Test", storageCategory: .systemCache), + Finding(title: "B", detail: "", bytes: 200, risk: .safe, category: "Test", storageCategory: .systemCache), + Finding(title: "C", detail: "", bytes: 300, risk: .review, category: "Test", storageCategory: .developerArtifact), + Finding(title: "D", detail: "", bytes: 400, risk: .advanced, category: "Test", storageCategory: .browserData), + Finding(title: "E", detail: "", bytes: 500, risk: .safe, category: "Test"), + ] + + let grouped = findings.groupedByStorageCategory() + + XCTAssertEqual(grouped["systemCache"]?.count, 2) + XCTAssertEqual(grouped["developerArtifact"]?.count, 1) + XCTAssertEqual(grouped["browserData"]?.count, 1) + XCTAssertEqual(grouped["uncategorized"]?.count, 1) + XCTAssertEqual(grouped["uncategorized"]?.first?.title, "E") + } + + func testGroupedByStorageCategoryAllUncategorized() { + let findings: [Finding] = [ + Finding(title: "A", detail: "", bytes: 100, risk: .safe, category: "Test"), + Finding(title: "B", detail: "", bytes: 200, risk: .safe, category: "Test"), + ] + + let grouped = findings.groupedByStorageCategory() + XCTAssertEqual(grouped.count, 1) + XCTAssertEqual(grouped["uncategorized"]?.count, 2) + } + + func testGroupedByStorageCategoryEmpty() { + let findings: [Finding] = [] + let grouped = findings.groupedByStorageCategory() + XCTAssertTrue(grouped.isEmpty) + } + + // MARK: - Mixed enriched and non-enriched rows + + func testParseDetailedFindingsWithMixedEnrichedAndBasicRows() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let dateFormatter = ISO8601DateFormatter() + let accessedDate = dateFormatter.string(from: Date().addingTimeInterval(-60 * 24 * 3600)) + + // Row 1: fully enriched (7 columns) + // Row 2: basic (3 columns) + // Row 3: partial (4 columns — risk only) + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 safe developerArtifact \(accessedDate) +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectB 2048 +Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512 review +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + // Both DerivedData rows should be grouped together + let derivedData = findings.first(where: { $0.title == "Xcode DerivedData" }) + XCTAssertNotNil(derivedData) + // First entry provided enrichment; second is basic — group should inherit from first + XCTAssertEqual(derivedData?.storageCategory, .developerArtifact) + XCTAssertEqual(derivedData?.risk, .safe) + XCTAssertNotNil(derivedData?.fileAge) + XCTAssertEqual(derivedData?.bytes, (1024 + 2048) * 1024) + + let chrome = findings.first(where: { $0.title == "Chrome cache" }) + XCTAssertNotNil(chrome) + XCTAssertEqual(chrome?.risk, .review) + // Fallback classification via path + XCTAssertEqual(chrome?.storageCategory, .browserData) + } + + // MARK: - Explanation generation for parsed findings + + func testDetailedFindingsGenerateNonEmptyExplanations() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension("tsv") + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + try """ +Developer tools /Users/test/Library/Developer/Xcode/DerivedData/ProjectA 1024 +Browsers /Users/test/Library/Caches/Google/Chrome/Default/Cache_Data 512 +Unknown /Users/test/Library/Logs/system.log 256 +""".write(to: fileURL, atomically: true, encoding: .utf8) + + let findings = MoleSmartCleanAdapter.parseDetailedFindings(from: fileURL) + + for finding in findings { + XCTAssertNotNil(finding.explanation, "Finding '\(finding.title)' should have an explanation") + XCTAssertFalse(finding.explanation?.isEmpty ?? true, "Finding '\(finding.title)' explanation should not be empty") + } + } + + func testParseFindingsGenerateNonEmptyExplanations() { + let sample = """ + ➤ Developer tools + → npm cache · would clean + → Xcode runtime volumes · 2 unused, 1 in use + • Runtime volumes total: 3.50GB (unused 2.25GB, in-use 1.25GB) + + ➤ Browsers + → Chrome old versions, 2 dirs, 1.37GB dry + + ➤ Orphaned data + → Would remove 4 orphaned launch agent(s), 12MB + """ + + let findings = MoleSmartCleanAdapter.parseFindings(from: sample) + + for finding in findings { + XCTAssertNotNil(finding.explanation, "Finding '\(finding.title)' should have an explanation") + XCTAssertFalse(finding.explanation?.isEmpty ?? true, "Finding '\(finding.title)' explanation should not be empty") + } + } } diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift index 01c9844ee..f4b3d99a1 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift +++ b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift @@ -137,6 +137,31 @@ public enum RiskLevel: String, CaseIterable, Codable, Hashable, Sendable { } } +public struct FileAgeInfo: Codable, Hashable, Sendable { + public var lastAccessedDate: Date? + public var creationDate: Date? + + public init( + lastAccessedDate: Date? = nil, + creationDate: Date? = nil + ) { + self.lastAccessedDate = lastAccessedDate + self.creationDate = creationDate + } +} + +public struct FindingAggregate: Codable, Hashable, Sendable { + public var risk: RiskLevel + public var totalBytes: Int64 + public var count: Int + + public init(risk: RiskLevel, totalBytes: Int64, count: Int) { + self.risk = risk + self.totalBytes = totalBytes + self.count = count + } +} + public struct Finding: Identifiable, Codable, Hashable, Sendable { public var id: UUID public var title: String @@ -145,6 +170,9 @@ public struct Finding: Identifiable, Codable, Hashable, Sendable { public var risk: RiskLevel public var category: String public var targetPaths: [String]? + public var explanation: String? + public var fileAge: FileAgeInfo? + public var storageCategory: AtlasStorageCategory? public init( id: UUID = UUID(), @@ -153,7 +181,10 @@ public struct Finding: Identifiable, Codable, Hashable, Sendable { bytes: Int64, risk: RiskLevel, category: String, - targetPaths: [String]? = nil + targetPaths: [String]? = nil, + explanation: String? = nil, + fileAge: FileAgeInfo? = nil, + storageCategory: AtlasStorageCategory? = nil ) { self.id = id self.title = title @@ -162,6 +193,32 @@ public struct Finding: Identifiable, Codable, Hashable, Sendable { self.risk = risk self.category = category self.targetPaths = targetPaths + self.explanation = explanation + self.fileAge = fileAge + self.storageCategory = storageCategory + } +} + +// MARK: - Finding Aggregation + +extension Array where Element == Finding { + /// Computes aggregate summaries grouped by risk level. + /// Returns one ``FindingAggregate`` per ``RiskLevel`` case, including + /// levels with zero findings so the UI can display complete summaries. + public func aggregatesByRisk() -> [FindingAggregate] { + RiskLevel.allCases.map { risk in + let matching = filter { $0.risk == risk } + let totalBytes = matching.reduce(Int64(0)) { $0 + $1.bytes } + return FindingAggregate(risk: risk, totalBytes: totalBytes, count: matching.count) + } + } + + /// Groups findings by their storage category. + /// Findings with a `nil` storageCategory are placed under the key "uncategorized". + public func groupedByStorageCategory() -> [String: [Finding]] { + Dictionary(grouping: self) { finding in + finding.storageCategory?.rawValue ?? "uncategorized" + } } } diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasFindingExplanations.swift b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasFindingExplanations.swift new file mode 100644 index 000000000..a83015056 --- /dev/null +++ b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasFindingExplanations.swift @@ -0,0 +1,167 @@ +import Foundation + +/// Generates human-readable explanations for why scan findings are recommended for cleanup. +/// +/// Each explanation is composed from a storage category template, risk level assessment, +/// and optional file age metadata. All strings are localized through ``AtlasL10n``. +public enum AtlasFindingExplanations { + + // MARK: - Public API + + /// Generates a human-readable explanation for why a finding is recommended for cleanup. + /// + /// - Parameters: + /// - category: The storage category of the finding (e.g., system cache, developer artifact). + /// - risk: The risk level of the finding (safe, review, or advanced). + /// - fileAge: Optional file age metadata for additional context. + /// - Returns: A localized explanation string describing why this finding is recommended for cleanup. + public static func explanation( + for category: AtlasStorageCategory, + risk: RiskLevel, + fileAge: FileAgeInfo? = nil + ) -> String { + buildExplanation(for: category, risk: risk, fileAge: fileAge, language: nil) + } + + /// Returns the explanation for a finding in the specified language. + /// + /// Uses the finding's own ``Finding/storageCategory``, ``Finding/risk``, and + /// ``Finding/fileAge`` fields to compose the explanation. Falls back to + /// ``AtlasStorageCategory/systemCache`` when the finding has no explicit category. + /// + /// - Parameters: + /// - finding: The finding to explain. + /// - language: The target language for the explanation. + /// - Returns: A localized explanation string in the requested language. + public static func localizedExplanation( + for finding: Finding, + language: AtlasLanguage + ) -> String { + let category = finding.storageCategory ?? .systemCache + return buildExplanation( + for: category, + risk: finding.risk, + fileAge: finding.fileAge, + language: language + ) + } + + /// Converts file age information into a human-readable descriptor. + /// + /// Produces strings like "not used in 6 months", "created 2 years ago", + /// or "not used in 30 days". Uses the last accessed date when available, + /// falling back to creation date. + /// + /// - Parameter fileAge: The file age metadata containing last accessed and creation dates. + /// - Returns: A localized human-readable age descriptor, or an empty string if no dates are available. + public static func ageDescriptor(from fileAge: FileAgeInfo) -> String { + formattedAgeDescriptor(from: fileAge, language: nil) + } + + // MARK: - Internal + + private static let calendar = Calendar.current + + private static func buildExplanation( + for category: AtlasStorageCategory, + risk: RiskLevel, + fileAge: FileAgeInfo?, + language: AtlasLanguage? + ) -> String { + let key = explanationKey(for: category, risk: risk) + let base = AtlasL10n.string(key, language: language) + + guard let fileAge = fileAge else { + return base + } + + let age = formattedAgeDescriptor(from: fileAge, language: language) + guard !age.isEmpty else { + return base + } + + return AtlasL10n.string("explanation.withAge", language: language, base, age) + } + + private static func explanationKey( + for category: AtlasStorageCategory, + risk: RiskLevel + ) -> String { + "explanation.\(category.rawValue).\(risk.rawValue)" + } + + private static func formattedAgeDescriptor( + from fileAge: FileAgeInfo, + language: AtlasLanguage? + ) -> String { + let now = Date() + + if let lastAccessed = fileAge.lastAccessedDate { + let components = calendar.dateComponents([.day], from: lastAccessed, to: now) + let days = max(components.day ?? 0, 0) + if days > 0 { + return lastAccessedDescriptor(days: days, language: language) + } + } + + if let creationDate = fileAge.creationDate { + let components = calendar.dateComponents([.day], from: creationDate, to: now) + let days = max(components.day ?? 0, 0) + if days > 0 { + return createdAgoDescriptor(days: days, language: language) + } + } + + return "" + } + + private static func lastAccessedDescriptor( + days: Int, + language: AtlasLanguage? + ) -> String { + let years = days / 365 + let months = days / 30 + + if years >= 2 { + let period = AtlasL10n.string("fileage.years", language: language, years) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } else if years >= 1 { + let period = AtlasL10n.string("fileage.year", language: language) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } else if months >= 6 { + let period = AtlasL10n.string("fileage.months", language: language, 6) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } else if months >= 3 { + let period = AtlasL10n.string("fileage.months", language: language, 3) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } else if months >= 1 { + let period = AtlasL10n.string("fileage.month", language: language) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } else { + let period = AtlasL10n.string("fileage.days", language: language, max(days, 1)) + return AtlasL10n.string("fileage.notUsedIn", language: language, period) + } + } + + private static func createdAgoDescriptor( + days: Int, + language: AtlasLanguage? + ) -> String { + let years = days / 365 + let months = days / 30 + + if years >= 2 { + return AtlasL10n.string("fileage.yearsAgo", language: language, years) + } else if years >= 1 { + return AtlasL10n.string("fileage.yearAgo", language: language) + } else if months >= 6 { + return AtlasL10n.string("fileage.monthsAgo", language: language, 6) + } else if months >= 3 { + return AtlasL10n.string("fileage.monthsAgo", language: language, 3) + } else if months >= 1 { + return AtlasL10n.string("fileage.monthAgo", language: language) + } else { + return AtlasL10n.string("fileage.daysAgo", language: language, max(days, 1)) + } + } +} diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasStorageCategory.swift b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasStorageCategory.swift new file mode 100644 index 000000000..142ca018f --- /dev/null +++ b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasStorageCategory.swift @@ -0,0 +1,54 @@ +import Foundation + +public enum AtlasStorageCategory: String, CaseIterable, Codable, Hashable, Sendable { + case systemCache + case appCache + case developerArtifact + case browserData + case logFile + case downloadArtifact + case mailAttachment + case oldBackup + + public var title: String { + switch self { + case .systemCache: + return AtlasL10n.string("storageCategory.systemCache") + case .appCache: + return AtlasL10n.string("storageCategory.appCache") + case .developerArtifact: + return AtlasL10n.string("storageCategory.developerArtifact") + case .browserData: + return AtlasL10n.string("storageCategory.browserData") + case .logFile: + return AtlasL10n.string("storageCategory.logFile") + case .downloadArtifact: + return AtlasL10n.string("storageCategory.downloadArtifact") + case .mailAttachment: + return AtlasL10n.string("storageCategory.mailAttachment") + case .oldBackup: + return AtlasL10n.string("storageCategory.oldBackup") + } + } + + public var systemImage: String { + switch self { + case .systemCache: + return "gearshape.2" + case .appCache: + return "square.grid.2x2" + case .developerArtifact: + return "hammer" + case .browserData: + return "globe" + case .logFile: + return "doc.text" + case .downloadArtifact: + return "arrow.down.circle" + case .mailAttachment: + return "envelope" + case .oldBackup: + return "externaldrive" + } + } +} diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings index 314959e74..00e044024 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings @@ -351,6 +351,13 @@ "smartclean.expectation.review" = "Review before removal"; "smartclean.expectation.advanced" = "Inspect carefully"; +"smartclean.summary.totalReclaimable" = "Total reclaimable space"; +"smartclean.summary.findingCount" = "%d findings"; +"smartclean.summary.safeSegment" = "%@ can be safely cleaned"; +"smartclean.summary.reviewSegment" = "%@ requires review"; +"smartclean.summary.advancedSegment" = "%@ needs advanced inspection"; +"smartclean.summary.empty" = "No reclaimable space found"; + "apps.screen.title" = "Apps"; "apps.screen.subtitle" = "Review each app's footprint, leftovers, and recovery path before you uninstall it."; "apps.callout.default.title" = "Review the uninstall plan before you remove anything"; @@ -712,3 +719,112 @@ "update.error.title" = "Update check failed"; "update.error.detail" = "Could not reach the update server. Please try again later."; "update.error.requestFailed" = "Could not connect to the GitHub release server."; + +// MARK: - Storage Categories + +"storageCategory.systemCache" = "System Cache"; +"storageCategory.appCache" = "App Cache"; +"storageCategory.developerArtifact" = "Developer Artifacts"; +"storageCategory.browserData" = "Browser Data"; +"storageCategory.logFile" = "Log Files"; +"storageCategory.downloadArtifact" = "Downloads"; +"storageCategory.mailAttachment" = "Mail Attachments"; +"storageCategory.oldBackup" = "Old Backups"; + +// MARK: - Explanation Templates (per StorageCategory × RiskLevel) + +"explanation.systemCache.safe" = "System cache files can be safely removed. They are automatically regenerated as needed."; +"explanation.systemCache.review" = "These system cache files should be reviewed before removal to avoid unexpected behavior."; +"explanation.systemCache.advanced" = "These system cache files are in protected locations and require careful inspection before cleanup."; + +"explanation.appCache.safe" = "Application cache files are safe to clean and will be recreated when the apps need them."; +"explanation.appCache.review" = "These application cache files should be reviewed to ensure the associated app is no longer needed."; +"explanation.appCache.advanced" = "These application cache files belong to system-critical apps and require advanced review."; + +"explanation.developerArtifact.safe" = "Developer build artifacts and indexes can be safely removed. They regenerate on the next build."; +"explanation.developerArtifact.review" = "These developer artifacts should be reviewed before removal to confirm they are no longer needed."; +"explanation.developerArtifact.advanced" = "These developer artifacts may be in use by active projects and require careful review."; + +"explanation.browserData.safe" = "Browser cache files are safe to remove and have low recovery risk."; +"explanation.browserData.review" = "These browser data files should be reviewed to ensure no important sessions or data are lost."; +"explanation.browserData.advanced" = "These browser data files include sensitive data and require careful inspection."; + +"explanation.logFile.safe" = "Log files can be safely removed to reclaim space. New logs are created automatically."; +"explanation.logFile.review" = "These log files should be reviewed before removal in case they contain useful diagnostic information."; +"explanation.logFile.advanced" = "These log files belong to system processes and require advanced inspection."; + +"explanation.downloadArtifact.safe" = "Downloaded files that have not been used recently can be safely archived or removed."; +"explanation.downloadArtifact.review" = "These download artifacts should be reviewed to confirm they are no longer needed."; +"explanation.downloadArtifact.advanced" = "These download artifacts may contain important installers or documents and require careful review."; + +"explanation.mailAttachment.safe" = "Cached mail attachments can be safely removed. The originals remain on the mail server."; +"explanation.mailAttachment.review" = "These mail attachment caches should be reviewed to ensure the attachments are still accessible."; +"explanation.mailAttachment.advanced" = "These mail attachment files may be the only local copy and require careful review before removal."; + +"explanation.oldBackup.safe" = "Old backup files that have been superseded can be safely removed."; +"explanation.oldBackup.review" = "These backup files should be reviewed to confirm they are no longer needed for recovery."; +"explanation.oldBackup.advanced" = "These backup files may be critical for system recovery and require advanced inspection."; + +// MARK: - Explanation with Age Context + +"explanation.withAge" = "%@ (%@)"; + +// MARK: - File Age Descriptors + +"fileage.notUsedIn" = "not used in %@"; +"fileage.years" = "%d years"; +"fileage.year" = "1 year"; +"fileage.months" = "%d months"; +"fileage.month" = "1 month"; +"fileage.days" = "%d days"; +"fileage.yearsAgo" = "%d years ago"; +"fileage.yearAgo" = "1 year ago"; +"fileage.monthsAgo" = "%d months ago"; +"fileage.monthAgo" = "1 month ago"; +"fileage.daysAgo" = "%d days ago"; + +// MARK: - Smart Clean Risk Level Descriptions + +"smartclean.risk.safe.description" = "Safe to remove without review. These items are fully recoverable through History."; +"smartclean.risk.review.description" = "Worth reviewing before removal. Some items may need your judgment."; +"smartclean.risk.advanced.description" = "Requires advanced inspection. These items may affect system behavior if removed."; + +// MARK: - Smart Clean Explanation Templates + +"smartclean.explanation.systemCache" = "System cache files that can be regenerated automatically. %@"; +"smartclean.explanation.appCache" = "Application cache files that will be recreated when needed. %@"; +"smartclean.explanation.developerArtifact" = "Developer build artifacts and indexes from older projects. %@"; +"smartclean.explanation.browserData" = "Browser cache and data files with low recovery risk. %@"; +"smartclean.explanation.logFile" = "Log files that can be safely removed to reclaim space. %@"; +"smartclean.explanation.downloadArtifact" = "Downloaded files that have not been used recently. %@"; +"smartclean.explanation.mailAttachment" = "Cached mail attachments. The originals remain on the mail server. %@"; +"smartclean.explanation.oldBackup" = "Old backup files that have been superseded by newer versions. %@"; + +// MARK: - Smart Clean File Age Labels + +"smartclean.fileage.lastAccessed" = "Last accessed %@"; +"smartclean.fileage.created" = "Created %@"; +"smartclean.fileage.notUsedIn" = "Not used in %@"; + +// MARK: - Smart Clean Aggregate Summary + +"smartclean.aggregate.title" = "Cleanup Summary"; +"smartclean.aggregate.safelyCleanable" = "%@ can be safely cleaned"; +"smartclean.aggregate.needsReview" = "%@ requires review"; +"smartclean.aggregate.requiresAdvanced" = "%@ needs advanced inspection"; + +// MARK: - Smart Clean Category Display Names + +"smartclean.category.systemCache" = "System Cache"; +"smartclean.category.appCache" = "App Cache"; +"smartclean.category.developerArtifact" = "Developer Artifacts"; +"smartclean.category.browserData" = "Browser Data"; +"smartclean.category.logFile" = "Log Files"; +"smartclean.category.downloadArtifact" = "Downloads"; +"smartclean.category.mailAttachment" = "Mail Attachments"; +"smartclean.category.oldBackup" = "Old Backups"; + +// MARK: - Finding Row + +"smartclean.finding.expand" = "Show more"; +"smartclean.finding.collapse" = "Show less"; diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings index 418ced254..8f5299067 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings @@ -351,6 +351,13 @@ "smartclean.expectation.review" = "删除前先复核"; "smartclean.expectation.advanced" = "建议谨慎检查"; +"smartclean.summary.totalReclaimable" = "预计可释放空间"; +"smartclean.summary.findingCount" = "%d 个发现项"; +"smartclean.summary.safeSegment" = "%@ 可安全清理"; +"smartclean.summary.reviewSegment" = "%@ 需要复核"; +"smartclean.summary.advancedSegment" = "%@ 需高级检查"; +"smartclean.summary.empty" = "未发现可释放空间"; + "apps.screen.title" = "应用"; "apps.screen.subtitle" = "先检查每个应用的占用、残留和恢复路径,再决定是否卸载。"; "apps.callout.default.title" = "先复核卸载计划,再决定是否移除"; @@ -712,3 +719,112 @@ "update.error.title" = "检查更新失败"; "update.error.detail" = "无法连接到更新服务器,请稍后再试。"; "update.error.requestFailed" = "无法连接到 GitHub 更新服务器。"; + +// MARK: - Storage Categories + +"storageCategory.systemCache" = "系统缓存"; +"storageCategory.appCache" = "应用缓存"; +"storageCategory.developerArtifact" = "开发者产物"; +"storageCategory.browserData" = "浏览器数据"; +"storageCategory.logFile" = "日志文件"; +"storageCategory.downloadArtifact" = "下载文件"; +"storageCategory.mailAttachment" = "邮件附件"; +"storageCategory.oldBackup" = "旧备份"; + +// MARK: - Explanation Templates (per StorageCategory × RiskLevel) + +"explanation.systemCache.safe" = "系统缓存文件可以安全清理。它们会在需要时自动重建。"; +"explanation.systemCache.review" = "这些系统缓存文件建议在删除前先复核,以避免意外行为。"; +"explanation.systemCache.advanced" = "这些系统缓存文件位于受保护路径下,需要仔细检查后再清理。"; + +"explanation.appCache.safe" = "应用缓存文件可以安全清理,应用需要时会自动重新创建。"; +"explanation.appCache.review" = "这些应用缓存文件建议先复核,确认关联应用已不再需要。"; +"explanation.appCache.advanced" = "这些应用缓存属于系统关键应用,需要高级审查。"; + +"explanation.developerArtifact.safe" = "开发者构建产物和索引可以安全清理。它们会在下次构建时自动重新生成。"; +"explanation.developerArtifact.review" = "这些开发者产物建议在删除前先复核,确认已不再需要。"; +"explanation.developerArtifact.advanced" = "这些开发者产物可能正在被活跃项目使用,需要仔细审查。"; + +"explanation.browserData.safe" = "浏览器缓存文件可以安全清理,恢复风险低。"; +"explanation.browserData.review" = "这些浏览器数据文件建议先复核,确保不会丢失重要的会话或数据。"; +"explanation.browserData.advanced" = "这些浏览器数据包含敏感信息,需要仔细检查。"; + +"explanation.logFile.safe" = "日志文件可以安全清理以释放空间。新日志会自动创建。"; +"explanation.logFile.review" = "这些日志文件建议在删除前先复核,可能包含有用的诊断信息。"; +"explanation.logFile.advanced" = "这些日志文件属于系统进程,需要高级检查。"; + +"explanation.downloadArtifact.safe" = "最近未使用的下载文件可以安全归档或清理。"; +"explanation.downloadArtifact.review" = "这些下载文件建议先复核,确认已不再需要。"; +"explanation.downloadArtifact.advanced" = "这些下载文件可能包含重要的安装器或文档,需要仔细审查。"; + +"explanation.mailAttachment.safe" = "邮件附件缓存可以安全清理。原件仍保留在邮件服务器上。"; +"explanation.mailAttachment.review" = "这些邮件附件缓存建议先复核,确保附件仍可访问。"; +"explanation.mailAttachment.advanced" = "这些邮件附件文件可能是唯一的本地副本,删除前需要仔细审查。"; + +"explanation.oldBackup.safe" = "已被取代的旧备份文件可以安全清理。"; +"explanation.oldBackup.review" = "这些备份文件建议先复核,确认已不再需要用于恢复。"; +"explanation.oldBackup.advanced" = "这些备份文件可能对系统恢复至关重要,需要高级检查。"; + +// MARK: - Explanation with Age Context + +"explanation.withAge" = "%@(%@)"; + +// MARK: - File Age Descriptors + +"fileage.notUsedIn" = "%@未使用"; +"fileage.years" = "%d 年"; +"fileage.year" = "1 年"; +"fileage.months" = "%d 个月"; +"fileage.month" = "1 个月"; +"fileage.days" = "%d 天"; +"fileage.yearsAgo" = "%d 年前"; +"fileage.yearAgo" = "1 年前"; +"fileage.monthsAgo" = "%d 个月前"; +"fileage.monthAgo" = "1 个月前"; +"fileage.daysAgo" = "%d 天前"; + +// MARK: - Smart Clean Risk Level Descriptions + +"smartclean.risk.safe.description" = "可以安全清理而无需复核。这些项目可通过历史记录完整恢复。"; +"smartclean.risk.review.description" = "建议删除前先复核。部分项目可能需要你的判断。"; +"smartclean.risk.advanced.description" = "需要高级检查。删除这些项目可能会影响系统行为。"; + +// MARK: - Smart Clean Explanation Templates + +"smartclean.explanation.systemCache" = "系统缓存文件,可以自动重建。%@"; +"smartclean.explanation.appCache" = "应用缓存文件,需要时会自动重新创建。%@"; +"smartclean.explanation.developerArtifact" = "来自旧项目的开发者构建产物和索引。%@"; +"smartclean.explanation.browserData" = "浏览器缓存和数据文件,恢复风险低。%@"; +"smartclean.explanation.logFile" = "日志文件,可以安全清理以释放空间。%@"; +"smartclean.explanation.downloadArtifact" = "最近未使用的下载文件。%@"; +"smartclean.explanation.mailAttachment" = "邮件附件缓存。原件仍保留在邮件服务器上。%@"; +"smartclean.explanation.oldBackup" = "已被新版本取代的旧备份文件。%@"; + +// MARK: - Smart Clean File Age Labels + +"smartclean.fileage.lastAccessed" = "上次访问于 %@"; +"smartclean.fileage.created" = "创建于 %@"; +"smartclean.fileage.notUsedIn" = "已 %@ 未使用"; + +// MARK: - Smart Clean Aggregate Summary + +"smartclean.aggregate.title" = "清理摘要"; +"smartclean.aggregate.safelyCleanable" = "%@ 可以安全清理"; +"smartclean.aggregate.needsReview" = "%@ 需要审查"; +"smartclean.aggregate.requiresAdvanced" = "%@ 需要高级检查"; + +// MARK: - Smart Clean Category Display Names + +"smartclean.category.systemCache" = "系统缓存"; +"smartclean.category.appCache" = "应用缓存"; +"smartclean.category.developerArtifact" = "开发者产物"; +"smartclean.category.browserData" = "浏览器数据"; +"smartclean.category.logFile" = "日志文件"; +"smartclean.category.downloadArtifact" = "下载文件"; +"smartclean.category.mailAttachment" = "邮件附件"; +"smartclean.category.oldBackup" = "旧备份"; + +// MARK: - Finding Row + +"smartclean.finding.expand" = "展开"; +"smartclean.finding.collapse" = "收起"; diff --git a/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift index f4efdfeb3..4b2d54d6e 100644 --- a/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift +++ b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasDomainTests.swift @@ -102,4 +102,223 @@ final class AtlasDomainTests: XCTestCase { XCTAssertEqual(reviewOnly.executionBoundary, .reviewOnly) } + // MARK: - Finding Initialization with New Fields + + func testFindingInitializesWithExplanationField() { + let finding = Finding( + title: "Xcode Derived Data", + detail: "Build artifacts from older projects", + bytes: 18_400_000_000, + risk: .safe, + category: "Developer", + explanation: "Safe to remove build artifacts" + ) + XCTAssertEqual(finding.explanation, "Safe to remove build artifacts") + XCTAssertNil(finding.fileAge) + XCTAssertNil(finding.storageCategory) + } + + func testFindingInitializesWithFileAgeField() { + let now = Date() + let fileAge = FileAgeInfo( + lastAccessedDate: now.addingTimeInterval(-90 * 86400), + creationDate: now.addingTimeInterval(-365 * 86400) + ) + let finding = Finding( + title: "Old Cache", + detail: "Cache not accessed in 90 days", + bytes: 500_000, + risk: .safe, + category: "System", + fileAge: fileAge + ) + XCTAssertNotNil(finding.fileAge) + XCTAssertNotNil(finding.fileAge?.lastAccessedDate) + XCTAssertNotNil(finding.fileAge?.creationDate) + XCTAssertNil(finding.explanation) + XCTAssertNil(finding.storageCategory) + } + + func testFindingInitializesWithStorageCategoryField() { + let finding = Finding( + title: "Browser Cache", + detail: "WebKit cache folder", + bytes: 4_800_000_000, + risk: .safe, + category: "Browsers", + storageCategory: .browserData + ) + XCTAssertEqual(finding.storageCategory, .browserData) + XCTAssertNil(finding.explanation) + XCTAssertNil(finding.fileAge) + } + + func testFindingInitializesWithAllNewFields() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-180 * 86400)) + let finding = Finding( + title: "System Cache", + detail: "System-level cache data", + bytes: 2_000_000, + risk: .review, + category: "System", + targetPaths: ["/Library/Caches/com.example"], + explanation: "Review before removal", + fileAge: fileAge, + storageCategory: .systemCache + ) + XCTAssertEqual(finding.explanation, "Review before removal") + XCTAssertNotNil(finding.fileAge) + XCTAssertEqual(finding.storageCategory, .systemCache) + XCTAssertEqual(finding.targetPaths?.count, 1) + } + + func testFindingDefaultsNewFieldsToNil() { + let finding = Finding( + title: "Basic", + detail: "Basic finding", + bytes: 100, + risk: .safe, + category: "System" + ) + XCTAssertNil(finding.explanation) + XCTAssertNil(finding.fileAge) + XCTAssertNil(finding.storageCategory) + XCTAssertNil(finding.targetPaths) + } + + // MARK: - AtlasStorageCategory + + func testStorageCategoryRawValues() { + XCTAssertEqual(AtlasStorageCategory.systemCache.rawValue, "systemCache") + XCTAssertEqual(AtlasStorageCategory.appCache.rawValue, "appCache") + XCTAssertEqual(AtlasStorageCategory.developerArtifact.rawValue, "developerArtifact") + XCTAssertEqual(AtlasStorageCategory.browserData.rawValue, "browserData") + XCTAssertEqual(AtlasStorageCategory.logFile.rawValue, "logFile") + XCTAssertEqual(AtlasStorageCategory.downloadArtifact.rawValue, "downloadArtifact") + XCTAssertEqual(AtlasStorageCategory.mailAttachment.rawValue, "mailAttachment") + XCTAssertEqual(AtlasStorageCategory.oldBackup.rawValue, "oldBackup") + } + + func testStorageCategoryCaseIterableCount() { + XCTAssertEqual(AtlasStorageCategory.allCases.count, 8) + } + + func testStorageCategoryTitlesAreLocalized() { + AtlasL10n.setCurrentLanguage(.en) + for category in AtlasStorageCategory.allCases { + XCTAssertFalse(category.title.isEmpty, "Title for \(category.rawValue) should not be empty") + } + AtlasL10n.setCurrentLanguage(.zhHans) + for category in AtlasStorageCategory.allCases { + XCTAssertFalse(category.title.isEmpty, "Title for \(category.rawValue) should not be empty in Chinese") + } + } + + func testStorageCategorySystemImages() { + for category in AtlasStorageCategory.allCases { + XCTAssertFalse(category.systemImage.isEmpty, "systemImage for \(category.rawValue) should not be empty") + } + } + + // MARK: - FileAgeInfo + + func testFileAgeInfoDefaultInit() { + let fileAge = FileAgeInfo() + XCTAssertNil(fileAge.lastAccessedDate) + XCTAssertNil(fileAge.creationDate) + } + + func testFileAgeInfoWithLastAccessedOnly() { + let date = Date().addingTimeInterval(-30 * 86400) + let fileAge = FileAgeInfo(lastAccessedDate: date) + XCTAssertEqual(fileAge.lastAccessedDate, date) + XCTAssertNil(fileAge.creationDate) + } + + func testFileAgeInfoWithCreationOnly() { + let date = Date().addingTimeInterval(-365 * 86400) + let fileAge = FileAgeInfo(creationDate: date) + XCTAssertNil(fileAge.lastAccessedDate) + XCTAssertEqual(fileAge.creationDate, date) + } + + func testFileAgeInfoWithBothDates() { + let accessed = Date().addingTimeInterval(-60 * 86400) + let created = Date().addingTimeInterval(-365 * 86400) + let fileAge = FileAgeInfo(lastAccessedDate: accessed, creationDate: created) + XCTAssertEqual(fileAge.lastAccessedDate, accessed) + XCTAssertEqual(fileAge.creationDate, created) + } + + func testFileAgeInfoIsHashable() { + let date = Date() + let a = FileAgeInfo(lastAccessedDate: date, creationDate: date) + let b = FileAgeInfo(lastAccessedDate: date, creationDate: date) + XCTAssertEqual(a, b) + XCTAssertEqual(a.hashValue, b.hashValue) + } + + func testFileAgeInfoIsCodable() throws { + let date = Date() + let original = FileAgeInfo(lastAccessedDate: date, creationDate: date) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(FileAgeInfo.self, from: data) + XCTAssertEqual(original, decoded) + } + + // MARK: - FindingAggregate + + func testFindingAggregateInit() { + let aggregate = FindingAggregate(risk: .safe, totalBytes: 1_000, count: 5) + XCTAssertEqual(aggregate.risk, .safe) + XCTAssertEqual(aggregate.totalBytes, 1_000) + XCTAssertEqual(aggregate.count, 5) + } + + func testAggregatesByRiskFromEmptyFindings() { + let findings: [Finding] = [] + let aggregates = findings.aggregatesByRisk() + XCTAssertEqual(aggregates.count, RiskLevel.allCases.count) + for aggregate in aggregates { + XCTAssertEqual(aggregate.totalBytes, 0) + XCTAssertEqual(aggregate.count, 0) + } + } + + func testAggregatesByRiskGroupsCorrectly() { + let findings: [Finding] = [ + Finding(title: "A", detail: "a", bytes: 100, risk: .safe, category: "System"), + Finding(title: "B", detail: "b", bytes: 200, risk: .safe, category: "System"), + Finding(title: "C", detail: "c", bytes: 500, risk: .review, category: "System"), + Finding(title: "D", detail: "d", bytes: 1_000, risk: .advanced, category: "System"), + ] + let aggregates = findings.aggregatesByRisk() + + let safeAgg = aggregates.first { $0.risk == .safe } + let reviewAgg = aggregates.first { $0.risk == .review } + let advancedAgg = aggregates.first { $0.risk == .advanced } + + XCTAssertEqual(safeAgg?.totalBytes, 300) + XCTAssertEqual(safeAgg?.count, 2) + XCTAssertEqual(reviewAgg?.totalBytes, 500) + XCTAssertEqual(reviewAgg?.count, 1) + XCTAssertEqual(advancedAgg?.totalBytes, 1_000) + XCTAssertEqual(advancedAgg?.count, 1) + } + + func testGroupedByStorageCategory() { + let findings: [Finding] = [ + Finding(title: "A", detail: "a", bytes: 100, risk: .safe, category: "System", storageCategory: .systemCache), + Finding(title: "B", detail: "b", bytes: 200, risk: .safe, category: "System", storageCategory: .systemCache), + Finding(title: "C", detail: "c", bytes: 300, risk: .review, category: "Developer", storageCategory: .developerArtifact), + Finding(title: "D", detail: "d", bytes: 400, risk: .safe, category: "System"), + ] + let grouped = findings.groupedByStorageCategory() + + XCTAssertEqual(grouped["systemCache"]?.count, 2) + XCTAssertEqual(grouped["developerArtifact"]?.count, 1) + XCTAssertEqual(grouped["uncategorized"]?.count, 1) + XCTAssertNil(grouped["browserData"]) + } + } diff --git a/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasFindingExplanationsTests.swift b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasFindingExplanationsTests.swift new file mode 100644 index 000000000..d6dd431ac --- /dev/null +++ b/Packages/AtlasDomain/Tests/AtlasDomainTests/AtlasFindingExplanationsTests.swift @@ -0,0 +1,281 @@ +import XCTest +@testable import AtlasDomain + +final class AtlasFindingExplanationsTests: XCTestCase { + override func setUp() { + super.setUp() + AtlasL10n.setCurrentLanguage(.zhHans) + } + + // MARK: - Explanation without File Age + + func testExplanationForSystemCacheSafe() { + let explanation = AtlasFindingExplanations.explanation( + for: .systemCache, + risk: .safe + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("系统缓存")) + } + + func testExplanationForSystemCacheReview() { + let explanation = AtlasFindingExplanations.explanation( + for: .systemCache, + risk: .review + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("系统缓存")) + } + + func testExplanationForSystemCacheAdvanced() { + let explanation = AtlasFindingExplanations.explanation( + for: .systemCache, + risk: .advanced + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("系统缓存")) + } + + func testExplanationForDeveloperArtifactSafe() { + let explanation = AtlasFindingExplanations.explanation( + for: .developerArtifact, + risk: .safe + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("开发者")) + } + + func testExplanationForBrowserDataReview() { + let explanation = AtlasFindingExplanations.explanation( + for: .browserData, + risk: .review + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("浏览器")) + } + + func testExplanationForAppCacheAdvanced() { + let explanation = AtlasFindingExplanations.explanation( + for: .appCache, + risk: .advanced + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("应用缓存")) + } + + func testExplanationForLogFileSafe() { + let explanation = AtlasFindingExplanations.explanation( + for: .logFile, + risk: .safe + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("日志")) + } + + func testExplanationForDownloadArtifactReview() { + let explanation = AtlasFindingExplanations.explanation( + for: .downloadArtifact, + risk: .review + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("下载")) + } + + func testExplanationForMailAttachmentSafe() { + let explanation = AtlasFindingExplanations.explanation( + for: .mailAttachment, + risk: .safe + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("邮件")) + } + + func testExplanationForOldBackupAdvanced() { + let explanation = AtlasFindingExplanations.explanation( + for: .oldBackup, + risk: .advanced + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("备份")) + } + + // MARK: - Explanation with File Age + + func testExplanationWithFileAgeAppendsAgeDescriptor() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-90 * 86400)) + let explanation = AtlasFindingExplanations.explanation( + for: .systemCache, + risk: .safe, + fileAge: fileAge + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertNotEqual(explanation, AtlasFindingExplanations.explanation(for: .systemCache, risk: .safe)) + } + + func testExplanationWithNilFileAgeReturnsBaseExplanation() { + let withAge = AtlasFindingExplanations.explanation( + for: .developerArtifact, + risk: .safe, + fileAge: nil + ) + let withoutAge = AtlasFindingExplanations.explanation( + for: .developerArtifact, + risk: .safe + ) + XCTAssertEqual(withAge, withoutAge) + } + + func testExplanationWithFileAgeWithNoDatesReturnsBaseExplanation() { + let fileAge = FileAgeInfo() + let explanation = AtlasFindingExplanations.explanation( + for: .systemCache, + risk: .safe, + fileAge: fileAge + ) + let base = AtlasFindingExplanations.explanation(for: .systemCache, risk: .safe) + XCTAssertEqual(explanation, base) + } + + // MARK: - Localized Explanation for Finding + + func testLocalizedExplanationForFindingWithStorageCategory() { + AtlasL10n.setCurrentLanguage(.en) + let finding = Finding( + title: "Test", + detail: "Test detail", + bytes: 100, + risk: .safe, + category: "System", + storageCategory: .browserData + ) + let explanation = AtlasFindingExplanations.localizedExplanation( + for: finding, + language: .en + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("Browser")) + AtlasL10n.setCurrentLanguage(.zhHans) + } + + func testLocalizedExplanationForFindingWithoutStorageCategoryFallsBackToSystemCache() { + AtlasL10n.setCurrentLanguage(.en) + let finding = Finding( + title: "Test", + detail: "Test detail", + bytes: 100, + risk: .safe, + category: "System" + ) + let explanation = AtlasFindingExplanations.localizedExplanation( + for: finding, + language: .en + ) + XCTAssertFalse(explanation.isEmpty) + XCTAssertTrue(explanation.contains("System cache")) + AtlasL10n.setCurrentLanguage(.zhHans) + } + + // MARK: - Age Descriptor + + func testAgeDescriptorWithRecentLastAccessedDate() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-10 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("天"), "Expected '天' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWith30DayLastAccessedDate() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-32 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("月"), "Expected '月' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWith6MonthLastAccessedDate() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-200 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("月"), "Expected '月' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWith1YearLastAccessedDate() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-380 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("年"), "Expected '年' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWith2YearLastAccessedDate() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(-800 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("年"), "Expected '年' in descriptor: \(descriptor)") + } + + func testAgeDescriptorFallsBackToCreationDate() { + let fileAge = FileAgeInfo( + lastAccessedDate: nil, + creationDate: Date().addingTimeInterval(-400 * 86400) + ) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("年前"), "Expected '年前' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWithCreationDateOnly() { + let fileAge = FileAgeInfo(creationDate: Date().addingTimeInterval(-60 * 86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertFalse(descriptor.isEmpty) + XCTAssertTrue(descriptor.contains("月前"), "Expected '月前' in descriptor: \(descriptor)") + } + + func testAgeDescriptorWithNoDatesReturnsEmpty() { + let fileAge = FileAgeInfo() + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertEqual(descriptor, "") + } + + func testAgeDescriptorWithFutureDateReturnsEmpty() { + let fileAge = FileAgeInfo(lastAccessedDate: Date().addingTimeInterval(86400)) + let descriptor = AtlasFindingExplanations.ageDescriptor(from: fileAge) + XCTAssertEqual(descriptor, "") + } + + // MARK: - All Categories × All Risk Levels + + func testAllCategoryRiskCombinationsReturnNonEmptyExplanation() { + for category in AtlasStorageCategory.allCases { + for risk in RiskLevel.allCases { + let explanation = AtlasFindingExplanations.explanation( + for: category, + risk: risk + ) + XCTAssertFalse( + explanation.isEmpty, + "Explanation should not be empty for \(category.rawValue).\(risk.rawValue)" + ) + } + } + } + + func testEnglishExplanationsAreDistinctFromChinese() { + for category in AtlasStorageCategory.allCases { + for risk in RiskLevel.allCases { + AtlasL10n.setCurrentLanguage(.zhHans) + let zhExplanation = AtlasFindingExplanations.explanation(for: category, risk: risk) + + AtlasL10n.setCurrentLanguage(.en) + let enExplanation = AtlasFindingExplanations.explanation(for: category, risk: risk) + + XCTAssertFalse(zhExplanation.isEmpty) + XCTAssertFalse(enExplanation.isEmpty) + XCTAssertNotEqual( + zhExplanation, + enExplanation, + "Chinese and English explanations should differ for \(category.rawValue).\(risk.rawValue)" + ) + } + } + AtlasL10n.setCurrentLanguage(.zhHans) + } +} diff --git a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/AggregateSummaryCard.swift b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/AggregateSummaryCard.swift new file mode 100644 index 000000000..aaf0299a7 --- /dev/null +++ b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/AggregateSummaryCard.swift @@ -0,0 +1,278 @@ +import AtlasDesignSystem +import AtlasDomain +import SwiftUI + +// MARK: - Data Model + +/// Aggregated metrics for a single risk-level bucket. +public struct RiskLevelAggregate: Identifiable, Equatable { + public let riskLevel: RiskLevel + public let totalBytes: Int64 + public let findingCount: Int + + public var id: RiskLevel { riskLevel } + + public init(riskLevel: RiskLevel, totalBytes: Int64, findingCount: Int) { + self.riskLevel = riskLevel + self.totalBytes = totalBytes + self.findingCount = findingCount + } +} + +// MARK: - Card View + +/// Hero-style card that summarises total reclaimable space grouped by risk level. +/// +/// Displays three segments (safe → success, review → warning, advanced → danger) +/// with a visual proportion bar, per-level byte counts, finding counts, and a +/// brief summary text like *"3.2 GB can be safely cleaned, 890 MB requires review"*. +/// +/// Follows the `AtlasHeroCard` pattern for card background, border, and tone tinting. +public struct AggregateSummaryCard: View { + + private let aggregates: [RiskLevelAggregate] + private let totalBytes: Int64 + private let summaryText: String + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(findings: [Finding]) { + let grouped = Dictionary(grouping: findings, by: \.risk) + + let safeAgg = RiskLevelAggregate( + riskLevel: .safe, + totalBytes: grouped[.safe]?.reduce(0) { $0 + $1.bytes } ?? 0, + findingCount: grouped[.safe]?.count ?? 0 + ) + let reviewAgg = RiskLevelAggregate( + riskLevel: .review, + totalBytes: grouped[.review]?.reduce(0) { $0 + $1.bytes } ?? 0, + findingCount: grouped[.review]?.count ?? 0 + ) + let advancedAgg = RiskLevelAggregate( + riskLevel: .advanced, + totalBytes: grouped[.advanced]?.reduce(0) { $0 + $1.bytes } ?? 0, + findingCount: grouped[.advanced]?.count ?? 0 + ) + + self.aggregates = [safeAgg, reviewAgg, advancedAgg] + self.totalBytes = safeAgg.totalBytes + reviewAgg.totalBytes + advancedAgg.totalBytes + self.summaryText = Self.buildSummary(safe: safeAgg, review: reviewAgg, advanced: advancedAgg) + } + + public var body: some View { + VStack(spacing: AtlasSpacing.xl) { + headerSection + proportionBar + breakdownRows + summaryFooter + } + .frame(maxWidth: .infinity) + .padding(.vertical, AtlasSpacing.section) + .padding(.horizontal, AtlasSpacing.xxl) + .background(cardBackground) + .overlay(cardBorder) + .clipShape(RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilitySummary) + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: AtlasSpacing.xs) { + Text(AtlasFormatters.byteCount(totalBytes)) + .font(AtlasTypography.heroMetric) + .foregroundStyle(.primary) + .contentTransition(.numericText()) + + Text(AtlasL10n.string("smartclean.summary.totalReclaimable")) + .font(AtlasTypography.body) + .foregroundStyle(.secondary) + } + } + + // MARK: - Proportion Bar + + private var proportionBar: some View { + let total = max(totalBytes, 1) + + return GeometryReader { geometry in + HStack(spacing: 2) { + ForEach(aggregates) { agg in + let fraction = CGFloat(agg.totalBytes) / CGFloat(total) + if agg.totalBytes > 0 { + RoundedRectangle(cornerRadius: fraction < 0.05 ? 1 : 3, style: .continuous) + .fill(agg.riskLevel.atlasTone.tint) + .frame(width: max(fraction * geometry.size.width, 2)) + .accessibilityHidden(true) + } + } + } + } + .frame(height: 8) + .animation(reduceMotion ? nil : AtlasMotion.standard, value: totalBytes) + } + + // MARK: - Breakdown Rows + + private var breakdownRows: some View { + VStack(alignment: .leading, spacing: AtlasSpacing.md) { + ForEach(aggregates) { agg in + HStack(spacing: AtlasSpacing.md) { + Circle() + .fill(agg.riskLevel.atlasTone.tint) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(agg.riskLevel.title) + .font(AtlasTypography.label) + .foregroundStyle(.primary) + + Text(AtlasL10n.string( + "smartclean.summary.findingCount", + agg.findingCount + )) + .font(AtlasTypography.bodySmall) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(AtlasFormatters.byteCount(agg.totalBytes)) + .font(AtlasTypography.cardMetric) + .foregroundStyle(agg.riskLevel.atlasTone.tint) + .contentTransition(.numericText()) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(agg.riskLevel.title): \(AtlasFormatters.byteCount(agg.totalBytes)), \(agg.findingCount) findings") + } + } + } + + // MARK: - Summary Footer + + private var summaryFooter: some View { + Text(summaryText) + .font(AtlasTypography.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + } + + // MARK: - Card Styling (follows AtlasHeroCard pattern) + + private var cardBackground: some View { + ZStack { + RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous) + .fill(AtlasColor.card) + + RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + AtlasColor.brand.opacity(0.06), + AtlasColor.brand.opacity(0.01), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [Color.primary.opacity(0.05), Color.clear], + startPoint: .topLeading, + endPoint: .center + ) + ) + + VStack { + Spacer() + RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color.clear, + AtlasColor.brand.opacity(0.03), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 48) + } + } + .shadow( + color: Color.black.opacity(AtlasElevation.prominent.shadowOpacity), + radius: AtlasElevation.prominent.shadowRadius, + y: AtlasElevation.prominent.shadowY + ) + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: AtlasElevation.prominent.cornerRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [ + AtlasColor.brand.opacity(0.18), + Color.primary.opacity(0.06), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1.5 + ) + } + + // MARK: - Helpers + + private var accessibilitySummary: String { + let total = AtlasFormatters.byteCount(totalBytes) + let lines = aggregates.map { agg in + "\(agg.riskLevel.title): \(AtlasFormatters.byteCount(agg.totalBytes)), \(agg.findingCount) findings" + } + return "\(total) total. " + lines.joined(separator: ". ") + } + + private static func buildSummary( + safe: RiskLevelAggregate, + review: RiskLevelAggregate, + advanced: RiskLevelAggregate + ) -> String { + var parts: [String] = [] + + if safe.totalBytes > 0 { + parts.append( + AtlasL10n.string( + "smartclean.summary.safeSegment", + AtlasFormatters.byteCount(safe.totalBytes) + ) + ) + } + if review.totalBytes > 0 { + parts.append( + AtlasL10n.string( + "smartclean.summary.reviewSegment", + AtlasFormatters.byteCount(review.totalBytes) + ) + ) + } + if advanced.totalBytes > 0 { + parts.append( + AtlasL10n.string( + "smartclean.summary.advancedSegment", + AtlasFormatters.byteCount(advanced.totalBytes) + ) + ) + } + + if parts.isEmpty { + return AtlasL10n.string("smartclean.summary.empty") + } + + return parts.joined(separator: ", ") + } +} diff --git a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/FindingRowView.swift b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/FindingRowView.swift new file mode 100644 index 000000000..bc26d3611 --- /dev/null +++ b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/FindingRowView.swift @@ -0,0 +1,154 @@ +import AtlasDesignSystem +import AtlasDomain +import SwiftUI + +/// Enhanced row component for displaying individual scan findings. +/// +/// Presents a finding with its title, detail, byte size, risk badge (``AtlasStatusChip``), +/// file age indicator, storage category tag, and a collapsible human-readable explanation. +/// Uses the ``AtlasDetailRow`` pattern for layout consistency with the rest of the UI. +/// +/// The risk badge is color-coded: green for safe, amber for review, and red for advanced. +/// File age is shown as a relative date string (e.g. "Last accessed 3 months ago"). +/// The explanation is displayed as a one-line summary by default, with an expand option. +public struct FindingRowView: View { + + // MARK: - Properties + + private let finding: Finding + private let showExplanation: Bool + + @State private var isExplanationExpanded = false + + // MARK: - Init + + public init(finding: Finding, showExplanation: Bool = true) { + self.finding = finding + self.showExplanation = showExplanation + } + + // MARK: - Body + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + mainRow + + if showExplanation, let explanation = resolvedExplanation { + explanationSection(explanation) + } + } + .accessibilityElement(children: .contain) + } + + // MARK: - Main Row + + private var mainRow: some View { + AtlasDetailRow( + title: finding.title, + subtitle: finding.detail, + footnote: footnoteText, + systemImage: AtlasCategoryIcon.systemImage(for: finding.category), + tone: finding.risk.atlasTone + ) { + VStack(alignment: .trailing, spacing: AtlasSpacing.sm) { + AtlasStatusChip( + finding.risk.title, + tone: finding.risk.atlasTone + ) + + Text(AtlasFormatters.byteCount(finding.bytes)) + .font(AtlasTypography.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Explanation Section + + @ViewBuilder + private func explanationSection(_ explanation: String) -> some View { + HStack(alignment: .top, spacing: AtlasSpacing.md) { + Image(systemName: "lightbulb") + .font(AtlasTypography.caption) + .foregroundStyle(AtlasColor.brand) + .frame(width: 16) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: AtlasSpacing.xs) { + Text(explanation) + .font(AtlasTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(isExplanationExpanded ? nil : 2) + .fixedSize(horizontal: false, vertical: true) + + Button { + withAnimation(AtlasMotion.standard) { + isExplanationExpanded.toggle() + } + } label: { + Text( + isExplanationExpanded + ? AtlasL10n.string("smartclean.finding.collapse") + : AtlasL10n.string("smartclean.finding.expand") + ) + .font(AtlasTypography.caption) + .foregroundStyle(AtlasColor.brand) + } + .buttonStyle(.plain) + .accessibilityLabel( + isExplanationExpanded + ? AtlasL10n.string("smartclean.finding.collapse") + : AtlasL10n.string("smartclean.finding.expand") + ) + } + } + .padding(.horizontal, AtlasSpacing.xxl) + .padding(.top, AtlasSpacing.sm) + .padding(.bottom, AtlasSpacing.md) + } + + // MARK: - Helpers + + private var footnoteText: String? { + var parts: [String] = [] + + parts.append(categoryLabel) + + if let fileAge = finding.fileAge, let ageText = ageIndicator(from: fileAge) { + parts.append(ageText) + } + + return parts.joined(separator: " \u{2022} ") + } + + private var categoryLabel: String { + finding.storageCategory?.title + ?? AtlasL10n.localizedCategory(finding.category) + } + + private var resolvedExplanation: String? { + if let explanation = finding.explanation, !explanation.isEmpty { + return explanation + } + + let category = finding.storageCategory ?? .systemCache + let generated = AtlasFindingExplanations.explanation( + for: category, + risk: finding.risk, + fileAge: finding.fileAge + ) + return generated.isEmpty ? nil : generated + } + + private func ageIndicator(from fileAge: FileAgeInfo) -> String? { + if let lastAccessed = fileAge.lastAccessedDate { + let relative = AtlasFormatters.relativeDate(lastAccessed) + return AtlasL10n.string("smartclean.fileage.lastAccessed", relative) + } + if let creationDate = fileAge.creationDate { + let relative = AtlasFormatters.relativeDate(creationDate) + return AtlasL10n.string("smartclean.fileage.created", relative) + } + return nil + } +} diff --git a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift index 2b32d531f..86707fa21 100644 --- a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift +++ b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift @@ -227,6 +227,8 @@ public struct SmartCleanFeatureView: View { onAction: onStartScan ) } else { + AggregateSummaryCard(findings: findings) + ForEach(RiskLevel.allCases, id: \.self) { risk in riskSection(risk) } @@ -248,19 +250,32 @@ public struct SmartCleanFeatureView: View { count: items.count, defaultExpanded: risk == .safe ) { - VStack(alignment: .leading, spacing: AtlasSpacing.md) { - ForEach(items) { finding in - AtlasDetailRow( - title: finding.title, - subtitle: finding.detail, - footnote: "\(AtlasL10n.localizedCategory(finding.category)) • \(actionExpectation(for: finding.risk))", - systemImage: AtlasCategoryIcon.systemImage(for: finding.category), - tone: risk.atlasTone + let grouped = Dictionary(grouping: items) { finding in + finding.storageCategory?.title ?? AtlasL10n.localizedCategory(finding.category) + } + + let sortedCategories = grouped.keys.sorted() + + ForEach(sortedCategories, id: \.self) { categoryTitle in + let categoryItems = grouped[categoryTitle] ?? [] + + if grouped.count > 1 { + AtlasSectionDisclosure( + title: categoryTitle, + count: categoryItems.count, + defaultExpanded: false ) { - AtlasStatusChip( - "\(AtlasL10n.localizedCategory(finding.category)) · \(AtlasFormatters.byteCount(finding.bytes))", - tone: risk.atlasTone - ) + VStack(alignment: .leading, spacing: AtlasSpacing.md) { + ForEach(categoryItems) { finding in + FindingRowView(finding: finding) + } + } + } + } else { + VStack(alignment: .leading, spacing: AtlasSpacing.md) { + ForEach(categoryItems) { finding in + FindingRowView(finding: finding) + } } } } diff --git a/cmd/analyze/json.go b/cmd/analyze/json.go index 1c2ab445c..21e613eba 100644 --- a/cmd/analyze/json.go +++ b/cmd/analyze/json.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "sync/atomic" + "time" ) type jsonOutput struct { @@ -17,10 +18,15 @@ type jsonOutput struct { } type jsonEntry struct { - Name string `json:"name"` - Path string `json:"path"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + RiskLevel string `json:"risk_level,omitempty"` + StorageCategory string `json:"storage_category,omitempty"` + LastAccessed string `json:"last_accessed,omitempty"` + CreatedDate string `json:"created_date,omitempty"` + ExplanationKey string `json:"explanation_key,omitempty"` } func runJSONMode(path string, isOverview bool) { @@ -51,25 +57,45 @@ func performScanForJSON(path string) jsonOutput { for _, item := range items { fullPath := path + "/" + item.Name() var size int64 + var lastAccess time.Time + var createdDate time.Time if item.IsDir() { size = calculateDirSizeFast(fullPath, &filesScanned, &dirsScanned, &bytesScanned, currentPath) } else { - info, err := item.Info() - if err == nil { + info, infoErr := item.Info() + if infoErr == nil { size = info.Size() + lastAccess = getLastAccessTimeFromInfo(info) + createdDate = getCreationTimeFromInfo(info) atomic.AddInt64(&filesScanned, 1) atomic.AddInt64(&bytesScanned, size) } } totalSize += size - entries = append(entries, jsonEntry{ - Name: item.Name(), - Path: fullPath, - Size: size, - IsDir: item.IsDir(), - }) + + risk := riskLevelForPath(fullPath) + category := categoryForPath(fullPath) + + entry := jsonEntry{ + Name: item.Name(), + Path: fullPath, + Size: size, + IsDir: item.IsDir(), + RiskLevel: string(risk), + StorageCategory: string(category), + ExplanationKey: explanationKeyFor(category, risk), + } + + if !lastAccess.IsZero() { + entry.LastAccessed = lastAccess.Format(time.RFC3339) + } + if !createdDate.IsZero() { + entry.CreatedDate = createdDate.Format(time.RFC3339) + } + + entries = append(entries, entry) } return jsonOutput{ diff --git a/cmd/analyze/main.go b/cmd/analyze/main.go index c8ed0304d..6b5e9c48a 100644 --- a/cmd/analyze/main.go +++ b/cmd/analyze/main.go @@ -22,11 +22,12 @@ var ( ) type dirEntry struct { - Name string - Path string - Size int64 - IsDir bool - LastAccess time.Time + Name string + Path string + Size int64 + IsDir bool + LastAccess time.Time + CreatedDate time.Time } type fileEntry struct { diff --git a/cmd/analyze/risk.go b/cmd/analyze/risk.go new file mode 100644 index 000000000..315cb71ae --- /dev/null +++ b/cmd/analyze/risk.go @@ -0,0 +1,457 @@ +package main + +import ( + "path/filepath" + "strings" +) + +// RiskLevel represents the safety of deleting a given path. +type RiskLevel string + +const ( + RiskSafe RiskLevel = "safe" // Safe to delete without concern. + RiskReview RiskLevel = "review" // Should review before deletion. + RiskAdvanced RiskLevel = "advanced" // Requires advanced knowledge to delete safely. +) + +// StorageCategory classifies what kind of storage a path occupies. +type StorageCategory string + +const ( + CategorySystemCache StorageCategory = "systemCache" + CategoryAppCache StorageCategory = "appCache" + CategoryDeveloperArtifact StorageCategory = "developerArtifact" + CategoryBrowserData StorageCategory = "browserData" + CategoryLogFile StorageCategory = "logFile" + CategoryDownloadArtifact StorageCategory = "downloadArtifact" + CategoryMailAttachment StorageCategory = "mailAttachment" + CategoryOldBackup StorageCategory = "oldBackup" + CategoryUnknown StorageCategory = "unknown" +) + +// safeDirNames maps directory base names that are safe to delete. +var safeDirNames = map[string]bool{ + // System caches. + ".cache": true, + "Caches": true, + ".tmp": true, + ".temp": true, + "_temp": true, + "_tmp": true, + "tmp": true, + "temp": true, + ".Trash": true, + "$RECYCLE.BIN": true, + + // macOS system artifacts. + "__MACOSX": true, + ".DS_Store": true, + ".Spotlight-V100": true, + ".fseventsd": true, + ".DocumentRevisions-V100": true, + ".TemporaryItems": true, + + // Developer build outputs. + "build": true, + "dist": true, + ".output": true, + "coverage": true, + ".coverage": true, + ".nyc_output": true, + "htmlcov": true, + "out": true, + "target": true, + "DerivedData": true, + + // Developer dependency caches. + "node_modules": true, + "bower_components": true, + ".yarn": true, + ".pnpm-store": true, + "vendor": true, + ".bundle": true, + "Pods": true, + "Carthage": true, + + // Developer tool caches. + "__pycache__": true, + ".pytest_cache": true, + ".mypy_cache": true, + ".ruff_cache": true, + ".tox": true, + ".eggs": true, + ".ipynb_checkpoints": true, + + // Framework caches. + ".next": true, + ".nuxt": true, + ".vite": true, + ".turbo": true, + ".parcel-cache": true, + ".nx": true, + ".angular": true, + ".svelte-kit": true, + ".astro": true, + ".docusaurus": true, + + // IDE caches. + ".idea": true, + ".vs": true, + + // Other safe caches. + ".Homebrew": true, + ".terraform": true, + ".dart_tool": true, +} + +// safePathPatterns marks path substrings that indicate safe-to-delete content. +var safePathPatterns = []string{ + "/Library/Caches/", + "/Library/Logs/", + "/Library/DiagnosticReports/", + "/.Trash/", + "/Library/Saved Application State/", +} + +// reviewDirNames maps directory base names that need review before deletion. +var reviewDirNames = map[string]bool{ + // Developer environments (may contain project-specific configurations). + "venv": true, + ".venv": true, + "virtualenv": true, + ".pyenv": true, + ".poetry": true, + ".pip": true, + ".pipx": true, + ".rbenv": true, + ".nvm": true, + ".rustup": true, + ".sdkman": true, + ".deno": true, + ".bun": true, + + // Developer build caches (may be expensive to rebuild). + ".gradle": true, + ".m2": true, + ".ivy2": true, + ".cargo": true, + ".build": true, + + // Package manager caches. + ".npm": true, + ".composer": true, + + // Application data. + "Saved Application State": true, + "Application Scripts": true, +} + +// reviewPathPatterns marks path substrings that indicate review-needed content. +var reviewPathPatterns = []string{ + "/Library/Application Support/", + "/Library/Preferences/", + "/Library/Containers/", + "/Library/Group Containers/", +} + +// advancedDirNames maps directory base names that require advanced knowledge to delete. +var advancedDirNames = map[string]bool{ + // System-level directories. + "System": true, + "bin": true, + "sbin": true, + "etc": true, + "var": true, + "usr": true, + "dev": true, + "opt": true, + "private": true, + "cores": true, + + // Virtualization / containers. + ".docker": true, + ".containerd": true, + + // Databases (deletion may cause data loss). + ".mysql": true, + ".postgres": true, + "mongodb": true, + + // iCloud. + "Mobile Documents": true, + + // Version control (critical data). + ".git": true, + ".svn": true, + ".hg": true, +} + +// advancedPathPatterns marks path substrings that indicate advanced-risk content. +var advancedPathPatterns = []string{ + "/Library/LaunchAgents/", + "/Library/LaunchDaemons/", + "/Library/PrivilegedHelperTools/", + "/Library/SystemExtensions/", + "/Library/Extensions/", +} + +// browserDataDirNames marks directories that contain browser data. +var browserDataDirNames = map[string]bool{ + "Safari": true, + "Google": true, + "Chrome": true, + "Chromium": true, + "Firefox": true, + "BraveSoftware": true, + "Microsoft Edge": true, + "com.apple.Safari": true, + "com.google.Chrome": true, + "org.mozilla.firefox": true, +} + +// browserPathPatterns marks path substrings that indicate browser data. +var browserPathPatterns = []string{ + "/Library/Safari/", + "/Library/Application Support/Google/Chrome/", + "/Library/Application Support/Firefox/", + "/Library/Application Support/BraveSoftware/", + "/Library/Application Support/Microsoft Edge/", + "/Library/Caches/com.apple.Safari/", + "/Library/Caches/com.google.Chrome/", + "/Library/Caches/org.mozilla.firefox/", +} + +// logFileExtensions marks file extensions that indicate log files. +var logFileExtensions = map[string]bool{ + ".log": true, + ".log.": true, +} + +// riskLevelForPath categorizes a filesystem path into a risk level. +func riskLevelForPath(path string) RiskLevel { + if path == "" { + return RiskReview + } + + baseName := filepath.Base(path) + + // Check advanced-risk first (highest risk → most restrictive). + if advancedDirNames[baseName] { + return RiskAdvanced + } + for _, pattern := range advancedPathPatterns { + if strings.Contains(path, pattern) { + return RiskAdvanced + } + } + + // Check safe patterns (lowest risk). + if safeDirNames[baseName] { + return RiskSafe + } + if projectDependencyDirs[baseName] { + return RiskSafe + } + for _, pattern := range safePathPatterns { + if strings.Contains(path, pattern) { + return RiskSafe + } + } + + // Check review patterns. + if reviewDirNames[baseName] { + return RiskReview + } + for _, pattern := range reviewPathPatterns { + if strings.Contains(path, pattern) { + return RiskReview + } + } + + // Check browser data — generally review-level risk. + if browserDataDirNames[baseName] { + return RiskReview + } + for _, pattern := range browserPathPatterns { + if strings.Contains(path, pattern) { + return RiskReview + } + } + + // Check foldDirs for known cache/dependency directories. + if foldDirs[baseName] { + return RiskSafe + } + + // Default to review for unrecognized paths. + return RiskReview +} + +// categoryForPath classifies a filesystem path into a storage category. +func categoryForPath(path string) StorageCategory { + if path == "" { + return CategoryUnknown + } + + baseName := filepath.Base(path) + + // Check log files first (extension-based match). + ext := strings.ToLower(filepath.Ext(path)) + if logFileExtensions[ext] { + return CategoryLogFile + } + // Also match common log naming patterns. + if strings.Contains(strings.ToLower(baseName), ".log") { + return CategoryLogFile + } + // Check known log path patterns. + if strings.Contains(path, "/Library/Logs/") || strings.Contains(path, "/var/log/") { + return CategoryLogFile + } + + // Browser data. + for _, pattern := range browserPathPatterns { + if strings.Contains(path, pattern) { + return CategoryBrowserData + } + } + if browserDataDirNames[baseName] { + return CategoryBrowserData + } + // Browser cache directories within Library/Caches. + if strings.Contains(path, "/Library/Caches/") { + for browser := range browserDataDirNames { + if strings.Contains(path, browser) { + return CategoryBrowserData + } + } + } + + // Developer artifacts. + if projectDependencyDirs[baseName] { + return CategoryDeveloperArtifact + } + if isDeveloperArtifactName(baseName) { + return CategoryDeveloperArtifact + } + // Developer tools configuration directories. + developerPaths := []string{ + "/.gradle/", "/.m2/", "/.ivy2/", "/.cargo/", "/.rustup/", + "/.pyenv/", "/.nvm/", "/.sdkman/", "/.poetry/", "/.pip/", + "/.composer/", "/.bundle/", + } + for _, pattern := range developerPaths { + if strings.Contains(path, pattern) { + return CategoryDeveloperArtifact + } + } + + // Mail attachments. + if strings.Contains(path, "/Library/Mail/") || + strings.Contains(path, "/Library/Containers/com.apple.Mail/") || + strings.Contains(path, "/Library/Group Containers/") && strings.Contains(path, "Mail") { + return CategoryMailAttachment + } + + // Download artifacts. + if strings.Contains(path, "/Downloads/") || + strings.Contains(path, "/Library/Messages/") { + return CategoryDownloadArtifact + } + + // Old backups. + if strings.Contains(path, "Backup") || strings.Contains(path, "backup") || + strings.Contains(path, ".backup") || strings.Contains(path, ".bak") || + strings.Contains(path, "TimeMachine") || strings.Contains(path, ".MobileBackups") { + return CategoryOldBackup + } + + // System cache. + if strings.Contains(path, "/Library/Caches/") || + strings.Contains(path, "/Library/Saved Application State/") || + strings.Contains(path, "/Library/DiagnosticReports/") || + strings.Contains(path, "/.Trash/") || + strings.Contains(path, "/.cache/") || + strings.Contains(path, "/Caches/") { + return CategorySystemCache + } + + // App cache — application-specific data that isn't strictly a system cache. + if strings.Contains(path, "/Library/Application Support/") || + strings.Contains(path, "/Library/Containers/") || + strings.Contains(path, "/Library/Preferences/") { + return CategoryAppCache + } + + // Check foldDirs for known cache/dependency directories → system cache. + if foldDirs[baseName] { + return CategorySystemCache + } + + return CategoryUnknown +} + +// isDeveloperArtifactName checks if a base name matches common developer artifact patterns. +func isDeveloperArtifactName(name string) bool { + developerNames := map[string]bool{ + // Language/tool caches. + "__pycache__": true, + ".pytest_cache": true, + ".mypy_cache": true, + ".ruff_cache": true, + ".tox": true, + ".eggs": true, + "htmlcov": true, + ".ipynb_checkpoints": true, + + // Framework caches. + ".next": true, + ".nuxt": true, + ".vite": true, + ".turbo": true, + ".parcel-cache": true, + ".nx": true, + ".angular": true, + ".svelte-kit": true, + ".astro": true, + ".docusaurus": true, + ".solid": true, + + // Build outputs. + "build": true, + "dist": true, + ".output": true, + "out": true, + "target": true, + "DerivedData": true, + ".build": true, + + // IDE. + ".idea": true, + ".vscode": true, + ".vs": true, + ".fleet": true, + + // Mobile dev. + "Pods": true, + "Carthage": true, + ".dart_tool": true, + + // Other tools. + ".terraform": true, + "coverage": true, + ".coverage": true, + ".nyc_output": true, + } + + return developerNames[name] +} + +// explanationKeyFor returns a localization key for the explanation template +// based on the storage category and risk level. +// The key format "explanation.." matches the Swift-side +// AtlasFindingExplanations.explanationKey(for:risk:) convention and the +// keys defined in en.lproj/Localizable.strings and zh-Hans.lproj/Localizable.strings. +func explanationKeyFor(category StorageCategory, risk RiskLevel) string { + return "explanation." + string(category) + "." + string(risk) +} diff --git a/cmd/analyze/risk_test.go b/cmd/analyze/risk_test.go new file mode 100644 index 000000000..9576be262 --- /dev/null +++ b/cmd/analyze/risk_test.go @@ -0,0 +1,538 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// --------------------------------------------------------------------------- +// riskLevelForPath – table-driven tests +// --------------------------------------------------------------------------- + +func TestRiskLevelForPath(t *testing.T) { + tests := []struct { + name string + path string + want RiskLevel + }{ + // -- Edge cases --------------------------------------------------- + {"empty path returns review", "", RiskReview}, + {"unknown single name returns review", "some_random_dir", RiskReview}, + {"unknown nested path returns review", "/Users/john/Documents/myfile.txt", RiskReview}, + + // -- Safe: system caches / temp / trash --------------------------- + {".cache", "/Users/john/.cache", RiskSafe}, + {"Caches", "/Users/john/Library/Caches/com.apple.Safari", RiskSafe}, + {".tmp", "/tmp/work/.tmp", RiskSafe}, + {".temp", "/tmp/work/.temp", RiskSafe}, + {"tmp", "/tmp/work/tmp", RiskSafe}, + {".Trash", "/Users/john/.Trash", RiskSafe}, + {"__MACOSX", "/Volumes/usb/__MACOSX", RiskSafe}, + {".DS_Store", "/Users/john/.DS_Store", RiskSafe}, + {".Spotlight-V100", "/Volumes/usb/.Spotlight-V100", RiskSafe}, + {".fseventsd", "/Volumes/usb/.fseventsd", RiskSafe}, + + // -- Safe: developer build outputs -------------------------------- + {"build", "/Users/john/project/build", RiskSafe}, + {"dist", "/Users/john/project/dist", RiskSafe}, + {".output", "/Users/john/project/.output", RiskSafe}, + {"target", "/Users/john/project/target", RiskSafe}, + {"DerivedData", "/Users/john/Library/Developer/Xcode/DerivedData", RiskSafe}, + {"coverage", "/Users/john/project/coverage", RiskSafe}, + {".nyc_output", "/Users/john/project/.nyc_output", RiskSafe}, + + // -- Safe: developer dependency caches ---------------------------- + {"node_modules", "/Users/john/project/node_modules", RiskSafe}, + {"bower_components", "/Users/john/project/bower_components", RiskSafe}, + {".yarn", "/Users/john/project/.yarn", RiskSafe}, + {"vendor", "/Users/john/project/vendor", RiskSafe}, + {".bundle", "/Users/john/project/.bundle", RiskSafe}, + {"Pods", "/Users/john/project/Pods", RiskSafe}, + {"Carthage", "/Users/john/project/Carthage", RiskSafe}, + + // -- Safe: developer tool caches ---------------------------------- + {"__pycache__", "/Users/john/project/__pycache__", RiskSafe}, + {".pytest_cache", "/Users/john/project/.pytest_cache", RiskSafe}, + {".mypy_cache", "/Users/john/project/.mypy_cache", RiskSafe}, + {".tox", "/Users/john/project/.tox", RiskSafe}, + + // -- Safe: framework caches --------------------------------------- + {".next", "/Users/john/project/.next", RiskSafe}, + {".nuxt", "/Users/john/project/.nuxt", RiskSafe}, + {".vite", "/Users/john/project/.vite", RiskSafe}, + {".turbo", "/Users/john/project/.turbo", RiskSafe}, + {".parcel-cache", "/Users/john/project/.parcel-cache", RiskSafe}, + {".nx", "/Users/john/project/.nx", RiskSafe}, + + // -- Safe: IDE caches --------------------------------------------- + {".idea", "/Users/john/project/.idea", RiskSafe}, + {".vs", "/Users/john/project/.vs", RiskSafe}, + + // -- Safe: other known caches ------------------------------------- + {".Homebrew", "/Users/john/.Homebrew", RiskSafe}, + {".terraform", "/Users/john/project/.terraform", RiskSafe}, + {".dart_tool", "/Users/john/project/.dart_tool", RiskSafe}, + + // -- Safe: safe path patterns (substring match) ------------------- + {"Library/Caches pattern", "/Users/john/Library/Caches/com.someapp", RiskSafe}, + {"Library/Logs pattern", "/Users/john/Library/Logs/com.someapp.log", RiskSafe}, + {"Library/DiagnosticReports pattern", "/Users/john/Library/DiagnosticReports/report.diag", RiskSafe}, + {".Trash pattern", "/Users/john/.Trash/oldfile", RiskSafe}, + {"Library/Saved Application State pattern", "/Users/john/Library/Saved Application State/com.someapp.savedState", RiskSafe}, + + // -- Safe: projectDependencyDirs ---------------------------------- + {"htmlcov", "/Users/john/project/htmlcov", RiskSafe}, + {".ipynb_checkpoints", "/Users/john/project/.ipynb_checkpoints", RiskSafe}, + {".angular", "/Users/john/project/.angular", RiskSafe}, + {".svelte-kit", "/Users/john/project/.svelte-kit", RiskSafe}, + {".astro", "/Users/john/project/.astro", RiskSafe}, + {".docusaurus", "/Users/john/project/.docusaurus", RiskSafe}, + {".ruff_cache", "/Users/john/project/.ruff_cache", RiskSafe}, + {".eggs", "/Users/john/project/.eggs", RiskSafe}, + + // -- Review: developer environments ------------------------------- + // Note: venv, .venv, virtualenv, .gradle, .build are in projectDependencyDirs + // and thus return RiskSafe. Only truly review-only names are tested here. + {".pyenv", "/Users/john/.pyenv", RiskReview}, + {".poetry", "/Users/john/.poetry", RiskReview}, + {".pip", "/Users/john/.pip", RiskReview}, + {".pipx", "/Users/john/.pipx", RiskReview}, + {".rbenv", "/Users/john/.rbenv", RiskReview}, + {".nvm", "/Users/john/.nvm", RiskReview}, + {".rustup", "/Users/john/.rustup", RiskReview}, + {".sdkman", "/Users/john/.sdkman", RiskReview}, + {".deno", "/Users/john/.deno", RiskReview}, + {".bun", "/Users/john/.bun", RiskReview}, + + // -- Review: developer build caches (expensive to rebuild) -------- + // Note: .gradle, .build are in projectDependencyDirs → RiskSafe. + {".m2", "/Users/john/.m2", RiskReview}, + {".ivy2", "/Users/john/.ivy2", RiskReview}, + {".cargo", "/Users/john/.cargo", RiskReview}, + + // -- Review: package manager caches -------------------------------- + {".npm (review)", "/Users/john/.npm", RiskReview}, + {".composer", "/Users/john/.composer", RiskReview}, + + // -- Review: path pattern matches --------------------------------- + {"Library/Application Support pattern", "/Users/john/Library/Application Support/SomeApp", RiskReview}, + {"Library/Preferences pattern", "/Users/john/Library/Preferences/com.example.app.plist", RiskReview}, + {"Library/Containers pattern", "/Users/john/Library/Containers/com.example.app", RiskReview}, + {"Library/Group Containers pattern", "/Users/john/Library/Group Containers/com.example.app", RiskReview}, + + // -- Review: browser data ----------------------------------------- + {"Safari dir", "/Users/john/Library/Safari", RiskReview}, + {"Google dir", "/Users/john/Library/Application Support/Google", RiskReview}, + {"Chrome dir", "/Users/john/Library/Application Support/Google/Chrome", RiskReview}, + {"Firefox dir", "/Users/john/Library/Application Support/Firefox", RiskReview}, + {"BraveSoftware dir", "/Users/john/Library/Application Support/BraveSoftware", RiskReview}, + + // -- Advanced: system directories --------------------------------- + {"System", "/System", RiskAdvanced}, + {"bin", "/usr/bin", RiskAdvanced}, + {"sbin", "/usr/sbin", RiskAdvanced}, + {"etc", "/private/etc", RiskAdvanced}, + {"var", "/private/var", RiskAdvanced}, + {"usr", "/usr", RiskAdvanced}, + {"dev", "/dev", RiskAdvanced}, + {"opt", "/opt", RiskAdvanced}, + {"private", "/private", RiskAdvanced}, + {"cores", "/cores", RiskAdvanced}, + + // -- Advanced: virtualization / containers ------------------------ + {".docker", "/Users/john/.docker", RiskAdvanced}, + {".containerd", "/Users/john/.containerd", RiskAdvanced}, + + // -- Advanced: databases ------------------------------------------ + {".mysql", "/Users/john/.mysql", RiskAdvanced}, + {".postgres", "/Users/john/.postgres", RiskAdvanced}, + {"mongodb", "/Users/john/mongodb", RiskAdvanced}, + + // -- Advanced: iCloud --------------------------------------------- + {"Mobile Documents", "/Users/john/Library/Mobile Documents", RiskAdvanced}, + + // -- Advanced: version control ------------------------------------ + {".git", "/Users/john/project/.git", RiskAdvanced}, + {".svn", "/Users/john/project/.svn", RiskAdvanced}, + {".hg", "/Users/john/project/.hg", RiskAdvanced}, + + // -- Advanced: path pattern matches -------------------------------- + {"Library/LaunchAgents pattern", "/Users/john/Library/LaunchAgents/com.example.agent.plist", RiskAdvanced}, + {"Library/LaunchDaemons pattern", "/Library/LaunchDaemons/com.example.daemon.plist", RiskAdvanced}, + {"Library/PrivilegedHelperTools pattern", "/Library/PrivilegedHelperTools/com.example.helper", RiskAdvanced}, + {"Library/SystemExtensions pattern", "/Library/SystemExtensions/com.example.extension", RiskAdvanced}, + {"Library/Extensions pattern", "/Library/Extensions/SomeExtension.kext", RiskAdvanced}, + + // -- Advanced takes priority over safe ---------------------------- + {".git (advanced overrides safe foldDirs)", "/Users/john/project/.git", RiskAdvanced}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := riskLevelForPath(tt.path) + if got != tt.want { + t.Errorf("riskLevelForPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// categoryForPath – table-driven tests +// --------------------------------------------------------------------------- + +func TestCategoryForPath(t *testing.T) { + tests := []struct { + name string + path string + want StorageCategory + }{ + // -- Edge cases --------------------------------------------------- + {"empty path returns unknown", "", CategoryUnknown}, + {"unrecognized path returns unknown", "/Users/john/some_random_thing", CategoryUnknown}, + + // -- Log files (extension-based) ---------------------------------- + {".log extension", "/Users/john/project/app.log", CategoryLogFile}, + {"log in name", "/Users/john/project/debug.log.1", CategoryLogFile}, + {"Library/Logs path", "/Users/john/Library/Logs/com.apple.install.log", CategoryLogFile}, + {"/var/log path", "/var/log/system.log", CategoryLogFile}, + + // -- Browser data ------------------------------------------------- + {"Safari browser path", "/Users/john/Library/Safari/History.db", CategoryBrowserData}, + {"Chrome browser path", "/Users/john/Library/Application Support/Google/Chrome/Default", CategoryBrowserData}, + {"Firefox browser path", "/Users/john/Library/Application Support/Firefox/Profiles", CategoryBrowserData}, + {"Brave browser path", "/Users/john/Library/Application Support/BraveSoftware/Brave-Browser", CategoryBrowserData}, + {"Edge browser path", "/Users/john/Library/Application Support/Microsoft Edge/Default", CategoryBrowserData}, + {"Safari cache", "/Users/john/Library/Caches/com.apple.Safari", CategoryBrowserData}, + {"Chrome cache", "/Users/john/Library/Caches/com.google.Chrome", CategoryBrowserData}, + {"Firefox cache", "/Users/john/Library/Caches/org.mozilla.firefox", CategoryBrowserData}, + {"Safari dir name", "/Users/john/Library/Safari", CategoryBrowserData}, + {"Google dir name", "/Users/john/Library/Application Support/Google", CategoryBrowserData}, + {"Chrome dir name", "/Users/john/Library/Application Support/Google/Chrome", CategoryBrowserData}, + {"Firefox dir name", "/Users/john/Library/Application Support/Firefox", CategoryBrowserData}, + {"BraveSoftware dir name", "/Users/john/Library/Application Support/BraveSoftware", CategoryBrowserData}, + {"Edge dir name", "/Users/john/Library/Application Support/Microsoft Edge", CategoryBrowserData}, + + // -- Developer artifacts ------------------------------------------ + {"node_modules", "/Users/john/project/node_modules", CategoryDeveloperArtifact}, + {"build dir", "/Users/john/project/build", CategoryDeveloperArtifact}, + {"dist dir", "/Users/john/project/dist", CategoryDeveloperArtifact}, + {"target dir", "/Users/john/project/target", CategoryDeveloperArtifact}, + {".next", "/Users/john/project/.next", CategoryDeveloperArtifact}, + {".nuxt", "/Users/john/project/.nuxt", CategoryDeveloperArtifact}, + {".vite", "/Users/john/project/.vite", CategoryDeveloperArtifact}, + {".turbo", "/Users/john/project/.turbo", CategoryDeveloperArtifact}, + {".parcel-cache", "/Users/john/project/.parcel-cache", CategoryDeveloperArtifact}, + {".nx", "/Users/john/project/.nx", CategoryDeveloperArtifact}, + {".angular", "/Users/john/project/.angular", CategoryDeveloperArtifact}, + {".svelte-kit", "/Users/john/project/.svelte-kit", CategoryDeveloperArtifact}, + {".astro", "/Users/john/project/.astro", CategoryDeveloperArtifact}, + {".docusaurus", "/Users/john/project/.docusaurus", CategoryDeveloperArtifact}, + {"__pycache__", "/Users/john/project/__pycache__", CategoryDeveloperArtifact}, + {".pytest_cache", "/Users/john/project/.pytest_cache", CategoryDeveloperArtifact}, + {".mypy_cache", "/Users/john/project/.mypy_cache", CategoryDeveloperArtifact}, + {".ruff_cache", "/Users/john/project/.ruff_cache", CategoryDeveloperArtifact}, + {".tox", "/Users/john/project/.tox", CategoryDeveloperArtifact}, + {".eggs", "/Users/john/project/.eggs", CategoryDeveloperArtifact}, + {"htmlcov", "/Users/john/project/htmlcov", CategoryDeveloperArtifact}, + {".ipynb_checkpoints", "/Users/john/project/.ipynb_checkpoints", CategoryDeveloperArtifact}, + {".idea", "/Users/john/project/.idea", CategoryDeveloperArtifact}, + {".vscode", "/Users/john/project/.vscode", CategoryDeveloperArtifact}, + {".vs", "/Users/john/project/.vs", CategoryDeveloperArtifact}, + {"Pods", "/Users/john/project/Pods", CategoryDeveloperArtifact}, + {"Carthage", "/Users/john/project/Carthage", CategoryDeveloperArtifact}, + {".dart_tool", "/Users/john/project/.dart_tool", CategoryDeveloperArtifact}, + {".terraform", "/Users/john/project/.terraform", CategoryDeveloperArtifact}, + {".gradle path", "/Users/john/.gradle/caches", CategoryDeveloperArtifact}, + {".m2 path", "/Users/john/.m2/repository", CategoryDeveloperArtifact}, + {".cargo path", "/Users/john/.cargo/registry", CategoryDeveloperArtifact}, + {".rustup path", "/Users/john/.rustup/toolchains", CategoryDeveloperArtifact}, + {".pyenv path", "/Users/john/.pyenv/versions", CategoryDeveloperArtifact}, + {".nvm path", "/Users/john/.nvm/versions", CategoryDeveloperArtifact}, + {".poetry path", "/Users/john/.poetry/virtualenvs", CategoryDeveloperArtifact}, + {".pip path", "/Users/john/.pip/cache", CategoryDeveloperArtifact}, + {".composer path", "/Users/john/.composer/cache", CategoryDeveloperArtifact}, + {".bundle path", "/Users/john/.bundle/gems", CategoryDeveloperArtifact}, + {"DerivedData", "/Users/john/Library/Developer/Xcode/DerivedData", CategoryDeveloperArtifact}, + {".build", "/Users/john/project/.build", CategoryDeveloperArtifact}, + {"coverage", "/Users/john/project/coverage", CategoryDeveloperArtifact}, + {".coverage", "/Users/john/project/.coverage", CategoryDeveloperArtifact}, + {".nyc_output", "/Users/john/project/.nyc_output", CategoryDeveloperArtifact}, + {".output", "/Users/john/project/.output", CategoryDeveloperArtifact}, + {"out", "/Users/john/project/out", CategoryDeveloperArtifact}, + {"bower_components", "/Users/john/project/bower_components", CategoryDeveloperArtifact}, + {".pnpm-store", "/Users/john/project/.pnpm-store", CategoryDeveloperArtifact}, + {"vendor", "/Users/john/project/vendor", CategoryDeveloperArtifact}, + {".bundle (project)", "/Users/john/project/.bundle", CategoryDeveloperArtifact}, + {"venv", "/Users/john/project/venv", CategoryDeveloperArtifact}, + {".venv", "/Users/john/project/.venv", CategoryDeveloperArtifact}, + {"virtualenv", "/Users/john/project/virtualenv", CategoryDeveloperArtifact}, + {"site-packages", "/Users/john/project/site-packages", CategorySystemCache}, // foldDirs → systemCache + {".solid", "/Users/john/project/.solid", CategoryDeveloperArtifact}, + + // -- Mail attachments --------------------------------------------- + {"Library/Mail path", "/Users/john/Library/Mail/V10/abc@icloud.com", CategoryMailAttachment}, + {"Library/Containers/com.apple.Mail", "/Users/john/Library/Containers/com.apple.Mail/Data", CategoryMailAttachment}, + {"Group Containers Mail (uppercase)", "/Users/john/Library/Group Containers/group.com.apple.Mail", CategoryMailAttachment}, + + // -- Download artifacts ------------------------------------------- + {"Downloads path", "/Users/john/Downloads/installer.dmg", CategoryDownloadArtifact}, + {"Library/Messages path", "/Users/john/Library/Messages/chat.db", CategoryDownloadArtifact}, + + // -- Old backups -------------------------------------------------- + {"Backup in path", "/Users/john/Backup/old_backup", CategoryOldBackup}, + {"backup in path", "/Users/john/backup/data", CategoryOldBackup}, + {".backup extension", "/Users/john/project/.backup", CategoryOldBackup}, + {".bak extension", "/Users/john/project/data.bak", CategoryOldBackup}, + {"TimeMachine in path", "/Users/john/TimeMachine/snapshot", CategoryOldBackup}, + {".MobileBackups in path", "/Users/john/.MobileBackups/snapshot", CategoryOldBackup}, + + // -- System cache ------------------------------------------------- + {"Library/Caches path", "/Users/john/Library/Caches/com.someapp.cache", CategorySystemCache}, + {"Library/Saved Application State path", "/Users/john/Library/Saved Application State/com.someapp.savedState", CategorySystemCache}, + {"Library/DiagnosticReports path", "/Users/john/Library/DiagnosticReports/some_report.diag", CategorySystemCache}, + {".Trash path", "/Users/john/.Trash/deleted_file.txt", CategorySystemCache}, + {".cache path", "/Users/john/.cache/some_cache", CategorySystemCache}, + {"/Caches/ path", "/Users/john/Library/Caches/someapp", CategorySystemCache}, + + // -- App cache ---------------------------------------------------- + {"Library/Application Support path", "/Users/john/Library/Application Support/SomeApp/data", CategoryAppCache}, + {"Library/Containers path", "/Users/john/Library/Containers/com.someapp/Data", CategoryAppCache}, + {"Library/Preferences path", "/Users/john/Library/Preferences/com.someapp.plist", CategoryAppCache}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := categoryForPath(tt.path) + if got != tt.want { + t.Errorf("categoryForPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// explanationKeyFor – every category+risk combination +// --------------------------------------------------------------------------- + +func TestExplanationKeyFor(t *testing.T) { + tests := []struct { + name string + category StorageCategory + risk RiskLevel + want string + }{ + // System cache at each risk level. + {"systemCache.safe", CategorySystemCache, RiskSafe, "explanation.systemCache.safe"}, + {"systemCache.review", CategorySystemCache, RiskReview, "explanation.systemCache.review"}, + {"systemCache.advanced", CategorySystemCache, RiskAdvanced, "explanation.systemCache.advanced"}, + + // App cache. + {"appCache.safe", CategoryAppCache, RiskSafe, "explanation.appCache.safe"}, + {"appCache.review", CategoryAppCache, RiskReview, "explanation.appCache.review"}, + {"appCache.advanced", CategoryAppCache, RiskAdvanced, "explanation.appCache.advanced"}, + + // Developer artifacts. + {"developerArtifact.safe", CategoryDeveloperArtifact, RiskSafe, "explanation.developerArtifact.safe"}, + {"developerArtifact.review", CategoryDeveloperArtifact, RiskReview, "explanation.developerArtifact.review"}, + {"developerArtifact.advanced", CategoryDeveloperArtifact, RiskAdvanced, "explanation.developerArtifact.advanced"}, + + // Browser data. + {"browserData.safe", CategoryBrowserData, RiskSafe, "explanation.browserData.safe"}, + {"browserData.review", CategoryBrowserData, RiskReview, "explanation.browserData.review"}, + {"browserData.advanced", CategoryBrowserData, RiskAdvanced, "explanation.browserData.advanced"}, + + // Log files. + {"logFile.safe", CategoryLogFile, RiskSafe, "explanation.logFile.safe"}, + {"logFile.review", CategoryLogFile, RiskReview, "explanation.logFile.review"}, + {"logFile.advanced", CategoryLogFile, RiskAdvanced, "explanation.logFile.advanced"}, + + // Download artifacts. + {"downloadArtifact.safe", CategoryDownloadArtifact, RiskSafe, "explanation.downloadArtifact.safe"}, + {"downloadArtifact.review", CategoryDownloadArtifact, RiskReview, "explanation.downloadArtifact.review"}, + {"downloadArtifact.advanced", CategoryDownloadArtifact, RiskAdvanced, "explanation.downloadArtifact.advanced"}, + + // Mail attachments. + {"mailAttachment.safe", CategoryMailAttachment, RiskSafe, "explanation.mailAttachment.safe"}, + {"mailAttachment.review", CategoryMailAttachment, RiskReview, "explanation.mailAttachment.review"}, + {"mailAttachment.advanced", CategoryMailAttachment, RiskAdvanced, "explanation.mailAttachment.advanced"}, + + // Old backups. + {"oldBackup.safe", CategoryOldBackup, RiskSafe, "explanation.oldBackup.safe"}, + {"oldBackup.review", CategoryOldBackup, RiskReview, "explanation.oldBackup.review"}, + {"oldBackup.advanced", CategoryOldBackup, RiskAdvanced, "explanation.oldBackup.advanced"}, + + // Unknown category. + {"unknown.safe", CategoryUnknown, RiskSafe, "explanation.unknown.safe"}, + {"unknown.review", CategoryUnknown, RiskReview, "explanation.unknown.review"}, + {"unknown.advanced", CategoryUnknown, RiskAdvanced, "explanation.unknown.advanced"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := explanationKeyFor(tt.category, tt.risk) + if got != tt.want { + t.Errorf("explanationKeyFor(%q, %q) = %q, want %q", tt.category, tt.risk, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// JSON entry serialization – new fields and backward compatibility +// --------------------------------------------------------------------------- + +func TestJSONEntryHasNewFields(t *testing.T) { + entry := jsonEntry{ + Name: "node_modules", + Path: "/Users/john/project/node_modules", + Size: 1024, + IsDir: true, + RiskLevel: "safe", + StorageCategory: "developerArtifact", + LastAccessed: "2025-01-01T00:00:00Z", + CreatedDate: "2024-06-15T12:00:00Z", + ExplanationKey: "explanation.developerArtifact.safe", + } + + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("json.Marshal entry: %v", err) + } + + // Verify all new fields are present in the JSON output. + fields := map[string]string{ + `"risk_level"`: "safe", + `"storage_category"`: "developerArtifact", + `"last_accessed"`: "2025-01-01T00:00:00Z", + `"created_date"`: "2024-06-15T12:00:00Z", + `"explanation_key"`: "explanation.developerArtifact.safe", + } + for field, val := range fields { + if !containsSubstring(string(data), field) { + t.Errorf("JSON output missing field %s: %s", field, string(data)) + } + if !containsSubstring(string(data), val) { + t.Errorf("JSON output missing value %s for field: %s", val, string(data)) + } + } +} + +func TestJSONEntryBackwardCompatibility(t *testing.T) { + entry := jsonEntry{ + Name: "build", + Path: "/Users/john/project/build", + Size: 2048, + IsDir: true, + } + + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("json.Marshal entry: %v", err) + } + + // Existing fields must still be present. + requiredFields := []string{`"name"`, `"path"`, `"size"`, `"is_dir"`} + for _, field := range requiredFields { + if !containsSubstring(string(data), field) { + t.Errorf("backward compat: JSON missing required field %s: %s", field, string(data)) + } + } + + // Omitted fields should not appear (omitempty). + omittedFields := []string{`"risk_level"`, `"storage_category"`, `"last_accessed"`, `"created_date"`, `"explanation_key"`} + for _, field := range omittedFields { + if containsSubstring(string(data), field) { + t.Errorf("backward compat: omitted field %s should not appear: %s", field, string(data)) + } + } +} + +func TestJSONOutputStructure(t *testing.T) { + output := jsonOutput{ + Path: "/Users/john", + TotalSize: 5000, + TotalFiles: 10, + Entries: []jsonEntry{ + { + Name: ".cache", + Path: "/Users/john/.cache", + Size: 1000, + IsDir: true, + RiskLevel: "safe", + StorageCategory: "systemCache", + ExplanationKey: "explanation.systemCache.safe", + }, + { + Name: "Documents", + Path: "/Users/john/Documents", + Size: 4000, + IsDir: true, + RiskLevel: "review", + StorageCategory: "unknown", + ExplanationKey: "explanation.unknown.review", + }, + }, + } + + data, err := json.Marshal(output) + if err != nil { + t.Fatalf("json.Marshal output: %v", err) + } + + // Top-level fields must exist. + topFields := []string{`"path"`, `"entries"`, `"total_size"`, `"total_files"`} + for _, field := range topFields { + if !containsSubstring(string(data), field) { + t.Errorf("JSON output missing top-level field %s: %s", field, string(data)) + } + } +} + +// --------------------------------------------------------------------------- +// isDeveloperArtifactName – spot checks +// --------------------------------------------------------------------------- + +func TestIsDeveloperArtifactName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"__pycache__", "__pycache__", true}, + {".pytest_cache", ".pytest_cache", true}, + {".next", ".next", true}, + {"build", "build", true}, + {"dist", "dist", true}, + {".idea", ".idea", true}, + {".vs", ".vs", true}, + {"Pods", "Pods", true}, + {".terraform", ".terraform", true}, + {"random_dir", "random_dir", false}, + {"Documents", "Documents", false}, + {"Downloads", "Downloads", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isDeveloperArtifactName(tt.input) + if got != tt.want { + t.Errorf("isDeveloperArtifactName(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// helper: string contains check. +func containsSubstring(s, sub string) bool { + return len(s) >= len(sub) && searchString(s, sub) +} + +func searchString(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/analyze/scanner.go b/cmd/analyze/scanner.go index f22387b87..485585e64 100644 --- a/cmd/analyze/scanner.go +++ b/cmd/analyze/scanner.go @@ -143,11 +143,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.AddInt64(&total, size) trySend(entryChan, dirEntry{ - Name: child.Name() + " →", - Path: fullPath, - Size: size, - IsDir: isDir, - LastAccess: getLastAccessTimeFromInfo(info), + Name: child.Name() + " →", + Path: fullPath, + Size: size, + IsDir: isDir, + LastAccess: getLastAccessTimeFromInfo(info), + CreatedDate: getCreationTimeFromInfo(info), }, 100*time.Millisecond) continue @@ -255,11 +256,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in localBytesScanned += size trySend(entryChan, dirEntry{ - Name: child.Name(), - Path: fullPath, - Size: size, - IsDir: false, - LastAccess: getLastAccessTimeFromInfo(info), + Name: child.Name(), + Path: fullPath, + Size: size, + IsDir: false, + LastAccess: getLastAccessTimeFromInfo(info), + CreatedDate: getCreationTimeFromInfo(info), }, 100*time.Millisecond) // Track large files only. @@ -740,3 +742,11 @@ func getLastAccessTimeFromInfo(info fs.FileInfo) time.Time { } return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec) } + +func getCreationTimeFromInfo(info fs.FileInfo) time.Time { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return time.Time{} + } + return time.Unix(stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +}