Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.vscode/launch.json
/tmp/
12 changes: 6 additions & 6 deletions Sources/Container-Compose/Commands/ComposeBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
26 changes: 16 additions & 10 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions Sources/Container-Compose/Commands/ComposeFileOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
21 changes: 11 additions & 10 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
142 changes: 142 additions & 0 deletions Tests/Container-Compose-StaticTests/ProjectNameResolutionTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading