diff --git a/2021/.formatter.exs b/2021/.formatter.exs
index d2cda26..fea6a6c 100644
--- a/2021/.formatter.exs
+++ b/2021/.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}"],
+  locals_without_parens: [assert_solution: 2]
 ]
diff --git a/2021/lib/advent_of_code.ex b/2021/lib/advent_of_code.ex
index 6b37c41..f8016fb 100644
--- a/2021/lib/advent_of_code.ex
+++ b/2021/lib/advent_of_code.ex
@@ -1,5 +1,12 @@
 defmodule AdventOfCode do
   @moduledoc """
-  Documentation for `AdventOfCode`.
+  Solutions to the 2021 Advent of Code puzzles
   """
+
+  def solver(selector) do
+    [day, part] = String.split(selector, ".")
+    day_module = Macro.camelize("Day#{day}")
+    part_module = Macro.camelize("Part#{part}")
+    Module.concat([AdventOfCode, day_module, part_module])
+  end
 end
diff --git a/2021/lib/advent_of_code/day0.ex b/2021/lib/advent_of_code/day0.ex
new file mode 100644
index 0000000..e69de29
diff --git a/2021/lib/advent_of_code/day0/part0.ex b/2021/lib/advent_of_code/day0/part0.ex
new file mode 100644
index 0000000..6c32387
--- /dev/null
+++ b/2021/lib/advent_of_code/day0/part0.ex
@@ -0,0 +1,11 @@
+defmodule AdventOfCode.Day0.Part0 do
+  alias AdventOfCode.PuzzleSolver
+
+  use PuzzleSolver
+
+  @impl PuzzleSolver
+  def solve(stream) do
+    Stream.run(stream)
+    "42"
+  end
+end
diff --git a/2021/lib/advent_of_code/puzzle_solver.ex b/2021/lib/advent_of_code/puzzle_solver.ex
index f79f334..212df08 100644
--- a/2021/lib/advent_of_code/puzzle_solver.ex
+++ b/2021/lib/advent_of_code/puzzle_solver.ex
@@ -6,7 +6,9 @@ defmodule AdventOfCode.PuzzleSolver do
   @doc """
   Given the input as a stream, return the solution as a string
   """
-  @callback solve(IO.Stream.t()) :: String.t()
+  @callback solve(Enumerable.t()) :: String.t()
+
+  def solve(mod, stream), do: apply(mod, :solve, [stream])
 
   defmacro __using__(_) do
     quote do
diff --git a/2021/lib/mix/tasks/advent_of_code.gen.solution.exs b/2021/lib/mix/tasks/advent_of_code.gen.solution.ex
similarity index 100%
rename from 2021/lib/mix/tasks/advent_of_code.gen.solution.exs
rename to 2021/lib/mix/tasks/advent_of_code.gen.solution.ex
diff --git a/2021/lib/mix/tasks/advent_of_code.solve.ex b/2021/lib/mix/tasks/advent_of_code.solve.ex
new file mode 100644
index 0000000..5c506c5
--- /dev/null
+++ b/2021/lib/mix/tasks/advent_of_code.solve.ex
@@ -0,0 +1,73 @@
+defmodule Mix.Tasks.AdventOfCode.Solve do
+  use Mix.Task
+
+  @shortdoc "Runs solution code with problem input"
+
+  @moduledoc """
+  #{@shortdoc}.
+
+  ## Options
+
+  `--input`
+    - name of a `.input` file in `priv/inputs/` without the `.input` extension or `-` to read from stdin
+
+  ## Examples
+
+      # Run Day 2, Part 1 solution program against the `2.1.input` file
+      $ mix advent_of_code.solve 2.1
+
+      # Run Day 1, Part 2 with a special input
+      $ mix advent_of_code.solve 1.2 --input day-1-part-2-small # priv/inputs/day-1-part-2-small.input
+
+      # Run Day 17, Part 1 with input from stdin
+  #   $ mix advent_of_code.solve 17.1 --input -
+  """
+
+  @switches [input: :string]
+
+  @impl Mix.Task
+  def run(args) do
+    case OptionParser.parse!(args, strict: @switches) do
+      {opts, [selector]} ->
+        case stream_for_input(selector, opts) do
+          {:indeterminate, stream} ->
+            ProgressBar.render_spinner(
+              [text: "Processing indeterminate data...", done: "Done."],
+              fn -> solve(selector, stream) end
+            )
+            |> IO.puts()
+
+          {size, stream} ->
+            stream =
+              stream
+              |> Stream.with_index(1)
+              |> Stream.each(fn {_, i} -> ProgressBar.render(i, size) end)
+              |> Stream.map(fn {l, _} -> l end)
+
+            solve(selector, stream)
+            |> IO.puts()
+        end
+
+      _ ->
+        nil
+    end
+  end
+
+  defp solve(selector, stream) do
+    selector
+    |> AdventOfCode.solver()
+    |> AdventOfCode.PuzzleSolver.solve(stream)
+  end
+
+  defp stream_for_input(selector, opts) do
+    input = Keyword.get(opts, :input, selector)
+
+    if input == "-" do
+      {:indeterminate, IO.stream()}
+    else
+      file = Path.join("priv/inputs", "#{input}.input")
+      stream = File.stream!(file)
+      {Enum.count(stream), stream}
+    end
+  end
+end
diff --git a/2021/lib/mix/tasks/advent_of_code.solve.exs b/2021/lib/mix/tasks/advent_of_code.solve.exs
deleted file mode 100644
index 645e550..0000000
--- a/2021/lib/mix/tasks/advent_of_code.solve.exs
+++ /dev/null
@@ -1,18 +0,0 @@
-defmodule Mix.Tasks.AdventOfCode.Solve do
-  use Mix.Task
-
-  @shortdoc "Runs solution code with problem input"
-
-  @moduledoc """
-  #{@shortdoc}.
-
-  ## Examples
-
-      # Run Day 2, Part 1 solution program
-      $ mix advent_of_code.solve 2.1
-  """
-
-  @impl Mix.Task
-  def run(_args) do
-  end
-end
diff --git a/2021/mix.exs b/2021/mix.exs
index 949a228..99a37cc 100644
--- a/2021/mix.exs
+++ b/2021/mix.exs
@@ -7,6 +7,7 @@ defmodule AdventOfCode.MixProject do
       version: "0.1.0",
       elixir: "~> 1.11",
       start_permanent: Mix.env() == :prod,
+      elixirc_paths: elixirc_paths(Mix.env()),
       deps: deps()
     ]
   end
@@ -18,11 +19,13 @@ defmodule AdventOfCode.MixProject do
     ]
   end
 
+  defp elixirc_paths(:test), do: ["lib", "test/support"]
+  defp elixirc_paths(_), do: ["lib"]
+
   # Run "mix help deps" to learn about dependencies.
   defp deps do
     [
-      # {:dep_from_hexpm, "~> 0.3.0"},
-      # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
+      {:progress_bar, "~> 2.0"}
     ]
   end
 end
diff --git a/2021/mix.lock b/2021/mix.lock
new file mode 100644
index 0000000..e43918e
--- /dev/null
+++ b/2021/mix.lock
@@ -0,0 +1,4 @@
+%{
+  "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
+  "progress_bar": {:hex, :progress_bar, "2.0.1", "7b40200112ae533d5adceb80ff75fbe66dc753bca5f6c55c073bfc122d71896d", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "2519eb58a2f149a3a094e729378256d8cb6d96a259ec94841bd69fdc71f18f87"},
+}
diff --git a/2021/priv/inputs/0.0.input b/2021/priv/inputs/0.0.input
new file mode 100644
index 0000000..86e041d
--- /dev/null
+++ b/2021/priv/inputs/0.0.input
@@ -0,0 +1,3 @@
+foo
+bar
+baz
diff --git a/2021/test/advent_of_code/day0/part_0_test.exs b/2021/test/advent_of_code/day0/part_0_test.exs
new file mode 100644
index 0000000..cd53141
--- /dev/null
+++ b/2021/test/advent_of_code/day0/part_0_test.exs
@@ -0,0 +1,7 @@
+defmodule AdventOfCode.Day0.Part0Test do
+  use AdventOfCode.PuzzleCase, module: AdventOfCode.Day0.Part0
+
+  test "returns the answer to live the universe and everything" do
+    assert_solution "life the universe and everything", "42"
+  end
+end
diff --git a/2021/test/advent_of_code_test.exs b/2021/test/advent_of_code_test.exs
new file mode 100644
index 0000000..589fa60
--- /dev/null
+++ b/2021/test/advent_of_code_test.exs
@@ -0,0 +1,13 @@
+defmodule AdventOfCodeTest do
+  use ExUnit.Case
+
+  import AdventOfCode
+
+  describe "solver/1" do
+    test "returns a module for a solver" do
+      assert solver("1.1") == AdventOfCode.Day1.Part1
+      assert solver("2.2") == AdventOfCode.Day2.Part2
+      assert solver("3.1") == AdventOfCode.Day3.Part1
+    end
+  end
+end
diff --git a/2021/test/support/puzzle_case.ex b/2021/test/support/puzzle_case.ex
index 3d51249..c0d7e09 100644
--- a/2021/test/support/puzzle_case.ex
+++ b/2021/test/support/puzzle_case.ex
@@ -9,8 +9,11 @@ defmodule AdventOfCode.PuzzleCase do
     quote bind_quoted: [module: module] do
       @module module
 
-      defp assert_solution(input, desired_output) do
-        actual_output = @module.run(input)
+      defp assert_solution(input, desired_output) when is_binary(input) do
+        {:ok, stream_pid} = StringIO.open(input)
+        stream_input = IO.stream(stream_pid, :line)
+        actual_output = AdventOfCode.PuzzleSolver.solve(@module, stream_input)
+        @module.solve(stream_input)
         assert actual_output == desired_output
       end
     end