diff --git a/.tool-versions b/.tool-versions index 8313347..be680ce 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.9.0-otp-22 -erlang 22.0.7 +elixir 1.10.1 +erlang 22.2.7 diff --git a/README.md b/README.md index 91ab772..61e3aa4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![Build Status](https://travis-ci.org/sascha-wolf/knigge.svg?branch=master)](https://travis-ci.org/sascha-wolf/knigge) [![Coverage Status](https://coveralls.io/repos/github/sascha-wolf/knigge/badge.svg?branch=master)](https://coveralls.io/github/sascha-wolf/knigge?branch=master) [![Inline docs](https://inch-ci.org/github/sascha-wolf/knigge.svg?branch=master)](https://inch-ci.org/github/sascha-wolf/knigge) +[![Hexdocs.pm](https://img.shields.io/badge/hexdocs-online-blue)](https://hexdocs.pm/knigge/) [![Hex.pm](https://img.shields.io/hexpm/v/knigge.svg)](https://hex.pm/packages/knigge) +[![Hex.pm Downloads](https://img.shields.io/hexpm/dt/knigge)](https://hex.pm/packages/knigge) [![Featured - ElixirRadar](https://img.shields.io/badge/featured-ElixirRadar-543A56)](https://app.rdstation.com.br/mail/0ddee1c8-2ce9-405b-b95f-09c883099090?utm_campaign=elixir_radar_202&utm_medium=email&utm_source=RD+Station) [![Featured - ElixirWeekly](https://img.shields.io/badge/featured-ElixirWeekly-875DB0)](https://elixirweekly.net/issues/161) @@ -22,11 +24,16 @@ passing the behaviour which should be "facaded" as an option. ## Overview -- [Installation](#installation) -- [Motivation](#motivation) -- [Examples](#examples) -- [Options](#options) -- [Knigge and the `:test` environment](#knigge-and-the-test-environment) +- [Knigge](#knigge) + - [Overview](#overview) + - [Installation](#installation) + - [Motivation](#motivation) + - [Examples](#examples) + - [`defdefault` - Fallback implementations for optional callbacks](#defdefault---fallback-implementations-for-optional-callbacks) + - [Options](#options) + - [Verifying your Implementations - `mix knigge.verify`](#verifying-your-implementations---mix-kniggeverify) + - [Knigge and the `:test` environment](#knigge-and-the-test-environment) + - [Compiler Warnings](#compiler-warnings) ## Installation @@ -195,6 +202,16 @@ as option - by default `Knigge` delegates at runtime in your `:test`s. For further information about options check the [`Knigge.Options` module](https://hexdocs.pm/knigge/Knigge.Options.html). +## Verifying your Implementations - `mix knigge.verify` + +Before version 1.2.0 `Knigge` tried to check at compile time if the implementation of your facade existed. +Due to the way the Elixir compiler goes about compiling your modules this didn't work as expected - [checkout this page if you're interested in the details](https://hexdocs.pm/knigge/the-existence-check.html). + +As an alternative `Knigge` now offers the `mix knigge.verify` task which verifies that the implementation modules of your facades actually exist. +The task returns with an error code when an implementation is missing, which allows you to plug it into your CI pipeline - for example as `MIX_ENV=prod mix knigge.verify`. + +For details check the documentation of `mix knigge.verify` by running `mix help knigge.verify`. + ## Knigge and the `:test` environment To give the maximum amount of flexibility `Knigge` delegates at runtime in your @@ -211,7 +228,7 @@ In case you change the `delegate_at_runtime?` configuration to anything which excludes the `:test` environment you will - most likely - encounter compiler warnings like this: -``` +```text warning: function MyMock.my_great_callback/1 is undefined (module MyMock is not available) lib/my_facade.ex:1 diff --git a/config/test.exs b/config/test.exs index 48ef3b6..a651774 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,4 @@ use Mix.Config config :knigge, - check_if_exists?: [except: :test], delegate_at_runtime?: false diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..be108de --- /dev/null +++ b/coveralls.json @@ -0,0 +1,5 @@ +{ + "skip_files": [ + "lib/mix/tasks" + ] +} diff --git a/lib/knigge.ex b/lib/knigge.ex index f8eaa7b..cc6b100 100644 --- a/lib/knigge.ex +++ b/lib/knigge.ex @@ -141,6 +141,16 @@ defmodule Knigge do For further information about options check the `Knigge.Options` module. + ## Verifying your Implementations - `mix knigge.verify` + + Before version 1.2.0 `Knigge` tried to check at compile time if the implementation of your facade existed. + Due to the way the Elixir compiler goes about compiling your modules this didn't work as expected - [checkout this page if you're interested in the details](https://hexdocs.pm/knigge/the-existence-check.html). + + As an alternative `Knigge` now offers the `mix knigge.verify` task which verifies that the implementation modules of your facades actually exist. + The task returns with an error code when an implementation is missing, which allows you to plug it into your CI pipeline - for example as `MIX_ENV=prod mix knigge.verify`. + + For details check the documentation of `mix knigge.verify` by running `mix help knigge.verify`. + ## Knigge and the `:test` environment To give the maximum amount of flexibility `Knigge` delegates at runtime in your @@ -198,10 +208,14 @@ defmodule Knigge do behaviour = options |> Knigge.Behaviour.fetch!() - |> Knigge.Module.ensure_exists!(options, __ENV__) + |> Knigge.Module.ensure_exists!(__ENV__) @__knigge__ {:options, options} + @doc "Acts as a \"flag\" to mark this module as a Knigge module." + @spec __knigge__() :: :ok + def __knigge__, do: :ok + @doc "Access Knigge internal values, such as the implementation being delegated to etc." @spec __knigge__(:behaviour) :: module() @spec __knigge__(:implementation) :: module() @@ -215,10 +229,7 @@ defmodule Knigge do Knigge.Implementation.fetch!(__knigge__(:options)) end else - implementation = - options - |> Knigge.Implementation.fetch!() - |> Knigge.Module.ensure_exists!(options, __ENV__) + implementation = Knigge.Implementation.fetch!(options) def __knigge__(:implementation) do unquote(implementation) diff --git a/lib/knigge/cli/output.ex b/lib/knigge/cli/output.ex new file mode 100644 index 0000000..146cd2f --- /dev/null +++ b/lib/knigge/cli/output.ex @@ -0,0 +1,15 @@ +defmodule Knigge.CLI.Output do + @moduledoc false + + @device :stdio + + def info(device \\ @device, message), do: print(device, [:blue, message]) + def success(device \\ @device, message), do: print(device, [:green, message]) + def error(device \\ @device, message), do: print(device, [:red, message]) + def warn(device \\ @device, message), do: print(device, [:gold, message]) + + def print(device \\ @device, message) + + def print(:stdio, message), do: Bunt.puts(message) + def print(:stderr, message), do: Bunt.warn(message) +end diff --git a/lib/knigge/code.ex b/lib/knigge/code.ex index 9bc058b..26ad001 100644 --- a/lib/knigge/code.ex +++ b/lib/knigge/code.ex @@ -63,27 +63,24 @@ defmodule Knigge.Code do Default.callback_to_defdefault(callback, from: module, default: default, - delegate_at_runtime?: delegate_at_runtime?, - env: env + delegate_at_runtime?: delegate_at_runtime? ) end true -> Delegate.callback_to_defdelegate(callback, from: module, - delegate_at_runtime?: delegate_at_runtime?, - env: env + delegate_at_runtime?: delegate_at_runtime? ) end end end defp get_behaviour(module, env) do - opts = Knigge.options!(module) - - opts + module + |> Knigge.options!() |> Knigge.Behaviour.fetch!() - |> Knigge.Module.ensure_exists!(opts, env) + |> Knigge.Module.ensure_exists!(env) end defp get_callbacks(module) do diff --git a/lib/knigge/code/default.ex b/lib/knigge/code/default.ex index 6c3dede..30a2c23 100644 --- a/lib/knigge/code/default.ex +++ b/lib/knigge/code/default.ex @@ -9,10 +9,9 @@ defmodule Knigge.Code.Default do {name, arity}, from: module, default: {args, block}, - delegate_at_runtime?: false, - env: env + delegate_at_runtime?: false ) do - implementation = Implementation.fetch_for!(module, env) + implementation = Implementation.fetch_for!(module) cond do Module.open?(implementation) -> @@ -26,8 +25,7 @@ defmodule Knigge.Code.Default do {name, arity}, from: module, default: {args, block}, - delegate_at_runtime?: true, - env: env + delegate_at_runtime?: true ) function_exported?(implementation, name, arity) -> @@ -48,8 +46,7 @@ defmodule Knigge.Code.Default do {name, arity}, from: _module, default: {args, block}, - delegate_at_runtime?: true, - env: _env + delegate_at_runtime?: true ) do quote do def unquote(name)(unquote_splicing(args)) do diff --git a/lib/knigge/code/delegate.ex b/lib/knigge/code/delegate.ex index dcc8a65..2f02fbc 100644 --- a/lib/knigge/code/delegate.ex +++ b/lib/knigge/code/delegate.ex @@ -3,14 +3,14 @@ defmodule Knigge.Code.Delegate do alias Knigge.Implementation - def callback_to_defdelegate({name, arity}, from: module, delegate_at_runtime?: false, env: env) do + def callback_to_defdelegate({name, arity}, from: module, delegate_at_runtime?: false) do callback_to_defdelegate({name, arity}, from: module, - to: Implementation.fetch_for!(module, env) + to: Implementation.fetch_for!(module) ) end - def callback_to_defdelegate({name, arity}, from: _module, delegate_at_runtime?: true, env: _env) do + def callback_to_defdelegate({name, arity}, from: _module, delegate_at_runtime?: true) do quote bind_quoted: [name: name, arity: arity] do args = Macro.generate_arguments(arity, __MODULE__) diff --git a/lib/knigge/implementation.ex b/lib/knigge/implementation.ex index 9e6e243..a639e88 100644 --- a/lib/knigge/implementation.ex +++ b/lib/knigge/implementation.ex @@ -17,11 +17,9 @@ defmodule Knigge.Implementation do implementation end - def fetch_for!(module, env) do - opts = Knigge.options!(module) - - opts + def fetch_for!(module) do + module + |> Knigge.options!() |> Knigge.Implementation.fetch!() - |> Knigge.Module.ensure_exists!(opts, env) end end diff --git a/lib/knigge/module.ex b/lib/knigge/module.ex index a640c45..a243d12 100644 --- a/lib/knigge/module.ex +++ b/lib/knigge/module.ex @@ -1,21 +1,59 @@ defmodule Knigge.Module do @moduledoc false - alias Knigge.Options + module = inspect(__MODULE__) - def ensure_exists!(module, opts, env) do - unless Knigge.Module.exists?(module, opts) do + def ensure_exists!(module, env) do + unless Knigge.Module.exists?(module) do Knigge.Error.module_not_loaded!(module, env) end module end - def exists?(module, %Options{} = opts) do - if opts.check_if_exists? do - Code.ensure_loaded?(module) or Module.open?(module) - else + @doc """ + Returns true if a module exists, false otherwise. + + ## Examples + + iex> #{module}.exists?(This.Does.Not.Exist) + false + + iex> #{module}.exists?(Knigge) true + """ + @spec exists?(module :: module()) :: boolean() + def exists?(module) do + Module.open?(module) or Code.ensure_loaded?(module) + end + + @doc """ + Returns all modules which `use Knigge` for the given app. If the app does not + exist an error is returned. To determine if `Knigge` is `use`d we check if the + module exports the `__knigge__/0` function which acts as a "tag". + + `fetch_for_app/1` makes use of `Code.ensure_loaded?/1` to force the module + being loaded. Since this results in a `GenServer.call/3` to the code server + __do not use this__ in a hot code path, as it __will__ result in a slowdown! + + ## Examples + + iex> #{module}.fetch_for_app(:this_does_not_exist) + {:error, :undefined} + + iex> #{module}.fetch_for_app(:knigge) + {:ok, []} + """ + @spec fetch_for_app(app :: atom()) :: {:ok, list(module())} | {:error, :undefined} + def fetch_for_app(app) do + with {:ok, modules} <- :application.get_key(app, :modules) do + {:ok, Enum.filter(modules, &uses_knigge?/1)} + else + :undefined -> {:error, :undefined} end end + + defp uses_knigge?(module) do + Code.ensure_loaded?(module) and function_exported?(module, :__knigge__, 0) + end end diff --git a/lib/knigge/options.ex b/lib/knigge/options.ex index 895996d..13db4f5 100644 --- a/lib/knigge/options.ex +++ b/lib/knigge/options.ex @@ -1,6 +1,5 @@ defmodule Knigge.Options do @defaults [ - check_if_exists?: true, delegate_at_runtime?: [only: :test], do_not_delegate: [], warn: true @@ -32,18 +31,6 @@ defmodule Knigge.Options do __Default__: the `use`ing `__MODULE__`. - ### `check_if_exists?` - Controls how `Knigge` checks if the given modules exist, accepts: - - - a boolean (turn the check fully on or off) - - one or many environment names (atom or list of atoms) - only checks in the given environments - - `[only: ]` - equivalent to the option above - - `[except: ]` - only checks if the current environment is __not__ contained in the list - - __Default__: `Application.gev_env(:knigge, :check_if_exists?, #{ - inspect(@defaults[:check_if_exists?]) - })` - ### `config_key` The configuration key from which `Knigge` should fetch the implementation. @@ -70,7 +57,13 @@ defmodule Knigge.Options do __Default__: `[]` ### `warn` - If set to `false` this disables all warnings generated by `Knigge`, use with care. + Allows to control in which environments `Knigge` should generate warnings, use with care. + Accepts: + + - a boolean (`true` always warns | `false` never warns) + - one or many environment names (atom or list of atoms) - only warns in the given environments + - `[only: ]` - equivalent to the option above + - `[except: ]` - only warns if the current environment is __not__ contained in the list __Default__: `Application.get_env(:knigge, :warn, #{inspect(@defaults[:warn])})` """ @@ -83,10 +76,9 @@ defmodule Knigge.Options do @type optional :: [ behaviour: behaviour(), config_key: config_key(), - check_if_exists?: boolean_or_envs(), delegate_at_runtime?: boolean_or_envs(), do_not_delegate: do_not_delegate(), - warn: warn() + warn: boolean_or_envs() ] @type behaviour :: module() @@ -96,20 +88,17 @@ defmodule Knigge.Options do @type do_not_delegate :: keyword(arity()) @type envs :: atom() | list(atom()) @type otp_app :: atom() - @type warn :: boolean() @type t :: %__MODULE__{ implementation: module() | {:config, otp_app(), config_key()}, behaviour: behaviour(), - check_if_exists?: boolean(), delegate_at_runtime?: boolean(), do_not_delegate: do_not_delegate(), - warn: warn() + warn: boolean() } defstruct [ :behaviour, - :check_if_exists?, :delegate_at_runtime?, :do_not_delegate, :implementation, @@ -143,21 +132,33 @@ defmodule Knigge.Options do end defp map_deprecated(opts) when is_list(opts) do - for {key, _} = kv <- opts do + opts + |> Enum.map(fn {key, _} = kv -> case map_deprecated(kv) do ^kv -> kv - {new_key, _} = kv -> + {new_key, _} = kv when is_atom(new_key) -> IO.warn("Knigge encountered the deprecated option `#{key}`, please use `#{new_key}`.") kv + + message when is_binary(message) -> + IO.warn( + "Knigge encountered the deprecated option `#{key}`, this option is no longer supported; #{ + message + }." + ) + + nil end - end + end) + |> Enum.reject(&is_nil/1) end - # TODO: Log deprecated - defp map_deprecated({:check_if_exists, value}), do: {:check_if_exists?, value} + defp map_deprecated({key, _}) + when key in [:check_if_exists, :check_if_exists?], + do: "please use the mix task `mix knigge.verify`" defp map_deprecated({:delegate_at, :compile_time}), do: {:delegate_at_runtime?, false} defp map_deprecated({:delegate_at, :runtime}), do: {:delegate_at_runtime?, true} @@ -187,7 +188,7 @@ defmodule Knigge.Options do defp defaults_from_config do :knigge |> Application.get_all_env() - |> Keyword.take([:check_if_exists?, :delegate_at_runtime?, :warn]) + |> Keyword.take([:delegate_at_runtime?, :warn]) end defp transform(opts, with_env: env) when is_list(opts) do @@ -195,7 +196,7 @@ defmodule Knigge.Options do end defp transform(key, envs, with_env: env) - when key in [:check_if_exists?, :delegate_at_runtime?], + when key in [:delegate_at_runtime?, :warn], do: active_env?(env, envs) defp transform(_key, value, with_env: _), do: value @@ -226,9 +227,6 @@ defmodule Knigge.Options do iex> Knigge.Options.validate!(otp_app: :knigge) [otp_app: :knigge] - iex> Knigge.Options.validate!(otp_app: :knigge, check_if_exists?: [:test, :prod]) - [otp_app: :knigge, check_if_exists?: [:test, :prod]] - iex> Knigge.Options.validate!(implementation: SomeModule, otp_app: :knigge) ** (ArgumentError) Knigge expects either the :implementation or the :otp_app option but both were given. @@ -240,9 +238,6 @@ defmodule Knigge.Options do iex> Knigge.Options.validate!(otp_app: :knigge, delegate_at_runtime?: "test") ** (ArgumentError) Knigge received invalid value for `delegate_at_runtime?`. Expected boolean or environment (atom or list of atoms) but received: "test" - - iex> Knigge.Options.validate!(otp_app: :knigge, check_if_exists?: "test") - ** (ArgumentError) Knigge received invalid value for `check_if_exists?`. Expected boolean or environment (atom or list of atoms) but received: "test" """ @spec validate!(raw()) :: no_return def validate!(opts) do @@ -306,13 +301,12 @@ defmodule Knigge.Options do @option_types [ behaviour: :module, - check_if_exists?: :envs, delegate_at_runtime?: :envs, do_not_delegate: :keyword, implementation: :module, otp_app: :atom, config_key: :atom, - warn: :boolean + warn: :envs ] @option_names Keyword.keys(@option_types) diff --git a/lib/knigge/otp.ex b/lib/knigge/otp.ex new file mode 100644 index 0000000..bb9c556 --- /dev/null +++ b/lib/knigge/otp.ex @@ -0,0 +1,6 @@ +defmodule Knigge.OTP do + @moduledoc false + + @otp_release :otp_release |> :erlang.system_info() |> List.to_string() |> String.to_integer() + def release, do: @otp_release +end diff --git a/lib/knigge/verification.ex b/lib/knigge/verification.ex new file mode 100644 index 0000000..b13f5da --- /dev/null +++ b/lib/knigge/verification.ex @@ -0,0 +1,53 @@ +defmodule Knigge.Verification do + @moduledoc false + + alias __MODULE__.Context + + @doc """ + Runs all verifications for the given `#{inspect(Context)}`. + + At the moment this only consists of checking whether or not the Implementations exist. + """ + @spec run(Context.t()) :: Context.t() + def run(%Context{} = context) do + verify_implementations(context) + end + + defp verify_implementations(context) do + context.modules + |> Enum.map(&verify_implementation/1) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> merge_with_context(context) + end + + defp verify_implementation(module) do + case check_implementation(module) do + {:ok, implementation} -> {:existing, {module, implementation}} + {:error, {:missing, implementation}} -> {:missing, {module, implementation}} + end + end + + defp merge_with_context(%{missing: _} = result, context) do + context + |> Map.put(:error, :missing_modules) + |> Map.merge(result) + end + + defp merge_with_context(result, context), do: Map.merge(context, result) + + @doc """ + Checks if the given `Knigge` module's implementation exists. Returns an error if not. + """ + @spec check_implementation(module :: module()) :: + {:ok, implementation :: module()} + | {:error, {:missing, implementation :: module()}} + def check_implementation(module) do + implementation = module.__knigge__(:implementation) + + if Knigge.Module.exists?(implementation) do + {:ok, implementation} + else + {:error, {:missing, implementation}} + end + end +end diff --git a/lib/knigge/verification/context.ex b/lib/knigge/verification/context.ex new file mode 100644 index 0000000..3798af1 --- /dev/null +++ b/lib/knigge/verification/context.ex @@ -0,0 +1,204 @@ +defmodule Knigge.Verification.Context do + @moduledoc false + + @type t :: %__MODULE__{ + app: atom(), + modules: list(module()), + existing: list(facade_with_module()), + missing: list(facade_with_module()), + error: nil | any(), + began_at: milliseconds(), + finished_at: milliseconds() + } + @type facade_with_module :: {facade :: module(), impl :: module()} + @type milliseconds :: non_neg_integer() + defstruct app: nil, + modules: [], + existing: [], + missing: [], + error: nil, + began_at: nil, + finished_at: nil + + module = inspect(__MODULE__) + + @doc """ + Creates a new `#{module}` struct while setting the `began_at` to now. + + ## Example + + iex> context = #{module}.new() + iex> context.began_at <= #{module}.timestamp() + true + + iex> context = #{module}.new(began_at: 123, app: :foobar) + iex> context.began_at + 123 + iex> context.app + :foobar + """ + @spec new() :: t() + @spec new(params :: map() | Keyword.t()) :: t() + def new(params \\ %{}) + + def new(params) when is_list(params) do + params |> Map.new() |> new() + end + + def new(params) do + struct!(__MODULE__, with_defaults(params)) + end + + defp with_defaults(params) do + Map.put_new(params, :began_at, timestamp()) + end + + def timestamp, do: :os.system_time(:millisecond) + + @doc """ + Loads the modules for the given app which `use Knigge`. + + Returns an error when the app does not exist or loading it fails. + + ## Example + + iex> {:ok, context} = #{module}.for_app(:knigge) + iex> context.app + :knigge + iex> context.modules + [] + + iex> context = %#{module}{began_at: 123} + iex> {:ok, context} = #{module}.for_app(context, :knigge) + iex> context.began_at + 123 + iex> context.app + :knigge + iex> context.modules + [] + + iex> #{module}.for_app(:does_not_exist) + {:error, {:unknown_app, :does_not_exist}} + """ + @spec for_app(app :: atom()) :: {:ok, t()} | {:error, reason :: any()} + @spec for_app(t(), app :: atom()) :: {:ok, t()} | {:error, reason :: any()} + def for_app(context \\ new(), app) + + def for_app(%__MODULE__{} = context, app) do + with :ok <- ensure_loaded(app), + {:ok, modules} <- Knigge.Module.fetch_for_app(app) do + {:ok, %__MODULE__{context | app: app, modules: modules}} + end + end + + defp ensure_loaded(nil), do: {:error, {:unknown_app, nil}} + + defp ensure_loaded(app) do + case Application.load(app) do + :ok -> :ok + {:error, {:already_loaded, ^app}} -> :ok + {:error, {'no such file or directory', _}} -> {:error, {:unknown_app, app}} + {:error, :undefined} -> {:error, {:unknown_app, app}} + other -> other + end + end + + @doc """ + Sets the `finished_at` field to the given time in milliseconds. + + If none was given it uses the current time. + + ## Examples + + iex> context = %#{module}{finished_at: nil} + iex> context = #{module}.finished(context) + iex> context.finished_at <= #{module}.timestamp() + true + + iex> context = %#{module}{finished_at: nil} + iex> context = #{module}.finished(context, 123) + iex> context.finished_at + 123 + + iex> context = %#{module}{finished_at: 123} + iex> new_context = #{module}.finished(context) + iex> context.finished_at != new_context.finished_at + true + """ + @spec finished(t()) :: t() + @spec finished(t(), milliseconds()) :: t() + def finished(%__MODULE__{} = context, finished_at \\ timestamp()) do + %__MODULE__{context | finished_at: finished_at} + end + + @doc """ + Returns the duration between `began_at` and `finished_at`. Uses the current + time if `finished_at` is `nil`. + + ## Examples + + iex> context = %#{module}{began_at: 100, finished_at: 110} + iex> #{module}.duration(context) + 10 + + iex> now = #{module}.timestamp() + iex> context = %#{module}{began_at: now - 100} + iex> duration = #{module}.duration(context) + iex> duration >= 100 + true + """ + @spec duration(t()) :: milliseconds() + def duration(%__MODULE__{began_at: began_at, finished_at: finished_at}) do + (finished_at || timestamp()) - began_at + end + + if Knigge.OTP.release() >= 21 do + @doc """ + Returns whether or not this context is considered an error. + + Can be used in guards. + + ## Examples + + iex> require #{module} + iex> context = %#{module}{error: nil} + iex> #{module}.is_error(context) + false + + iex> require #{module} + iex> context = %#{module}{error: :some_error} + iex> #{module}.is_error(context) + true + """ + @spec is_error(t()) :: boolean() + defguard is_error(context) + when :erlang.is_map_key(:__struct__, context) and + :erlang.map_get(:__struct__, context) == __MODULE__ and + :erlang.is_map_key(:error, context) and + :erlang.map_get(:error, context) != nil + + @doc """ + Returns whether or not this context is considered an error. + + Uses `is_error/1`. + """ + @spec error?(t()) :: boolean() + def error?(context), do: is_error(context) + else + @doc """ + Returns whether or not this context is considered an error. + + ## Examples + + iex> context = %#{module}{error: nil} + iex> #{module}.error?(context) + false + + iex> context = %#{module}{error: :some_error} + iex> #{module}.error?(context) + true + """ + @spec error?(t()) :: boolean() + def error?(%__MODULE__{error: error}), do: not is_nil(error) + end +end diff --git a/lib/mix/tasks/knigge/verify.ex b/lib/mix/tasks/knigge/verify.ex new file mode 100644 index 0000000..e95cbf1 --- /dev/null +++ b/lib/mix/tasks/knigge/verify.ex @@ -0,0 +1,174 @@ +defmodule Mix.Tasks.Knigge.Verify do + use Mix.Task + + import Knigge.CLI.Output + + alias Knigge.Verification + alias Knigge.Verification.Context + + require Context + + @shortdoc "Verify the validity of your facades and their implementations." + @moduledoc """ + #{@shortdoc} + + At the moment `knigge.verify` "only" ensures that the implementation modules + of your facades exist. Running the task on a code base with two facades might + look like this: + + $ mix knigge.verify + Verify 2 Knigge facades in 'my_app'. + + 1/2 Facades passed: + MyApp.MyGreatFacade -> MyApp.MyGreatImpl + + 1/2 Facades failed: + MyApp.AnotherFacade -> MyApp.AnothrImpl (implementation does not exist) + + Completed in 0.009 seconds. + + Validation failed for 1/2 facades. + + The attentive reader might have noticed that `MyApp.AnothrImpl` contains a + spelling error: `Anothr` instead of `Another`. + + Catching errors like this is the main responsibility of `knigge.verify`. When + an issue is detected the task will exit with an error code, which allows you + to use it in your CI pipeline - for example before you build your production + release. + + ## Options + + At the moment `knigge.verify` offers no options. If you think this should be + different please open an issue. + """ + + @impl Mix.Task + def run(_args) do + Mix.Task.run("compile") + + calling_app() + |> run_for() + |> exit_with() + end + + defp calling_app, do: Mix.Project.get().project()[:app] + + defp run_for(app) do + with {:ok, context} <- Context.for_app(app) do + context + |> begin_verification() + |> Verification.run() + |> finish_verification() + else + {:error, {:unknown_app, app}} -> + error("Unable to load modules for #{app || "current app"}, are you sure the app exists?") + + {:error, :unknown_app} + + other -> + other + end + end + + defp begin_verification(%Context{app: app, modules: []} = context) do + warn("No modules in `#{app}` found which `use Knigge`.") + + context + end + + defp begin_verification(%Context{app: app, modules: modules} = context) do + info("Verify #{length(modules)} Knigge facades in '#{app}'.") + + context + end + + defp finish_verification(context) do + context + |> print_result() + |> completed_in() + end + + defp print_result(context) do + print_existing(context) + print_missing(context) + + context + end + + defp print_existing(%Context{existing: []}), do: :ok + + defp print_existing(%Context{existing: facades, modules: modules}) do + success("\n#{length(facades)}/#{length(modules)} Facades passed:") + + facades + |> Enum.map_join("\n", fn {module, implementation} -> + " #{inspect(module)} -> #{inspect(implementation)}" + end) + |> success() + end + + defp print_missing(%Context{missing: []}), do: :ok + + defp print_missing(%Context{missing: facades, modules: modules}) do + error("\n#{length(facades)}/#{length(modules)} Facades failed:") + + facades + |> Enum.map_join("\n", fn {module, implementation} -> + " #{inspect(module)} -> #{inspect(implementation)} (implementation does not exist)" + end) + |> error() + end + + defp completed_in(context) do + context = Context.finished(context) + + duration = + context + |> Context.duration() + |> Kernel./(1_000) + |> Float.round(3) + + info("\nCompleted in #{duration} seconds.\n") + + context + end + + defp exit_with(%Context{error: :missing_modules} = context) do + error( + :stderr, + "Validation failed for #{length(context.missing)}/#{length(context.modules)} facades." + ) + + exit_with({:error, :missing_modules}) + end + + if Knigge.OTP.release() >= 21 do + defp exit_with(%Context{} = context) when Context.is_error(context) do + exit_with({:error, context.error}) + end + else + defp exit_with(%Context{} = context) do + if Context.error?(context) do + exit_with({:error, context.error}) + else + :ok + end + end + end + + @exit_codes %{unknown_app: 1, missing_modules: 2} + @exit_reasons Map.keys(@exit_codes) + defp exit_with({:error, reason}) when reason in @exit_reasons do + exit({:shutdown, @exit_codes[reason]}) + end + + @unknown_error_code 64 + defp exit_with({:error, unknown_reason}) do + error("An unknown error occurred: #{inspect(unknown_reason)}") + + exit({:shutdown, @unknown_error_code}) + end + + defp exit_with(_), do: :ok +end diff --git a/mix.exs b/mix.exs index e5a57cc..3ec8cc8 100644 --- a/mix.exs +++ b/mix.exs @@ -26,6 +26,7 @@ defmodule Knigge.MixProject do # Hex description: description(), + docs: docs(), package: package(), version: @version ] @@ -44,8 +45,10 @@ defmodule Knigge.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:bunt, "~> 0.2"}, + # No Runtime - {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, + {:credo, ">= 1.0.0", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.0.0-rc.6", only: [:dev], runtime: false}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, @@ -66,6 +69,30 @@ defmodule Knigge.MixProject do "An opinionated way of dealing with behaviours." end + @extras Path.wildcard("pages/**/*.md") + def docs do + [ + main: "Knigge", + source_ref: "v#{@version}", + source_url: "https://github.com/sascha-wolf/knigge", + extras: @extras, + groups_for_modules: [ + "Overview & Configuration": [ + Knigge, + Knigge.Options + ], + "Code Generation": [ + Knigge.Behaviour, + Knigge.Code, + Knigge.Implementation + ], + Verification: [ + Knigge.Verification + ] + ] + ] + end + def package do [ files: ["lib", "mix.exs", "LICENSE*", "README*", "version"], diff --git a/mix.lock b/mix.lock index 17e716a..1acb25e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,23 +1,23 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, - "credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.3.3", "5e8be428fcef362692b6dbd7dc55bdc7023da26d995cb3fb19aa4bd682bfd3f9", [:mix], [], "hexpm"}, - "erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, - "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, + "credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "16105fac37c5c4b3f6e1f70ba0784511fec4275cd8bb979386e3c739cf4e6455"}, + "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "49496d63267bc1a4614ffd5f67c45d9fc3ea62701a6797975bc98bc156d2763f"}, + "earmark": {:hex, :earmark, "1.3.3", "5e8be428fcef362692b6dbd7dc55bdc7023da26d995cb3fb19aa4bd682bfd3f9", [:mix], [], "hexpm", "a4f21ad675cd496b4b124b00cd7884add73ef836bbfeaa6a85a818df5690a182"}, + "erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm", "4a12ebc7cd8f24f2d0fce93d279fa34eb5068e0e885bb841d558c4d83c52c439"}, + "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "93d2fee94d2f88abf507628378371ea5fab08ed03fa59a6daa3d4469d9159ddd"}, + "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "493daf5a2dd92d022a1c29e7edcc30f1bce1ffe10fb3690fac63889346d3af2f"}, + "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "c2790c9f0f7205f4a362512192dee8179097394400e745e4d20bab7226a8eaad"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm", "603561dc0fd62f4f2ea9b890f4e20e1a0d388746d6e20557cafb1b16950de88c"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, } diff --git a/pages/The Existence Check.md b/pages/The Existence Check.md new file mode 100644 index 0000000..a1727f2 --- /dev/null +++ b/pages/The Existence Check.md @@ -0,0 +1,54 @@ +# The Existence Check + +Before version 1.2.0 `Knigge` used to try to verify at compile time whether the implementation module of a facade exists. +If `Knigge` found the implementation missing it would raise an error. + +The idea being that this would catch spelling mistakes and the like **before** the application. +Having a spelling error in delegating to the implementation is **no bueno**: it will crash and burn in a horrible disaster at runtime. + +At the time the idea seemed great - and to be honest I still think it does - but reality turned out to be more complicated. + +## Knigge VS the Compiler + +Sometimes `Knigge` would find the implementing module to be missing even though it was there, no spelling error, no nothing. +Anybody who had the pleasure experiencing this would - very rightfully I must say - wonder what the hell was going on. + +As it turns out there is no guarantee about the order in which the Elixir compiler will compile the modules of your project. +While it does ensure that dependencies get resolved - such as specifying a `@behaviour` in your module - it will happily chug along compiling your modules in parallel. + +And don't get me wrong, that's a good thing, I like that the compiler does this. +It's a great way to speed up compilation, and proves that there are no weird interdependencies in the order of compilation; but it does lead to a problem with `Knigge`. + +You see, sometimes the compiler might start with your implementation module `MyImplementation`. +It would then encounter the `@behaviour MyFacade` line, interrupt compilation of the module and compile `MyFacade`. +In cases like this the existence check works just fine. + +In other cases the compiler might start with compiling `MyFacade`. +Finding no dependencies it would happily chug along, resolve `use Knigge` and ... BOOM! +`Knigge` would raise a tantrum because it cannot find `MyImplementation`. + +So what can we do about it? + +## Introducing `mix knigge.verify` + +Instead of doing the existence check at compile time `Knigge` now offers the `knigge.verify` mix task. + +By using a `Mix.Task` `Knigge` bypasses the whole compilation conundrum since the task runs after your project was fully compiled. + +The task scans your app for all modules which `use Knigge` by checking for a `__knigge__/0` function. If this function is found the task fetches the implementation of the facade using `__knigge__(:implementation)` and then verifies that the returned module actually exists. + +After performing this check for all found modules it prints the results and exits with an error code if an implementing module was found missing. + +As such you can easily integrate `mix knigge.verify` into your CI pipeline to ensure that all implementations exist before pushing to production. + +## Roadmap + +In addition to having the `mix knigge.verify` task I would like to create a compiler step which performs the existence check. This could then be added to your project in `mix.exs` under the `compilers` key in your `project` function (similar to how `phoenix` and `gettext` add additional compiler steps). + +Furthermore I could imagine adding additional verification steps in the future: + +- ensuring the implementation actually implements all necessary callbacks +- somehow integrating with `dialyzer` to check the types of the implementation + +But until the necessary research and experiments have been done it's hard to say where the journey will go. +Nevertheless feel free to open issues to discuss potential features at any time. diff --git a/test/behaviour/with_missing_modules_test.exs b/test/behaviour/with_missing_modules_test.exs index e269a3a..494b3c6 100644 --- a/test/behaviour/with_missing_modules_test.exs +++ b/test/behaviour/with_missing_modules_test.exs @@ -14,38 +14,24 @@ defmodule Behaviour.WithMissingModulesTest do def some_function, do: nil end - test "raises a CompileError when the Implementation does not exist" do - assert_raise CompileError, - ~r"the given module could not be found: DoesNotExist", - fn -> - define_facade( - behaviour: Behaviour, - implementation: DoesNotExist, - check_if_exists?: :test - ) - end - end - test "raises a CompileError when the Behaviour does not exist" do assert_raise CompileError, ~r"the given module could not be found: MissingBehaviour", fn -> define_facade( behaviour: MissingBehaviour, - implementation: Implementation, - check_if_exists?: [except: :dev] + implementation: Implementation ) end end - test "raises a CompileError when both don't exist and `only: [:test, :dev]` is passed for existence check" do + test "raises a CompileError when both don't exist" do assert_raise CompileError, - ~r"the given module could not be found: DoesNotExist", + ~r"the given module could not be found: MissingBehaviour", fn -> define_facade( - behaviour: Behaviour, - implementation: DoesNotExist, - check_if_exists?: [only: [:test, :dev]] + behaviour: MissingBehaviour, + implementation: DoesNotExist ) end end @@ -54,14 +40,6 @@ defmodule Behaviour.WithMissingModulesTest do define_facade(behaviour: Behaviour, implementation: Implementation) end - test "does not raise any error when the implementation is missing but check_if_exists is set to false" do - define_facade( - behaviour: Behaviour, - implementation: MissingImplementation, - check_if_exists?: false - ) - end - defp define_facade(opts) do defmodule_salted Facade do use Knigge, opts diff --git a/test/knigge/module_test.exs b/test/knigge/module_test.exs new file mode 100644 index 0000000..5461a92 --- /dev/null +++ b/test/knigge/module_test.exs @@ -0,0 +1,5 @@ +defmodule Knigge.ModuleTest do + use ExUnit.Case, async: true + + doctest Knigge.Module +end diff --git a/test/knigge/options_test.exs b/test/knigge/options_test.exs index efe3306..2418021 100644 --- a/test/knigge/options_test.exs +++ b/test/knigge/options_test.exs @@ -11,7 +11,18 @@ defmodule Knigge.OptionsTest do warnings = capture_io(:stderr, fn -> valid_opts(check_if_exists: true) end) assert warnings =~ - "Knigge encountered the deprecated option `check_if_exists`, please use `check_if_exists?`." + "Knigge encountered the deprecated option `check_if_exists`, " <> + "this option is no longer supported; " <> + "please use the mix task `mix knigge.verify`." + end + + test "using `check_if_exists?` prints a deprecation warning" do + warnings = capture_io(:stderr, fn -> valid_opts(check_if_exists?: true) end) + + assert warnings =~ + "Knigge encountered the deprecated option `check_if_exists?`, " <> + "this option is no longer supported; " <> + "please use the mix task `mix knigge.verify`." end test "using `delegate_at` prints a deprecation warning" do diff --git a/test/knigge/verification/context_test.exs b/test/knigge/verification/context_test.exs new file mode 100644 index 0000000..eb5cc1c --- /dev/null +++ b/test/knigge/verification/context_test.exs @@ -0,0 +1,5 @@ +defmodule Knigge.Verification.ContextTest do + use ExUnit.Case, async: true + + doctest Knigge.Verification.Context +end diff --git a/test/knigge/verification_test.exs b/test/knigge/verification_test.exs new file mode 100644 index 0000000..09b6e21 --- /dev/null +++ b/test/knigge/verification_test.exs @@ -0,0 +1,58 @@ +defmodule Knigge.VerificationTest do + use ExUnit.Case, async: true + + alias Knigge.Verification + alias Knigge.Verification.Context + + defmodule FacadeWithImpl do + use Knigge, implementation: Knigge.VerificationTest.FacadeImpl + + @callback some_function() :: :ok + end + + defmodule FacadeWithoutImpl do + use Knigge, + implementation: Does.Not.Exist, + # Surpresses some warnings + delegate_at_runtime?: true + + @callback some_function() :: :ok + end + + defmodule FacadeImpl do + @behaviour Knigge.VerificationTest.FacadeWithImpl + + def some_function, do: :ok + end + + describe ".run/1" do + test "returns a context without an error when passing a context with only `FacadeWithImpl`" do + raw_context = %Context{app: :knigge, modules: [FacadeWithImpl]} + context = Verification.run(raw_context) + + assert context.existing == [{FacadeWithImpl, FacadeImpl}] + assert context.missing == [] + assert context.error == nil + end + + test "returns a context containing an error when passing a context with `FacadeWithImpl` and `FacadeWithoutImpl`" do + raw_context = %Context{app: :knigge, modules: [FacadeWithImpl, FacadeWithoutImpl]} + context = Verification.run(raw_context) + + assert context.existing == [{FacadeWithImpl, FacadeImpl}] + assert context.missing == [{FacadeWithoutImpl, Does.Not.Exist}] + assert context.error == :missing_modules + end + end + + describe ".check_implementation/1" do + test "returns :ok if the implementation exists" do + assert Verification.check_implementation(FacadeWithImpl) == {:ok, FacadeImpl} + end + + test "returns an error if the implementation is missing" do + assert Verification.check_implementation(FacadeWithoutImpl) == + {:error, {:missing, Does.Not.Exist}} + end + end +end diff --git a/version b/version index 524cb55..26aaba0 100644 --- a/version +++ b/version @@ -1 +1 @@ -1.1.1 +1.2.0