diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index fd60b7c..2f86020 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -200,11 +200,31 @@ public struct Service: Codable, Hashable { environment = nil } - // Decode 'env_file' which can be either a single string or an array of strings. - if let envFileArray = try? container.decodeIfPresent([String].self, forKey: .env_file) { - env_file = envFileArray - } else if let envFileString = try? container.decodeIfPresent(String.self, forKey: .env_file) { - env_file = [envFileString] + // `env_file` accepts three forms per the Compose spec: + // env_file: path.env → single string + // env_file: [path1.env, path2.env] → array of strings + // env_file: → array of {path:, required:?} dicts (Compose 2.x extended form) + // - path: optional.env + // required: false + // Arrays may also mix plain strings and dict entries. + // Missing optional files (required: false) are loaded silently as empty — loadEnvFile + // already suppresses read errors, which is the correct behaviour for optional files. + struct EnvFileEntry: Decodable { + let path: String + init(from decoder: Decoder) throws { + if let s = try? decoder.singleValueContainer().decode(String.self) { + path = s + } else { + enum Keys: String, CodingKey { case path } + let c = try decoder.container(keyedBy: Keys.self) + path = try c.decode(String.self, forKey: .path) + } + } + } + if let entries = try? container.decodeIfPresent([EnvFileEntry].self, forKey: .env_file) { + env_file = entries.map(\.path) + } else if let single = try? container.decodeIfPresent(String.self, forKey: .env_file) { + env_file = [single] } else { env_file = nil } diff --git a/Tests/Container-Compose-StaticTests/EnvFileDictFormTests.swift b/Tests/Container-Compose-StaticTests/EnvFileDictFormTests.swift new file mode 100644 index 0000000..2e8e66f --- /dev/null +++ b/Tests/Container-Compose-StaticTests/EnvFileDictFormTests.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +/// Tests for the Compose 2.x extended `env_file` dict form: +/// env_file: +/// - path: ./config.env +/// required: false +/// +/// The dict form may appear alone, alongside plain string entries in the same +/// array, or as the sole `env_file` value. All three must decode correctly. +@Suite("env_file dict-form parsing") +struct EnvFileDictFormTests { + + private func decodeService(_ serviceYaml: String) throws -> Service { + let yaml = """ + services: + svc: + \(serviceYaml.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" }.joined(separator: "\n")) + """ + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = compose.services["svc"].flatMap({ $0 }) else { + Issue.record("service 'svc' missing or nil") + throw TestError.missingService + } + return service + } + + enum TestError: Error { case missingService } + + // MARK: - Regression: existing string forms still parse + + @Test("plain string env_file still parses") + func plainString() throws { + let svc = try decodeService(""" + image: alpine + env_file: config/app.env + """) + #expect(svc.env_file == ["config/app.env"]) + } + + @Test("array of plain strings env_file still parses") + func arrayOfStrings() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - config/app.env + - config/secrets.env + """) + #expect(svc.env_file == ["config/app.env", "config/secrets.env"]) + } + + // MARK: - New: dict form + + @Test("single dict entry extracts path") + func singleDictEntry() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - path: config/generated.env + required: false + """) + #expect(svc.env_file == ["config/generated.env"]) + } + + @Test("dict entry with required: true extracts path") + func dictEntryRequired() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - path: config/app.env + required: true + """) + #expect(svc.env_file == ["config/app.env"]) + } + + @Test("dict entry without required field extracts path") + func dictEntryNoRequired() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - path: config/app.env + """) + #expect(svc.env_file == ["config/app.env"]) + } + + @Test("multiple dict entries extract all paths in order") + func multipleDictEntries() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - path: config/base.env + required: true + - path: config/optional.env + required: false + """) + #expect(svc.env_file == ["config/base.env", "config/optional.env"]) + } + + @Test("mixed array of strings and dict entries extracts all paths") + func mixedArray() throws { + let svc = try decodeService(""" + image: alpine + env_file: + - config/base.env + - path: config/generated.env + required: false + """) + #expect(svc.env_file == ["config/base.env", "config/generated.env"]) + } +}