diff --git a/lib/mix/tasks/usage_rules.validate.ex b/lib/mix/tasks/usage_rules.validate.ex index 17a79e0..0802dc5 100644 --- a/lib/mix/tasks/usage_rules.validate.ex +++ b/lib/mix/tasks/usage_rules.validate.ex @@ -26,12 +26,15 @@ defmodule Mix.Tasks.UsageRules.Validate do * `*.md` files under skills managed by usage-rules (skills whose `SKILL.md` contains `managed-by: usage-rules`) - Explicit file paths can also be given to validate those instead. + Explicit file paths or glob patterns can also be given to validate those + instead. Every argument must match at least one file; the task fails, + naming the argument, when one does not. ## Examples $ mix usage_rules.validate $ mix usage_rules.validate AGENTS.md usage-rules.md + $ mix usage_rules.validate "docs/**/*.md" ## Exit status @@ -64,7 +67,7 @@ defmodule Mix.Tasks.UsageRules.Validate do files = case files do [] -> managed_files() - files -> files + patterns -> expand_patterns(patterns) end if files == [] do @@ -83,6 +86,28 @@ defmodule Mix.Tasks.UsageRules.Validate do end end + # Each argument is a path or a glob. Shells pass a glob that matches nothing + # through literally, so expand it here; an argument that matches no regular + # file is an error rather than something to hand on to ex_doc. + defp expand_patterns(patterns) do + expanded = + Enum.map(patterns, fn pattern -> + if File.regular?(pattern) do + {pattern, [pattern]} + else + {pattern, Enum.filter(Path.wildcard(pattern), &File.regular?/1)} + end + end) + + case for {pattern, []} <- expanded, do: pattern do + [] -> + expanded |> Enum.flat_map(&elem(&1, 1)) |> Enum.uniq() + + unmatched -> + Mix.raise("No files matched: #{Enum.map_join(unmatched, ", ", &inspect/1)}\n\n#{usage()}") + end + end + defp managed_files do config = Mix.Project.config()[:usage_rules] || [] composed_file(config) ++ managed_skill_files(Keyword.get(config, :skills) || []) diff --git a/mix.exs b/mix.exs index 6958f3b..4cca301 100644 --- a/mix.exs +++ b/mix.exs @@ -19,7 +19,7 @@ defmodule UsageRules.MixProject do elixir: "~> 1.18", package: package(), aliases: aliases(), - dialyzer: [plt_add_apps: [:mix, :iex]], + dialyzer: [plt_add_apps: [:mix, :iex, :ex_doc]], docs: &docs/0, description: @description, source_url: @source_url, diff --git a/test/mix/tasks/usage_rules.validate_test.exs b/test/mix/tasks/usage_rules.validate_test.exs index daf9bd2..0076aca 100644 --- a/test/mix/tasks/usage_rules.validate_test.exs +++ b/test/mix/tasks/usage_rules.validate_test.exs @@ -28,4 +28,42 @@ defmodule Mix.Tasks.UsageRules.ValidateTest do capture_io(fn -> Validate.run(["--bogus"]) end) end end + + @tag :tmp_dir + test "raises naming each pattern that matches no files", %{tmp_dir: tmp_dir} do + existing = Path.join(tmp_dir, "exists.md") + File.write!(existing, "# Exists\n") + missing_glob = Path.join(tmp_dir, "nope/*.md") + missing_file = Path.join(tmp_dir, "missing.md") + + error = + assert_raise Mix.Error, fn -> + capture_io(fn -> Validate.run([existing, missing_glob, missing_file]) end) + end + + assert error.message =~ "No files matched" + assert error.message =~ inspect(missing_glob) + assert error.message =~ inspect(missing_file) + refute error.message =~ inspect(existing) + end + + @tag :tmp_dir + test "treats a pattern matching only directories as matching no files", %{tmp_dir: tmp_dir} do + File.mkdir_p!(Path.join(tmp_dir, "dir.md")) + pattern = Path.join(tmp_dir, "*.md") + + assert_raise Mix.Error, ~r/No files matched: #{Regex.escape(inspect(pattern))}/, fn -> + capture_io(fn -> Validate.run([pattern]) end) + end + end + + @tag :tmp_dir + test "treats an existing file name containing glob characters literally", %{tmp_dir: tmp_dir} do + file = Path.join(tmp_dir, "[draft].md") + File.write!(file, "# Draft\n") + + output = capture_io(fn -> Validate.run([file]) end) + + refute output =~ "No files matched" + end end