defmodule Mix.Tasks.Log.Search do @moduledoc """ Search and filter log entries from files. ## Usage mix log.search [options] ## Options --level Filter by log level (info, warn, error, debug) --format Filter by log format (morgan, java, elixir, nginx) --source Filter by source (substring match) --grep Filter by message content (substring match, case-insensitive) --limit Maximum number of results (default: 50) ## Examples mix log.search logs/app.log mix log.search logs/app.log --level error mix log.search logs/app.log --format java --level warn mix log.search logs/ --grep "timeout" --limit 10 """ @shortdoc "Search and filter log entries" use Mix.Task alias TelemetryData.FileReader @impl Mix.Task def run(args) do {opts, positional, _} = OptionParser.parse(args, strict: [level: :string, format: :string, source: :string, grep: :string, limit: :integer] ) path = List.first(positional) || "logs/" limit = Keyword.get(opts, :limit, 50) entries = load_entries(path) filtered = apply_filters(entries, opts) total = length(filtered) display = Enum.take(filtered, limit) Mix.shell().info("\n#{IO.ANSI.bright()}Found #{total} entries#{IO.ANSI.reset()} (showing #{min(limit, total)})\n") Enum.each(display, &print_entry/1) if total > limit do Mix.shell().info("\n... #{total - limit} more entries (use --limit to see more)") end end defp load_entries(path) do if File.dir?(path) do case FileReader.read_dir(path) do {:ok, entries} -> entries {:error, reason} -> Mix.raise("Failed to read directory #{path}: #{reason}") end else case FileReader.read_file(path) do {:ok, entries} -> entries {:error, reason} -> Mix.raise("Failed to read file #{path}: #{reason}") end end end defp apply_filters(entries, opts) do entries |> maybe_filter(:level, opts[:level]) |> maybe_filter(:format, opts[:format]) |> maybe_filter(:source, opts[:source]) |> maybe_filter(:grep, opts[:grep]) end defp maybe_filter(entries, _key, nil), do: entries defp maybe_filter(entries, :level, level) do level = String.downcase(level) Enum.filter(entries, &(&1.level == level)) end defp maybe_filter(entries, :format, format) do format = String.to_existing_atom(format) Enum.filter(entries, &(&1.format == format)) end defp maybe_filter(entries, :source, source) do source_down = String.downcase(source) Enum.filter(entries, fn entry -> entry.source && String.contains?(String.downcase(entry.source), source_down) end) end defp maybe_filter(entries, :grep, pattern) do pattern_down = String.downcase(pattern) Enum.filter(entries, fn entry -> String.contains?(String.downcase(entry.message), pattern_down) end) end defp print_entry(entry) do level_tag = colorize_level(entry.level) format_tag = IO.ANSI.cyan() <> "[#{entry.format}]" <> IO.ANSI.reset() source = if entry.source, do: IO.ANSI.faint() <> " #{entry.source}" <> IO.ANSI.reset(), else: "" Mix.shell().info("#{level_tag} #{format_tag}#{source} #{entry.timestamp}") Mix.shell().info(" #{entry.message}\n") end defp colorize_level("error"), do: IO.ANSI.red() <> "[ERROR]" <> IO.ANSI.reset() defp colorize_level("warn"), do: IO.ANSI.yellow() <> "[WARN] " <> IO.ANSI.reset() defp colorize_level("warning"), do: IO.ANSI.yellow() <> "[WARN] " <> IO.ANSI.reset() defp colorize_level("info"), do: IO.ANSI.green() <> "[INFO] " <> IO.ANSI.reset() defp colorize_level("debug"), do: IO.ANSI.faint() <> "[DEBUG]" <> IO.ANSI.reset() defp colorize_level(other), do: "[#{String.upcase(other)}]" end