diff --git a/Sources/mas/Commands/Ignore.swift b/Sources/mas/Commands/Ignore.swift new file mode 100644 index 000000000..1bab38631 --- /dev/null +++ b/Sources/mas/Commands/Ignore.swift @@ -0,0 +1,176 @@ +// +// Ignore.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +internal import ArgumentParser +private import Foundation + +extension MAS { + struct Ignore: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Manage ignored apps and versions", + subcommands: [Add.self, Remove.self, List.self, Clear.self] + ) + } +} + +private func promptYesNo(_ message: String) -> Bool { + MAS.printer.info(message, terminator: " ") + guard let response = readLine() else { + return false + } + + let normalized = response.trimmingCharacters(in: .whitespaces).lowercased() + // Default to "yes" if user just presses ENTER + return normalized.isEmpty || normalized == "y" || normalized == "yes" +} + +extension MAS.Ignore { + struct Add: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Add an app or app version to the ignore list" + ) + + @Argument(help: "App ID to ignore") + var adamID: ADAMID + + @Option(name: .shortAndLong, help: "Specific version to ignore (optional)") + var version: String? + + @Flag(name: .shortAndLong, help: "Skip confirmation prompts") + var yes = false + + func run() async throws { + let cleanedVersion = version?.trimmingCharacters(in: CharacterSet(charactersIn: "()")) + let ignoreList = IgnoreList.shared + + // Case 1: User wants to add a specific version, but all versions are already ignored + if let cleanedVersion, await ignoreList.hasAllVersionsIgnore(adamID: adamID) { + MAS.printer.warning( + "App \(adamID) already has all versions ignored." + ) + if yes || promptYesNo("Replace with version-specific ignore for \(cleanedVersion)? [Y/n]:") { + // Remove the all-versions entry + try await ignoreList.remove(IgnoreEntry(adamID: adamID, version: nil)) + // Add the specific version entry + let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion) + try await ignoreList.add(entry) + MAS.printer.info("Replaced all-versions ignore with version-specific ignore for \(cleanedVersion)") + } else { + MAS.printer.info("Keeping existing all-versions ignore") + } + return + } + + // Case 2: User wants to ignore all versions, but specific versions are already ignored + if cleanedVersion == nil, await ignoreList.hasSpecificVersionIgnores(adamID: adamID) { + let versionsList = await ignoreList.entriesFor(adamID: adamID) + .compactMap(\.version) + .sorted() + .joined(separator: ", ") + MAS.printer.warning( + "App \(adamID) already has specific version(s) ignored: \(versionsList)" + ) + if yes || promptYesNo("Replace with all-versions ignore? [Y/n]:") { + // Remove all existing entries for this adamID + try await ignoreList.removeAll(forADAMID: adamID) + // Add the all-versions entry + let entry = IgnoreEntry(adamID: adamID, version: nil) + try await ignoreList.add(entry) + MAS.printer.info("Replaced version-specific ignores with all-versions ignore") + } else { + MAS.printer.info("Keeping existing version-specific ignores") + } + return + } + + // Normal case: no conflicts + let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion) + try await ignoreList.add(entry) + + if let cleanedVersion { + MAS.printer.info("Ignoring \(adamID) version \(cleanedVersion)") + } else { + MAS.printer.info("Ignoring all versions of \(adamID)") + } + } + } + + struct Remove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Remove an app or app version from the ignore list" + ) + + @Argument(help: "App ID to stop ignoring") + var adamID: ADAMID + + @Option(name: .shortAndLong, help: "Specific version to stop ignoring (optional)") + var version: String? + + @Flag(name: .shortAndLong, help: "Remove all ignore entries for this app ID") + var all = false + + func run() async throws { + if all { + try await IgnoreList.shared.removeAll(forADAMID: adamID) + MAS.printer.info("Removed all ignore entries for \(adamID)") + } else { + let cleanedVersion = version?.trimmingCharacters(in: CharacterSet(charactersIn: "()")) + let entry = IgnoreEntry(adamID: adamID, version: cleanedVersion) + try await IgnoreList.shared.remove(entry) + + if let cleanedVersion { + MAS.printer.info("No longer ignoring \(adamID) version \(cleanedVersion)") + } else { + MAS.printer.info("No longer ignoring all versions of \(adamID)") + } + } + } + } + + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List all ignored apps and versions" + ) + + func run() async { + let entries = await IgnoreList.shared.all() + + guard !entries.isEmpty else { + MAS.printer.info("No ignored apps") + return + } + + let maxADAMIDLength = entries.map { String(describing: $0.adamID).count }.max() ?? 0 + let format = "%\(maxADAMIDLength)lu %@" + + MAS.printer.info( + entries.map { entry in + String( + format: format, + entry.adamID, + entry.version ?? "(all versions)" + ) + } + .joined(separator: "\n") + ) + } + } + + struct Clear: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Clear all ignored apps and versions" + ) + + func run() async throws { + let entries = await IgnoreList.shared.all() + for entry in entries { + try await IgnoreList.shared.remove(entry) + } + MAS.printer.info("Cleared all ignore entries") + } + } +} diff --git a/Sources/mas/Commands/MAS.swift b/Sources/mas/Commands/MAS.swift index 0ad38fc39..437b59ada 100644 --- a/Sources/mas/Commands/MAS.swift +++ b/Sources/mas/Commands/MAS.swift @@ -18,6 +18,7 @@ struct MAS: AsyncParsableCommand, Sendable { Config.self, Get.self, Home.self, + Ignore.self, Install.self, List.self, Lookup.self, diff --git a/Sources/mas/Commands/OutdatedAppCommand.swift b/Sources/mas/Commands/OutdatedAppCommand.swift index e98a6fe9a..2ea8c19c6 100644 --- a/Sources/mas/Commands/OutdatedAppCommand.swift +++ b/Sources/mas/Commands/OutdatedAppCommand.swift @@ -37,6 +37,7 @@ extension OutdatedAppCommand { // swiftlint:disable:this file_types_order await withTaskGroup(of: OutdatedApp?.self, returning: [OutdatedApp].self) { group in let installedApps = await installedApps .filter(by: optionalAppIDsOptionGroup) // swiftformat:disable indent + .filterOutIgnoredApps() .filterOutApps( unknownTo: appCatalog, if: shouldIgnoreUnknownApps, @@ -70,9 +71,10 @@ extension OutdatedAppCommand { // swiftlint:disable:this file_types_order }, inaccurate: { await installedApps - .filter(by: optionalAppIDsOptionGroup) // swiftformat:disable:this indent - .outdated(appCatalog: appCatalog, shouldWarnIfUnknownApp: verboseOptionGroup.verbose) - } // swiftformat:disable:previous indent + .filter(by: optionalAppIDsOptionGroup) + .filterOutIgnoredApps() + .outdated(appCatalog: appCatalog, shouldWarnIfUnknownApp: verboseOptionGroup.verbose) + } ) } } @@ -85,18 +87,26 @@ typealias OutdatedApp = ( private extension InstalledApp { var outdated: OutdatedApp? { get async { - await withCheckedContinuation { continuation in + let ignoreList = IgnoreList.shared + if await ignoreList.isIgnored(adamID: adamID) { + return nil + } + + return await withCheckedContinuation { continuation in Task { let alreadyResumed = ManagedAtomic(false) do { try await AppStore.install.app(withADAMID: adamID) { appStoreVersion, shouldOutput in - if - shouldOutput, - let appStoreVersion, - version != appStoreVersion, - !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing) - { - continuation.resume(returning: OutdatedApp(self, appStoreVersion)) + Task { + if + shouldOutput, + let appStoreVersion, + version != appStoreVersion, + !alreadyResumed.exchange(true, ordering: .acquiringAndReleasing), + !(await ignoreList.isIgnored(adamID: adamID, version: appStoreVersion)) + { + continuation.resume(returning: OutdatedApp(self, appStoreVersion)) + } } return true } @@ -163,10 +173,14 @@ private extension [InstalledApp] { } func outdated(appCatalog: some AppCatalog, shouldWarnIfUnknownApp: Bool) async -> [OutdatedApp] { - await compactMap { installedApp in + let ignoreList = IgnoreList.shared + return await compactMap { installedApp in do { let catalogApp = try await appCatalog.lookup(appID: .adamID(installedApp.adamID)) if installedApp.isOutdated(comparedTo: catalogApp) { + if await ignoreList.isIgnored(adamID: installedApp.adamID, version: catalogApp.version) { + return nil + } return OutdatedApp(installedApp, catalogApp.version) } } catch { @@ -177,6 +191,17 @@ private extension [InstalledApp] { } } +private extension [InstalledApp] { + func filterOutIgnoredApps() async -> Self { + let ignoreList = IgnoreList.shared + var filtered = [InstalledApp]() + for app in self where !(await ignoreList.isIgnored(adamID: app.adamID)) { + filtered.append(app) + } + return filtered + } +} + private extension Error { func print(forExpectedAppName appName: String, shouldWarnIfUnknownApp: Bool) { guard let error = self as? MASError, case MASError.unknownAppID = error else { diff --git a/Sources/mas/Controllers/IgnoreList.swift b/Sources/mas/Controllers/IgnoreList.swift new file mode 100644 index 000000000..478c8be2e --- /dev/null +++ b/Sources/mas/Controllers/IgnoreList.swift @@ -0,0 +1,80 @@ +// +// IgnoreList.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +private import Foundation + +actor IgnoreList { + static let shared = IgnoreList() + + private var entries = Set() + private let fileURL: URL + + private init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let masDirectory = appSupport.appendingPathComponent("mas", isDirectory: true) + fileURL = masDirectory.appendingPathComponent("ignore.json") + + try? FileManager.default.createDirectory(at: masDirectory, withIntermediateDirectories: true) + + if let data = try? Data(contentsOf: fileURL) { + entries = (try? JSONDecoder().decode(Set.self, from: data)) ?? [] + } + } + + func add(_ entry: IgnoreEntry) throws { + entries.insert(entry) + try save() + } + + func remove(_ entry: IgnoreEntry) throws { + entries.remove(entry) + try save() + } + + func removeAll(forADAMID adamID: ADAMID) throws { + entries = entries.filter { $0.adamID != adamID } + try save() + } + + func isIgnored(adamID: ADAMID, version: String) -> Bool { + entries.contains { $0.matches(adamID: adamID, version: version) } + } + + func isIgnored(adamID: ADAMID) -> Bool { + entries.contains { $0.adamID == adamID && $0.version == nil } + } + + func all() -> [IgnoreEntry] { + Array(entries).sorted { lhs, rhs in + if lhs.adamID != rhs.adamID { + return lhs.adamID < rhs.adamID + } + guard let lhsVersion = lhs.version, let rhsVersion = rhs.version else { + return lhs.version == nil + } + + return lhsVersion < rhsVersion + } + } + + func entriesFor(adamID: ADAMID) -> [IgnoreEntry] { + entries.filter { $0.adamID == adamID } + } + + func hasAllVersionsIgnore(adamID: ADAMID) -> Bool { + entries.contains { $0.adamID == adamID && $0.version == nil } + } + + func hasSpecificVersionIgnores(adamID: ADAMID) -> Bool { + entries.contains { $0.adamID == adamID && $0.version != nil } + } + + private func save() throws { + let data = try JSONEncoder().encode(entries) + try data.write(to: fileURL, options: .atomic) + } +} diff --git a/Sources/mas/Models/IgnoreEntry.swift b/Sources/mas/Models/IgnoreEntry.swift new file mode 100644 index 000000000..df55b5ec0 --- /dev/null +++ b/Sources/mas/Models/IgnoreEntry.swift @@ -0,0 +1,24 @@ +// +// IgnoreEntry.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +struct IgnoreEntry: Codable, Hashable, Sendable { + let adamID: ADAMID + let version: String? + + init(adamID: ADAMID, version: String? = nil) { + self.adamID = adamID + self.version = version + } + + func matches(adamID: ADAMID, version: String) -> Bool { + guard self.adamID == adamID else { + return false + } + + return self.version == nil || self.version == version + } +} diff --git a/Tests/MASTests/Commands/MASTests+Ignore.swift b/Tests/MASTests/Commands/MASTests+Ignore.swift new file mode 100644 index 000000000..46dd79443 --- /dev/null +++ b/Tests/MASTests/Commands/MASTests+Ignore.swift @@ -0,0 +1,267 @@ +// +// MASTests+Ignore.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +private import ArgumentParser +@testable private import mas +internal import Testing + +private extension MASTests { + @Test + func addsVersionSpecificIgnore() async throws { + // Clear any existing entries first + try await IgnoreList.shared.removeAll(forADAMID: 12345) + + let actual = await consequencesOf(try await MAS.main(try MAS.Ignore.Add.parse(["12345", "--version", "1.0"]))) + let expected = Consequences(nil, "Ignoring 12345 version 1.0\n") + #expect(actual == expected) + + // Verify entry was added + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 12345, version: "1.0") + #expect(isIgnored == true) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 12345) + } + + @Test + func addsAllVersionsIgnore() async throws { + // Clear any existing entries first + try await IgnoreList.shared.removeAll(forADAMID: 67890) + + let actual = await consequencesOf(try await MAS.main(try MAS.Ignore.Add.parse(["67890"]))) + let expected = Consequences(nil, "Ignoring all versions of 67890\n") + #expect(actual == expected) + + // Verify entry was added + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 67890) + #expect(isIgnored == true) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 67890) + } + + @Test + func stripsParenthesesFromVersion() async throws { + // Clear any existing entries first + try await IgnoreList.shared.removeAll(forADAMID: 11111) + + let actual = await consequencesOf(try await MAS.main(try MAS.Ignore.Add.parse(["11111", "--version", "(2.5)"]))) + let expected = Consequences(nil, "Ignoring 11111 version 2.5\n") + #expect(actual == expected) + + // Verify entry was added with cleaned version + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 11111, version: "2.5") + #expect(isIgnored == true) + + // Should not match with parentheses + let isIgnoredWithParens = await IgnoreList.shared.isIgnored(adamID: 11111, version: "(2.5)") + #expect(isIgnoredWithParens == false) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 11111) + } + + @Test + func warnsWhenAddingSpecificVersionWithExistingAllVersionsIgnore() async throws { + // Setup: Add all-versions ignore first + try await IgnoreList.shared.removeAll(forADAMID: 22222) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 22222, version: nil)) + + // Try to add specific version with --yes flag (to skip prompt) + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Add.parse(["22222", "--version", "1.0", "--yes"])) + ) + + // Should warn and replace + #expect(actual.stderr.contains("Warning: App 22222 already has all versions ignored.")) + #expect(actual.stdout.contains("Replaced all-versions ignore with version-specific ignore for 1.0")) + + // Verify all-versions was removed and specific version was added + let hasAllVersions = await IgnoreList.shared.isIgnored(adamID: 22222) + #expect(hasAllVersions == false) + let hasSpecificVersion = await IgnoreList.shared.isIgnored(adamID: 22222, version: "1.0") + #expect(hasSpecificVersion == true) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 22222) + } + + @Test + func warnsWhenAddingAllVersionsWithExistingSpecificVersionIgnores() async throws { + // Setup: Add specific version ignores first + try await IgnoreList.shared.removeAll(forADAMID: 33333) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 33333, version: "1.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 33333, version: "1.1")) + + // Try to add all-versions with --yes flag (to skip prompt) + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Add.parse(["33333", "--yes"])) + ) + + // Should warn and replace + #expect(actual.stderr.contains("Warning: App 33333 already has specific version(s) ignored")) + #expect(actual.stdout.contains("Replaced version-specific ignores with all-versions ignore")) + + // Verify specific version entries were removed and all-versions was added + let entries = await IgnoreList.shared.entriesFor(adamID: 33333) + #expect(entries.count == 1) + #expect(entries[0].version == nil) + + // The isIgnored checks should all return true now because all versions are ignored + let hasAllVersions = await IgnoreList.shared.isIgnored(adamID: 33333) + #expect(hasAllVersions == true) + let matchesAnyVersion1 = await IgnoreList.shared.isIgnored(adamID: 33333, version: "1.0") + #expect(matchesAnyVersion1 == true) + let matchesAnyVersion2 = await IgnoreList.shared.isIgnored(adamID: 33333, version: "1.1") + #expect(matchesAnyVersion2 == true) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 33333) + } + + @Test + func removesVersionSpecificIgnore() async throws { + // Setup: Add an entry first + try await IgnoreList.shared.removeAll(forADAMID: 44444) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 44444, version: "3.0")) + + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Remove.parse(["44444", "--version", "3.0"])) + ) + let expected = Consequences(nil, "No longer ignoring 44444 version 3.0\n") + #expect(actual == expected) + + // Verify entry was removed + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 44444, version: "3.0") + #expect(isIgnored == false) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 44444) + } + + @Test + func removesAllVersionsIgnore() async throws { + // Setup: Add an entry first + try await IgnoreList.shared.removeAll(forADAMID: 55555) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 55555, version: nil)) + + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Remove.parse(["55555"])) + ) + let expected = Consequences(nil, "No longer ignoring all versions of 55555\n") + #expect(actual == expected) + + // Verify entry was removed + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 55555) + #expect(isIgnored == false) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 55555) + } + + @Test + func removesAllEntriesForAppID() async throws { + // Setup: Add multiple entries + try await IgnoreList.shared.removeAll(forADAMID: 66666) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 66666, version: "1.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 66666, version: "2.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 66666, version: nil)) + + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Remove.parse(["66666", "--all"])) + ) + let expected = Consequences(nil, "Removed all ignore entries for 66666\n") + #expect(actual == expected) + + // Verify all entries were removed + let entries = await IgnoreList.shared.entriesFor(adamID: 66666) + #expect(entries.isEmpty == true) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 66666) + } + + @Test + func removeStripsParenthesesFromVersion() async throws { + // Setup: Add entry with clean version + try await IgnoreList.shared.removeAll(forADAMID: 77777) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 77777, version: "4.5")) + + // Remove with parentheses + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Remove.parse(["77777", "--version", "(4.5)"])) + ) + let expected = Consequences(nil, "No longer ignoring 77777 version 4.5\n") + #expect(actual == expected) + + // Verify entry was removed + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 77777, version: "4.5") + #expect(isIgnored == false) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 77777) + } + + @Test + func listsIgnoredApps() async throws { + // Setup: Add multiple entries + try await IgnoreList.shared.removeAll(forADAMID: 88888) + try await IgnoreList.shared.removeAll(forADAMID: 99999) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 88888, version: "1.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 99999, version: nil)) + + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.List.parse([])) + ) + + // Should contain both entries + #expect(actual.stdout.contains("88888")) + #expect(actual.stdout.contains("1.0")) + #expect(actual.stdout.contains("99999")) + #expect(actual.stdout.contains("(all versions)")) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 88888) + try await IgnoreList.shared.removeAll(forADAMID: 99999) + } + + @Test + func listsNoIgnoredAppsWhenEmpty() async throws { + // Setup: Clear specific test IDs + try await IgnoreList.shared.removeAll(forADAMID: 10001) + + // If there are other entries from other tests, we can't guarantee empty list + // So we'll just check that our specific IDs aren't there + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.List.parse([])) + ) + + // Should not contain our test ID + #expect(!actual.stdout.contains("10001")) + } + + @Test + func clearsAllIgnoredApps() async throws { + // Setup: Add some entries + try await IgnoreList.shared.removeAll(forADAMID: 11111) + try await IgnoreList.shared.removeAll(forADAMID: 22222) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 11111, version: "1.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 22222, version: nil)) + + let actual = await consequencesOf( + try await MAS.main(try MAS.Ignore.Clear.parse([])) + ) + let expected = Consequences(nil, "Cleared all ignore entries\n") + #expect(actual == expected) + + // Verify entries were removed + let entries1 = await IgnoreList.shared.entriesFor(adamID: 11111) + let entries2 = await IgnoreList.shared.entriesFor(adamID: 22222) + #expect(entries1.isEmpty == true) + #expect(entries2.isEmpty == true) + } +} diff --git a/Tests/MASTests/Controllers/MASTests+IgnoreList.swift b/Tests/MASTests/Controllers/MASTests+IgnoreList.swift new file mode 100644 index 000000000..742365d51 --- /dev/null +++ b/Tests/MASTests/Controllers/MASTests+IgnoreList.swift @@ -0,0 +1,165 @@ +// +// MASTests+IgnoreList.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +@testable private import mas +internal import Testing + +private extension MASTests { + @Test + func addsIgnoreEntry() async throws { + let entry = IgnoreEntry(adamID: 12345, version: "1.0") + try await IgnoreList.shared.add(entry) + + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 12345, version: "1.0") + #expect(isIgnored == true) + + // Cleanup + try await IgnoreList.shared.remove(entry) + } + + @Test + func removesIgnoreEntry() async throws { + let entry = IgnoreEntry(adamID: 23456, version: "2.0") + try await IgnoreList.shared.add(entry) + try await IgnoreList.shared.remove(entry) + + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 23456, version: "2.0") + #expect(isIgnored == false) + } + + @Test + func checksIfSpecificVersionIsIgnored() async throws { + let entry = IgnoreEntry(adamID: 34567, version: "3.0") + try await IgnoreList.shared.add(entry) + + // Should match exact version + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 34567, version: "3.0") + #expect(isIgnored == true) + + // Should not match different version + let isIgnoredDifferent = await IgnoreList.shared.isIgnored(adamID: 34567, version: "4.0") + #expect(isIgnoredDifferent == false) + + // Cleanup + try await IgnoreList.shared.remove(entry) + } + + @Test + func checksIfAllVersionsAreIgnored() async throws { + let entry = IgnoreEntry(adamID: 45678, version: nil) + try await IgnoreList.shared.add(entry) + + // Should match with no version specified + let isIgnored = await IgnoreList.shared.isIgnored(adamID: 45678) + #expect(isIgnored == true) + + // Should also match any specific version + let isIgnoredVersion1 = await IgnoreList.shared.isIgnored(adamID: 45678, version: "1.0") + #expect(isIgnoredVersion1 == true) + let isIgnoredVersion2 = await IgnoreList.shared.isIgnored(adamID: 45678, version: "2.0") + #expect(isIgnoredVersion2 == true) + + // Cleanup + try await IgnoreList.shared.remove(entry) + } + + @Test + func removesAllEntriesForADAMID() async throws { + let entry1 = IgnoreEntry(adamID: 56789, version: "1.0") + let entry2 = IgnoreEntry(adamID: 56789, version: "2.0") + let entry3 = IgnoreEntry(adamID: 56789, version: nil) + + try await IgnoreList.shared.add(entry1) + try await IgnoreList.shared.add(entry2) + try await IgnoreList.shared.add(entry3) + + try await IgnoreList.shared.removeAll(forADAMID: 56789) + + let entries = await IgnoreList.shared.entriesFor(adamID: 56789) + #expect(entries.isEmpty == true) + } + + @Test + func getsEntriesForADAMID() async throws { + let entry1 = IgnoreEntry(adamID: 67890, version: "1.0") + let entry2 = IgnoreEntry(adamID: 67890, version: "2.0") + let entry3 = IgnoreEntry(adamID: 78901, version: "1.0") + + try await IgnoreList.shared.add(entry1) + try await IgnoreList.shared.add(entry2) + try await IgnoreList.shared.add(entry3) + + let entries = await IgnoreList.shared.entriesFor(adamID: 67890) + #expect(entries.count == 2) + #expect(entries.contains(entry1) == true) + #expect(entries.contains(entry2) == true) + #expect(entries.contains(entry3) == false) + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 67890) + try await IgnoreList.shared.removeAll(forADAMID: 78901) + } + + @Test + func checksIfHasAllVersionsIgnore() async throws { + let entry = IgnoreEntry(adamID: 11223, version: nil) + try await IgnoreList.shared.add(entry) + + let hasAllVersions = await IgnoreList.shared.hasAllVersionsIgnore(adamID: 11223) + #expect(hasAllVersions == true) + + // Should return false for app with no ignore + let hasAllVersionsNone = await IgnoreList.shared.hasAllVersionsIgnore(adamID: 99998) + #expect(hasAllVersionsNone == false) + + // Cleanup + try await IgnoreList.shared.remove(entry) + } + + @Test + func checksIfHasSpecificVersionIgnores() async throws { + let entry = IgnoreEntry(adamID: 22334, version: "1.0") + try await IgnoreList.shared.add(entry) + + let hasSpecific = await IgnoreList.shared.hasSpecificVersionIgnores(adamID: 22334) + #expect(hasSpecific == true) + + // Should return false for app with no ignore + let hasSpecificNone = await IgnoreList.shared.hasSpecificVersionIgnores(adamID: 99997) + #expect(hasSpecificNone == false) + + // Cleanup + try await IgnoreList.shared.remove(entry) + } + + @Test + func sortsEntriesCorrectly() async throws { + // Add entries in random order + try await IgnoreList.shared.add(IgnoreEntry(adamID: 33445, version: "2.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 33445, version: nil)) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 22334, version: "1.0")) + try await IgnoreList.shared.add(IgnoreEntry(adamID: 33445, version: "1.0")) + + let all = await IgnoreList.shared.all() + let filtered = all.filter { $0.adamID == 33445 || $0.adamID == 22334 } + + // Should be sorted by adamID first + #expect(filtered[0].adamID < filtered[1].adamID) + + // Within same adamID, all-versions (nil) should come first + let adam33445 = filtered.filter { $0.adamID == 33445 } + #expect(adam33445[0].version == nil) + + // Then version-specific should be sorted alphabetically + #expect(adam33445[1].version == "1.0") + #expect(adam33445[2].version == "2.0") + + // Cleanup + try await IgnoreList.shared.removeAll(forADAMID: 22334) + try await IgnoreList.shared.removeAll(forADAMID: 33445) + } +} diff --git a/Tests/MASTests/Models/MASTests+IgnoreEntry.swift b/Tests/MASTests/Models/MASTests+IgnoreEntry.swift new file mode 100644 index 000000000..a0dc368b0 --- /dev/null +++ b/Tests/MASTests/Models/MASTests+IgnoreEntry.swift @@ -0,0 +1,119 @@ +// +// MASTests+IgnoreEntry.swift +// mas +// +// Copyright © 2025 mas-cli. All rights reserved. +// + +private import Foundation +@testable private import mas +internal import Testing + +private extension MASTests { + @Test + func createsEntryWithVersion() { + let entry = IgnoreEntry(adamID: 12345, version: "1.0") + #expect(entry.adamID == 12345) + #expect(entry.version == "1.0") + } + + @Test + func createsEntryWithoutVersion() { + let entry = IgnoreEntry(adamID: 67890, version: nil) + #expect(entry.adamID == 67890) + #expect(entry.version == nil) + } + + @Test + func matchesExactVersion() { + let entry = IgnoreEntry(adamID: 12345, version: "2.0") + + // Should match exact version + #expect(entry.matches(adamID: 12345, version: "2.0") == true) + + // Should not match different version + #expect(entry.matches(adamID: 12345, version: "3.0") == false) + + // Should not match different adamID + #expect(entry.matches(adamID: 67890, version: "2.0") == false) + } + + @Test + func matchesAllVersionsWithNilVersion() { + let entry = IgnoreEntry(adamID: 12345, version: nil) + + // Should match any version + #expect(entry.matches(adamID: 12345, version: "1.0") == true) + #expect(entry.matches(adamID: 12345, version: "2.0") == true) + #expect(entry.matches(adamID: 12345, version: "99.99") == true) + + // Should not match different adamID + #expect(entry.matches(adamID: 67890, version: "1.0") == false) + } + + @Test + func encodesToJSON() throws { + let entry = IgnoreEntry(adamID: 12345, version: "1.0") + let encoder = JSONEncoder() + let data = try encoder.encode(entry) + let json = String(data: data, encoding: .utf8)! // swiftlint:disable:this force_unwrapping + + #expect(json.contains("12345")) + #expect(json.contains("1.0")) + } + + @Test + func decodesFromJSON() throws { + let json = """ + {"adamID":12345,"version":"1.0"} + """ + let decoder = JSONDecoder() + let entry = try decoder.decode(IgnoreEntry.self, from: Data(json.utf8)) + + #expect(entry.adamID == 12345) + #expect(entry.version == "1.0") + } + + @Test + func encodesAndDecodesWithoutVersion() throws { + let entry = IgnoreEntry(adamID: 67890, version: nil) + let encoder = JSONEncoder() + let data = try encoder.encode(entry) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(IgnoreEntry.self, from: data) + + #expect(decoded.adamID == 67890) + #expect(decoded.version == nil) + } + + @Test + func implementsHashable() { + let entry1 = IgnoreEntry(adamID: 12345, version: "1.0") + let entry2 = IgnoreEntry(adamID: 12345, version: "1.0") + let entry3 = IgnoreEntry(adamID: 12345, version: "2.0") + + // Same entries should be equal and have same hash + #expect(entry1 == entry2) + #expect(entry1.hashValue == entry2.hashValue) + + // Different entries should not be equal + #expect(entry1 != entry3) + } + + @Test + func worksInSet() { + var set = Set() + let entry1 = IgnoreEntry(adamID: 12345, version: "1.0") + let entry2 = IgnoreEntry(adamID: 12345, version: "1.0") + let entry3 = IgnoreEntry(adamID: 12345, version: "2.0") + + set.insert(entry1) + set.insert(entry2) // Duplicate, should not be added + set.insert(entry3) + + #expect(set.count == 2) + #expect(set.contains(entry1) == true) + #expect(set.contains(entry3) == true) + } +}