diff --git a/Sources/Container-Compose/Codable Structs/Build.swift b/Sources/Container-Compose/Codable Structs/Build.swift index 0c389f5..5b47e4d 100644 --- a/Sources/Container-Compose/Codable Structs/Build.swift +++ b/Sources/Container-Compose/Codable Structs/Build.swift @@ -42,7 +42,17 @@ public struct Build: Codable, Hashable { let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) self.context = try keyedContainer.decode(String.self, forKey: .context) self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile) - self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args) + // `args:` accepts both forms per the Compose spec: + // args: args: + // KEY: value - KEY=value + // The list form previously threw `typeMismatch` and crashed `up` (#136). + if let asMap = try? keyedContainer.decodeIfPresent([String: String].self, forKey: .args) { + self.args = asMap + } else if let asList = try? keyedContainer.decodeIfPresent([String].self, forKey: .args) { + self.args = parseComposeKeyValueList(asList) + } else { + self.args = nil + } } } diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 78fcd73..995cc7f 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -369,17 +369,7 @@ public struct Service: Codable, Hashable { /// the host doesn't define it, falls /// back to an empty string) static func parseEnvironmentList(_ entries: [String]) -> [String: String] { - var dict: [String: String] = [:] - for entry in entries { - if let eqIdx = entry.firstIndex(of: "=") { - let key = String(entry[.. return resolvedValue } +/// Parses the Compose "list form" of `KEY=VALUE` entries — shared by +/// `environment:` and `build.args:` — into the same `[String: String]` shape the +/// map form produces. Handles two cases: +/// - `KEY=value` → `KEY: value` (split on the FIRST `=`; later `=` chars stay +/// in the value, so values like +/// `postgres://u:p@h/db?sslmode=require` +/// round-trip correctly) +/// - `KEY` → `KEY: ` (Compose's +/// "inherit from host" shorthand) +public func parseComposeKeyValueList(_ entries: [String]) -> [String: String] { + var dict: [String: String] = [:] + for entry in entries { + if let eqIdx = entry.firstIndex(of: "=") { + let key = String(entry[.. [String: String] { + fileArgs.merging(parseComposeKeyValueList(cliArgs)) { _, cli in cli } +} + /// Derives a project name from the current working directory. It replaces any '.' characters with /// '_' to ensure compatibility with container naming conventions. /// diff --git a/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift b/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift index c40b2e0..2b3557e 100644 --- a/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift +++ b/Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift @@ -57,16 +57,72 @@ struct BuildConfigurationTests { NODE_VERSION: "18" ENV: "production" """ - + let decoder = YAMLDecoder() let build = try decoder.decode(Build.self, from: yaml) - + #expect(build.context == ".") #expect(build.args?["NODE_VERSION"] == "18") #expect(build.args?["ENV"] == "production") } + + // Compose allows `build.args` in list form (`- KEY=VALUE`), not only the + // map form above. Previously the list form threw + // `DecodingError.typeMismatch` and crashed the whole `up` (see #136). + @Test("Parse build args in list form (KEY=VALUE)") + func parseBuildArgsListForm() throws { + let yaml = """ + context: nginx + args: + - DIR=development + """ + + let decoder = YAMLDecoder() + let build = try decoder.decode(Build.self, from: yaml) + + #expect(build.context == "nginx") + #expect(build.args?["DIR"] == "development") + } + - + // Exact repro from #136: a service using the list form of `build.args` + // (alongside other fields) used to crash the whole `up` at decode time. + @Test("Regression #136: full service with list-form build args decodes") + func regressionListFormBuildArgsInService() throws { + let yaml = """ + services: + nginx: + container_name: proxy + build: + context: nginx + args: + - DIR=development + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + let build = try #require(compose.services["nginx"]??.build) + + #expect(build.context == "nginx") + #expect(build.args?["DIR"] == "development") + } + + // A build-arg value that itself contains `=` must split on the FIRST `=` only, + // so the remainder is preserved verbatim. + @Test("List-form build arg value keeps internal '=' characters") + func listFormBuildArgValueKeepsEquals() throws { + let yaml = """ + context: . + args: + - DATABASE_URL=postgres://u:p@h/db?sslmode=require + """ + + let decoder = YAMLDecoder() + let build = try decoder.decode(Build.self, from: yaml) + + #expect(build.args?["DATABASE_URL"] == "postgres://u:p@h/db?sslmode=require") + } + @Test("Service with build configuration") func serviceWithBuildConfiguration() throws { let yaml = """ diff --git a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift index e269c3f..0364508 100644 --- a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift @@ -165,4 +165,28 @@ struct ComposeBuildParsingTests { let cmd = try ComposeBuild.parse(["-f", "my-compose.yaml"]) #expect(cmd.projectOptions.composeFileOptions.composeFilename == "my-compose.yaml") } + + @Test("ComposeBuild command accepts repeated --build-arg flags") + func composeBuildCommandAcceptsBuildArgFlags() throws { + let cmd = try ComposeBuild.parse(["--build-arg", "A=1", "--build-arg", "B=2"]) + #expect(cmd.buildArgs == ["A=1", "B=2"]) + } + + @Test("ComposeBuild command defaults build-args to empty") + func composeBuildCommandDefaultsBuildArgsToEmpty() throws { + let cmd = try ComposeBuild.parse([]) + #expect(cmd.buildArgs.isEmpty) + } + + @Test("CLI --build-arg overrides a matching compose-file arg") + func cliBuildArgOverridesFileArg() { + let merged = mergedBuildArgs(fileArgs: ["TOKEN": "from-file"], cliArgs: ["TOKEN=from-cli"]) + #expect(merged["TOKEN"] == "from-cli") + } + + @Test("CLI and file build-args that don't collide are both kept") + func cliAndFileBuildArgsMerge() { + let merged = mergedBuildArgs(fileArgs: ["ENV": "production"], cliArgs: ["TOKEN=abc123"]) + #expect(merged == ["ENV": "production", "TOKEN": "abc123"]) + } }