diff --git a/.gitignore b/.gitignore index 88185914..949c2b40 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc .vscode/launch.json +/tmp/ diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 5abde194..3e10793e 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -94,12 +94,12 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) let environmentVariables = loadEnvFile(path: envFilePath) - let projectName: String - if let name = dockerCompose.name { - projectName = name - } else { - projectName = deriveProjectName(cwd: cwd) - } + let projectName = resolveProjectName( + flagValue: composeFileOptions.projectName, + composeName: dockerCompose.name, + envVars: environmentVariables, + cwd: cwd + ) var servicesToBuild: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, service in guard let service, service.build != nil else { return nil } diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 30240c65..c123350c 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -72,6 +72,11 @@ public struct ComposeDown: AsyncParsableCommand { return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path } + private var envFilePath: String { + let envFile = process.envFile.first ?? ".env" + return resolvedPath(for: envFile, relativeTo: cwdURL) + } + private var fileManager: FileManager { FileManager.default } private var projectName: String? @@ -89,17 +94,18 @@ public struct ComposeDown: AsyncParsableCommand { let dockerComposeString = String(data: yamlData, encoding: .utf8)! let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + // Load environment variables from .env file + let environmentVariables = loadEnvFile(path: envFilePath) + // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) - } else { - projectName = deriveProjectName(cwd: cwd) - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } + let resolvedProjectName = resolveProjectName( + flagValue: composeFileOptions.projectName, + composeName: dockerCompose.name, + envVars: environmentVariables, + cwd: cwd + ) + projectName = resolvedProjectName + print("Info: Docker Compose project name resolved as: \(resolvedProjectName)") var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in guard let service else { return nil } diff --git a/Sources/Container-Compose/Commands/ComposeFileOptions.swift b/Sources/Container-Compose/Commands/ComposeFileOptions.swift index e27782af..c48e47a8 100644 --- a/Sources/Container-Compose/Commands/ComposeFileOptions.swift +++ b/Sources/Container-Compose/Commands/ComposeFileOptions.swift @@ -21,4 +21,7 @@ public struct ComposeFileOptions: ParsableArguments, Sendable { @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") public var composeFilename: String? + + @Option(name: [.customShort("p"), .customLong("project-name")], help: "Project name") + public var projectName: String? } diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 3a874f08..e3df884f 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -131,16 +131,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) - } else { - projectName = deriveProjectName(cwd: cwd) - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } + let resolvedProjectName = resolveProjectName( + flagValue: composeFileOptions.projectName, + composeName: dockerCompose.name, + envVars: environmentVariables, + cwd: cwd + ) + projectName = resolvedProjectName + print("Info: Docker Compose project name resolved as: \(resolvedProjectName)") + print( + "Note: The project name currently only affects container naming (e.g., '\(resolvedProjectName)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) // Get Services to use var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 371d135b..779189aa 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -110,6 +110,38 @@ public func deriveProjectName(cwd: String) -> String { return projectName } +/// Resolves the project name using the same precedence as Docker Compose: +/// 1. The `-p`/`--project-name` flag, 2. the `COMPOSE_PROJECT_NAME` environment variable +/// (process environment takes precedence over the .env file), 3. the top-level `name` field +/// from the compose file (with variable interpolation), 4. the working directory name. +/// +/// - Parameters: +/// - flagValue: The value of the `-p`/`--project-name` flag, if provided. +/// - composeName: The top-level `name` field from the compose file, if present. +/// - envVars: Environment variables loaded from the .env file. +/// - cwd: The current working directory path, used as the fallback. +/// - processEnv: The process environment (injectable for testing). +/// - Returns: The resolved project name. +public func resolveProjectName( + flagValue: String?, + composeName: String?, + envVars: [String: String], + cwd: String, + processEnv: [String: String] = ProcessInfo.processInfo.environment +) -> String { + if let flagValue, !flagValue.isEmpty { + return flagValue + } + let combinedEnv = processEnv.merging(envVars) { (current, _) in current } + if let envName = combinedEnv["COMPOSE_PROJECT_NAME"], !envName.isEmpty { + return envName + } + if let composeName { + return resolveVariable(composeName, with: envVars) + } + return deriveProjectName(cwd: cwd) +} + /// Converts Docker Compose port specification into a container run -p format. /// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol. /// - Parameter portSpec: The port specification string from docker-compose.yml. diff --git a/Tests/Container-Compose-StaticTests/ProjectNameResolutionTests.swift b/Tests/Container-Compose-StaticTests/ProjectNameResolutionTests.swift new file mode 100644 index 00000000..c84bf1fa --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ProjectNameResolutionTests.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerComposeCore + +@Suite("Project Name Resolution Tests") +struct ProjectNameResolutionTests { + + @Test("Interpolate variable with default in 'name' field when variable is unset") + func interpolateNameFieldWithDefault() { + // Regression: a literal "${VAR:-default}" project name produced invalid container names. + // Uses a unique variable name so the real process environment cannot interfere. + let result = resolveProjectName( + flagValue: nil, + composeName: "${CC_TEST_PROJECT_NAME_UNSET:-sample_project}", + envVars: [:], + cwd: "/tmp/somedir", + processEnv: [:] + ) + + #expect(result == "sample_project") + } + + @Test("Interpolate variable in 'name' field from .env file") + func interpolateNameFieldFromEnvFile() { + let result = resolveProjectName( + flagValue: nil, + composeName: "${CC_TEST_PROJECT_NAME_UNSET:-fallback}", + envVars: ["CC_TEST_PROJECT_NAME_UNSET": "from_env_file"], + cwd: "/tmp/somedir", + processEnv: [:] + ) + + #expect(result == "from_env_file") + } + + @Test("Literal 'name' field is used as-is") + func literalNameField() { + let result = resolveProjectName( + flagValue: nil, + composeName: "my-project", + envVars: [:], + cwd: "/tmp/somedir", + processEnv: [:] + ) + + #expect(result == "my-project") + } + + @Test("COMPOSE_PROJECT_NAME overrides 'name' field") + func composeProjectNameOverridesNameField() { + let result = resolveProjectName( + flagValue: nil, + composeName: "from-compose-file", + envVars: [:], + cwd: "/tmp/somedir", + processEnv: ["COMPOSE_PROJECT_NAME": "from-process-env"] + ) + + #expect(result == "from-process-env") + } + + @Test("COMPOSE_PROJECT_NAME from .env file is used when no 'name' field") + func composeProjectNameFromEnvFile() { + let result = resolveProjectName( + flagValue: nil, + composeName: nil, + envVars: ["COMPOSE_PROJECT_NAME": "from-env-file"], + cwd: "/tmp/somedir", + processEnv: [:] + ) + + #expect(result == "from-env-file") + } + + @Test("Process environment beats .env file for COMPOSE_PROJECT_NAME") + func processEnvBeatsEnvFile() { + let result = resolveProjectName( + flagValue: nil, + composeName: nil, + envVars: ["COMPOSE_PROJECT_NAME": "from-env-file"], + cwd: "/tmp/somedir", + processEnv: ["COMPOSE_PROJECT_NAME": "from-process-env"] + ) + + #expect(result == "from-process-env") + } + + @Test("Project name flag beats environment and 'name' field") + func flagBeatsEverything() { + let result = resolveProjectName( + flagValue: "from-flag", + composeName: "from-compose-file", + envVars: ["COMPOSE_PROJECT_NAME": "from-env-file"], + cwd: "/tmp/somedir", + processEnv: ["COMPOSE_PROJECT_NAME": "from-process-env"] + ) + + #expect(result == "from-flag") + } + + @Test("Falls back to directory name when nothing else is provided") + func fallsBackToDirectoryName() { + let result = resolveProjectName( + flagValue: nil, + composeName: nil, + envVars: [:], + cwd: "/tmp/my.project", + processEnv: [:] + ) + + #expect(result == "my_project") + } + + @Test("Empty COMPOSE_PROJECT_NAME is ignored") + func emptyComposeProjectNameIsIgnored() { + let result = resolveProjectName( + flagValue: nil, + composeName: "from-compose-file", + envVars: [:], + cwd: "/tmp/somedir", + processEnv: ["COMPOSE_PROJECT_NAME": ""] + ) + + #expect(result == "from-compose-file") + } +}