defmodule NPM.Monorepo do
@moduledoc """
Detects monorepo tooling and structure.
Identifies which monorepo tool is in use (npm workspaces, lerna,
turborepo, nx, rush) and provides workspace discovery.
"""
@doc """
Detects the monorepo tool in use.
"""
@spec detect(String.t()) :: [atom()]
def detect(project_dir \\ ".") do
checks = [
{:npm_workspaces, "package.json", &has_workspaces?/1},
{:lerna, "lerna.json", &File.exists?/1},
{:turborepo, "turbo.json", &File.exists?/1},
{:nx, "nx.json", &File.exists?/1},
{:rush, "rush.json", &File.exists?/1},
{:pnpm_workspaces, "pnpm-workspace.yaml", &File.exists?/1}
]
Enum.flat_map(checks, fn {tool, file, checker} ->
path = Path.join(project_dir, file)
if checker.(path), do: [tool], else: []
end)
end
@doc """
Returns the primary monorepo tool.
"""
@spec primary(String.t()) :: atom() | nil
def primary(project_dir \\ ".") do
detect(project_dir) |> List.first()
end
@doc """
Checks if the project is a monorepo.
"""
@spec monorepo?(String.t()) :: boolean()
def monorepo?(project_dir \\ ".") do
detect(project_dir) != []
end
@doc """
Returns monorepo metadata.
"""
@spec info(String.t()) :: map()
def info(project_dir \\ ".") do
tools = detect(project_dir)
%{
is_monorepo: tools != [],
tools: tools,
primary: List.first(tools),
tool_count: length(tools)
}
end
@doc """
Formats detection results.
"""
@spec format_info(map()) :: String.t()
def format_info(%{is_monorepo: false}), do: "Not a monorepo."
def format_info(%{tools: tools, primary: primary}) do
tool_list = Enum.map_join(tools, ", ", &to_string/1)
"Monorepo detected (#{tool_list}), primary: #{primary}"
end
defp has_workspaces?(path) do
case File.read(path) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
is_list(data["workspaces"]) and data["workspaces"] != []
_ ->
false
end
rescue
_ -> false
end
end