lib/mix/tasks/confispex.gen.doc.md.ex

defmodule Mix.Tasks.Confispex.Gen.Doc.Md do
  use Mix.Task
  @shortdoc "Generate a doc in markdown format"
  @moduledoc """
  #{@shortdoc}

  ## Examples

      $ mix confispex.gen.doc.md --output=RUNTIME_ENV_PROD.md --schema=MyRuntimeConfigSchema --env=prod --target=abc

  ## Example of generated markdown

      # Variables (env=dev target=host)

      ## GROUP :dev_space

      | Name               | Required | Default | Description               |
      | ------------------ | -------- | ------- | ------------------------- |
      | DEV_SPACE_PASSWORD | required |         | A password for /dev space |
      | DEV_SPACE_USERNAME | required |         | A username for /dev space |

      ## GROUP :primary_db

      | Name               | Required | Default   | Description            |
      | ------------------ | -------- | --------- | ---------------------- |
      | DATABASE_HOST      |          | localhost | DB host (postgres)     |
      | DATABASE_NAME      |          | myapp_dev | DB name (postgres)     |
      | DATABASE_PASSWORD  |          | postgres  | DB password (postgres) |
      | DATABASE_POOL_SIZE |          | 10        |                        |
      | DATABASE_PORT      |          | 5432      | DB port (postgres)     |
      | DATABASE_USERNAME  |          | postgres  | DB username (postgres) |

  ## Integration with `ex_doc`

  Adjust your `mix.exs` file with the following content

      defmodule MyApp.MixProject do
        use Mix.Project

        def project do
          [
            # ...
            aliases: aliases(),
            docs: [
              extras: [
                "tmp/runtime_env_prod.md": [title: "prod"],
                "tmp/runtime_env_dev.md": [title: "dev"],
                "tmp/runtime_env_test.md": [title: "test"]
              ],
              groups_for_extras: [
                "Runtime ENV": ~r|tmp/runtime_env_.+\\.md|
              ]
            ]
          ]
        end

        defp aliases do
          [
            # ...
            docs: [
              fn _ -> File.mkdir_p!("tmp") end
              | Enum.map(["test", "prod", "dev"], fn env ->
                  fn _ ->
                    Mix.Task.rerun("confispex.gen.doc.md", [
                      "--output",
                      "tmp/runtime_env_\#{env}.md",
                      "--schema",
                      "MyApp.RuntimeConfigSchema", # <--- Change module name
                      "--env",
                      env
                    ])
                  end
                end) ++ ["docs"]
            ]
          ]
        end
      end

  """
  @requirements ["app.config"]

  def run(args) do
    {opts, []} =
      OptionParser.parse!(args,
        switches: [env: :string, target: :string, output: :string, schema: :string]
      )

    output_path = Keyword.fetch!(opts, :output)
    schema = Keyword.fetch!(opts, :schema)

    schema = Module.concat([schema])

    env =
      case opts[:env] do
        nil -> Mix.env()
        value -> String.to_existing_atom(value)
      end

    target =
      case opts[:target] do
        nil -> Mix.target()
        value -> String.to_existing_atom(value)
      end

    context = %{env: env, target: target}

    iodata =
      schema.variables_schema()
      |> Confispex.Schema.variables_in_context(context)
      |> Confispex.Schema.grouped_variables()
      |> Enum.sort_by(fn {group_name, _variables} -> group_name end)
      |> Enum.flat_map(fn {group_name, variables} ->
        [
          "## GROUP #{inspect(group_name)}",
          variables
          |> Enum.sort_by(fn {variable_name, definition} ->
            # required first, then sort by name
            {not Confispex.Schema.variable_required?(definition, group_name, context),
             variable_name}
          end)
          |> Enum.map(fn {variable_name, definition} ->
            doc =
              case definition do
                %{doc: doc} when is_binary(doc) ->
                  String.replace(definition.doc, "\n", "")

                _ ->
                  ""
              end

            default =
              case definition do
                %{default: default} -> default
                %{default_lazy: callback} -> context |> callback.()
                _ -> nil
              end

            required? = Confispex.Schema.variable_required?(definition, group_name, context)

            [
              variable_name,
              if(required?, do: "required", else: ""),
              default,
              doc
            ]
          end)
          |> as_table(["Name", "Required", "Default", "Description"])
        ]
      end)
      |> Enum.intersperse("\n\n")

    iodata = ["# Variables (#{stringify_context(context)})\n\n", iodata]

    File.write!(output_path, iodata)
  end

  defp stringify_context(context) do
    Enum.map_join(context, " ", fn {key, value} -> "#{key}=#{value}" end)
  end

  defp as_table(rows, header) do
    columns_number = length(header)
    # rows_number = length(rows)

    column_widths =
      1..columns_number
      |> Map.new(fn column_number ->
        max_length =
          [header | rows]
          |> Enum.map(fn row ->
            row |> Enum.at(column_number - 1) |> to_string |> String.length()
          end)
          |> Enum.max()

        {column_number, max_length}
      end)

    header_delimiter =
      1..columns_number
      |> Enum.map(fn column_number -> String.duplicate("-", column_widths[column_number]) end)

    ([header, header_delimiter] ++ rows)
    |> Enum.map(fn row ->
      body =
        row
        |> Enum.with_index(1)
        |> Enum.map_join(" | ", fn {cell, column_number} ->
          cell |> to_string() |> String.pad_trailing(column_widths[column_number])
        end)

      "| " <> body <> " |"
    end)
    |> Enum.intersperse("\n")
  end
end