import cowl
import fio
import gleam/dict
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import testcontainer/formula
import testcontainer_compose/error
pub opaque type ComposeConfig {
ComposeConfig(
file_path: String,
project_name: String,
env_overrides: List(#(String, cowl.Secret(String))),
)
}
pub opaque type ComposeServices {
ComposeServices(
file_path: String,
project_name: String,
services: List(Service),
)
}
pub type Service {
Service(name: String, image: String, ports: List(#(Int, Int)))
}
pub fn from_file(file_path: String) -> ComposeConfig {
ComposeConfig(
file_path: file_path,
project_name: "testcontainer",
env_overrides: [],
)
}
pub fn with_project_name(
cfg: ComposeConfig,
project_name: String,
) -> ComposeConfig {
ComposeConfig(..cfg, project_name: project_name)
}
pub fn with_env_override(
cfg: ComposeConfig,
key: String,
value: String,
) -> ComposeConfig {
let secret = cowl.secret(value)
let overrides = list.append(cfg.env_overrides, [#(key, secret)])
ComposeConfig(..cfg, env_overrides: overrides)
}
pub fn formula(
cfg: ComposeConfig,
) -> Result(
formula.StandaloneFormula(ComposeServices, error.Error),
error.Error,
) {
use services <- result.try(load_compose_file(cfg))
let stack =
ComposeServices(
file_path: cfg.file_path,
project_name: cfg.project_name,
services: services,
)
let env_pairs = reveal_env_overrides(cfg.env_overrides)
Ok(
formula.new_standalone(
fn() {
compose_up(cfg.file_path, cfg.project_name, env_pairs)
|> result.map(fn(_) { stack })
|> result.map_error(fn(reason) {
error.ComposeFailed(path: cfg.file_path, reason: reason)
})
},
fn() {
compose_down(cfg.file_path, cfg.project_name)
|> result.map(fn(_) { Nil })
|> result.map_error(fn(reason) {
error.ComposeFailed(path: cfg.file_path, reason: reason)
})
},
),
)
}
/// Spin up a compose stack, run the body, and always tear it down.
pub fn with_compose(
cfg: ComposeConfig,
body: fn(ComposeServices) -> Result(a, error.Error),
) -> Result(a, error.Error) {
use services <- result.try(load_compose_file(cfg))
let env_pairs = reveal_env_overrides(cfg.env_overrides)
use _ <- result.try(
compose_up(cfg.file_path, cfg.project_name, env_pairs)
|> result.map_error(fn(reason) {
error.ComposeFailed(path: cfg.file_path, reason: reason)
}),
)
let stack =
ComposeServices(
file_path: cfg.file_path,
project_name: cfg.project_name,
services: services,
)
let body_result = body(stack)
let down_result =
compose_down(cfg.file_path, cfg.project_name)
|> result.map(fn(_) { Nil })
|> result.map_error(fn(reason) {
error.ComposeFailed(path: cfg.file_path, reason: reason)
})
case body_result, down_result {
Ok(_), Error(e) -> Error(e)
_, _ -> body_result
}
}
@external(erlang, "testcontainer_compose_ffi", "run_compose_up")
fn compose_up(
file_path: String,
project_name: String,
env_overrides: List(#(String, String)),
) -> Result(String, String)
@external(erlang, "testcontainer_compose_ffi", "run_compose_down")
fn compose_down(
file_path: String,
project_name: String,
) -> Result(String, String)
pub fn services(stack: ComposeServices) -> List(Service) {
stack.services
}
pub fn service_by_name(
stack: ComposeServices,
name: String,
) -> Option(Service) {
case list.find(stack.services, fn(svc) { svc.name == name }) {
Ok(svc) -> Some(svc)
Error(_) -> None
}
}
pub fn service_name(svc: Service) -> String {
svc.name
}
pub fn service_image(svc: Service) -> String {
svc.image
}
pub fn service_ports(svc: Service) -> List(#(Int, Int)) {
svc.ports
}
fn load_compose_file(cfg: ComposeConfig) -> Result(List(Service), error.Error) {
case fio.exists(cfg.file_path) {
False -> Error(error.ComposeFileNotFound(path: cfg.file_path))
True -> {
let env_pairs = reveal_env_overrides(cfg.env_overrides)
use json_str <- result.try(
compose_config_json(cfg.file_path, env_pairs)
|> result.map_error(fn(reason) {
error.InvalidYaml(path: cfg.file_path, reason: reason)
}),
)
parse_services_json(cfg.file_path, json_str)
}
}
}
@external(erlang, "testcontainer_compose_ffi", "compose_config_json")
fn compose_config_json(
file_path: String,
env_overrides: List(#(String, String)),
) -> Result(String, String)
fn reveal_env_overrides(
overrides: List(#(String, cowl.Secret(String))),
) -> List(#(String, String)) {
list.map(overrides, fn(pair) {
let #(key, secret) = pair
#(key, cowl.reveal(secret))
})
}
/// Parse a `docker compose config --format json` output string into typed
/// `Service` records. Exposed for testability — useful when you have a
/// pre-rendered compose JSON and want to validate parsing without Docker.
///
/// `path` is only used to enrich error messages.
pub fn parse_services_json(
path: String,
json_str: String,
) -> Result(List(Service), error.Error) {
let decoder = {
use services_dict <- decode.field(
"services",
decode.dict(decode.string, decode.dynamic),
)
decode.success(services_dict)
}
case json.parse(from: json_str, using: decoder) {
Ok(services_dict) ->
case dict.size(services_dict) {
0 -> Error(error.InvalidYaml(path: path, reason: "no services defined"))
_ -> Ok(services_from_dict(services_dict))
}
Error(_) ->
Error(error.InvalidYaml(
path: path,
reason: "compose config json parse failed",
))
}
}
fn services_from_dict(
services_dict: dict.Dict(String, decode.Dynamic),
) -> List(Service) {
services_dict
|> dict.to_list
|> list.map(fn(entry) {
let #(name, svc_dyn) = entry
let image = decode_service_image(svc_dyn)
let ports = decode_service_ports(svc_dyn)
Service(name: name, image: image, ports: ports)
})
}
fn decode_service_image(svc_dyn: decode.Dynamic) -> String {
let decoder = decode.field("image", decode.string, decode.success)
case decode.run(svc_dyn, decoder) {
Ok(image) -> image
Error(_) -> ""
}
}
fn decode_service_ports(svc_dyn: decode.Dynamic) -> List(#(Int, Int)) {
let port_decoder = {
use target <- decode.field("target", decode.int)
use published <- decode.optional_field(
"published",
target,
decode.one_of(decode.int, [
decode.string |> decode.then(parse_int_string),
]),
)
decode.success(#(published, target))
}
let decoder = decode.field("ports", decode.list(port_decoder), decode.success)
case decode.run(svc_dyn, decoder) {
Ok(ports) -> ports
Error(_) -> []
}
}
fn parse_int_string(s: String) -> decode.Decoder(Int) {
case int.parse(s) {
Ok(n) -> decode.success(n)
Error(_) -> decode.failure(0, "Int")
}
}