Packages
A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/usage_rules.sync.ex
# SPDX-FileCopyrightText: 2025 usage_rules contributors <https://github.com/ash-project/usage_rules/graphs.contributors>
#
# SPDX-License-Identifier: MIT
defmodule Mix.Tasks.UsageRules.Sync.Docs do
@moduledoc false
@spec short_doc() :: String.t()
def short_doc do
"Combine the package rules for the provided packages into the provided file, or list/gather all dependencies."
end
@spec example() :: String.t()
def example do
"mix usage_rules.sync AGENTS.md --all --link-to-folder deps"
end
@spec long_doc() :: String.t()
def long_doc do
"""
#{short_doc()}
## Package Specifications
Packages can be specified in the following formats:
* `package_name` - Include the main usage-rules.md file for the package
* `package_name:sub_rule` - Include a specific sub-rule from the package's usage-rules/ folder
* `package_name:all` - Include all sub-rules from the package's usage-rules/ folder
Sub-rules are discovered from `usage-rules/` folders within package directories. For example:
* `deps/ash/usage-rules/testing.md` can be included with `ash:testing`
* `deps/phoenix/usage-rules/views.md` can be included with `phoenix:views`
## Options
* `--all` - Gather usage rules from all dependencies that have them (includes both main rules and all sub-rules)
* `--list` - List all dependencies with usage rules. If a file is provided, shows status (present, missing, stale)
* `--remove` - Remove specified packages from the target file instead of adding them
* `--remove-missing` - Remove any packages from the target file that are not listed in the command
* `--link-to-folder <folder>` - Save usage rules for each package in separate files within the specified folder and create links to them
* `--link-style <style>` - Style of links to create when using --link-to-folder (markdown|at). Defaults to 'markdown'
* `--inline <specs>` - Force specific packages to be inlined even when using --link-to-folder. Supports same specs as packages (comma-separated)
* `--folder-only <folder>` - Extract usage rules to the specified folder without updating any other files
* `--merge-sub-rules` - When used with --folder-only, merge sub-rules into the same file per package
## Examples
Combine specific packages:
```sh
#{example()}
```
Gather all dependencies with usage rules:
```sh
mix usage_rules.sync AGENTS.md --all
```
List all dependencies with usage rules:
```sh
mix usage_rules.sync --list
```
Check status of dependencies against a specific file:
```sh
mix usage_rules.sync AGENTS.md --list
```
Remove specific packages from a file:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --remove
```
Save usage rules to individual files in a folder with markdown links:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --link-to-folder rules
```
Save usage rules with @-style links:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --link-to-folder rules --link-style at
```
Link directly to deps files without copying:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --link-to-folder deps
```
Combine all dependencies with folder links:
```sh
mix usage_rules.sync AGENTS.md --all --link-to-folder docs
```
Check status of packages using folder links:
```sh
mix usage_rules.sync AGENTS.md --list --link-to-folder rules
```
Remove packages and their folder files:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --remove --link-to-folder rules
```
Include specific sub-rules:
```sh
mix usage_rules.sync AGENTS.md ash:testing phoenix:views
```
Include all sub-rules from a package:
```sh
mix usage_rules.sync AGENTS.md ash:all
```
Mix main package rules with sub-rules:
```sh
mix usage_rules.sync AGENTS.md ash ash:testing phoenix:views
```
Inline all sub-rules while linking main packages (recommended for agents):
```sh
mix usage_rules.sync AGENTS.md --all --inline usage_rules:all --link-to-folder deps
```
Inline specific packages while linking others:
```sh
mix usage_rules.sync AGENTS.md ash:testing phoenix --inline ash:testing --link-to-folder docs
```
Remove unused packages that are no longer dependencies:
```sh
mix usage_rules.sync AGENTS.md ash phoenix --remove-missing
```
Extract usage rules to folder without updating any files:
```sh
mix usage_rules.sync ash phoenix --folder-only rules
```
Extract all usage rules to folder:
```sh
mix usage_rules.sync --all --folder-only docs
```
Extract sub-rules as separate files:
```sh
mix usage_rules.sync ash:testing ash:queries --folder-only rules
```
Extract and merge sub-rules into single files per package:
```sh
mix usage_rules.sync ash:testing ash:queries --folder-only rules --merge-sub-rules
```
Extract all usage rules with merging:
```sh
mix usage_rules.sync --all --folder-only docs --merge-sub-rules
```
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.UsageRules.Sync do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
# Groups allow for overlapping arguments for tasks by the same author
# See the generators guide for more.
group: :usage_rules,
example: __MODULE__.Docs.example(),
positional: [
file: [optional: true],
packages: [rest: true, optional: true]
],
schema: [
all: :boolean,
list: :boolean,
remove: :boolean,
remove_missing: :boolean,
link_to_folder: :string,
link_style: :string,
inline: :string,
folder_only: :string,
merge_sub_rules: :boolean
]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
igniter =
if is_nil(igniter.parent) do
igniter
|> Igniter.assign(:prompt_on_git_changes?, false)
|> Igniter.assign(:quiet_on_no_changes?, true)
else
igniter
end
# Add all usage-rules.md files and usage_rules/ folders from deps directory to igniter
igniter =
igniter
|> Igniter.include_glob("deps/*/usage-rules.md")
|> Igniter.include_glob("deps/*/usage_rules/*.md")
top_level_deps =
Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0))
# Get all deps from both Mix.Project.deps_paths and Igniter rewrite sources
mix_deps =
Mix.Project.deps_paths()
|> Enum.filter(fn {dep, _path} ->
dep in top_level_deps
end)
|> Enum.map(fn {dep, path} ->
{dep, Path.relative_to_cwd(path)}
end)
igniter_deps = get_deps_from_igniter(igniter)
all_deps = (mix_deps ++ igniter_deps) |> Enum.uniq()
all_option = igniter.args.options[:all]
list_option = igniter.args.options[:list]
remove_option = igniter.args.options[:remove]
remove_missing_option = igniter.args.options[:remove_missing]
link_to_folder = igniter.args.options[:link_to_folder]
link_style = igniter.args.options[:link_style] || "markdown"
inline_specs = parse_inline_specs(igniter.args.options[:inline])
folder_only = igniter.args.options[:folder_only]
merge_sub_rules = igniter.args.options[:merge_sub_rules] || false
# Fix argument parsing for folder-only: when no file should be specified,
# the first argument gets incorrectly parsed as 'file' instead of the first package
provided_packages =
if folder_only && igniter.args.positional[:file] &&
!File.exists?(igniter.args.positional[:file]) do
# The 'file' is actually the first package
[igniter.args.positional[:file]] ++ (igniter.args.positional.packages || [])
else
igniter.args.positional.packages || []
end
cond do
# If --link-style is used with invalid value, add error
link_style && link_style not in ["markdown", "at"] ->
Igniter.add_issue(igniter, "--link-style must be either 'markdown' or 'at'")
# If --link-style is used without --link-to-folder, add error
igniter.args.options[:link_style] && !link_to_folder ->
Igniter.add_issue(igniter, "--link-style can only be used with --link-to-folder")
# If --merge-sub-rules is used without --folder-only, add error
merge_sub_rules && !folder_only ->
Igniter.add_issue(igniter, "--merge-sub-rules can only be used with --folder-only")
# If --folder-only is used with incompatible options, add error
folder_only && (list_option || remove_option || remove_missing_option || link_to_folder) ->
Igniter.add_issue(
igniter,
"--folder-only cannot be used with --list, --remove, --remove-missing, or --link-to-folder options"
)
# If --folder-only is used without packages and not with --all, add error
folder_only && Enum.empty?(provided_packages) && !all_option ->
Igniter.add_issue(
igniter,
"--folder-only requires either --all or specific packages to be provided"
)
# If --remove is used with --all or --list, add error
remove_option && (all_option || list_option) ->
Igniter.add_issue(igniter, "Cannot use --remove with --all or --list options")
# If --remove-missing is used without a file, add error
remove_missing_option && is_nil(igniter.args.positional[:file]) ->
Igniter.add_issue(igniter, "--remove-missing option requires a file to modify")
# If --remove-missing is used with --list, add error
remove_missing_option && list_option ->
Igniter.add_issue(igniter, "Cannot use --remove-missing with --list option")
# If --remove is used without a file, add error
remove_option && is_nil(igniter.args.positional[:file]) ->
Igniter.add_issue(igniter, "--remove option requires a file to remove from")
# If --remove is used without packages, add error
remove_option && Enum.empty?(provided_packages) ->
Igniter.add_issue(igniter, "--remove option requires packages to remove")
# If --list is given and packages list is not empty, add error
list_option && !Enum.empty?(provided_packages) ->
Igniter.add_issue(igniter, "Cannot specify packages when using --list option")
# If --all is given and packages list is not empty (except with --folder-only), add error
all_option && !Enum.empty?(provided_packages) && !folder_only ->
Igniter.add_issue(igniter, "Cannot specify packages when using --all option")
# If --all is used without a file (and not with --folder-only), add error
all_option && is_nil(igniter.args.positional[:file]) && !folder_only ->
Igniter.add_issue(igniter, "--all option requires a file to write to")
# If --link-to-folder is used without a file, add error
link_to_folder && is_nil(igniter.args.positional[:file]) ->
Igniter.add_issue(igniter, "--link-to-folder option requires a file to write to")
# If no packages are given and neither --list nor --all nor --remove nor --remove-missing nor --folder-only is
# set, add error
Enum.empty?(provided_packages) && !all_option && !list_option && !remove_option &&
!remove_missing_option && !folder_only ->
add_usage_error(igniter)
# Handle --folder-only option
folder_only ->
handle_folder_only_option(
igniter,
all_deps,
provided_packages,
folder_only,
all_option,
merge_sub_rules
)
# Handle --remove option
remove_option ->
handle_remove_packages(igniter, provided_packages, link_to_folder)
# Handle --all option
all_option ->
handle_all_option(
igniter,
all_deps,
link_to_folder,
link_style,
inline_specs,
remove_missing_option
)
# Handle --list option
list_option ->
handle_list_option(igniter, all_deps, link_to_folder, link_style, inline_specs)
# Handle specific packages
true ->
handle_specific_packages(
igniter,
all_deps,
provided_packages,
link_to_folder,
link_style,
inline_specs,
remove_missing_option
)
end
|> notice_about_all_option(all_option)
end
@impl Igniter.Mix.Task
def supports_umbrella?, do: true
defp notice_about_all_option(igniter, all_option) do
file = igniter.args.positional[:file]
if all_option do
Igniter.add_warning(igniter, """
Usage Rules:
We've synchronized usage rules for all of your direct
dependencies into #{file}. When working with agents, it
is important to manage your context window. Consider
which packages you wish to have present. You can use
the `--remove-missing` flag to select exactly what to sync.
For example:
mix usage_rules.sync #{file} pkg1 pkg2 \\
usage_rules:all \\
--inline usage_rules:all \\
--link-to-folder deps \\
--remove-missing
""")
else
igniter
end
end
defp usage_rules_header do
"""
<!-- usage-rules-header -->
# Usage Rules
**IMPORTANT**: Consult these usage rules early and often when working with the packages listed below.
Before attempting to use any of these packages or to discover if you should use them, review their
usage rules to understand the correct patterns, conventions, and best practices.
<!-- usage-rules-header-end -->
"""
end
defp get_package_description(name) do
case Application.spec(name, :description) do
nil -> ""
desc -> String.trim_trailing(to_string(desc))
end
end
defp get_deps_from_igniter(igniter) do
if igniter.assigns[:test_mode?] do
igniter.rewrite.sources
|> Enum.filter(fn {path, _source} ->
String.match?(path, ~r|^deps/[^/]+/usage-rules\.md$|) ||
String.match?(path, ~r|^deps/[^/]+/usage-rules/[^/]+\.md$|)
end)
|> Enum.map(fn {path, _source} ->
# Extract package name from deps/package_name/usage-rules.md or deps/package_name/usage-rules/sub-rule.md
package_name =
path
|> String.split("/")
|> Enum.at(1)
|> String.to_atom()
# Extract package path from deps/package_name/...
package_path = Path.join("deps", to_string(package_name))
{package_name, package_path}
end)
|> Enum.uniq()
else
[]
end
end
defp parse_package_spec(package_spec) when is_binary(package_spec) do
case String.split(package_spec, ":", parts: 2) do
[package_name] ->
{String.to_atom(package_name), nil}
[package_name, sub_rule] ->
{String.to_atom(package_name), sub_rule}
end
end
defp find_available_sub_rules(igniter, package_path) do
usage_rules_dir = Path.join(package_path, "usage-rules")
# Try to find sub-rules from igniter sources first (works in both test and regular mode)
source_sub_rules =
igniter.rewrite.sources
|> Enum.filter(fn {path, _source} ->
String.starts_with?(path, usage_rules_dir <> "/") &&
String.ends_with?(path, ".md")
end)
|> Enum.map(fn {path, _source} ->
path
|> Path.basename()
|> Path.rootname()
end)
|> Enum.sort()
# If we found sub-rules in sources, return them
if Enum.any?(source_sub_rules) do
source_sub_rules
else
# Otherwise, try file system
case File.ls(usage_rules_dir) do
{:ok, files} ->
files
|> Enum.filter(&String.ends_with?(&1, ".md"))
|> Enum.map(&Path.rootname/1)
|> Enum.sort()
{:error, _} ->
[]
end
end
end
defp parse_inline_specs(nil), do: []
defp parse_inline_specs(inline_string) do
inline_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp should_inline_package?(package_name, sub_rule, inline_specs) do
package_name_str = to_string(package_name)
section_name =
case sub_rule do
nil -> package_name_str
sub_rule_name -> "#{package_name_str}:#{sub_rule_name}"
end
Enum.any?(inline_specs, fn inline_spec ->
case String.split(inline_spec, ":", parts: 2) do
[^package_name_str] when sub_rule == nil ->
true
[^package_name_str, "all"] ->
true
[^package_name_str, sub_rule_spec] when sub_rule == sub_rule_spec ->
true
[^section_name] ->
true
# Special case: "usage_rules:all" means inline all sub-rules
["usage_rules", "all"] when sub_rule != nil ->
true
_ ->
false
end
end)
end
defp expand_wildcard_specs(igniter, all_deps, provided_packages) do
Enum.flat_map(provided_packages, fn package_spec ->
{package_name, sub_rule} = parse_package_spec(package_spec)
case sub_rule do
"all" ->
# Find the package path
case Enum.find(all_deps, fn {name, _path} -> name == package_name end) do
{_name, package_path} ->
available_sub_rules = find_available_sub_rules(igniter, package_path)
Enum.map(available_sub_rules, fn sub_rule_name ->
"#{package_name}:#{sub_rule_name}"
end)
nil ->
[package_spec]
end
_ ->
[package_spec]
end
end)
end
defp add_usage_error(igniter) do
Igniter.add_issue(igniter, """
Usage:
mix usage_rules.sync AGENTS.md --all --link-to-folder deps
Standard usage: gather all dependencies and link directly to deps files
mix usage_rules.sync <file> <packages...>
Combine specific packages' usage rules into the target file
mix usage_rules.sync <file> --all
Gather usage rules from all dependencies into the target file
mix usage_rules.sync [file] --list
List packages with usage rules (optionally check status against file)
mix usage_rules.sync <file> <packages...> --remove
Remove specific packages from the target file
mix usage_rules.sync <file> <packages...> --link-to-folder <folder>
Save usage rules for each package in separate files within the specified folder and create links to them
mix usage_rules.sync <file> --list --link-to-folder <folder>
List packages with usage rules and check status against folder links
mix usage_rules.sync <file> <packages...> --remove --link-to-folder <folder>
Remove specific packages from the target file and delete their folder files
mix usage_rules.sync <packages...> --folder-only <folder>
Extract usage rules for specific packages to the specified folder without updating any other files
mix usage_rules.sync --all --folder-only <folder>
Extract usage rules for all dependencies to the specified folder without updating any other files
mix usage_rules.sync <packages...> --folder-only <folder> --merge-sub-rules
Extract usage rules for specific packages to the specified folder, merging sub-rules into single files
mix usage_rules.sync --all --folder-only <folder> --merge-sub-rules
Extract usage rules for all dependencies to the specified folder, merging sub-rules into single files
""")
end
defp handle_all_option(
igniter,
all_deps,
link_to_folder,
link_style,
inline_specs,
remove_missing
) do
all_packages_with_rules = get_packages_with_usage_rules(igniter, all_deps)
# Discover all package rules including sub-rules
all_package_rules =
all_packages_with_rules
|> Enum.flat_map(fn {package_name, package_path} ->
# Check for main usage-rules.md file
main_rules =
if Igniter.exists?(igniter, Path.join(package_path, "usage-rules.md")) do
[{package_name, package_path, nil}]
else
[]
end
# Check for sub-rules in usage_rules/ folder
sub_rules =
find_available_sub_rules(igniter, package_path)
|> Enum.map(fn sub_rule_name ->
{package_name, package_path, sub_rule_name}
end)
main_rules ++ sub_rules
end)
igniter
|> Igniter.add_notice(
"Found #{length(all_packages_with_rules)} dependencies with usage rules"
)
|> then(fn igniter ->
Enum.reduce(all_package_rules, igniter, fn {name, _path, sub_rule}, acc ->
case sub_rule do
nil ->
Igniter.add_notice(acc, "Including usage rules for: #{name}")
sub_rule_name ->
Igniter.add_notice(acc, "Including usage rules for: #{name}:#{sub_rule_name}")
end
end)
end)
|> generate_usage_rules_file(
all_package_rules,
link_to_folder,
link_style,
inline_specs,
remove_missing
)
end
defp handle_list_option(igniter, all_deps, link_to_folder, link_style, inline_specs) do
packages_with_rules = get_packages_with_usage_rules(igniter, all_deps)
if Enum.empty?(packages_with_rules) do
Igniter.add_notice(igniter, "No packages found with usage-rules.md files")
else
file_path = igniter.args.positional[:file]
if file_path do
list_packages_with_file_comparison(
igniter,
packages_with_rules,
file_path,
link_to_folder,
link_style,
inline_specs
)
else
list_packages_without_comparison(igniter, packages_with_rules)
end
end
end
defp handle_specific_packages(
igniter,
all_deps,
provided_packages,
link_to_folder,
link_style,
inline_specs,
remove_missing
) do
# Expand wildcard specs first
expanded_packages = expand_wildcard_specs(igniter, all_deps, provided_packages)
# Parse and process each package spec
package_rules =
expanded_packages
|> Enum.flat_map(fn package_spec ->
{package_name, sub_rule} = parse_package_spec(package_spec)
case Enum.find(all_deps, fn {name, _path} -> name == package_name end) do
{_name, package_path} ->
case sub_rule do
nil ->
# Standard package without sub-rule - check for usage-rules.md
usage_rules_path = Path.join(package_path, "usage-rules.md")
if Igniter.exists?(igniter, usage_rules_path) do
[{package_name, package_path, nil}]
else
[]
end
sub_rule_name ->
# Sub-rule specified - check for usage-rules/sub_rule.md
sub_rule_path = Path.join([package_path, "usage-rules", "#{sub_rule_name}.md"])
if Igniter.exists?(igniter, sub_rule_path) do
[{package_name, package_path, sub_rule_name}]
else
[]
end
end
nil ->
[]
end
end)
igniter
|> generate_usage_rules_file(
package_rules,
link_to_folder,
link_style,
inline_specs,
remove_missing
)
end
defp handle_remove_packages(igniter, provided_packages, link_to_folder) do
file_path = igniter.args.positional[:file]
if Igniter.exists?(igniter, file_path) do
remove_packages_from_file(igniter, file_path, provided_packages, link_to_folder)
else
Igniter.add_issue(igniter, "File #{file_path} does not exist")
end
end
defp handle_folder_only_option(
igniter,
all_deps,
provided_packages,
folder_name,
all_option,
merge_sub_rules
) do
packages_to_process =
if all_option do
all_packages_with_rules = get_packages_with_usage_rules(igniter, all_deps)
all_packages_with_rules
|> Enum.flat_map(fn {package_name, package_path} ->
main_rules =
if Igniter.exists?(igniter, Path.join(package_path, "usage-rules.md")) do
[{package_name, package_path, nil}]
else
[]
end
sub_rules =
find_available_sub_rules(igniter, package_path)
|> Enum.map(fn sub_rule_name ->
{package_name, package_path, sub_rule_name}
end)
main_rules ++ sub_rules
end)
else
expanded_packages = expand_wildcard_specs(igniter, all_deps, provided_packages)
expanded_packages
|> Enum.flat_map(fn package_spec ->
{package_name, sub_rule} = parse_package_spec(package_spec)
case Enum.find(all_deps, fn {name, _path} -> name == package_name end) do
{_name, package_path} ->
case sub_rule do
nil ->
usage_rules_path = Path.join(package_path, "usage-rules.md")
if Igniter.exists?(igniter, usage_rules_path) do
[{package_name, package_path, nil}]
else
[]
end
sub_rule_name ->
sub_rule_path =
Path.join([package_path, "usage-rules", "#{sub_rule_name}.md"])
if Igniter.exists?(igniter, sub_rule_path) do
[{package_name, package_path, sub_rule_name}]
else
[]
end
end
nil ->
[]
end
end)
end
if merge_sub_rules do
# Merge sub-rules into single files per package
packages_to_process
|> Enum.group_by(fn {name, _path, _sub_rule} -> name end)
|> Enum.reduce(igniter, fn {package_name, rules}, acc ->
target_file_name = "#{package_name}.md"
package_file_path = Path.join(folder_name, target_file_name)
package_contents =
rules
|> Enum.map(fn {_name, path, sub_rule} ->
usage_rules_path =
case sub_rule do
nil ->
Path.join(path, "usage-rules.md")
sub_rule_name ->
Path.join([path, "usage-rules", "#{sub_rule_name}.md"])
end
content =
case Rewrite.source(acc.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
section_name =
case sub_rule do
nil -> to_string(package_name)
sub_rule_name -> "#{package_name}:#{sub_rule_name}"
end
description =
case sub_rule do
nil -> get_package_description(package_name)
_ -> ""
end
description_part = if description == "", do: "", else: "_#{description}_\n\n"
{section_name,
"<!-- #{section_name}-start -->\n" <>
"## #{section_name} usage\n" <>
description_part <>
content <>
"\n<!-- #{section_name}-end -->"}
end)
all_rules_content = Enum.map_join(package_contents, "\n", &elem(&1, 1))
full_contents_for_new_file = all_rules_content
section_names =
rules
|> Enum.map_join(", ", fn {_name, _path, sub_rule} ->
case sub_rule do
nil -> to_string(package_name)
sub_rule_name -> "#{package_name}:#{sub_rule_name}"
end
end)
acc
|> Igniter.add_notice(
"Extracting and merging usage rules for: #{section_names} -> #{package_file_path}"
)
|> Igniter.create_or_update_file(
package_file_path,
full_contents_for_new_file,
fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
if current_contents == "" do
# New file, just concatenate all package contents
Enum.map_join(package_contents, "\n", &elem(&1, 1))
else
# Existing file, update each section
Enum.reduce(package_contents, current_contents, fn {name, package_content},
acc_content ->
case String.split(acc_content, [
"<!-- #{name}-start -->\n",
"\n<!-- #{name}-end -->"
]) do
[prelude, _, postlude] ->
prelude <> package_content <> postlude
_ ->
acc_content <> "\n" <> package_content
end
end)
end
Rewrite.Source.update(source, :content, new_content)
end
)
end)
else
# Create separate files for each package and sub-rule
Enum.reduce(packages_to_process, igniter, fn {name, path, sub_rule}, acc ->
{usage_rules_path, target_file_name} =
case sub_rule do
nil ->
{Path.join(path, "usage-rules.md"), "#{name}.md"}
sub_rule_name ->
{Path.join([path, "usage-rules", "#{sub_rule_name}.md"]),
"#{name}_#{sub_rule_name}.md"}
end
content =
case Rewrite.source(acc.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
package_file_path = Path.join(folder_name, target_file_name)
section_name =
case sub_rule do
nil -> to_string(name)
sub_rule_name -> "#{name}:#{sub_rule_name}"
end
acc
|> Igniter.add_notice(
"Extracting usage rules for: #{section_name} -> #{package_file_path}"
)
|> Igniter.create_or_update_file(
package_file_path,
content,
fn source ->
Rewrite.Source.update(source, :content, content)
end
)
end)
end
end
defp get_packages_with_usage_rules(igniter, all_deps) do
Enum.filter(all_deps, fn
{_name, path} when is_binary(path) and path != "" ->
Igniter.exists?(igniter, Path.join(path, "usage-rules.md")) ||
Igniter.exists?(igniter, Path.join(path, "usage-rules"))
_ ->
false
end)
end
defp list_packages_with_file_comparison(
igniter,
packages_with_rules,
file_path,
link_to_folder,
link_style,
inline_specs
) do
current_file_content = read_current_file_content(igniter, file_path)
Enum.reduce(packages_with_rules, igniter, fn {name, path}, acc ->
# Ensure name is a string
name = to_string(name)
# Check for main package and sub-rules
usage_rules_path = Path.join(path, "usage-rules.md")
has_main = Igniter.exists?(acc, usage_rules_path)
sub_rules = find_available_sub_rules(igniter, path)
# Build the notice message
message_parts = []
# Add main package status if exists
message_parts =
if has_main do
package_rules_content =
case Rewrite.source(acc.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
status =
get_package_status_in_file(
acc,
name,
package_rules_content,
current_file_content,
link_to_folder,
link_style,
inline_specs
)
[" #{name} - #{colorize_status(status)}" | message_parts]
else
message_parts
end
# Add sub-rules status if any
message_parts =
if Enum.any?(sub_rules) do
sub_rule_lines =
sub_rules
|> Enum.filter(fn sub_rule_name ->
sub_rule_path = Path.join([path, "usage-rules", "#{sub_rule_name}.md"])
Igniter.exists?(acc, sub_rule_path)
end)
|> Enum.map(fn sub_rule_name ->
sub_rule_path = Path.join([path, "usage-rules", "#{sub_rule_name}.md"])
sub_rule_content =
case Rewrite.source(acc.rewrite, sub_rule_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(sub_rule_path)
end
sub_status =
get_package_status_in_file(
acc,
"#{name}:#{sub_rule_name}",
sub_rule_content,
current_file_content,
link_to_folder,
link_style,
inline_specs
)
" #{name}:#{sub_rule_name} - #{colorize_status(sub_status)}"
end)
message_parts ++ sub_rule_lines
else
message_parts
end
# Add the combined notice if we have anything to show
if Enum.any?(message_parts) do
# For file comparison, don't add the standalone package name
full_message = Enum.join([name | Enum.reverse(message_parts)], "\n")
Igniter.add_notice(acc, full_message)
else
acc
end
end)
end
defp list_packages_without_comparison(igniter, packages_with_rules) do
Enum.reduce(packages_with_rules, igniter, fn {package_name, package_path}, acc ->
# Ensure name is a string
package_name = to_string(package_name)
# Check for main package and sub-rules
usage_rules_path = Path.join(package_path, "usage-rules.md")
has_main = Igniter.exists?(acc, usage_rules_path)
available_sub_rules = find_available_sub_rules(igniter, package_path)
# Build message lines for this specific package
lines = []
# Add main package line if it exists
lines =
if has_main do
[" #{package_name} - #{IO.ANSI.green()}has usage rules#{IO.ANSI.green()}"] ++ lines
else
lines
end
# Add sub-rules lines if they exist
lines =
if Enum.any?(available_sub_rules) do
valid_sub_rules =
available_sub_rules
|> Enum.filter(fn sub_rule_name ->
sub_rule_path = Path.join([package_path, "usage-rules", "#{sub_rule_name}.md"])
Igniter.exists?(acc, sub_rule_path)
end)
|> Enum.sort()
sub_rule_lines =
Enum.map(valid_sub_rules, fn sub_rule_name ->
" #{package_name}:#{sub_rule_name} - #{IO.ANSI.green()}has sub-rule#{IO.ANSI.green()}"
end)
lines ++ sub_rule_lines
else
lines
end
# Add notice for this package if we have anything to show
if Enum.any?(lines) do
message = Enum.join([package_name | Enum.reverse(lines)], "\n")
Igniter.add_notice(acc, message)
else
acc
end
end)
end
defp read_current_file_content(igniter, file_path) do
if Igniter.exists?(igniter, file_path) do
case Rewrite.source(igniter.rewrite, file_path) do
{:ok, source} ->
Rewrite.Source.get(source, :content)
{:error, _} ->
case File.read(file_path) do
{:ok, content} -> content
{:error, _} -> ""
end
end
else
""
end
end
defp generate_usage_rules_file(
igniter,
packages,
link_to_folder,
link_style,
inline_specs,
remove_missing
) do
if link_to_folder do
generate_usage_rules_with_folder_links(
igniter,
packages,
link_to_folder,
link_style,
inline_specs,
remove_missing
)
else
generate_usage_rules_inline(igniter, packages, remove_missing)
end
end
defp extract_existing_package_names(content) do
# Extract package names from <!-- package-name-start --> markers
Regex.scan(~r/<!-- ([^-]+(?::[^-]+)?)-start -->/, content, capture: :all_but_first)
|> Enum.map(fn [name] -> name end)
end
defp remove_missing_packages_from_content(content, packages_to_keep) do
existing_packages = extract_existing_package_names(content)
packages_to_remove = existing_packages -- packages_to_keep
Enum.reduce(packages_to_remove, content, fn package_name, acc ->
case String.split(acc, [
"<!-- #{package_name}-start -->\n",
"\n<!-- #{package_name}-end -->"
]) do
[prelude, _, postlude] ->
# Remove the package section, keeping proper spacing
prelude <> postlude
_ ->
acc
end
end)
end
defp update_usage_rules_content(current_packages_contents, package_contents, remove_missing) do
# Apply remove_missing logic if requested
cleaned_content =
if remove_missing do
packages_to_keep = Enum.map(package_contents, fn {name, _} -> name end)
remove_missing_packages_from_content(current_packages_contents, packages_to_keep)
else
current_packages_contents
end
Enum.reduce(package_contents, cleaned_content, fn {name, package_content}, acc ->
case String.split(acc, [
"<!-- #{name}-start -->\n",
"\n<!-- #{name}-end -->"
]) do
[prelude, _, postlude] ->
prelude <> package_content <> postlude
_ ->
acc <> "\n" <> package_content
end
end)
end
defp generate_usage_rules_inline(igniter, packages, remove_missing) do
package_contents =
packages
|> Enum.map(fn {name, path, sub_rule} ->
{usage_rules_path, section_name} =
case sub_rule do
nil ->
{Path.join(path, "usage-rules.md"), to_string(name)}
sub_rule_name ->
{Path.join([path, "usage-rules", "#{sub_rule_name}.md"]),
"#{name}:#{sub_rule_name}"}
end
content =
case Rewrite.source(igniter.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
description =
case sub_rule do
nil -> get_package_description(name)
# Sub-rules don't get package descriptions
_ -> ""
end
description_part = if description == "", do: "", else: "_#{description}_\n\n"
{section_name,
"<!-- #{section_name}-start -->\n" <>
"## #{section_name} usage\n" <>
description_part <>
content <>
"\n<!-- #{section_name}-end -->"}
end)
all_rules_content = Enum.map_join(package_contents, "\n", &elem(&1, 1))
full_contents_for_new_file =
"<!-- usage-rules-start -->\n" <>
usage_rules_header() <>
"\n" <>
all_rules_content <>
"\n<!-- usage-rules-end -->"
Igniter.create_or_update_file(
igniter,
igniter.args.positional[:file],
full_contents_for_new_file,
fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
case String.split(current_contents, [
"<!-- usage-rules-start -->\n",
"\n<!-- usage-rules-end -->"
]) do
[prelude, current_packages_contents, postlude] ->
update_usage_rules_content(
current_packages_contents,
package_contents,
remove_missing
)
|> then(fn content ->
# Ensure header is present
content_with_header =
if String.contains?(content, "<!-- usage-rules-header -->") do
content
else
usage_rules_header() <> "\n" <> content
end
prelude <>
"<!-- usage-rules-start -->\n" <>
content_with_header <>
"\n<!-- usage-rules-end -->" <>
postlude
end)
_ ->
current_contents <>
"\n<!-- usage-rules-start -->\n" <>
usage_rules_header() <>
"\n" <>
all_rules_content <>
"\n<!-- usage-rules-end -->\n"
end
Rewrite.Source.update(source, :content, new_content)
end
)
end
defp generate_usage_rules_with_folder_links(
igniter,
packages,
folder_name,
link_style,
inline_specs,
remove_missing
) do
# Create individual files for each package in the folder (unless folder is "deps")
igniter =
if folder_name == "deps" do
igniter
else
Enum.reduce(packages, igniter, fn {name, path, sub_rule}, acc ->
# Skip creating files for packages that should be inlined
if should_inline_package?(name, sub_rule, inline_specs) do
acc
else
{usage_rules_path, target_file_name} =
case sub_rule do
nil ->
{Path.join(path, "usage-rules.md"), "#{name}.md"}
sub_rule_name ->
{Path.join([path, "usage-rules", "#{sub_rule_name}.md"]),
"#{name}_#{sub_rule_name}.md"}
end
content =
case Rewrite.source(acc.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
package_file_path = Path.join(folder_name, target_file_name)
Igniter.create_or_update_file(
acc,
package_file_path,
content,
fn source ->
Rewrite.Source.update(source, :content, content)
end
)
end
end)
end
# Then, create the main file with links or inline content
package_contents =
packages
|> Enum.map(fn {name, path, sub_rule} ->
section_name =
case sub_rule do
nil -> to_string(name)
sub_rule_name -> "#{name}:#{sub_rule_name}"
end
description =
case sub_rule do
nil -> get_package_description(name)
# Sub-rules don't get package descriptions
_ -> ""
end
description_part = if description == "", do: "", else: "_#{description}_\n\n"
content =
if should_inline_package?(name, sub_rule, inline_specs) do
# Inline the actual content
{usage_rules_path, _} =
case sub_rule do
nil ->
{Path.join(path, "usage-rules.md"), "#{name}.md"}
sub_rule_name ->
{Path.join([path, "usage-rules", "#{sub_rule_name}.md"]),
"#{name}_#{sub_rule_name}.md"}
end
case Rewrite.source(igniter.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
else
# Create link
case sub_rule do
nil ->
case {link_style, folder_name} do
{"at", "deps"} -> "@deps/#{name}/usage-rules.md"
{"at", _} -> "@#{folder_name}/#{name}.md"
{_, "deps"} -> "[#{name} usage rules](deps/#{name}/usage-rules.md)"
_ -> "[#{name} usage rules](#{folder_name}/#{name}.md)"
end
sub_rule_name ->
case {link_style, folder_name} do
{"at", "deps"} ->
"@deps/#{name}/usage-rules/#{sub_rule_name}.md"
{"at", _} ->
"@#{folder_name}/#{name}_#{sub_rule_name}.md"
{_, "deps"} ->
"[#{section_name} usage rules](deps/#{name}/usage-rules/#{sub_rule_name}.md)"
_ ->
"[#{section_name} usage rules](#{folder_name}/#{name}_#{sub_rule_name}.md)"
end
end
end
{section_name,
"<!-- #{section_name}-start -->\n" <>
"## #{section_name} usage\n" <>
description_part <>
content <>
"\n<!-- #{section_name}-end -->"}
end)
all_rules_content = Enum.map_join(package_contents, "\n", &elem(&1, 1))
full_contents_for_new_file =
"<!-- usage-rules-start -->\n" <>
usage_rules_header() <>
"\n" <>
all_rules_content <>
"\n<!-- usage-rules-end -->"
Igniter.create_or_update_file(
igniter,
igniter.args.positional[:file],
full_contents_for_new_file,
fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
case String.split(current_contents, [
"<!-- usage-rules-start -->\n",
"\n<!-- usage-rules-end -->"
]) do
[prelude, current_packages_contents, postlude] ->
update_usage_rules_content(
current_packages_contents,
package_contents,
remove_missing
)
|> then(fn content ->
# Ensure header is present
content_with_header =
if String.contains?(content, "<!-- usage-rules-header -->") do
content
else
usage_rules_header() <> "\n" <> content
end
prelude <>
"<!-- usage-rules-start -->\n" <>
content_with_header <>
"\n<!-- usage-rules-end -->" <>
postlude
end)
_ ->
current_contents <>
"\n<!-- usage-rules-start -->\n" <>
usage_rules_header() <>
"\n" <>
all_rules_content <>
"\n<!-- usage-rules-end -->\n"
end
Rewrite.Source.update(source, :content, new_content)
end
)
end
defp remove_packages_from_file(igniter, file_path, packages_to_remove, link_to_folder) do
# If using link-to-folder, also remove the individual package files
igniter =
if link_to_folder do
Enum.reduce(packages_to_remove, igniter, fn package_name, acc ->
package_file_path = Path.join(link_to_folder, "#{package_name}.md")
if Igniter.exists?(acc, package_file_path) do
Igniter.rm(acc, package_file_path)
else
acc
end
end)
else
igniter
end
Igniter.update_file(igniter, file_path, fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
Enum.reduce(packages_to_remove, current_contents, fn package_name, acc ->
remove_package_from_content(acc, package_name)
end)
|> clean_empty_package_rules_section()
Rewrite.Source.update(source, :content, new_content)
end)
end
defp remove_package_from_content(content, package_name) do
package_start_marker = "<!-- #{package_name}-start -->\n"
package_end_marker = "\n<!-- #{package_name}-end -->"
case String.split(content, [package_start_marker, package_end_marker]) do
[prelude, _package_content, postlude] ->
# Remove the package section completely, handling newlines properly
cleaned_prelude = String.trim_trailing(prelude)
cleaned_postlude = String.trim_leading(postlude)
if cleaned_postlude == "" do
cleaned_prelude
else
cleaned_prelude <> "\n" <> cleaned_postlude
end
_ ->
# Package not found, return content unchanged
content
end
end
defp clean_empty_package_rules_section(content) do
# Handle both cases: empty section and section with only whitespace
case String.split(content, "<!-- usage-rules-start -->") do
[prelude, remainder] ->
case String.split(remainder, "<!-- usage-rules-end -->") do
[package_section, postlude] ->
# Check if package section is empty or only contains whitespace
if String.trim(package_section) == "" do
# Remove the entire usage-rules section if empty
cleaned_prelude = String.trim_trailing(prelude)
cleaned_postlude = String.trim_leading(postlude)
if cleaned_postlude == "" do
cleaned_prelude
else
cleaned_prelude <> "\n\n" <> cleaned_postlude
end
else
# Keep the usage-rules section
prelude <>
"<!-- usage-rules-start -->" <>
package_section <> "<!-- usage-rules-end -->" <> postlude
end
_ ->
# No end marker found
content
end
_ ->
# No usage-rules section found
content
end
end
defp get_package_status_in_file(
igniter,
name,
package_rules_content,
file_content,
link_to_folder,
link_style,
inline_specs
) do
package_start_marker = "<!-- #{name}-start -->"
package_end_marker = "<!-- #{name}-end -->"
case String.split(file_content, [package_start_marker, package_end_marker]) do
[_, current_package_content, _] ->
# Package is present in file, check if content matches
{package_name, sub_rule} =
case String.split(name, ":", parts: 2) do
[l, r] -> {l, r}
[l] -> {l, nil}
end
expected_content =
if link_to_folder && !should_inline_package?(package_name, sub_rule, inline_specs) do
# Generate the correct link format based on link_style
link_content =
case sub_rule do
nil ->
case {link_style, link_to_folder} do
{"at", "deps"} ->
"@deps/#{package_name}/usage-rules.md"
{"at", _} ->
"@#{link_to_folder}/#{package_name}.md"
{_, "deps"} ->
"[#{package_name} usage rules](deps/#{package_name}/usage-rules.md)"
_ ->
"[#{package_name} usage rules](#{link_to_folder}/#{package_name}.md)"
end
sub_rule_name ->
case {link_style, link_to_folder} do
{"at", "deps"} ->
"@deps/#{package_name}/usage-rules/#{sub_rule_name}.md"
{"at", _} ->
"@#{link_to_folder}/#{package_name}_#{sub_rule_name}.md"
{_, "deps"} ->
"[#{name} usage rules](deps/#{package_name}/usage-rules/#{sub_rule_name}.md)"
_ ->
"[#{name} usage rules](#{link_to_folder}/#{package_name}_#{sub_rule_name}.md)"
end
end
"\n## #{name} usage\n#{link_content}\n"
else
"\n## #{name} usage\n" <> package_rules_content <> "\n"
end
if String.trim(current_package_content) == String.trim(expected_content) do
# If using link-to-folder, also check the linked file exists and matches
if link_to_folder do
check_linked_file_status(igniter, name, package_rules_content, link_to_folder)
else
"present"
end
else
"stale"
end
_ ->
# Package not found in file
"missing"
end
end
defp check_linked_file_status(igniter, name, expected_content, link_to_folder) do
# Generate the correct file path based on package name and sub-rule
{package_name, sub_rule} =
case String.split(name, ":", parts: 2) do
[l, r] -> {l, r}
[l] -> {l, nil}
end
linked_file_path =
case sub_rule do
nil ->
case link_to_folder do
"deps" -> Path.join(["deps", package_name, "usage-rules.md"])
_ -> Path.join(link_to_folder, "#{package_name}.md")
end
sub_rule_name ->
case link_to_folder do
"deps" ->
Path.join(["deps", package_name, "usage-rules", "#{sub_rule_name}.md"])
_ ->
Path.join(link_to_folder, "#{package_name}_#{sub_rule_name}.md")
end
end
if Igniter.exists?(igniter, linked_file_path) do
actual_content =
case Rewrite.source(igniter.rewrite, linked_file_path) do
{:ok, source} ->
Rewrite.Source.get(source, :content)
{:error, _} ->
if File.exists?(linked_file_path) do
File.read!(linked_file_path)
else
""
end
end
if String.trim(actual_content) == String.trim(expected_content) do
"present"
else
"stale"
end
else
"stale"
end
end
defp colorize_status("present"), do: "#{IO.ANSI.green()}present#{IO.ANSI.green()}"
defp colorize_status("stale"), do: "#{IO.ANSI.yellow()}stale#{IO.ANSI.green()}"
defp colorize_status("missing"), do: "#{IO.ANSI.red()}missing#{IO.ANSI.green()}"
end
else
defmodule Mix.Tasks.UsageRules.Sync do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'usage_rules.sync' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end