Packages
oaspec
0.47.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.0
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.1
0.58.0
0.57.0
0.56.0
0.55.0
0.54.0
0.53.0
0.52.0
0.51.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.3
0.6.1
0.6.0
0.5.0
0.4.0
0.3.0
0.1.3
Generate Gleam code from OpenAPI 3.x specifications
Current section
Files
Jump to
Current section
Files
src/oaspec/internal/cli.gleam
import filepath
import gleam/int
import gleam/io
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/result
import gleam/string
import glint
import oaspec/codegen/writer
import oaspec/config
import oaspec/generate
import oaspec/internal/codegen/context
import oaspec/internal/formatter
import oaspec/internal/progress
import oaspec/openapi/diagnostic
import oaspec/openapi/parser
import simplifile
@external(erlang, "oaspec_ffi", "is_stdout_tty")
fn is_stdout_tty() -> Bool
@external(erlang, "oaspec_ffi", "no_color_set")
fn no_color_set() -> Bool
/// Apply colour styling only when stdout is a terminal and NO_COLOR is unset,
/// per <https://no-color.org/> and the CLIG "colorize when stdout is a TTY only"
/// guideline. When either check fails, glint keeps its `pretty_help` as `None`
/// and emits plain text.
fn maybe_pretty_help(glint: glint.Glint(a)) -> glint.Glint(a) {
case is_stdout_tty(), no_color_set() {
True, False -> glint.pretty_help(glint, glint.default_pretty_help())
_, _ -> glint
}
}
/// Long-option flag names that take a value across all subcommands. Used by
/// the argv-normalisation step in `oaspec.main` to translate the GNU
/// `--name value` form into glint's expected `--name=value` form. Must stay
/// in sync with the `glint.string_flag(...)` calls below: any new
/// value-bearing long-option flag must be added here, otherwise the
/// space-separated form will fail with `invalid flag '<name>'`.
pub const value_flag_names = ["config", "mode", "output"]
/// Set up the CLI application.
pub fn app() -> glint.Glint(Nil) {
glint.new()
|> glint.with_name("oaspec")
|> glint.global_help(
"Generate Gleam code from OpenAPI 3.x specifications\n\nCommands:\n init Create a default oaspec.yaml config file\n generate Generate Gleam code from an OpenAPI spec\n validate Validate an OpenAPI spec without generating code\n version Print the oaspec version and exit\n\nRun 'oaspec <command> --help' for more information.\nUse 'oaspec --version' for a flag-style version check.",
)
|> maybe_pretty_help
|> glint.add(at: ["init"], do: init_command())
|> glint.add(at: ["generate"], do: generate_command())
|> glint.add(at: ["validate"], do: validate_command())
|> glint.add(at: ["version"], do: version_command())
}
/// The version command definition.
fn version_command() -> glint.Command(Nil) {
glint.command_help("Print the oaspec version and exit", fn() {
glint.command(fn(_named_args, _args, _flags) {
io.println("oaspec v" <> context.version)
})
})
}
/// The generate command definition.
fn generate_command() -> glint.Command(Nil) {
{
use config_path <- glint.flag(
glint.string_flag("config")
|> glint.flag_default("./oaspec.yaml")
|> glint.flag_help("Path to config file"),
)
use mode <- glint.flag(
glint.string_flag("mode")
|> glint.flag_default("")
|> glint.flag_help(
"Generation mode: server, client, or both (overrides config)",
),
)
use output <- glint.flag(
glint.string_flag("output")
|> glint.flag_default("")
|> glint.flag_help("Output directory override"),
)
use check <- glint.flag(
glint.bool_flag("check")
|> glint.flag_default(False)
|> glint.flag_help(
"Check that generated code matches existing files without writing",
),
)
use fail_on_warnings <- glint.flag(
glint.bool_flag("fail-on-warnings")
|> glint.flag_default(False)
|> glint.flag_help("Treat warnings as errors"),
)
use validate <- glint.flag(
glint.bool_flag("validate")
|> glint.flag_default(False)
|> glint.flag_help(
"Force-enable guard validation in generated server/client code. One-way override: cannot disable validation set by oaspec.yaml. Set validate: false in the config to turn it off.",
),
)
glint.command_help(
"Generate Gleam code from an OpenAPI specification",
fn() {
glint.command(fn(_named_args, _args, flags) {
let config_path = config_path(flags) |> result.unwrap("./oaspec.yaml")
let mode_opt = case mode(flags) |> result.unwrap("") {
"" -> None
s -> Some(s)
}
let output_opt = case output(flags) |> result.unwrap("") {
"" -> None
s -> Some(s)
}
let check_mode = check(flags) |> result.unwrap(False)
let fail_on_warnings_mode =
fail_on_warnings(flags) |> result.unwrap(False)
let validate_mode = validate(flags) |> result.unwrap(False)
run_generate(
config_path,
mode_opt,
output_opt,
check_mode,
fail_on_warnings_mode,
validate_mode,
)
})
},
)
}
}
/// The validate command definition.
fn validate_command() -> glint.Command(Nil) {
{
use config_path <- glint.flag(
glint.string_flag("config")
|> glint.flag_default("./oaspec.yaml")
|> glint.flag_help("Path to config file"),
)
use mode <- glint.flag(
glint.string_flag("mode")
|> glint.flag_default("")
|> glint.flag_help(
"Generation mode: server, client, or both (overrides config)",
),
)
glint.command_help(
"Validate an OpenAPI specification without generating code",
fn() {
glint.command(fn(_named_args, _args, flags) {
let config_path = config_path(flags) |> result.unwrap("./oaspec.yaml")
let mode_opt = case mode(flags) |> result.unwrap("") {
"" -> None
s -> Some(s)
}
run_validate(config_path, mode_opt)
})
},
)
}
}
/// The init command definition.
fn init_command() -> glint.Command(Nil) {
{
use output_path <- glint.flag(
glint.string_flag("output")
|> glint.flag_default("./oaspec.yaml")
|> glint.flag_help("Output path for the config file"),
)
glint.command_help("Create a oaspec.yaml config file", fn() {
glint.command(fn(_named_args, _args, flags) {
let path = output_path(flags) |> result.unwrap("./oaspec.yaml")
run_init(path)
})
})
}
}
/// Create a config file template.
fn run_init(path: String) -> Nil {
let template =
"# oaspec configuration file
# See https://github.com/nao1215/oaspec for documentation.
# Path to your OpenAPI 3.x specification (YAML or JSON).
input: openapi.yaml
# Gleam module namespace for generated code.
# Generated imports will be `import <package>/types`, etc.
package: api
# Generation mode: server, client, or both (default: both).
# mode: both
# Enable guard validation in generated server/client code.
# Default: true for mode: server / both (fail-closed: server handlers
# should not receive schema-invalid input by default), false for
# mode: client (clients pre-validating before sending is nice but
# optional). When enabled, generated routers validate request bodies
# against schema constraints and return 422 on failure. Generated
# clients validate request bodies before sending. Set explicitly to
# override the mode-dependent default.
# validate: true
# Output settings (optional).
# output:
# dir: ./gen # Base directory; default paths are
# # server -> <dir>/<package>
# # client -> <dir>/<package>_client
# # so a single `gleam build` rooted at <dir>
# # picks up both.
# server: ./gen/api # Override server output path
# client: ./gen/api_client # Override client output path
# Operation filter (optional, Issue #387). When set, codegen only
# emits operations whose tag list intersects `tags` OR whose path
# matches one of `paths` (the two lists are unioned, not
# intersected). Path patterns ending in `/**` match any path that
# extends the prefix with a `/<rest>` segment. Both lists empty /
# both keys omitted = no filter.
# include:
# tags:
# - issues
# paths:
# - \"/users/{username}\"
# - \"/repos/**\"
# Multi-target codegen (optional, Issue #387). When `targets:` is
# set, the same input spec is generated once per entry, each with
# its own `package`, `output`, and `include`. The top-level
# `input`, `mode`, and `validate` are shared across every target.
# Targets whose output paths overlap are rejected at config-load
# time. `--output` cannot be used with multi-target configs.
# targets:
# - package: my_app/issues
# output:
# dir: ./src
# include:
# tags: [issues]
# - package: my_app/repos
# output:
# dir: ./src
# include:
# paths: [\"/repos/**\"]
"
case simplifile.is_file(path) {
Ok(True) -> {
io.println_error("Error: " <> path <> " already exists")
halt(1)
}
_ -> {
case simplifile.write(path, template) {
Ok(_) -> io.println("Created " <> path)
Error(write_err) -> {
io.println_error(
"Error: failed to write "
<> path
<> ": "
<> simplifile.describe_error(write_err),
)
halt(1)
}
}
}
}
}
/// Execute the generation pipeline.
/// Handles IO (printing, exit codes) while delegating pure logic to
/// generate.generate and load_config.
fn run_generate(
config_path: String,
mode_opt: Option(String),
output_opt: Option(String),
check_mode: Bool,
fail_on_warnings: Bool,
validate_mode: Bool,
) -> Nil {
io.println("oaspec v" <> context.version)
io.println("Loading config from: " <> config_path)
// Issue #387: a config may declare multiple `targets:`. Each target
// produces its own package + output tree from the same input spec.
// The single-target legacy shape returns a 1-element list here.
use cfgs <- require(
load_configs(config_path, mode_opt, output_opt),
load_config_error_to_string,
)
let cfgs =
list.map(cfgs, fn(c) {
case validate_mode {
True -> config.with_validate(c, True)
False -> c
}
})
// Reject configs where two targets would write to the same
// directory — generated files would clobber each other and there
// is no sensible default order.
use _ <- require(
validate_no_target_overlap(cfgs),
load_config_error_to_string,
)
print_resolved_paths_for_all(config_path, cfgs)
// The input spec is shared across every target, so parse it once.
// `load_configs` rejects empty target lists at config-load time,
// so `cfgs` is guaranteed non-empty by the time we get here.
// nolint: assert_ok_pattern -- `load_configs` rejects empty target lists; reaching the empty branch would be an internal invariant violation.
let assert [first_cfg, ..] = cfgs
let shared_input = config.input(first_cfg)
io.println("Parsing OpenAPI spec: " <> shared_input)
let reporter = progress.stdout_with_elapsed()
use spec <- require(
parser.parse_file_with_progress(shared_input, reporter),
fn(e) { "Error: " <> parser.parse_error_to_string(e) },
)
// Run codegen once per target. `generate_with_progress` re-runs
// every per-target stage (filter / hoist / dedup / validate /
// codegen) so each target sees only the operations its filter
// keeps. `parse` and `normalize` would be re-run too if we passed
// the same `OpenApiSpec(Unresolved)` value through the pipeline,
// but those stages are pure and cheap relative to codegen.
let multi_target = case cfgs {
[_, _, ..] -> True
_ -> False
}
list.each(cfgs, fn(cfg) {
case multi_target {
True -> {
io.println("")
io.println("[target: " <> config.package(cfg) <> "]")
}
False -> Nil
}
use summary <- require(
generate.generate_with_progress(spec, cfg, reporter),
format_generate_error,
)
io.println("Spec loaded: " <> summary.spec_title)
print_warnings(summary.warnings)
case fail_on_warnings && summary.warnings != [] {
True -> {
io.println_error("")
io.println_error(
"Error: warnings present and --fail-on-warnings is set.",
)
halt(1)
}
False -> Nil
}
case check_mode {
True -> run_check(summary.files, cfg)
False -> write_files(summary.files, cfg)
}
})
}
fn print_resolved_paths_for_all(
config_path: String,
cfgs: List(config.Config),
) -> Nil {
case cfgs {
[single] -> print_resolved_paths(config_path, single)
multiple -> {
case simplifile.current_directory() {
Ok(cwd) -> {
io.println("Resolved paths:")
io.println(" config: " <> resolve_path_from_cwd(config_path, cwd))
let inputs =
multiple
|> list.map(fn(c) { config.input(c) })
|> list.unique
list.each(inputs, fn(p) {
io.println(" input: " <> resolve_path_from_cwd(p, cwd))
})
list.each(multiple, fn(cfg) {
io.println(" target [" <> config.package(cfg) <> "]:")
print_target_outputs(cfg, cwd, " ")
})
}
// nolint: thrown_away_error -- path printing is best-effort.
Error(_) -> Nil
}
}
}
}
fn print_target_outputs(cfg: config.Config, cwd: String, prefix: String) -> Nil {
case config.mode(cfg) {
config.Server -> {
io.println(
prefix
<> "output.server: "
<> resolve_path_from_cwd(config.output_server(cfg), cwd),
)
}
config.Client -> {
io.println(
prefix
<> "output.client: "
<> resolve_path_from_cwd(config.output_client(cfg), cwd),
)
}
config.Both -> {
io.println(
prefix
<> "output.server: "
<> resolve_path_from_cwd(config.output_server(cfg), cwd),
)
io.println(
prefix
<> "output.client: "
<> resolve_path_from_cwd(config.output_client(cfg), cwd),
)
}
}
}
/// Issue #387: every active output path across every target must be
/// unique. Two targets writing to the same directory would clobber
/// each other on disk and surface random "wrong package" errors at
/// the Gleam compiler level depending on which generate ran second.
fn validate_no_target_overlap(
cfgs: List(config.Config),
) -> Result(Nil, LoadConfigError) {
let paths =
cfgs
|> list.flat_map(fn(cfg) { active_output_paths(cfg) })
find_duplicate_path(paths, [])
|> result.map_error(fn(err) {
let DuplicateOutputPath(path) = err
OutputValidationError(error: config.InvalidValue(
field: "targets",
detail: "two targets resolve to the same output directory '"
<> path
<> "' — give them distinct package or output paths so generated files do not clobber each other",
))
})
}
fn active_output_paths(cfg: config.Config) -> List(String) {
case config.mode(cfg) {
config.Server -> [config.output_server(cfg)]
config.Client -> [config.output_client(cfg)]
config.Both -> [config.output_server(cfg), config.output_client(cfg)]
}
}
/// Internal error from `find_duplicate_path/2`. Wrapping the offending
/// path in a named record (rather than a bare String) keeps the
/// linter happy and makes the failure mode explicit for any future
/// caller that wants the path on its own.
type DuplicateOutputPathError {
DuplicateOutputPath(path: String)
}
fn find_duplicate_path(
remaining: List(String),
seen: List(String),
) -> Result(Nil, DuplicateOutputPathError) {
case remaining {
[] -> Ok(Nil)
[path, ..rest] ->
case list.contains(seen, path) {
True -> Error(DuplicateOutputPath(path: path))
False -> find_duplicate_path(rest, [path, ..seen])
}
}
}
fn print_resolved_paths(config_path: String, cfg: config.Config) -> Nil {
case simplifile.current_directory() {
Ok(cwd) -> {
io.println("Resolved paths:")
resolved_path_entries(config_path, cfg, cwd)
|> list.each(fn(entry) {
let #(label, path) = entry
io.println(" " <> label <> ": " <> path)
})
}
// nolint: thrown_away_error -- path printing is best-effort; if the cwd cannot be read we silently skip rather than abort generation.
Error(_) -> Nil
}
}
@internal
pub fn resolved_path_entries(
config_path: String,
cfg: config.Config,
cwd: String,
) -> List(#(String, String)) {
let base_entries = [
#("config", resolve_path_from_cwd(config_path, cwd)),
#("input", resolve_path_from_cwd(config.input(cfg), cwd)),
]
let output_entries = case config.mode(cfg) {
config.Server -> [
#("output.server", resolve_path_from_cwd(config.output_server(cfg), cwd)),
]
config.Client -> [
#("output.client", resolve_path_from_cwd(config.output_client(cfg), cwd)),
]
config.Both -> [
#("output.server", resolve_path_from_cwd(config.output_server(cfg), cwd)),
#("output.client", resolve_path_from_cwd(config.output_client(cfg), cwd)),
]
}
list.append(base_entries, output_entries)
}
@internal
pub fn resolve_path_from_cwd(path: String, cwd: String) -> String {
let candidate = case path_is_absolute(path) {
True -> path
False -> filepath.join(cwd, path)
}
filepath.expand(candidate) |> result.unwrap(candidate)
}
fn path_is_absolute(path: String) -> Bool {
filepath.is_absolute(path)
|| string.starts_with(path, "\\\\")
|| string.starts_with(path, "//")
|| is_windows_drive_absolute(path)
}
fn is_windows_drive_absolute(path: String) -> Bool {
let prefix = string.slice(from: path, at_index: 0, length: 3)
case string.to_graphemes(prefix) {
[drive, ":", slash] ->
is_ascii_letter(drive) && { slash == "/" || slash == "\\" }
_ -> False
}
}
fn is_ascii_letter(value: String) -> Bool {
case string.lowercase(value) {
"a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z" -> True
_ -> False
}
}
/// Write generated files to disk, format them, and print summary.
fn write_files(files: List(context.GeneratedFile), cfg: config.Config) -> Nil {
io.println("Generating code...")
use written <- require(
writer.write_all(files, cfg, fn(path) {
io.println(" Generated: " <> path)
}),
fn(e) { "Error: " <> writer.error_to_string(e) },
)
use _ <- require(formatter.format_files(writer.output_dirs(cfg)), fn(e) {
"Error: " <> formatter.error_to_string(e)
})
io.println(" Formatted generated code")
io.println("")
io.println(
"Successfully generated " <> int.to_string(list.length(written)) <> " files",
)
print_dep_hints(files)
}
/// Issue #284: when the generated code imports a Gleam stdlib module
/// whose package is not a direct dep of the consumer's project (today,
/// only `gleam/regexp` for pattern validation), `gleam build` warns
/// about the transitive import and a future Gleam release turns this
/// into a hard error. Print an actionable hint so the user adds the
/// missing dep — we deliberately do not rewrite the consumer's
/// `gleam.toml` because modifying user-managed config invisibly is
/// surprising.
fn print_dep_hints(files: List(context.GeneratedFile)) -> Nil {
let needs_regexp =
list.any(files, fn(f) { string.contains(f.content, "import gleam/regexp") })
case needs_regexp {
True -> {
io.println("")
io.println(
"Note: generated code imports gleam/regexp for pattern validation.",
)
io.println(
" Run 'gleam add gleam_regexp' in your project before 'gleam build'.",
)
}
False -> Nil
}
}
/// Check that generated code matches existing files on disk.
/// Exits 0 if all files match, exits 1 if any differ, missing, or orphaned.
///
/// Issue #247: user-owned `handlers.gleam` (`SkipIfExists`) is dropped from
/// the byte-comparison list (`resolve_paths` does that filtering) but kept in
/// the expected-paths list so orphan detection still recognises it as a known
/// generator output instead of flagging it as an orphan.
fn run_check(files: List(context.GeneratedFile), cfg: config.Config) -> Nil {
io.println("Checking generated code against existing files...")
let resolved = writer.resolve_paths(files, cfg)
let expected_paths = writer.expected_paths(files, cfg)
// Format generated content via temp files before comparison
let resolved = format_resolved_content(resolved)
// Check for missing or differing files
let diffs =
list.filter_map(resolved, fn(entry) {
let #(path, content) = entry
case simplifile.read(path) {
// nolint: thrown_away_error -- read failure means file is missing on disk, which is the reported diagnostic
Error(_) -> Ok(path <> " (missing)")
Ok(existing) ->
case string.compare(existing, content) {
order.Eq -> Error(Nil)
_ -> Ok(path <> " (differs)")
}
}
})
// Check for orphaned files in output directories
let output_dirs = writer.output_dirs(cfg)
let orphans =
list.flat_map(output_dirs, fn(dir) {
case simplifile.read_directory(dir) {
// nolint: thrown_away_error -- unreadable output dir simply has no orphans to report
Error(_) -> []
Ok(entries) ->
list.filter_map(entries, fn(name) {
case string.ends_with(name, ".gleam") {
False -> Error(Nil)
True -> {
let full_path = dir <> "/" <> name
case list.contains(expected_paths, full_path) {
True -> Error(Nil)
False -> Ok(full_path <> " (orphaned)")
}
}
}
})
}
})
let all_issues = list.append(diffs, orphans)
case all_issues {
[] -> {
io.println("")
io.println("All files up to date, check passed.")
}
_ -> {
io.println_error("")
list.each(all_issues, fn(d) { io.println_error(" " <> d) })
io.println_error(
"\n"
<> int.to_string(list.length(all_issues))
<> " file(s) out of date. Run 'oaspec generate' to update.",
)
halt(1)
}
}
}
/// Format resolved content by writing to a temp directory, running gleam format,
/// and reading back the formatted content.
fn format_resolved_content(
resolved: List(#(String, String)),
) -> List(#(String, String)) {
let temp_dir = "/tmp/oaspec_check_" <> unique_id()
case simplifile.create_directory_all(temp_dir) {
// nolint: thrown_away_error -- if temp dir cannot be created we fall back to unformatted content
Error(_) -> resolved
Ok(Nil) -> {
// Write each file to the temp directory with unique indexed names
let temp_entries =
list.index_map(resolved, fn(entry, idx) {
let #(original_path, content) = entry
let temp_path = temp_dir <> "/" <> int.to_string(idx) <> ".gleam"
// Best-effort temp write; formatter step falls back if anything failed
let _ignored_write = simplifile.write(temp_path, content)
#(original_path, temp_path)
})
// Format all temp files
let temp_paths = list.map(temp_entries, fn(entry) { entry.1 })
let formatted_resolved = case formatter.format_files(temp_paths) {
Ok(Nil) -> {
list.map(temp_entries, fn(entry) {
let #(original_path, temp_path) = entry
case simplifile.read(temp_path) {
Ok(formatted) -> #(original_path, formatted)
// nolint: thrown_away_error -- unreadable temp file falls back to unformatted original content
Error(_) -> {
let content =
list.find(resolved, fn(r) { r.0 == original_path })
|> result.map(fn(r) { r.1 })
|> result.unwrap("")
#(original_path, content)
}
}
})
}
// nolint: thrown_away_error -- formatter failure falls back to unformatted content
Error(_) -> resolved
}
// Clean up temp directory; leaked temp dirs are not fatal
let _ignored_delete = simplifile.delete(temp_dir)
formatted_resolved
}
}
}
/// Generate a unique ID string for temp directories.
@external(erlang, "erlang", "unique_integer")
fn unique_integer() -> Int
fn unique_id() -> String {
let integer_value = unique_integer()
case integer_value < 0 {
True -> "n" <> int.to_string(-integer_value)
False -> int.to_string(integer_value)
}
}
/// Execute the validation-only pipeline. With a multi-target
/// config, every target is validated against the same parsed spec
/// and the run only succeeds when each target validates cleanly.
fn run_validate(config_path: String, mode_opt: Option(String)) -> Nil {
io.println("oaspec v" <> context.version)
io.println("Loading config from: " <> config_path)
use cfgs <- require(
load_configs(config_path, mode_opt, None),
load_config_error_to_string,
)
// `load_configs` rejects empty target lists; the destructure
// documents that invariant for any future reader.
// nolint: assert_ok_pattern -- `load_configs` rejects empty target lists; reaching the empty branch would be an internal invariant violation.
let assert [first_cfg, ..] = cfgs
let shared_input = config.input(first_cfg)
io.println("Parsing OpenAPI spec: " <> shared_input)
let reporter = progress.stdout_with_elapsed()
use spec <- require(
parser.parse_file_with_progress(shared_input, reporter),
fn(e) { "Error: " <> parser.parse_error_to_string(e) },
)
let multi_target = case cfgs {
[_, _, ..] -> True
_ -> False
}
list.each(cfgs, fn(cfg) {
case multi_target {
True -> {
io.println("")
io.println("[target: " <> config.package(cfg) <> "]")
}
False -> Nil
}
use summary <- require(
generate.validate_only_with_progress(spec, cfg, reporter),
format_generate_error,
)
io.println("Spec loaded: " <> summary.spec_title)
print_warnings(summary.warnings)
})
io.println("")
io.println("Validation passed.")
}
/// Errors surfaced while loading and validating the oaspec config.
/// Wraps the underlying `config.ConfigError` so `load_config` can return a
/// structured error type rather than a plain string.
type LoadConfigError {
ConfigLoadError(error: config.ConfigError)
ModeParseError(error: config.ConfigError)
OutputValidationError(error: config.ConfigError)
}
/// Format a `LoadConfigError` into a user-facing message.
fn load_config_error_to_string(error: LoadConfigError) -> String {
let detail = case error {
ConfigLoadError(error:) -> config.error_to_string(error)
ModeParseError(error:) -> config.error_to_string(error)
OutputValidationError(error:) -> config.error_to_string(error)
}
"Error: " <> detail
}
/// Pure config loading and validation pipeline. Issue #387: returns
/// every target the config declares (1 for legacy single-target,
/// N for multi-target). CLI overrides (`--mode`, `--output`) are
/// applied uniformly to every target; `--output` over a multi-
/// target config is rejected because each target already declares
/// its own per-package output directory.
fn load_configs(
config_path: String,
mode_opt: Option(String),
output_opt: Option(String),
) -> Result(List(config.Config), LoadConfigError) {
use cfgs <- result.try(
config.load_all(config_path)
|> result.map_error(ConfigLoadError),
)
use cfgs <- result.try(case mode_opt {
None -> Ok(cfgs)
Some(mode_str) ->
config.parse_mode(mode_str)
|> result.map(fn(parsed) {
list.map(cfgs, fn(c) { config.with_mode(c, parsed) })
})
|> result.map_error(ModeParseError)
})
use cfgs <- result.try(case output_opt, cfgs {
None, _ -> Ok(cfgs)
Some(path), [single] -> Ok([config.with_output(single, Some(path))])
Some(_), [_, _, ..] ->
Error(
OutputValidationError(error: config.InvalidValue(
field: "--output",
detail: "cannot override the output directory for a multi-target config; each target already declares its own output (set per-target output: in oaspec.yaml or drop the --output flag)",
)),
)
Some(_), [] ->
Error(
OutputValidationError(error: config.InvalidValue(
field: "targets",
detail: "must declare at least one target",
)),
)
})
// Each target's own server/client paths must satisfy the per-
// target invariants (basename matches package, src placement is
// safe). Run both validators on every target.
use _ <- result.try(
cfgs
|> list.try_map(fn(cfg) { validate_target_paths(cfg) })
|> result.map(fn(_) { Nil }),
)
Ok(cfgs)
}
fn validate_target_paths(cfg: config.Config) -> Result(Nil, LoadConfigError) {
use _ <- result.try(
config.validate_output_package_match(cfg)
|> result.map_error(OutputValidationError),
)
config.validate_output_dir_layout(cfg)
|> result.map_error(OutputValidationError)
}
/// Unwrap a Result or print the error message and exit.
/// Designed for use with `use value <- require(result, to_message)`.
fn require(
result: Result(a, b),
to_message: fn(b) -> String,
continue: fn(a) -> Nil,
) -> Nil {
case result {
Ok(value) -> continue(value)
Error(err) -> {
io.println_error(to_message(err))
halt(1)
}
}
}
/// Format a GenerateError into a printable error message.
fn format_generate_error(err: generate.GenerateError) -> String {
let generate.ValidationErrors(errors:) = err
"Error: OpenAPI spec contains unsupported features:\n"
<> string.join(
list.map(errors, fn(validation_error) {
" - " <> diagnostic.to_short_string(validation_error)
}),
"\n",
)
}
/// Print warnings if any exist. Warnings are diagnostics, so they go to stderr
/// to keep stdout reserved for requested output.
fn print_warnings(warnings: List(diagnostic.Diagnostic)) -> Nil {
case warnings {
[] -> Nil
_ -> {
io.println_error("Warnings:")
list.each(warnings, fn(w) {
io.println_error(" - " <> diagnostic.to_short_string(w))
})
}
}
}
/// Exit the process with a status code.
@external(erlang, "erlang", "halt")
fn halt(code: Int) -> Nil