Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions lib/mix/tasks/usage_rules.validate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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) || [])
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions test/mix/tasks/usage_rules.validate_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading