diff --git a/lib/net_runner.ex b/lib/net_runner.ex index b92b8a0..f6a00b2 100644 --- a/lib/net_runner.ex +++ b/lib/net_runner.ex @@ -27,6 +27,8 @@ defmodule NetRunner do @doc """ Runs a command and collects all output. + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct. + Returns `{output, exit_status}` where output is the concatenated stdout. ## Options @@ -50,8 +52,25 @@ defmodule NetRunner do {:error, {:max_output_exceeded, _partial}} = NetRunner.run(["sh", "-c", "yes"], max_output_size: 1000) + + # With a Command struct: + cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000) + {output, 0} = NetRunner.run(cmd) """ - def run([cmd | args], opts \\ []) do + @spec run(NetRunner.Command.t() | [String.t()], keyword()) :: + {binary(), non_neg_integer()} | {:error, term()} + def run(command, opts \\ []) + + def run(%NetRunner.Command{} = command, opts) do + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts) + run_impl(cmd, args, merged_opts) + end + + def run([cmd | args], opts) do + run_impl(cmd, args, opts) + end + + defp run_impl(cmd, args, opts) do input = Keyword.get(opts, :input, nil) timeout = Keyword.get(opts, :timeout, nil) max_output_size = Keyword.get(opts, :max_output_size, nil) @@ -84,6 +103,8 @@ defmodule NetRunner do @doc """ Creates a stream for incremental I/O with the command. + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct. + Returns a `Stream` that yields stdout binary chunks. Raises on process start failure. @@ -102,15 +123,38 @@ defmodule NetRunner do NetRunner.stream!(~w(tr a-z A-Z), input: "hello") |> Enum.join() # => "HELLO" + + # With a Command struct: + cmd = NetRunner.Command.new("cat", [], input: "hello") + NetRunner.stream!(cmd) |> Enum.to_list() """ - def stream!([cmd | args], opts \\ []) do + @spec stream!(NetRunner.Command.t() | [String.t()], keyword()) :: Enumerable.t() + def stream!(command, opts \\ []) + + def stream!(%NetRunner.Command{} = command, opts) do + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts) + NRStream.stream!(cmd, args, merged_opts) + end + + def stream!([cmd | args], opts) do NRStream.stream!(cmd, args, opts) end @doc """ Like `stream!/2` but returns `{:ok, stream}` or `{:error, reason}`. + + Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct. """ - def stream([cmd | args], opts \\ []) do + @spec stream(NetRunner.Command.t() | [String.t()], keyword()) :: + {:ok, Enumerable.t()} | {:error, term()} + def stream(command, opts \\ []) + + def stream(%NetRunner.Command{} = command, opts) do + {cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts) + NRStream.stream(cmd, args, merged_opts) + end + + def stream([cmd | args], opts) do NRStream.stream(cmd, args, opts) end diff --git a/lib/net_runner/command.ex b/lib/net_runner/command.ex new file mode 100644 index 0000000..a9ed441 --- /dev/null +++ b/lib/net_runner/command.ex @@ -0,0 +1,171 @@ +defmodule NetRunner.Command do + @moduledoc """ + Reusable command templates with default arguments and options. + + Define commands once, reuse them everywhere: + + defmodule MyApp.Commands do + use NetRunner.Command + + defcommand :curl, "curl", + args: ["-s", "--compressed", "-L"], + timeout: 10_000 + + defcommand :rg, "rg", + args: ["--no-heading", "--color=never"], + stderr: :consume + end + + # Returns a %Command{} struct: + cmd = MyApp.Commands.curl(["https://example.com"]) + + # Pass to NetRunner API: + NetRunner.run(cmd) + NetRunner.run(cmd, timeout: 30_000) + NetRunner.stream!(cmd) + + # Introspection: + MyApp.Commands.__commands__() + #=> [:curl, :rg] + + Commands can also be built at runtime without macros: + + cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000) + NetRunner.run(cmd) + """ + + @enforce_keys [:executable] + defstruct [:executable, args: [], opts: []] + + @type t :: %__MODULE__{ + executable: String.t(), + args: [String.t()], + opts: keyword() + } + + @doc """ + Creates a new command struct. + + ## Examples + + iex> NetRunner.Command.new("echo", ["hello"]) + %NetRunner.Command{executable: "echo", args: ["hello"], opts: []} + + iex> NetRunner.Command.new("curl", ["-s"], timeout: 10_000) + %NetRunner.Command{executable: "curl", args: ["-s"], opts: [timeout: 10_000]} + """ + @spec new(String.t(), [String.t()], keyword()) :: t() + def new(executable, args \\ [], opts \\ []) do + unless is_binary(executable) do + raise ArgumentError, "executable must be a string, got: #{inspect(executable)}" + end + + unless is_list(args) do + raise ArgumentError, "args must be a list, got: #{inspect(args)}" + end + + %__MODULE__{executable: executable, args: args, opts: opts} + end + + @doc """ + Decomposes a command struct into `{executable, args, opts}`. + + Runtime `override_opts` are merged on top of the command's default opts, + so callers can override specific options per invocation. + + ## Examples + + iex> cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000) + iex> NetRunner.Command.to_cmd_args_opts(cmd) + {"echo", ["hello"], [timeout: 5_000]} + + iex> cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000) + iex> NetRunner.Command.to_cmd_args_opts(cmd, timeout: 30_000) + {"echo", ["hello"], [timeout: 30_000]} + """ + @spec to_cmd_args_opts(t(), keyword()) :: {String.t(), [String.t()], keyword()} + def to_cmd_args_opts(%__MODULE__{} = command, override_opts \\ []) do + {command.executable, command.args, Keyword.merge(command.opts, override_opts)} + end + + @doc false + defmacro __using__(_opts) do + quote do + import NetRunner.Command, only: [defcommand: 2, defcommand: 3] + Module.register_attribute(__MODULE__, :net_runner_commands, accumulate: true) + @before_compile NetRunner.Command + end + end + + @doc false + defmacro __before_compile__(env) do + commands = Module.get_attribute(env.module, :net_runner_commands) |> Enum.reverse() + + quote do + @doc "Returns the list of command names defined in this module." + @spec __commands__() :: [atom()] + def __commands__, do: unquote(commands) + end + end + + @doc """ + Defines a reusable command template. + + Generates a function with the given `name` that returns a `%NetRunner.Command{}` struct. + + ## Options + + * `:args` - default arguments prepended to any extra args at call time + * All other options (`:timeout`, `:stderr`, `:pty`, etc.) become default + process options, overridable when passed to `NetRunner.run/2` et al. + + ## Examples + + defcommand :echo, "echo" + + defcommand :curl, "curl", + args: ["-s", "--compressed"], + timeout: 10_000 + + The above generates: + + def curl(extra_args \\\\ []) + + So that: + + curl(["https://example.com"]) + #=> %NetRunner.Command{ + #=> executable: "curl", + #=> args: ["-s", "--compressed", "https://example.com"], + #=> opts: [timeout: 10_000] + #=> } + """ + defmacro defcommand(name, executable, definition_opts \\ []) do + quote bind_quoted: [name: name, executable: executable, definition_opts: definition_opts] do + unless is_binary(executable) do + raise ArgumentError, + "defcommand executable must be a string, got: #{inspect(executable)}" + end + + default_args = Keyword.get(definition_opts, :args, []) + + unless is_list(default_args) do + raise ArgumentError, "defcommand :args must be a list, got: #{inspect(default_args)}" + end + + default_opts = Keyword.drop(definition_opts, [:args]) + + @net_runner_commands name + + @doc "Builds a `%NetRunner.Command{}` for `#{executable}` with optional extra args." + @spec unquote(name)([String.t()]) :: NetRunner.Command.t() + def unquote(name)(extra_args \\ []) do + %NetRunner.Command{ + executable: unquote(executable), + args: unquote(default_args) ++ extra_args, + opts: unquote(Macro.escape(default_opts)) + } + end + end + end +end diff --git a/mix.exs b/mix.exs index e76b438..02e57a4 100644 --- a/mix.exs +++ b/mix.exs @@ -76,7 +76,13 @@ defmodule NetRunner.MixProject do "docs/modules.md" ], groups_for_modules: [ - "Public API": [NetRunner, NetRunner.Process, NetRunner.Stream, NetRunner.Daemon], + "Public API": [ + NetRunner, + NetRunner.Command, + NetRunner.Process, + NetRunner.Stream, + NetRunner.Daemon + ], Internals: [ NetRunner.Process.Exec, NetRunner.Process.Nif, diff --git a/test/command_test.exs b/test/command_test.exs new file mode 100644 index 0000000..8678cfc --- /dev/null +++ b/test/command_test.exs @@ -0,0 +1,548 @@ +defmodule NetRunner.CommandTest do + use ExUnit.Case, async: true + + alias NetRunner.Command + + # ── Test modules defined via defcommand ────────────────────────────── + + defmodule BasicCommands do + use NetRunner.Command + + defcommand(:echo, "echo") + + defcommand(:cat, "cat") + + defcommand(:curl, "curl", + args: ["-s", "--compressed", "-L"], + timeout: 10_000 + ) + + defcommand(:rg, "rg", + args: ["--no-heading", "--color=never"], + stderr: :consume + ) + + defcommand(:ffmpeg, "ffmpeg", + args: ["-y", "-hide_banner"], + timeout: 300_000, + kill_timeout: 10_000 + ) + end + + defmodule MinimalCommands do + use NetRunner.Command + + defcommand(:ls, "ls") + end + + defmodule EmptyModule do + use NetRunner.Command + end + + # ── Struct construction (Command.new/3) ────────────────────────────── + + describe "Command.new/3" do + test "creates struct with all fields" do + cmd = Command.new("echo", ["hello", "world"], timeout: 5_000) + + assert %Command{} = cmd + assert cmd.executable == "echo" + assert cmd.args == ["hello", "world"] + assert cmd.opts == [timeout: 5_000] + end + + test "defaults args to empty list" do + cmd = Command.new("echo") + + assert cmd.args == [] + assert cmd.opts == [] + end + + test "defaults opts to empty list" do + cmd = Command.new("echo", ["hello"]) + + assert cmd.opts == [] + end + + test "preserves multiple opts" do + cmd = Command.new("cmd", [], timeout: 5_000, stderr: :redirect, pty: true) + + assert cmd.opts == [timeout: 5_000, stderr: :redirect, pty: true] + end + + test "raises on non-binary executable" do + assert_raise ArgumentError, ~r/executable must be a string/, fn -> + Command.new(:echo) + end + + assert_raise ArgumentError, ~r/executable must be a string/, fn -> + Command.new(123) + end + + assert_raise ArgumentError, ~r/executable must be a string/, fn -> + Command.new(nil) + end + end + + test "raises on non-list args" do + assert_raise ArgumentError, ~r/args must be a list/, fn -> + Command.new("echo", "hello") + end + + assert_raise ArgumentError, ~r/args must be a list/, fn -> + Command.new("echo", :bad) + end + end + end + + # ── to_cmd_args_opts/2 ────────────────────────────────────────────── + + describe "Command.to_cmd_args_opts/2" do + test "decomposes struct with no overrides" do + cmd = Command.new("echo", ["hello"], timeout: 5_000) + + assert {"echo", ["hello"], [timeout: 5_000]} = Command.to_cmd_args_opts(cmd) + end + + test "decomposes struct with empty overrides" do + cmd = Command.new("echo", ["hello"], timeout: 5_000) + + assert {"echo", ["hello"], [timeout: 5_000]} = Command.to_cmd_args_opts(cmd, []) + end + + test "override opts win over defaults" do + cmd = Command.new("echo", ["hello"], timeout: 5_000) + + assert {"echo", ["hello"], [timeout: 30_000]} = + Command.to_cmd_args_opts(cmd, timeout: 30_000) + end + + test "preserves non-overridden defaults" do + cmd = Command.new("cmd", [], timeout: 5_000, stderr: :consume) + + {_, _, opts} = Command.to_cmd_args_opts(cmd, timeout: 30_000) + + assert opts[:timeout] == 30_000 + assert opts[:stderr] == :consume + end + + test "adds new opts not in defaults" do + cmd = Command.new("cat", []) + + {_, _, opts} = Command.to_cmd_args_opts(cmd, input: "hello") + + assert opts[:input] == "hello" + end + + test "args are not affected by overrides" do + cmd = Command.new("echo", ["hello", "world"], timeout: 5_000) + + {_, args, _} = Command.to_cmd_args_opts(cmd, timeout: 30_000) + + assert args == ["hello", "world"] + end + + test "works with empty struct opts" do + cmd = Command.new("echo", ["hello"]) + + assert {"echo", ["hello"], [input: "data"]} = + Command.to_cmd_args_opts(cmd, input: "data") + end + end + + # ── defcommand macro ──────────────────────────────────────────────── + + describe "defcommand macro" do + test "generates a function for each command" do + assert function_exported?(BasicCommands, :echo, 0) + assert function_exported?(BasicCommands, :echo, 1) + assert function_exported?(BasicCommands, :cat, 0) + assert function_exported?(BasicCommands, :cat, 1) + assert function_exported?(BasicCommands, :curl, 0) + assert function_exported?(BasicCommands, :curl, 1) + end + + test "generated function returns a Command struct" do + cmd = BasicCommands.echo() + + assert %Command{} = cmd + assert cmd.executable == "echo" + end + + test "command with no opts has empty args and opts" do + cmd = BasicCommands.echo() + + assert cmd.executable == "echo" + assert cmd.args == [] + assert cmd.opts == [] + end + + test "command with default args includes them" do + cmd = BasicCommands.curl() + + assert cmd.executable == "curl" + assert cmd.args == ["-s", "--compressed", "-L"] + assert cmd.opts == [timeout: 10_000] + end + + test "extra args are appended to defaults" do + cmd = BasicCommands.curl(["https://example.com"]) + + assert cmd.args == ["-s", "--compressed", "-L", "https://example.com"] + end + + test "multiple extra args are appended in order" do + cmd = BasicCommands.curl(["https://example.com", "-o", "output.html"]) + + assert cmd.args == ["-s", "--compressed", "-L", "https://example.com", "-o", "output.html"] + end + + test "extra args on command with no defaults" do + cmd = BasicCommands.echo(["hello", "world"]) + + assert cmd.args == ["hello", "world"] + end + + test "empty extra args does not change defaults" do + cmd = BasicCommands.curl([]) + + assert cmd.args == ["-s", "--compressed", "-L"] + end + + test "opts are separated from args correctly" do + cmd = BasicCommands.rg() + + assert cmd.args == ["--no-heading", "--color=never"] + assert cmd.opts == [stderr: :consume] + end + + test "multiple opts are preserved" do + cmd = BasicCommands.ffmpeg() + + assert cmd.opts == [timeout: 300_000, kill_timeout: 10_000] + end + + test "calling function multiple times returns independent structs" do + cmd1 = BasicCommands.curl(["url1"]) + cmd2 = BasicCommands.curl(["url2"]) + + assert cmd1.args == ["-s", "--compressed", "-L", "url1"] + assert cmd2.args == ["-s", "--compressed", "-L", "url2"] + end + end + + # ── __commands__/0 introspection ──────────────────────────────────── + + describe "__commands__/0 introspection" do + test "lists all commands in definition order" do + assert BasicCommands.__commands__() == [:echo, :cat, :curl, :rg, :ffmpeg] + end + + test "single command module" do + assert MinimalCommands.__commands__() == [:ls] + end + + test "empty module returns empty list" do + assert EmptyModule.__commands__() == [] + end + end + + # ── Integration: NetRunner.run/2 with Command ────────────────────── + + describe "NetRunner.run/2 with Command struct" do + test "runs a command struct" do + cmd = Command.new("echo", ["hello"]) + + assert {"hello\n", 0} = NetRunner.run(cmd) + end + + test "runs command struct with opts" do + cmd = Command.new("cat", [], input: "from stdin") + + # Note: input is a process-level opt consumed by run, not passed to Process.start + # We test it works by passing in override opts + assert {"from stdin", 0} = NetRunner.run(cmd) + end + + test "override opts take precedence" do + cmd = Command.new("cat", []) + + assert {"overridden\n", 0} = + NetRunner.run(cmd, input: "overridden\n") + end + + test "timeout via command struct" do + cmd = Command.new("sleep", ["100"], timeout: 200) + + assert {:error, :timeout} = NetRunner.run(cmd) + end + + test "timeout override on command struct" do + cmd = Command.new("sleep", ["100"], timeout: 60_000) + + assert {:error, :timeout} = NetRunner.run(cmd, timeout: 200) + end + + test "max_output_size via command struct" do + cmd = Command.new("sh", ["-c", "yes"], max_output_size: 100) + + assert {:error, {:max_output_exceeded, _partial}} = NetRunner.run(cmd) + end + + test "backward compatible: list form still works" do + assert {"hello\n", 0} = NetRunner.run(~w(echo hello)) + end + + test "backward compatible: list form with opts still works" do + assert {"stdin data", 0} = NetRunner.run(~w(cat), input: "stdin data") + end + end + + # ── Integration: NetRunner.stream!/2 with Command ────────────────── + + describe "NetRunner.stream!/2 with Command struct" do + test "streams output from command struct" do + cmd = Command.new("echo", ["hello"]) + output = NetRunner.stream!(cmd) |> Enum.join() + + assert output == "hello\n" + end + + test "streams with input from command struct" do + cmd = Command.new("cat", []) + output = NetRunner.stream!(cmd, input: "streamed data") |> Enum.join() + + assert output == "streamed data" + end + + test "streams with input in command opts" do + cmd = Command.new("cat", [], input: "from command") + output = NetRunner.stream!(cmd) |> Enum.join() + + assert output == "from command" + end + + test "backward compatible: list form still works" do + output = NetRunner.stream!(~w(echo hello)) |> Enum.join() + + assert output == "hello\n" + end + end + + # ── Integration: NetRunner.stream/2 with Command ─────────────────── + + describe "NetRunner.stream/2 with Command struct" do + test "returns {:ok, stream} for command struct" do + cmd = Command.new("echo", ["hello"]) + + assert {:ok, stream} = NetRunner.stream(cmd) + assert Enum.join(stream) == "hello\n" + end + + test "accepts override opts" do + cmd = Command.new("cat", []) + + assert {:ok, stream} = NetRunner.stream(cmd, input: "data") + assert Enum.join(stream) == "data" + end + + test "backward compatible: list form still works" do + assert {:ok, stream} = NetRunner.stream(~w(echo hello)) + assert Enum.join(stream) == "hello\n" + end + end + + # ── End-to-end: defcommand → run/stream ───────────────────────────── + + describe "end-to-end: defcommand to execution" do + test "defcommand → run" do + cmd = BasicCommands.echo(["end-to-end"]) + + assert {"end-to-end\n", 0} = NetRunner.run(cmd) + end + + test "defcommand → run with extra opts" do + cmd = BasicCommands.cat() + + assert {"piped in", 0} = NetRunner.run(cmd, input: "piped in") + end + + test "defcommand → stream!" do + cmd = BasicCommands.echo(["streaming"]) + output = NetRunner.stream!(cmd) |> Enum.join() + + assert output == "streaming\n" + end + + test "defcommand → stream" do + cmd = BasicCommands.echo(["ok"]) + + assert {:ok, stream} = NetRunner.stream(cmd) + assert Enum.join(stream) == "ok\n" + end + + test "defcommand with default args → run appends correctly" do + # curl is not available in all test envs, so test with echo-like behavior + # Use a command that lets us verify arg ordering + cmd = BasicCommands.echo(["extra1", "extra2"]) + + assert {"extra1 extra2\n", 0} = NetRunner.run(cmd) + end + + test "defcommand opts are used by run" do + # Define a command with a very short timeout to verify opts flow through + cmd = %Command{executable: "sleep", args: ["100"], opts: [timeout: 200]} + + assert {:error, :timeout} = NetRunner.run(cmd) + end + + test "override defcommand opts at run time" do + cmd = BasicCommands.cat() + + assert {"hello", 0} = NetRunner.run(cmd, input: "hello") + end + end + + # ── Edge cases ────────────────────────────────────────────────────── + + describe "edge cases" do + test "command with empty executable path" do + # Empty string is technically valid for new/3 (it will fail at OS level) + cmd = Command.new("", []) + + assert cmd.executable == "" + end + + test "command struct with no args, no opts" do + cmd = Command.new("true") + + assert {"", 0} = NetRunner.run(cmd) + end + + test "command struct preserves argument order" do + cmd = Command.new("echo", ["a", "b", "c"]) + + assert {"a b c\n", 0} = NetRunner.run(cmd) + end + + test "large number of args" do + args = Enum.map(1..50, &to_string/1) + cmd = Command.new("echo", args) + + {output, 0} = NetRunner.run(cmd) + expected = Enum.join(1..50, " ") <> "\n" + assert output == expected + end + + test "args with special characters" do + cmd = Command.new("echo", ["hello world", "foo\tbar"]) + + {output, 0} = NetRunner.run(cmd) + assert output == "hello world foo\tbar\n" + end + + test "opts with all recognized keys" do + cmd = Command.new("echo", ["test"], stderr: :consume, kill_timeout: 3_000) + + {_, _, opts} = Command.to_cmd_args_opts(cmd) + assert opts[:stderr] == :consume + assert opts[:kill_timeout] == 3_000 + end + + test "struct can be pattern matched" do + cmd = Command.new("echo", ["hello"]) + + assert %Command{executable: "echo", args: ["hello"]} = cmd + end + + test "struct equality" do + cmd1 = Command.new("echo", ["hello"], timeout: 5_000) + cmd2 = Command.new("echo", ["hello"], timeout: 5_000) + + assert cmd1 == cmd2 + end + + test "struct inequality with different args" do + cmd1 = Command.new("echo", ["hello"]) + cmd2 = Command.new("echo", ["world"]) + + refute cmd1 == cmd2 + end + + test "struct inequality with different opts" do + cmd1 = Command.new("echo", [], timeout: 1_000) + cmd2 = Command.new("echo", [], timeout: 2_000) + + refute cmd1 == cmd2 + end + end + + # ── Compile-time validation ───────────────────────────────────────── + + describe "compile-time validation" do + test "defcommand with non-binary executable raises at compile time" do + assert_raise ArgumentError, ~r/executable must be a string/, fn -> + Code.compile_string(""" + defmodule TestBadExec do + use NetRunner.Command + defcommand :bad, :not_a_string + end + """) + end + end + + test "defcommand with non-list args raises at compile time" do + assert_raise ArgumentError, ~r/:args must be a list/, fn -> + Code.compile_string(""" + defmodule TestBadArgs do + use NetRunner.Command + defcommand :bad, "echo", args: "not a list" + end + """) + end + end + + test "defcommand with integer executable raises at compile time" do + assert_raise ArgumentError, ~r/executable must be a string/, fn -> + Code.compile_string(""" + defmodule TestIntExec do + use NetRunner.Command + defcommand :bad, 42 + end + """) + end + end + end + + # ── Command reuse patterns ───────────────────────────────────────── + + describe "command reuse patterns" do + test "same command can be run with different inputs" do + cmd = BasicCommands.cat() + + assert {"first", 0} = NetRunner.run(cmd, input: "first") + assert {"second", 0} = NetRunner.run(cmd, input: "second") + end + + test "same command can be run and streamed" do + cmd = BasicCommands.echo(["reusable"]) + + {run_output, 0} = NetRunner.run(cmd) + stream_output = NetRunner.stream!(cmd) |> Enum.join() + + assert run_output == stream_output + end + + test "command struct is immutable — run does not modify it" do + cmd = BasicCommands.curl(["https://example.com"]) + original_args = cmd.args + original_opts = cmd.opts + + # to_cmd_args_opts with overrides should not mutate the struct + Command.to_cmd_args_opts(cmd, timeout: 99_999) + + assert cmd.args == original_args + assert cmd.opts == original_opts + end + end +end