diff --git a/.formatter.exs b/.formatter.exs index d2cda26..935d310 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + plugins: [Styler] ] diff --git a/.mash.exs b/.mash.exs index fc57bd1..97704d3 100644 --- a/.mash.exs +++ b/.mash.exs @@ -7,26 +7,29 @@ defmodule MashConfig do [ %{ name: :restore_cache, + if: fn _jobs -> + System.get_env("CI") + end, run: restore_cache() }, %{ name: :compile_dev, - needs: [:restore_cache], + needs: [restore_cache: :optional], run: mix("do", ["deps.compile,", "compile"], env: [{"MIX_ENV", "dev"}]) }, %{ name: :test, - needs: [:restore_cache], + needs: [restore_cache: :optional], run: mix("test") }, %{ name: :credo, - needs: [:restore_cache], + needs: [restore_cache: :optional], run: mix("credo", ["--all"]) }, %{ name: :save_cache, - needs: [:restore_cache], + needs: [:test, :compile_dev], run: save_cache() }, %{ @@ -34,6 +37,12 @@ defmodule MashConfig do run: fn _io_pid -> IO.puts("This line should be captured.") end + }, + %{ + if: false, + run: fn _io_pid -> + {:error, "This job should not run."} + end } ] end diff --git a/README.md b/README.md index 7864448..82b98cd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ defmodule MashConfig do run: mix("test") }, %{ - name: :credo, + name: :credo, run: mix("credo", ["--all"]) }, %{ @@ -53,13 +53,15 @@ Each entry in the jobs list must, at a minimum, have two keys: - `:run` -- a function which defines the work for the job to do. We recommend using the helper functions from Mash.Helpers to define your run function, as they ensure that your function handles setup and teardown correctly for many common scenarios, e.g.: - `Mash.Helpers.mix(task_name, args)` -- execute a mix task with the given `task_name` and `args`. - `Mash.Helpers.shell(command, args)` -- run an executable/script named `command` with `args`. - - Or you can pass a function with arity 1. + - Or you can pass a function with arity 1. - The function should return `:ok` or `{:ok, value}` if the job succeeds, or `{:error, error}` if the job fails. - The argument passed to your function will be the PID of the IO device which is capturing IO. Since we make this PID the "group leader" for your job automatically, you can probably ignore this argument, but you may need to for interfacing with certain libraries that require you to pass an io device PID directly. Optionally, a job may also contain a key `needs` whose value is an array of other jobs that must pass before this job will be run. +See [the docs for Mash.Job](https://hexdocs.pm/mash/Mash.Job.html) for more details, including conditional execution. + ## Running ```shell diff --git a/lib/mash/config.ex b/lib/mash/config.ex index 49c17a4..a486fcd 100644 --- a/lib/mash/config.ex +++ b/lib/mash/config.ex @@ -9,7 +9,7 @@ defmodule Mash.Config do @callback jobs() :: [%{name: atom(), run: function()}] - def start_link() do + def start_link do case Agent.start_link(&compile_config_module!/0, name: __MODULE__) do {:error, {:already_started, pid}} -> {:ok, pid} diff --git a/lib/mash/helpers.ex b/lib/mash/helpers.ex index 787add6..7373c03 100644 --- a/lib/mash/helpers.ex +++ b/lib/mash/helpers.ex @@ -75,9 +75,7 @@ defmodule Mash.Helpers do if File.exists?(path) do cmd("tar", ["-xzf", "#{name}.tar.gz", "--atime-preserve"], io_pid, []) else - IO.puts( - IO.ANSI.yellow() <> "Warning: cache file #{path} does not exist." <> IO.ANSI.reset() - ) + IO.puts(IO.ANSI.yellow() <> "Warning: cache file #{path} does not exist." <> IO.ANSI.reset()) end end end diff --git a/lib/mash/job.ex b/lib/mash/job.ex index 70a2e55..0e577c2 100644 --- a/lib/mash/job.ex +++ b/lib/mash/job.ex @@ -1,9 +1,22 @@ defmodule Mash.Job do @moduledoc """ Structure that represents the configuration and status of one Mash job. + + Keys: + - `:name`: an atom which is the name of the job. It should be unique in the job list. + - `:if`: either a function w/ arity 1 or a plain value. The job will be run if the value is truthy \ + or the function returns true. Otherwise it will be skipped. The argument to an if function is the runner's \ + job list. + - `:needs`: a list of job name atoms which are required before running this job. If a dependency fails or is skipped, \ + this job will be skipped. You can also include {job_name, :optional} to indicate an optional dependency. \ + Optional dependencies do not block a job from running if they are skipped, only if they fail. + - `:run`: a function with arity 1 that is the code to be executed for the job. See `Mash.Helpers` for shortcut functions.\ + The argument to the run function is the PID of the IO device which is capturing the output of this job. You\ + probably don't need this, but you might need it for certain libaries that need to be passed an explicit IO PID. + - `:env`: an array of {string, string} pairs which represent ENV variables (name first, then value). """ - defstruct name: nil, needs: [], run: nil, env: [], state: nil, task: nil, io_pid: nil + defstruct name: nil, if: true, needs: [], run: nil, env: [], state: nil, task: nil, io_pid: nil def from_map(map) do struct(__MODULE__, map) @@ -11,11 +24,13 @@ defmodule Mash.Job do def validate(%__MODULE__{run: run, name: name} = job) do errors = - [ - if(!is_function(run, 1), do: "run is required and must be a function with arity 1"), - if(!is_atom(name), do: "name is required and must be an atom") - ] - |> Enum.reject(&is_nil(&1)) + Enum.reject( + [ + if(!is_function(run, 1), do: "run is required and must be a function with arity 1"), + if(!is_atom(name), do: "name is required and must be an atom") + ], + &is_nil(&1) + ) if errors == [] do {:ok, job} diff --git a/lib/mash/runner.ex b/lib/mash/runner.ex index 2de7d81..d241397 100644 --- a/lib/mash/runner.ex +++ b/lib/mash/runner.ex @@ -22,7 +22,7 @@ defmodule Mash.Runner do tick() - jobs = Mash.Config.jobs() |> Enum.map(&Map.put(&1, :state, :to_run)) + jobs = Enum.map(Mash.Config.jobs(), &Map.put(&1, :state, :to_run)) {:ok, {parent, jobs}} end @@ -34,7 +34,7 @@ defmodule Mash.Runner do if any_running?(new_jobs) do tick() else - send(parent, {:result, Enum.count(jobs, &(&1.state == :failed))}) + send(parent, {:result, Enum.filter(jobs, &(&1.state == :failed))}) Process.exit(self(), :normal) end @@ -44,6 +44,9 @@ defmodule Mash.Runner do @spec tick_job(%{state: job_state()}, [Mash.Job.t()]) :: Mash.Job.t() def tick_job(%{state: :to_run} = job, jobs) do cond do + !if?(job, jobs) -> + %{job | state: :skipped} + ready?(job, jobs) -> %{job | state: :running} @@ -56,15 +59,14 @@ defmodule Mash.Runner do end def tick_job(%{name: name, state: :running, task: nil} = job, _jobs) do - IO.puts("Running #{name}") + IO.puts("[runner] Running #{name}") {task, io_pid} = Mash.Execution.run(job) %{job | task: task, io_pid: io_pid} end - def tick_job(%{name: name, state: :running, task: task, io_pid: io_pid} = job, _jobs) - when is_struct(task, Task) do + def tick_job(%{name: name, state: :running, task: task, io_pid: io_pid} = job, _jobs) when is_struct(task, Task) do flush_io(name, io_pid) job @@ -72,6 +74,9 @@ defmodule Mash.Runner do def tick_job(%{state: state} = job, _jobs) when state in [:passed, :failed, :skipped], do: job + def if?(%{if: if_fun}, jobs) when is_function(if_fun), do: !!if_fun.(jobs) + def if?(%{if: if_val}, _jobs), do: !!if_val + def any_running?(jobs) do Enum.any?(jobs, &(&1.state == :running)) end @@ -93,18 +98,29 @@ defmodule Mash.Runner do end defp ready?(%{needs: needs}, jobs) do - Enum.all?(needs, fn needed_job_name -> - needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) + Enum.all?(needs, fn + {needed_job_name, :optional} -> + needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) - needed_job.state == :passed + needed_job.state in [:skipped, :passed] + + needed_job_name -> + needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) + + needed_job.state == :passed end) end defp skippable?(%{needs: needs}, jobs) do - Enum.any?(needs, fn needed_job_name -> - needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) + Enum.any?(needs, fn + {needed_job_name, :optional} -> + needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) + needed_job.state == :failed - needed_job.state in [:failed, :skipped] + needed_job_name -> + needed_job = Enum.find(jobs, &(&1.name == needed_job_name)) + + needed_job.state in [:failed, :skipped] end) end @@ -120,7 +136,7 @@ defmodule Mash.Runner do StringIO.close(job.io_pid) new_state = interpret_result(result) - IO.puts("Finishing #{job_name} -- #{new_state}") + IO.puts("[runner] Finishing #{job_name} -- #{new_state}") new_job = %{ job diff --git a/lib/mix/tasks/mash.ex b/lib/mix/tasks/mash.ex index bd87c7f..c4b0d73 100644 --- a/lib/mix/tasks/mash.ex +++ b/lib/mix/tasks/mash.ex @@ -1,20 +1,22 @@ defmodule Mix.Tasks.Mash do + @shortdoc "Run Mash jobs." + @moduledoc """ Task to run Mash jobs. """ - @shortdoc "Run Mash jobs." - use Mix.Task def run(_args) do {:ok, _pid} = Mash.Runner.start_link() receive do - {:result, failure_count} when failure_count > 0 -> - Mix.raise("#{failure_count} job(s) failed.", exit_status: 1) - - {:result, 0} -> + {:result, []} -> :ok + + {:result, failures} -> + failure_list = Enum.map_join(failures, "\n", fn %{name: name} -> "- #{name}" end) + failure_count = Enum.count(failures) + Mix.raise("#{failure_count} job(s) failed.\n#{failure_list}", exit_status: 1) end end end diff --git a/mix.exs b/mix.exs index fb5ae5e..bb0d2ec 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Mash.MixProject do def project do [ app: :mash, - version: "0.1.2", + version: "0.2.0", elixir: "~> 1.14", start_permanent: Mix.env() == :prod, deps: deps(), @@ -31,7 +31,8 @@ defmodule Mash.MixProject do defp deps do [ {:ex_doc, "~> 0.30.4", only: :dev}, - {:credo, "~> 1.7"} + {:credo, "~> 1.7"}, + {:styler, "~> 0.8.4"} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ] diff --git a/mix.lock b/mix.lock index 0cd7fd6..e6d1e79 100644 --- a/mix.lock +++ b/mix.lock @@ -9,4 +9,5 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "styler": {:hex, :styler, "0.8.4", "70580b48e728c81e0ad02f9de26839da35281547fb2369e397d84eb7840ec6d8", [:mix], [], "hexpm", "4525686e49d1784a53c0964f1602c33250caf7646c14a0e1c7dddca806337428"}, } diff --git a/test/mash_test.exs b/test/mash_test.exs index 0d5cdb5..81633c5 100644 --- a/test/mash_test.exs +++ b/test/mash_test.exs @@ -1,4 +1,5 @@ defmodule MashTest do use ExUnit.Case + doctest Mash end