diff --git a/Sources/ContainerClient/Core/ClientContainer.swift b/Sources/ContainerClient/Core/ClientContainer.swift index d2e16e3c..128267d7 100644 --- a/Sources/ContainerClient/Core/ClientContainer.swift +++ b/Sources/ContainerClient/Core/ClientContainer.swift @@ -122,6 +122,60 @@ extension ClientContainer { } } + public static func search(searches: [String]) async throws -> [ClientContainer] { + do { + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerSearch) + try request.set(key: .searches, value: searches) + + let response = try await xpcSend( + client: client, + message: request, + timeout: .seconds(10) + ) + + let data = response.dataNoCopy(key: .containers) + guard let data else { + return [] + } + let configs = try JSONDecoder().decode([ContainerSnapshot].self, from: data) + return configs.map { ClientContainer(snapshot: $0) } + } catch { + throw ContainerizationError( + .internalError, + message: "failed to search containers", + cause: error + ) + } + } + + public static func searchOne(search: String) async throws -> ClientContainer { + do { + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerSearchOne) + request.set(key: .search, value: search) + + let response = try await xpcSend( + client: client, + message: request, + timeout: .seconds(10) + ) + + let data = response.dataNoCopy(key: .containers) + guard let data else { + throw ContainerizationError(.notFound, message: "container \(search) not found") + } + let snapshot = try JSONDecoder().decode(ContainerSnapshot.self, from: data) + return ClientContainer(snapshot: snapshot) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to search for container", + cause: error + ) + } + } + /// Get the container for the provided id. public static func get(id: String) async throws -> ClientContainer { let containers = try await list() diff --git a/Sources/ContainerClient/Core/ClientNetwork.swift b/Sources/ContainerClient/Core/ClientNetwork.swift index 80106865..66b39a68 100644 --- a/Sources/ContainerClient/Core/ClientNetwork.swift +++ b/Sources/ContainerClient/Core/ClientNetwork.swift @@ -70,6 +70,34 @@ extension ClientNetwork { return states } + public static func search(searches: [String]) async throws -> [NetworkState] { + let client = Self.newClient() + let request = XPCMessage(route: .networkSearch) + try request.set(key: .searches, value: searches) + + let response = try await xpcSend(client: client, message: request, timeout: .seconds(1)) + let responseData = response.dataNoCopy(key: .networkStates) + guard let responseData else { + return [] + } + let states = try JSONDecoder().decode([NetworkState].self, from: responseData) + return states + } + + public static func searchOne(search: String) async throws -> NetworkState { + let client = Self.newClient() + let request = XPCMessage(route: .networkSearchOne) + request.set(key: .search, value: search) + + let response = try await xpcSend(client: client, message: request, timeout: .seconds(1)) + let responseData = response.dataNoCopy(key: .networkState) + guard let responseData else { + throw ContainerizationError(.notFound, message: "network \(search) not found") + } + let state = try JSONDecoder().decode(NetworkState.self, from: responseData) + return state + } + /// Get the network for the provided id. public static func get(id: String) async throws -> NetworkState { let networks = try await list() diff --git a/Sources/ContainerClient/Core/ClientVolume.swift b/Sources/ContainerClient/Core/ClientVolume.swift index c16fce19..612d039a 100644 --- a/Sources/ContainerClient/Core/ClientVolume.swift +++ b/Sources/ContainerClient/Core/ClientVolume.swift @@ -67,6 +67,32 @@ public struct ClientVolume { return try JSONDecoder().decode([Volume].self, from: responseData) } + public static func search(searches: [String]) async throws -> [Volume] { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeSearch) + try message.set(key: .searches, value: searches) + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .volumes) else { + return [] + } + + return try JSONDecoder().decode([Volume].self, from: responseData) + } + + public static func searchOne(search: String) async throws -> Volume { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeSearchOne) + message.set(key: .search, value: search) + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .volume) else { + throw VolumeError.volumeNotFound(search) + } + + return try JSONDecoder().decode(Volume.self, from: responseData) + } + public static func inspect(_ name: String) async throws -> Volume { let client = XPCClient(service: serviceIdentifier) let message = XPCMessage(route: .volumeInspect) diff --git a/Sources/ContainerClient/Core/StringMatcher.swift b/Sources/ContainerClient/Core/StringMatcher.swift new file mode 100644 index 00000000..7db4abbf --- /dev/null +++ b/Sources/ContainerClient/Core/StringMatcher.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A helper class for matching partial strings against a collection of candidates. +/// Used primarily for resolving partial container IDs to full IDs. +public struct StringMatcher { + + /// Represents the result of a match operation + public enum MatchResult: Equatable { + case exactMatch(String) + case singleMatch(String) + case noMatch + case multipleMatches([String]) + } + + /// Matches a partial string against a collection of candidates using prefix matching. + /// + /// Matching rules: + /// 1. Exact match takes precedence over prefix match + /// 2. Prefix matching is case-sensitive + /// 3. Only prefix matches are considered (no substring matching) + /// 4. If multiple candidates match, returns ambiguous result + /// + /// - Parameters: + /// - partial: The partial string to match + /// - candidates: The collection of strings to match against + /// - Returns: A MatchResult indicating the outcome of the match operation + public static func match(partial: String, candidates: [String]) -> MatchResult { + var matches: [String] = [] + for candidate in candidates { + if candidate == partial { + return .exactMatch(candidate) + } else { + if partial.count >= 2 && candidate.hasPrefix(partial) { + matches.append(candidate) + } + } + } + if matches.count == 0 { + return .noMatch + } else if matches.count == 1 { + return .singleMatch(matches[0]) + } else { + return .multipleMatches(matches) + } + } +} diff --git a/Sources/ContainerClient/Core/XPC+.swift b/Sources/ContainerClient/Core/XPC+.swift index 4b94d36c..76ca04dc 100644 --- a/Sources/ContainerClient/Core/XPC+.swift +++ b/Sources/ContainerClient/Core/XPC+.swift @@ -115,10 +115,17 @@ public enum XPCKeys: String { case volumeLabels case volumeReadonly case volumeContainerId + + /// Search terms + case searches + /// Single search term + case search } public enum XPCRoute: String { case containerList + case containerSearch + case containerSearchOne case containerCreate case containerBootstrap case containerCreateProcess @@ -142,10 +149,14 @@ public enum XPCRoute: String { case networkCreate case networkDelete case networkList + case networkSearch + case networkSearchOne case volumeCreate case volumeDelete case volumeList + case volumeSearch + case volumeSearchOne case volumeInspect case volumePrune @@ -243,6 +254,14 @@ extension XPCMessage { public func set(key: XPCKeys, value: xpc_endpoint_t) { set(key: key.rawValue, value: value) } + + public func stringArray(key: XPCKeys) -> [String]? { + stringArray(key: key.rawValue) + } + + public func set(key: XPCKeys, value: [String]) throws { + try set(key: key.rawValue, value: value) + } } #endif diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 8c262d68..0f6b31cc 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -53,70 +53,74 @@ extension Application { } public mutating func run() async throws { - let set = Set(containerIds) var containers = [ClientContainer]() if all { containers = try await ClientContainer.list() } else { - let ctrs = try await ClientContainer.list() - containers = ctrs.filter { c in - set.contains(c.id) - } - // If one of the containers requested isn't present, let's throw. We don't need to do + containers = try await ClientContainer.search(searches: containerIds) + + // If one of the search terms requested has no containers present, let's throw. We don't need to do // this for --all as --all should be perfectly usable with no containers to remove; otherwise, // it'd be quite clunky. - if containers.count != set.count { - let missing = set.filter { id in - !containers.contains { c in - c.id == id - } + var missingTerms: [String] = [] + + for searchTerm in containerIds { + if StringMatcher.match(partial: searchTerm, candidates: containers.map({ $0.id })) == .noMatch { + missingTerms.append(searchTerm) } + } + + if missingTerms.count > 0 { throw ContainerizationError( .notFound, - message: "failed to delete one or more containers: \(missing)" + message: "failed to delete one or more containers: \(missingTerms)" ) } } - var failed = [String]() - let force = self.force - let all = self.all - try await withThrowingTaskGroup(of: String?.self) { group in - for container in containers { - group.addTask { - do { - if container.status == .running && !force { - guard all else { - throw ContainerizationError(.invalidState, message: "container is running") - } - return nil // Skip running container when using --all - } + let failed = try await deleteContainers(containers: containers, force: self.force, all: self.all) + + if failed.count > 0 { + throw ContainerizationError( + .internalError, + message: "delete failed for one or more containers: \(failed)" + ) + } + } + } - try await container.delete(force: force) - print(container.id) - return nil - } catch { - log.error("failed to delete container \(container.id): \(error)") - return container.id + static func deleteContainers(containers: [ClientContainer], force: Bool, all: Bool) async throws -> [String] { + var failed = [String]() + try await withThrowingTaskGroup(of: String?.self) { group in + for container in containers { + group.addTask { + do { + if container.status == .running && !force { + guard all else { + throw ContainerizationError(.invalidState, message: "container is running") + } + return nil // Skip running container when using --all } - } - } - for try await ctr in group { - guard let ctr else { - continue + try await container.delete(force: force) + print(container.id) + return nil + } catch { + log.error("one or more search terms have no container matches \(container.id): \(error)") + return container.id } - failed.append(ctr) } } - if failed.count > 0 { - throw ContainerizationError( - .internalError, - message: "delete failed for one or more containers: \(failed)" - ) + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr) } } + + return failed } } diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift index 2573dbbc..96f9bcb3 100644 --- a/Sources/ContainerCommands/Container/ContainerExec.swift +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -45,7 +45,7 @@ extension Application { public func run() async throws { var exitCode: Int32 = 127 - let container = try await ClientContainer.get(id: containerId) + let container = try await ClientContainer.searchOne(search: containerId) try ensureRunning(container: container) let stdin = self.processFlags.interactive diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 68c1bc68..bd4130ed 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -34,12 +34,11 @@ extension Application { var containerIds: [String] public func run() async throws { - let objects: [any Codable] = try await ClientContainer.list().filter { - containerIds.contains($0.id) - }.map { - PrintableContainer($0) - } - print(try objects.jsonArray()) + let containers: [any Codable] = try await ClientContainer.search( + searches: containerIds + ) + .map({ PrintableContainer($0) }) + print(try containers.jsonArray()) } } } diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift index c6bc4079..6941d768 100644 --- a/Sources/ContainerCommands/Container/ContainerKill.swift +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -50,17 +50,7 @@ extension Application { } public mutating func run() async throws { - let set = Set(containerIds) - - var containers = try await ClientContainer.list().filter { c in - c.status == .running - } - if !self.all { - containers = containers.filter { c in - set.contains(c.id) - } - } - + let containers = try await ClientContainer.search(searches: containerIds) let signalNumber = try Signals.parseSignal(signal) var failed: [String] = [] diff --git a/Sources/ContainerCommands/Container/ContainerLogs.swift b/Sources/ContainerCommands/Container/ContainerLogs.swift index 35f09755..46f3c9ad 100644 --- a/Sources/ContainerCommands/Container/ContainerLogs.swift +++ b/Sources/ContainerCommands/Container/ContainerLogs.swift @@ -47,7 +47,7 @@ extension Application { public func run() async throws { do { - let container = try await ClientContainer.get(id: containerId) + let container = try await ClientContainer.searchOne(search: containerId) let fhs = try await container.logs() let fileHandle = boot ? fhs[1] : fhs[0] diff --git a/Sources/ContainerCommands/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift index cdef25a9..c023da96 100644 --- a/Sources/ContainerCommands/Container/ContainerStart.swift +++ b/Sources/ContainerCommands/Container/ContainerStart.swift @@ -53,7 +53,7 @@ extension Application { progress.start() let detach = !self.attach && !self.interactive - let container = try await ClientContainer.get(id: containerId) + let container = try await ClientContainer.searchOne(search: containerId) // Bootstrap and process start are both idempotent and don't fail the second time // around, however not doing an rpc is always faster :). The other bit is we don't diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 09654656..e30878fc 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -54,14 +54,11 @@ extension Application { } public mutating func run() async throws { - let set = Set(containerIds) var containers = [ClientContainer]() if self.all { containers = try await ClientContainer.list() } else { - containers = try await ClientContainer.list().filter { c in - set.contains(c.id) - } + containers = try await ClientContainer.search(searches: containerIds) } let opts = ContainerStopOptions( diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 35c57925..6787028e 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -51,34 +51,33 @@ extension Application { } public mutating func run() async throws { - let uniqueNetworkNames = Set(networkNames) let networks: [NetworkState] if all { networks = try await ClientNetwork.list() } else { - networks = try await ClientNetwork.list() - .filter { c in - uniqueNetworkNames.contains(c.id) - } + networks = try await ClientNetwork.search(searches: networkNames) - // If one of the networks requested isn't present lets throw. We don't need to do + // If one of the search terms requested has no networks present, let's throw. We don't need to do // this for --all as --all should be perfectly usable with no networks to remove, // otherwise it'd be quite clunky. - if networks.count != uniqueNetworkNames.count { - let missing = uniqueNetworkNames.filter { id in - !networks.contains { n in - n.id == id - } + var missingTerms: [String] = [] + + for searchTerm in networkNames { + if StringMatcher.match(partial: searchTerm, candidates: networks.map({ $0.id })) == .noMatch { + missingTerms.append(searchTerm) } + } + + if missingTerms.count > 0 { throw ContainerizationError( .notFound, - message: "failed to delete one or more networks: \(missing)" + message: "failed to delete one or more networks: \(missingTerms)" ) } } - if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) { + if networks.contains(where: { $0.id == ClientNetwork.defaultNetworkName }) { throw ContainerizationError( .invalidArgument, message: "cannot delete the default network" diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index bde6873e..a45d6ba2 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -35,11 +35,10 @@ extension Application { public init() {} public func run() async throws { - let objects: [any Codable] = try await ClientNetwork.list().filter { - networks.contains($0.id) - }.map { - PrintableNetwork($0) - } + let objects: [any Codable] = try await ClientNetwork.search(searches: networks) + .map { + PrintableNetwork($0) + } print(try objects.jsonArray()) } } diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 14447f6e..6be62cc0 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -39,29 +39,28 @@ extension Application.VolumeCommand { public init() {} public func run() async throws { - let uniqueVolumeNames = Set(names) let volumes: [Volume] if all { volumes = try await ClientVolume.list() } else { - volumes = try await ClientVolume.list() - .filter { v in - uniqueVolumeNames.contains(v.id) - } + volumes = try await ClientVolume.search(searches: names) - // If one of the volumes requested isn't present lets throw. We don't need to do + // If one of the search terms requested has no volumes present, let's throw. We don't need to do // this for --all as --all should be perfectly usable with no volumes to remove, // otherwise it'd be quite clunky. - if volumes.count != uniqueVolumeNames.count { - let missing = uniqueVolumeNames.filter { id in - !volumes.contains { v in - v.id == id - } + var missingTerms: [String] = [] + + for searchTerm in names { + if StringMatcher.match(partial: searchTerm, candidates: volumes.map({ $0.id })) == .noMatch { + missingTerms.append(searchTerm) } + } + + if missingTerms.count > 0 { throw ContainerizationError( .notFound, - message: "failed to delete one or more volumes: \(missing)" + message: "failed to delete one or more volumes: \(missingTerms)" ) } } diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift index 9a922f29..46adbb3b 100644 --- a/Sources/ContainerCommands/Volume/VolumeInspect.swift +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -34,12 +34,7 @@ extension Application.VolumeCommand { public init() {} public func run() async throws { - var volumes: [Volume] = [] - - for name in names { - let volume = try await ClientVolume.inspect(name) - volumes.append(volume) - } + let volumes = try await ClientVolume.search(searches: names) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index d14658a8..20af0140 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -165,6 +165,34 @@ extension XPCMessage { } } + public func stringArray(key: String) -> [String]? { + let xpcArray = lock.withLock { + xpc_dictionary_get_value(self.object, key) + } + guard let xpcArray else { + return nil + } + var result: [String] = [] + let count = xpc_array_get_count(xpcArray) + for i in 0.. Bool { lock.withLock { xpc_dictionary_get_bool(self.object, key) diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index da9a8412..83208661 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -202,6 +202,8 @@ extension APIServer { let harness = ContainersHarness(service: service, log: log) routes[XPCRoute.containerList] = harness.list + routes[XPCRoute.containerSearch] = harness.search + routes[XPCRoute.containerSearchOne] = harness.searchOne routes[XPCRoute.containerCreate] = harness.create routes[XPCRoute.containerDelete] = harness.delete routes[XPCRoute.containerLogs] = harness.logs @@ -246,6 +248,8 @@ extension APIServer { routes[XPCRoute.networkCreate] = harness.create routes[XPCRoute.networkDelete] = harness.delete routes[XPCRoute.networkList] = harness.list + routes[XPCRoute.networkSearch] = harness.search + routes[XPCRoute.networkSearchOne] = harness.searchOne return service } @@ -263,6 +267,8 @@ extension APIServer { routes[XPCRoute.volumeCreate] = harness.create routes[XPCRoute.volumeDelete] = harness.delete routes[XPCRoute.volumeList] = harness.list + routes[XPCRoute.volumeSearch] = harness.search + routes[XPCRoute.volumeSearchOne] = harness.searchOne routes[XPCRoute.volumeInspect] = harness.inspect routes[XPCRoute.volumePrune] = harness.prune } diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift index e34c78d5..d2f3066d 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Containers/ContainersHarness.swift @@ -41,6 +41,34 @@ public struct ContainersHarness: Sendable { return reply } + @Sendable + public func search(_ message: XPCMessage) async throws -> XPCMessage { + let searches = message.stringArray(key: .searches) ?? [] + let containers = try await service.search(searches: searches) + let data = try JSONEncoder().encode(containers) + + let reply = message.reply() + reply.set(key: .containers, value: data) + return reply + } + + @Sendable + public func searchOne(_ message: XPCMessage) async throws -> XPCMessage { + let search = message.string(key: .search) + guard let search else { + throw ContainerizationError( + .invalidArgument, + message: "search term cannot be empty" + ) + } + let container = try await service.searchOne(search: search) + let data = try JSONEncoder().encode(container) + + let reply = message.reply() + reply.set(key: .containers, value: data) + return reply + } + @Sendable public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift index 8f1b7694..c49ceddc 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift @@ -117,6 +117,54 @@ public actor ContainersService { return self.containers.values.map { $0.snapshot } } + /// List all containers that match the provided search terms. + public func search(searches: [String]) async throws -> [ContainerSnapshot] { + self.log.debug("\(#function)") + + let allSnapshots = self.containers.values.map { $0.snapshot } + + var allSnapshotsIds = Set() + for search in searches { + let matchResult = StringMatcher.match(partial: search, candidates: allSnapshots.map({ $0.configuration.id })) + switch matchResult { + case .exactMatch(let m), .singleMatch(let m): + allSnapshotsIds.insert(m) + case .multipleMatches(let mList): + allSnapshotsIds.formUnion(mList) + case .noMatch: + break + } + } + + return allSnapshots.filter { + allSnapshotsIds.contains($0.configuration.id) + } + } + + /// Search for a single container using a partial ID match. + /// Returns an error if no match or multiple matches are found. + public func searchOne(search: String) async throws -> ContainerSnapshot { + self.log.debug("\(#function)") + + let allSnapshots = self.containers.values.map { $0.snapshot } + let matchResult = StringMatcher.match(partial: search, candidates: allSnapshots.map({ $0.configuration.id })) + + switch matchResult { + case .exactMatch(let id), .singleMatch(let id): + guard let snapshot = allSnapshots.first(where: { $0.configuration.id == id }) else { + throw ContainerizationError(.notFound, message: "container \(search) not found") + } + return snapshot + case .multipleMatches(let matches): + throw ContainerizationError( + .invalidArgument, + message: "Ambiguous search term '\(search)': matches multiple containers [\(matches.joined(separator: ", "))]. Please use a more specific identifier." + ) + case .noMatch: + throw ContainerizationError(.notFound, message: "container \(search) not found") + } + } + /// Execute an operation with the current container list while maintaining atomicity /// This prevents race conditions where containers are created during the operation public func withContainerList(_ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T) async throws -> T { diff --git a/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift b/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift index e3094dab..6c9a8d6b 100644 --- a/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift +++ b/Sources/Services/ContainerAPIService/Networks/NetworksHarness.swift @@ -40,6 +40,34 @@ public struct NetworksHarness: Sendable { return reply } + @Sendable + public func search(_ message: XPCMessage) async throws -> XPCMessage { + let searches = message.stringArray(key: .searches) ?? [] + let networks = try await service.search(searches: searches) + let data = try JSONEncoder().encode(networks) + + let reply = message.reply() + reply.set(key: .networkStates, value: data) + return reply + } + + @Sendable + public func searchOne(_ message: XPCMessage) async throws -> XPCMessage { + let search = message.string(key: .search) + guard let search else { + throw ContainerizationError( + .invalidArgument, + message: "search term cannot be empty" + ) + } + let network = try await service.searchOne(search: search) + let data = try JSONEncoder().encode(network) + + let reply = message.reply() + reply.set(key: .networkState, value: data) + return reply + } + @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { let data = message.dataNoCopy(key: .networkConfig) diff --git a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Networks/NetworksService.swift index 39e288e6..a9d64878 100644 --- a/Sources/Services/ContainerAPIService/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Networks/NetworksService.swift @@ -99,6 +99,55 @@ public actor NetworksService { } } + /// List all networks that match the provided search terms. + public func search(searches: [String]) async throws -> [NetworkState] { + log.info("network service: search") + + let allStates = networkStates.reduce(into: [NetworkState]()) { + $0.append($1.value) + } + + var allNetworkIds = Set() + for search in searches { + let matchResult = StringMatcher.match(partial: search, candidates: Array(networkStates.keys)) + switch matchResult { + case .exactMatch(let m), .singleMatch(let m): + allNetworkIds.insert(m) + case .multipleMatches(let mList): + allNetworkIds.formUnion(mList) + case .noMatch: + break + } + } + + return allStates.filter { + allNetworkIds.contains($0.id) + } + } + + /// Search for a single network using a partial ID match. + /// Returns an error if no match or multiple matches are found. + public func searchOne(search: String) async throws -> NetworkState { + log.info("network service: searchOne") + + let matchResult = StringMatcher.match(partial: search, candidates: Array(networkStates.keys)) + + switch matchResult { + case .exactMatch(let id), .singleMatch(let id): + guard let state = networkStates[id] else { + throw ContainerizationError(.notFound, message: "network \(search) not found") + } + return state + case .multipleMatches(let matches): + throw ContainerizationError( + .invalidArgument, + message: "Ambiguous search term '\(search)': matches multiple networks [\(matches.joined(separator: ", "))]. Please use a more specific identifier." + ) + case .noMatch: + throw ContainerizationError(.notFound, message: "network \(search) not found") + } + } + /// Create a new network from the provided configuration. public func create(configuration: NetworkConfiguration) async throws -> NetworkState { log.info( diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift index 68a01a7a..118f9250 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift @@ -39,6 +39,34 @@ public struct VolumesHarness: Sendable { return reply } + @Sendable + public func search(_ message: XPCMessage) async throws -> XPCMessage { + let searches = message.stringArray(key: .searches) ?? [] + let volumes = try await service.search(searches: searches) + let data = try JSONEncoder().encode(volumes) + + let reply = message.reply() + reply.set(key: .volumes, value: data) + return reply + } + + @Sendable + public func searchOne(_ message: XPCMessage) async throws -> XPCMessage { + let search = message.string(key: .search) + guard let search else { + throw ContainerizationError( + .invalidArgument, + message: "search term cannot be empty" + ) + } + let volume = try await service.searchOne(search: search) + let data = try JSONEncoder().encode(volume) + + let reply = message.reply() + reply.set(key: .volume, value: data) + return reply + } + @Sendable public func create(_ message: XPCMessage) async throws -> XPCMessage { guard let name = message.string(key: .volumeName) else { diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift index 3525c7d6..35d64467 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift @@ -66,6 +66,49 @@ public actor VolumesService { try await store.list() } + /// List all volumes that match the provided search terms. + public func search(searches: [String]) async throws -> [Volume] { + let allVolumes = try await store.list() + + var allVolumeNames = Set() + for search in searches { + let matchResult = StringMatcher.match(partial: search, candidates: allVolumes.map({ $0.name })) + switch matchResult { + case .exactMatch(let m), .singleMatch(let m): + allVolumeNames.insert(m) + case .multipleMatches(let mList): + allVolumeNames.formUnion(mList) + case .noMatch: + break + } + } + + return allVolumes.filter { + allVolumeNames.contains($0.name) + } + } + + /// Search for a single volume using a partial name match. + /// Returns an error if no match or multiple matches are found. + public func searchOne(search: String) async throws -> Volume { + let allVolumes = try await store.list() + let matchResult = StringMatcher.match(partial: search, candidates: allVolumes.map({ $0.name })) + + switch matchResult { + case .exactMatch(let name), .singleMatch(let name): + guard let volume = allVolumes.first(where: { $0.name == name }) else { + throw VolumeError.volumeNotFound(search) + } + return volume + case .multipleMatches(let matches): + throw VolumeError.invalidVolumeName( + "Ambiguous search term '\(search)': matches multiple volumes [\(matches.joined(separator: ", "))]. Please use a more specific identifier." + ) + case .noMatch: + throw VolumeError.volumeNotFound(search) + } + } + public func inspect(_ name: String) async throws -> Volume { try await lock.withLock { _ in try await self._inspect(name) diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIContainerSearch.swift b/Tests/CLITests/Subcommands/Containers/TestCLIContainerSearch.swift new file mode 100644 index 00000000..09426d79 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIContainerSearch.swift @@ -0,0 +1,245 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +/// Tests for container search functionality with partial ID matching +@Suite(.serialized) +class TestCLIContainerSearch: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + // MARK: - Container Logs with Partial ID Tests + + @Test func testLogsWithPartialID() throws { + let name = getTestName() + #expect(throws: Never.self, "expected container logs with partial ID to succeed") { + try doCreate(name: name) + try doStart(name: name) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + // Use first 8 characters of the container ID as partial match + let partialID = String(name.prefix(8)) + let result = try run(arguments: ["logs", partialID]) + #expect(result.status == 0, "expected logs to succeed with partial ID") + } + } + + @Test func testLogsWithAmbiguousID() throws { + let baseName = getTestName() + let name1 = "\(baseName)-1" + let name2 = "\(baseName)-2" + + #expect(throws: Never.self, "expected ambiguous container logs to fail gracefully") { + try doCreate(name: name1) + try doCreate(name: name2) + defer { + try? doRemove(name: name1) + try? doRemove(name: name2) + } + + // Use a prefix that matches both containers + let partialID = String(baseName.prefix(min(8, baseName.count))) + let result = try run(arguments: ["logs", partialID]) + + // Should fail with an error about ambiguous match + #expect(result.status != 0, "expected logs to fail with ambiguous ID") + #expect(result.error.contains("Ambiguous") || result.error.contains("ambiguous"), + "expected error message to mention ambiguity") + } + } + + @Test func testLogsWithExactMatch() throws { + let name = getTestName() + #expect(throws: Never.self, "expected container logs with exact ID to succeed") { + try doCreate(name: name) + try doStart(name: name) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + // Use exact container name + let result = try run(arguments: ["logs", name]) + #expect(result.status == 0, "expected logs to succeed with exact ID") + } + } + + // MARK: - Container Start with Partial ID Tests + + @Test func testStartWithPartialID() throws { + let name = getTestName() + #expect(throws: Never.self, "expected container start with partial ID to succeed") { + try doCreate(name: name) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + // Use first 8 characters of the container ID as partial match + let partialID = String(name.prefix(8)) + let result = try run(arguments: ["start", partialID]) + #expect(result.status == 0, "expected start to succeed with partial ID") + + // Verify container is running + try waitForContainerRunning(name) + } + } + + @Test func testStartWithAmbiguousID() throws { + let baseName = getTestName() + let name1 = "\(baseName)-1" + let name2 = "\(baseName)-2" + + #expect(throws: Never.self, "expected ambiguous container start to fail gracefully") { + try doCreate(name: name1) + try doCreate(name: name2) + defer { + try? doRemove(name: name1) + try? doRemove(name: name2) + } + + // Use a prefix that matches both containers + let partialID = String(baseName.prefix(min(8, baseName.count))) + let result = try run(arguments: ["start", partialID]) + + // Should fail with an error about ambiguous match + #expect(result.status != 0, "expected start to fail with ambiguous ID") + #expect(result.error.contains("Ambiguous") || result.error.contains("ambiguous"), + "expected error message to mention ambiguity") + } + } + + // MARK: - Container Exec with Partial ID Tests + + @Test func testExecWithPartialID() throws { + let name = getTestName() + #expect(throws: Never.self, "expected container exec with partial ID to succeed") { + try doCreate(name: name) + try doStart(name: name) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + try waitForContainerRunning(name) + + // Use first 8 characters of the container ID as partial match + let partialID = String(name.prefix(8)) + let result = try doExec(name: partialID, cmd: ["echo", "test"]) + #expect(result.contains("test"), "expected echo output") + } + } + + @Test func testExecWithAmbiguousID() throws { + let baseName = getTestName() + let name1 = "\(baseName)-1" + let name2 = "\(baseName)-2" + + #expect(throws: Never.self, "expected ambiguous container exec to fail gracefully") { + try doCreate(name: name1) + try doCreate(name: name2) + try doStart(name: name1) + try doStart(name: name2) + defer { + try? doStop(name: name1) + try? doStop(name: name2) + try? doRemove(name: name1) + try? doRemove(name: name2) + } + + try waitForContainerRunning(name1) + try waitForContainerRunning(name2) + + // Use a prefix that matches both containers + let partialID = String(baseName.prefix(min(8, baseName.count))) + + // Should fail with an error about ambiguous match + do { + _ = try doExec(name: partialID, cmd: ["echo", "test"]) + #expect(Bool(false), "expected exec to fail with ambiguous ID") + } catch { + let errorMessage = String(describing: error) + #expect(errorMessage.contains("Ambiguous") || errorMessage.contains("ambiguous"), + "expected error message to mention ambiguity") + } + } + } + + // MARK: - Container Delete with Multiple Partial IDs Tests + + @Test func testDeleteWithMultiplePartialIDs() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc123" + let name2 = "\(baseName)-def456" + let name3 = "\(baseName)-ghi789" + + #expect(throws: Never.self, "expected container delete with multiple partial IDs to succeed") { + try doCreate(name: name1) + try doCreate(name: name2) + try doCreate(name: name3) + + // Delete using partial IDs for first two containers + let partial1 = String(name1.suffix(6)) // "abc123" + let partial2 = String(name2.suffix(6)) // "def456" + + let result = try run(arguments: ["delete", partial1, partial2]) + #expect(result.status == 0, "expected delete to succeed with multiple partial IDs") + + // Verify only name3 remains + let listResult = try run(arguments: ["list", "--all"]) + #expect(listResult.output.contains(name3), "expected name3 to still exist") + #expect(!listResult.output.contains(name1), "expected name1 to be deleted") + #expect(!listResult.output.contains(name2), "expected name2 to be deleted") + + // Cleanup + try? doRemove(name: name3) + } + } + + // MARK: - Container Inspect with Multiple Partial IDs Tests + + @Test func testInspectWithMultiplePartialIDs() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc123" + let name2 = "\(baseName)-def456" + + #expect(throws: Never.self, "expected container inspect with multiple partial IDs to succeed") { + try doCreate(name: name1) + try doCreate(name: name2) + defer { + try? doRemove(name: name1) + try? doRemove(name: name2) + } + + // Inspect using partial IDs + let partial1 = String(name1.suffix(6)) // "abc123" + let partial2 = String(name2.suffix(6)) // "def456" + + let result = try run(arguments: ["inspect", partial1, partial2]) + #expect(result.status == 0, "expected inspect to succeed with multiple partial IDs") + + // Verify both containers are in the output + #expect(result.output.contains(name1), "expected name1 in inspect output") + #expect(result.output.contains(name2), "expected name2 in inspect output") + } + } +} diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetworkSearch.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetworkSearch.swift new file mode 100644 index 00000000..78a34629 --- /dev/null +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetworkSearch.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +/// Tests for network search functionality with partial ID matching +@Suite(.serialized) +class TestCLINetworkSearch: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + // MARK: - Network Delete with Partial ID Tests + + @Test func testDeleteNetworkWithPartialID() throws { + let name = getTestName() + #expect(throws: Never.self, "expected network delete with partial ID to succeed") { + _ = try run(arguments: ["network", "create", name]) + defer { + _ = try? run(arguments: ["network", "delete", name]) + } + + // Use first 6 characters as partial match + let partialID = String(name.prefix(min(6, name.count))) + let result = try run(arguments: ["network", "delete", partialID]) + #expect(result.status == 0, "expected network delete to succeed with partial ID") + + // Verify network is deleted + let listResult = try run(arguments: ["network", "list"]) + #expect(!listResult.output.contains(name), "expected network to be deleted") + } + } + + @Test func testDeleteNetworkWithAmbiguousID() throws { + let baseName = getTestName() + let name1 = "\(baseName)-net1" + let name2 = "\(baseName)-net2" + + #expect(throws: Never.self, "expected ambiguous network delete to fail gracefully") { + _ = try run(arguments: ["network", "create", name1]) + _ = try run(arguments: ["network", "create", name2]) + defer { + _ = try? run(arguments: ["network", "delete", name1]) + _ = try? run(arguments: ["network", "delete", name2]) + } + + // Use a prefix that matches both networks + let partialID = String(baseName.prefix(min(6, baseName.count))) + let result = try run(arguments: ["network", "delete", partialID]) + + // Should fail with an error about ambiguous match or missing network + #expect(result.status != 0, "expected network delete to fail with ambiguous ID") + } + } + + @Test func testDeleteMultipleNetworksWithPartialIDs() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc" + let name2 = "\(baseName)-def" + + #expect(throws: Never.self, "expected delete multiple networks with partial IDs to succeed") { + _ = try run(arguments: ["network", "create", name1]) + _ = try run(arguments: ["network", "create", name2]) + + // Delete using partial IDs + let partial1 = String(name1.suffix(3)) // "abc" + let partial2 = String(name2.suffix(3)) // "def" + + let result = try run(arguments: ["network", "delete", partial1, partial2]) + #expect(result.status == 0, "expected network delete to succeed with multiple partial IDs") + + // Verify both networks are deleted + let listResult = try run(arguments: ["network", "list"]) + #expect(!listResult.output.contains(name1), "expected name1 to be deleted") + #expect(!listResult.output.contains(name2), "expected name2 to be deleted") + } + } + + // MARK: - Network Inspect with Partial ID Tests + + @Test func testInspectNetworkWithPartialID() throws { + let name = getTestName() + #expect(throws: Never.self, "expected network inspect with partial ID to succeed") { + _ = try run(arguments: ["network", "create", name]) + defer { + _ = try? run(arguments: ["network", "delete", name]) + } + + // Use first 6 characters as partial match + let partialID = String(name.prefix(min(6, name.count))) + let result = try run(arguments: ["network", "inspect", partialID]) + #expect(result.status == 0, "expected network inspect to succeed with partial ID") + #expect(result.output.contains(name), "expected network name in inspect output") + } + } + + @Test func testInspectMultipleNetworksWithPartialIDs() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc" + let name2 = "\(baseName)-def" + + #expect(throws: Never.self, "expected inspect multiple networks with partial IDs to succeed") { + _ = try run(arguments: ["network", "create", name1]) + _ = try run(arguments: ["network", "create", name2]) + defer { + _ = try? run(arguments: ["network", "delete", name1]) + _ = try? run(arguments: ["network", "delete", name2]) + } + + // Inspect using partial IDs + let partial1 = String(name1.suffix(3)) // "abc" + let partial2 = String(name2.suffix(3)) // "def" + + let result = try run(arguments: ["network", "inspect", partial1, partial2]) + #expect(result.status == 0, "expected network inspect to succeed with multiple partial IDs") + + // Verify both networks are in the output + #expect(result.output.contains(name1), "expected name1 in inspect output") + #expect(result.output.contains(name2), "expected name2 in inspect output") + } + } +} diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumeSearch.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumeSearch.swift new file mode 100644 index 00000000..f488c7cd --- /dev/null +++ b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumeSearch.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +/// Tests for volume search functionality with partial name matching +@Suite(.serialized) +class TestCLIVolumeSearch: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + // MARK: - Volume Delete with Partial Name Tests + + @Test func testDeleteVolumeWithPartialName() throws { + let name = getTestName() + #expect(throws: Never.self, "expected volume delete with partial name to succeed") { + _ = try run(arguments: ["volume", "create", name]) + defer { + _ = try? run(arguments: ["volume", "delete", name]) + } + + // Use first 6 characters as partial match + let partialName = String(name.prefix(min(6, name.count))) + let result = try run(arguments: ["volume", "delete", partialName]) + #expect(result.status == 0, "expected volume delete to succeed with partial name") + + // Verify volume is deleted + let listResult = try run(arguments: ["volume", "list"]) + #expect(!listResult.output.contains(name), "expected volume to be deleted") + } + } + + @Test func testDeleteVolumeWithAmbiguousName() throws { + let baseName = getTestName() + let name1 = "\(baseName)-vol1" + let name2 = "\(baseName)-vol2" + + #expect(throws: Never.self, "expected ambiguous volume delete to fail gracefully") { + _ = try run(arguments: ["volume", "create", name1]) + _ = try run(arguments: ["volume", "create", name2]) + defer { + _ = try? run(arguments: ["volume", "delete", name1]) + _ = try? run(arguments: ["volume", "delete", name2]) + } + + // Use a prefix that matches both volumes + let partialName = String(baseName.prefix(min(6, baseName.count))) + let result = try run(arguments: ["volume", "delete", partialName]) + + // Should fail with an error about ambiguous match or missing volume + #expect(result.status != 0, "expected volume delete to fail with ambiguous name") + } + } + + @Test func testDeleteMultipleVolumesWithPartialNames() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc" + let name2 = "\(baseName)-def" + + #expect(throws: Never.self, "expected delete multiple volumes with partial names to succeed") { + _ = try run(arguments: ["volume", "create", name1]) + _ = try run(arguments: ["volume", "create", name2]) + + // Delete using partial names + let partial1 = String(name1.suffix(3)) // "abc" + let partial2 = String(name2.suffix(3)) // "def" + + let result = try run(arguments: ["volume", "delete", partial1, partial2]) + #expect(result.status == 0, "expected volume delete to succeed with multiple partial names") + + // Verify both volumes are deleted + let listResult = try run(arguments: ["volume", "list"]) + #expect(!listResult.output.contains(name1), "expected name1 to be deleted") + #expect(!listResult.output.contains(name2), "expected name2 to be deleted") + } + } + + // MARK: - Volume Inspect with Partial Name Tests + + @Test func testInspectVolumeWithPartialName() throws { + let name = getTestName() + #expect(throws: Never.self, "expected volume inspect with partial name to succeed") { + _ = try run(arguments: ["volume", "create", name]) + defer { + _ = try? run(arguments: ["volume", "delete", name]) + } + + // Use first 6 characters as partial match + let partialName = String(name.prefix(min(6, name.count))) + let result = try run(arguments: ["volume", "inspect", partialName]) + #expect(result.status == 0, "expected volume inspect to succeed with partial name") + #expect(result.output.contains(name), "expected volume name in inspect output") + } + } + + @Test func testInspectMultipleVolumesWithPartialNames() throws { + let baseName = getTestName() + let name1 = "\(baseName)-abc" + let name2 = "\(baseName)-def" + + #expect(throws: Never.self, "expected inspect multiple volumes with partial names to succeed") { + _ = try run(arguments: ["volume", "create", name1]) + _ = try run(arguments: ["volume", "create", name2]) + defer { + _ = try? run(arguments: ["volume", "delete", name1]) + _ = try? run(arguments: ["volume", "delete", name2]) + } + + // Inspect using partial names + let partial1 = String(name1.suffix(3)) // "abc" + let partial2 = String(name2.suffix(3)) // "def" + + let result = try run(arguments: ["volume", "inspect", partial1, partial2]) + #expect(result.status == 0, "expected volume inspect to succeed with multiple partial names") + + // Verify both volumes are in the output + #expect(result.output.contains(name1), "expected name1 in inspect output") + #expect(result.output.contains(name2), "expected name2 in inspect output") + } + } +} diff --git a/Tests/ContainerClientTests/StringMatcherTests.swift b/Tests/ContainerClientTests/StringMatcherTests.swift new file mode 100644 index 00000000..f82ffb84 --- /dev/null +++ b/Tests/ContainerClientTests/StringMatcherTests.swift @@ -0,0 +1,387 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import XCTest + +@testable import ContainerClient + +/// Tests for StringMatcher partial ID matching functionality +/// +/// Matching Rules: +/// - 0 characters (empty): Always `.noMatch` +/// - 1 character: Only exact match (no prefix matching) +/// - 2+ characters: Full matching logic (exact first, then prefix) +final class StringMatcherTests: XCTestCase { + + // MARK: - Exact Match Tests + + /// Test that exact match overrides prefix ambiguity + /// Given: candidates ["A", "ABCD", "ABEF"] + /// When: matching "A" + /// Then: should return exactMatch("A") even though "ABCD" and "ABEF" also start with "A" + func testExactMatchOverridesPrefixAmbiguity() { + let candidates = ["A", "ABCD", "ABEF"] + let result = StringMatcher.match(partial: "A", candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, "A") + } else { + XCTFail("Expected exactMatch but got \(result)") + } + } + + /// Test exact match when it's the only candidate + func testExactMatchSingleCandidate() { + let candidates = ["ABCD-1234"] + let result = StringMatcher.match(partial: "ABCD-1234", candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, "ABCD-1234") + } else { + XCTFail("Expected exactMatch but got \(result)") + } + } + + /// Test exact match with full UUID + func testExactMatchFullUUID() { + let candidates = ["550e8400-e29b-41d4-a716-446655440000", "661f9511-f3ac-52e5-b827-557766551111"] + let fullID = "550e8400-e29b-41d4-a716-446655440000" + let result = StringMatcher.match(partial: fullID, candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, fullID) + } else { + XCTFail("Expected exactMatch but got \(result)") + } + } + + // MARK: - Single Character Special Cases + + /// Test single character with exact match should return exactMatch + /// Rule: Single character only matches exactly, no prefix matching + func testSingleCharacter_ExactMatch() { + let candidates = ["A", "ABCD", "ABEF"] + let result = StringMatcher.match(partial: "A", candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, "A") + } else { + XCTFail("Expected exactMatch('A') but got \(result)") + } + } + + /// Test single character without exact match should return noMatch (no prefix matching) + /// Rule: Single character does NOT do prefix matching + func testSingleCharacter_NoExactMatch_NoPrefix() { + let candidates = ["ABCD", "ABEF", "ACGH"] + let result = StringMatcher.match(partial: "A", candidates: candidates) + + if case .noMatch = result { + // Expected - single character without exact match returns noMatch + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test single character with different candidates + func testSingleCharacter_DifferentLetter() { + let candidates = ["A123", "B456", "C789"] + let result = StringMatcher.match(partial: "B", candidates: candidates) + + if case .noMatch = result { + // Expected - "B" doesn't exactly match any candidate + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + // MARK: - Prefix Match Tests (2+ Characters) + + /// Test single valid prefix match with UUID-like ID (2+ chars) + func testPrefixMatch_UUID_TwoChars() { + let candidates = ["550e8400-e29b-41d4-a716", "661f9511-f3ac-52e5-b827"] + let result = StringMatcher.match(partial: "55", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "550e8400-e29b-41d4-a716") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + /// Test single valid prefix match with longer prefix + func testPrefixMatch_UUID_LongerPrefix() { + let candidates = ["550e8400-e29b-41d4-a716", "661f9511-f3ac-52e5-b827"] + let result = StringMatcher.match(partial: "550e84", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "550e8400-e29b-41d4-a716") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + /// Test single valid prefix match with short alphanumeric ID + func testPrefixMatch_ShortID() { + let candidates = ["a1b2c3", "d4e5f6", "g7h8i9"] + let result = StringMatcher.match(partial: "a1", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "a1b2c3") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + /// Test single valid prefix match with custom format ID + func testPrefixMatch_CustomFormat() { + let candidates = ["ABCD-1234-5678", "BCDE-2345-6789", "CDEF-3456-7890"] + let result = StringMatcher.match(partial: "AB", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "ABCD-1234-5678") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + /// Test prefix match up to a dash separator + func testPrefixMatch_UpToDash() { + let candidates = ["ABCD-1234-5678", "BCDE-2345-6789"] + let result = StringMatcher.match(partial: "ABCD", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "ABCD-1234-5678") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + // MARK: - Multiple Prefix Matches (Ambiguous) + + /// Test ambiguous match with two candidates + func testMultipleMatches_TwoCandidates() { + let candidates = ["ABCD-1234", "ABEF-5678"] + let result = StringMatcher.match(partial: "AB", candidates: candidates) + + if case .multipleMatches(let matches) = result { + XCTAssertEqual(Set(matches), Set(["ABCD-1234", "ABEF-5678"])) + } else { + XCTFail("Expected multipleMatches but got \(result)") + } + } + + /// Test ambiguous match with multiple candidates (3+) + func testMultipleMatches_ThreeCandidates() { + let candidates = ["AA-123", "AA-456", "AA-789", "BB-000"] + let result = StringMatcher.match(partial: "AA", candidates: candidates) + + if case .multipleMatches(let matches) = result { + XCTAssertEqual(Set(matches), Set(["AA-123", "AA-456", "AA-789"])) + } else { + XCTFail("Expected multipleMatches but got \(result)") + } + } + + /// Test that longer prefix can resolve ambiguity + func testMultipleMatches_ResolveWithLongerPrefix() { + let candidates = ["ABCD-1234", "ABEF-5678"] + + // First verify "AB" is ambiguous + let ambiguousResult = StringMatcher.match(partial: "AB", candidates: candidates) + if case .multipleMatches = ambiguousResult { + // Expected + } else { + XCTFail("Expected AB to be ambiguous") + } + + // Then verify "ABCD" resolves to single match + let resolvedResult = StringMatcher.match(partial: "ABCD", candidates: candidates) + if case .singleMatch(let matched) = resolvedResult { + XCTAssertEqual(matched, "ABCD-1234") + } else { + XCTFail("Expected singleMatch but got \(resolvedResult)") + } + } + + // MARK: - No Substring Match Tests + + /// Test that substring in middle does not match + func testNoSubstringMatch_Middle() { + let candidates = ["XXABCD", "YYABEF", "ZZABGH"] + let result = StringMatcher.match(partial: "AB", candidates: candidates) + + if case .noMatch = result { + // Expected - "AB" is not a prefix of any candidate + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test that substring at end does not match + func testNoSubstringMatch_End() { + let candidates = ["1234-ABCD", "5678-ABEF", "9012-ABGH"] + let result = StringMatcher.match(partial: "AB", candidates: candidates) + + if case .noMatch = result { + // Expected - "AB" is not a prefix of any candidate + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test the specific example from requirements: "A" should NOT match "BBBA-123123..." + /// This tests both substring matching (fails) AND single-char rule (fails) + func testNoSubstringMatch_RequirementExample() { + let candidates = ["BBBA-123123-456456"] + let result = StringMatcher.match(partial: "A", candidates: candidates) + + if case .noMatch = result { + // Expected - "A" is not a prefix of "BBBA-123123-456456" + // Also, single char "A" doesn't exactly match + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + // MARK: - Case Sensitivity Tests + + /// Test that matching is case-sensitive (lowercase vs uppercase) + func testCaseSensitivity_LowercaseVsUppercase() { + let candidates = ["ABCD-1234", "abcd-5678"] + let result = StringMatcher.match(partial: "ab", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "abcd-5678") + } else { + XCTFail("Expected singleMatch('abcd-5678') but got \(result)") + } + } + + /// Test that wrong case doesn't match + func testCaseSensitivity_WrongCase() { + let candidates = ["ABCD-1234", "BCDE-5678"] + let result = StringMatcher.match(partial: "ab", candidates: candidates) + + if case .noMatch = result { + // Expected - "ab" doesn't match "ABCD-1234" (case sensitive) + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test mixed case matching + func testCaseSensitivity_MixedCase() { + let candidates = ["AbCd-1234", "aBcD-5678", "ABCD-9999"] + let result = StringMatcher.match(partial: "AbC", candidates: candidates) + + if case .singleMatch(let matched) = result { + XCTAssertEqual(matched, "AbCd-1234") + } else { + XCTFail("Expected singleMatch but got \(result)") + } + } + + // MARK: - Backward Compatibility Tests + + /// Test full ID match (backward compatibility) + func testBackwardCompatibility_FullID() { + let candidates = ["550e8400-e29b-41d4-a716-446655440000", "661f9511-f3ac-52e5-b827-557766551111"] + let fullID = "550e8400-e29b-41d4-a716-446655440000" + let result = StringMatcher.match(partial: fullID, candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, fullID) + } else { + XCTFail("Expected exactMatch but got \(result)") + } + } + + /// Test that full IDs still work even with partial matches available + func testBackwardCompatibility_FullIDWithPartialMatches() { + let candidates = ["ABCD-1234-5678", "ABCD-1234-9999"] + let fullID = "ABCD-1234-5678" + let result = StringMatcher.match(partial: fullID, candidates: candidates) + + if case .exactMatch(let matched) = result { + XCTAssertEqual(matched, fullID) + } else { + XCTFail("Expected exactMatch but got \(result)") + } + } + + // MARK: - Edge Cases + + /// Test empty partial string should return noMatch + func testEdgeCase_EmptyPartialString() { + let candidates = ["ABCD-1234", "BCDE-5678"] + let result = StringMatcher.match(partial: "", candidates: candidates) + + if case .noMatch = result { + // Expected - empty string should always return noMatch + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test empty candidates list + func testEdgeCase_EmptyCandidatesList() { + let candidates: [String] = [] + let result = StringMatcher.match(partial: "ABC", candidates: candidates) + + if case .noMatch = result { + // Expected + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test single candidate that doesn't match + func testEdgeCase_SingleCandidateNoMatch() { + let candidates = ["ABCD-1234"] + let result = StringMatcher.match(partial: "XYZ", candidates: candidates) + + if case .noMatch = result { + // Expected + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test whitespace in partial string (should not match) + func testEdgeCase_WhitespaceInPartial() { + let candidates = ["ABCD-1234", "BCDE-5678"] + let result = StringMatcher.match(partial: "AB ", candidates: candidates) + + if case .noMatch = result { + // Expected - "AB " (with space) doesn't match "ABCD-1234" + } else { + XCTFail("Expected noMatch but got \(result)") + } + } + + /// Test very long prefix (longer than all candidates) + func testEdgeCase_VeryLongPrefix() { + let candidates = ["ABC", "DEF"] + let result = StringMatcher.match(partial: "ABCDEFGHIJKLMNOP", candidates: candidates) + + if case .noMatch = result { + // Expected + } else { + XCTFail("Expected noMatch but got \(result)") + } + } +}