diff --git a/Sources/ContainerCommands/HelpCommand.swift b/Sources/ContainerCommands/HelpCommand.swift index 8d97b1c8..43e9107c 100644 --- a/Sources/ContainerCommands/HelpCommand.swift +++ b/Sources/ContainerCommands/HelpCommand.swift @@ -26,8 +26,43 @@ struct HelpCommand: AsyncLoggableCommand { @OptionGroup(visibility: .hidden) public var logOptions: Flags.Logging + @Argument(parsing: .captureForPassthrough) + var subcommandPath: [String] = [] + func run() async throws { - let pluginLoader = try? await Application.createPluginLoader() - await Application.printModifiedHelpText(pluginLoader: pluginLoader) + if subcommandPath.isEmpty { + let pluginLoader = try? await Application.createPluginLoader() + await Application.printModifiedHelpText(pluginLoader: pluginLoader) + return + } + guard let target = Self.resolveSubcommand(path: subcommandPath) else { + throw ValidationError("unknown command '\(subcommandPath.joined(separator: " "))'") + } + print(Application.helpMessage(for: target)) + } + + static func resolveSubcommand(path: [String]) -> ParsableCommand.Type? { + var current: ParsableCommand.Type = Application.self + for name in path { + guard let next = childSubcommands(of: current).first(where: { matches($0, name: name) }) else { + return nil + } + current = next + } + return current + } + + private static func childSubcommands(of command: ParsableCommand.Type) -> [ParsableCommand.Type] { + var all = command.configuration.subcommands + for group in command.configuration.groupedSubcommands { + all.append(contentsOf: group.subcommands) + } + return all + } + + private static func matches(_ command: ParsableCommand.Type, name: String) -> Bool { + let cfg = command.configuration + if cfg.commandName == name { return true } + return cfg.aliases.contains(name) } } diff --git a/Tests/ContainerCommandsTests/HelpCommandTests.swift b/Tests/ContainerCommandsTests/HelpCommandTests.swift new file mode 100644 index 00000000..a728c0fe --- /dev/null +++ b/Tests/ContainerCommandsTests/HelpCommandTests.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 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 ArgumentParser +import Foundation +import Testing + +@testable import ContainerCommands + +struct HelpCommandTests { + @Test + func everyStaticSubcommandReachableViaHelp() { + func walk(_ command: ParsableCommand.Type, path: [String]) { + let cfg = command.configuration + var children = cfg.subcommands + for group in cfg.groupedSubcommands { + children.append(contentsOf: group.subcommands) + } + for child in children { + guard let name = child.configuration.commandName else { continue } + let canonical = path + [name] + #expect( + HelpCommand.resolveSubcommand(path: canonical) != nil, + "help should resolve '\(canonical.joined(separator: " "))' but returned nil" + ) + for alias in child.configuration.aliases { + let aliasPath = path + [alias] + #expect( + HelpCommand.resolveSubcommand(path: aliasPath) != nil, + "help should resolve alias path '\(aliasPath.joined(separator: " "))' but returned nil" + ) + } + walk(child, path: canonical) + } + } + walk(Application.self, path: []) + } + + @Test + func unknownSubcommandReturnsNil() { + #expect(HelpCommand.resolveSubcommand(path: ["nonexistent"]) == nil) + #expect(HelpCommand.resolveSubcommand(path: ["image", "nonexistent"]) == nil) + } +}