defmodule Mastomation.Parser do @moduledoc """ Centralized Optimus parser for Mastomation commands. """ @default_delay_ms 60_000 @doc """ Parses CLI arguments and returns an Optimus parse result. """ @spec parse([String.t()]) :: term() def parse(args) do args |> normalize_args() |> then(&Optimus.parse(parser(), &1)) end @doc """ Parses CLI arguments and raises on invalid input. """ @spec parse!([String.t()]) :: term() def parse!(args) do args |> normalize_args() |> then(&Optimus.parse!(parser(), &1)) end @doc """ Prints parser output/help and converts parser halts into tuples. """ @spec print_and_parse!([String.t()]) :: :ok | {:halt, non_neg_integer()} def print_and_parse!(args) do _ = args |> normalize_args() |> then(&Optimus.parse!(parser(), &1, fn code -> throw({:optimus_halt, code}) end)) :ok catch {:optimus_halt, code} -> {:halt, code} end @doc """ Returns the Optimus parser configuration. """ @spec parser() :: term() def parser do Optimus.new!( name: "mastomation", about: "Inspect, export, and delete your Mastodon posts with safety defaults.", allow_unknown_args: false, parse_double_dash: true, subcommands: [ delete: [ name: "delete", about: "Delete posts permanently. Use --dry-run first. Subcommands: all, thread .", subcommands: [ all: [ name: "all", about: "Delete timeline statuses (excluding pinned/bookmarked/favourited by default).", options: common_auth_options() ++ [ delay_ms: [ value_name: "MILLISECONDS", long: "--delay-ms", short: "-d", help: "Delay between deletions in milliseconds (default: 60000)", parser: :integer, default: @default_delay_ms, required: false ], keyword: [ value_name: "KEYWORD", long: "--keyword", help: "Delete only statuses containing this keyword (repeatable)", parser: :string, multiple: true, default: [] ] ], flags: common_delete_flags() ], thread: [ name: "thread", about: "Delete your posts in a specific thread: delete thread .", args: [ source: [ value_name: "STATUS_ID_OR_URL", help: "Thread source status ID or full URL", required: true, parser: :string ] ], options: common_auth_options() ++ [ delay_ms: [ value_name: "MILLISECONDS", long: "--delay-ms", short: "-d", help: "Delay between thread deletions in milliseconds (capped at 2000)", parser: :integer, default: @default_delay_ms, required: false ], keyword: [ value_name: "KEYWORD", long: "--keyword", help: "Delete only statuses containing this keyword (repeatable)", parser: :string, multiple: true, default: [] ] ], flags: common_delete_flags() ] ] ], thread: [ name: "thread", about: "Thread operations. Use `see` to inspect and `export` to write files.", subcommands: [ see: [ name: "see", about: "Print a joined same-author thread to the terminal.", args: [ source: [ value_name: "STATUS_ID_OR_URL", help: "Thread source status ID or full URL", required: true, parser: :string ] ], options: common_auth_options(), flags: [ dry_run: [ long: "--dry-run", help: "Explicit inspect mode (no files written)" ] ] ], export: [ name: "export", about: "Export joined thread markdown and media to ./export.", args: [ source: [ value_name: "STATUS_ID_OR_URL", help: "Thread source status ID or full URL", required: true, parser: :string ] ], options: common_auth_options(), flags: [ frontmatter: [ long: "--frontmatter", help: "Include YAML frontmatter in exported markdown" ] ] ] ] ], notes: [ name: "notes", about: "Private profile notes operations.", subcommands: [ download: [ name: "download", about: "Download notes for followers and followed accounts.", options: common_auth_options() ++ [ path: [ value_name: "FILE_PATH", long: "--path", help: "Where to store notes JSON (default: XDG data path)", parser: :string, default: nil, required: false ] ] ], search: [ name: "search", about: "Search locally downloaded notes.", args: [ query: [ value_name: "QUERY", help: "Search query text", required: false, parser: :string ] ], options: [ path: [ value_name: "FILE_PATH", long: "--path", help: "Path to notes JSON file", parser: :string, default: nil, required: false ], term: [ value_name: "TERM", long: "--term", help: "Search term (repeatable)", parser: :string, multiple: true, default: [] ] ] ] ] ] ] ) end @spec common_auth_options() :: keyword() defp common_auth_options do [ instance_url: [ value_name: "INSTANCE_URL", short: "-i", long: "--instance-url", help: "Mastodon instance URL (fallback: MASTODON_INSTANCE_URL)", parser: :string, default: nil, required: false ], access_token: [ value_name: "ACCESS_TOKEN", short: "-t", long: "--access-token", help: "Access token (fallback: MASTODON_ACCESS_TOKEN)", parser: :string, default: nil, required: false ], override_instance_url: [ value_name: "INSTANCE_URL", long: "--override-instance-url", help: "Force instance URL for this run (highest priority)", parser: :string, default: nil, required: false ], override_access_token: [ value_name: "ACCESS_TOKEN", long: "--override-access-token", help: "Force access token for this run (highest priority)", parser: :string, default: nil, required: false ] ] end @spec common_delete_flags() :: keyword() defp common_delete_flags do [ dry_run: [ long: "--dry-run", help: "Only print what would be deleted; do not delete" ], no_backup: [ long: "--no-backup", help: "Disable backups (backups are ON by default)" ], frontmatter: [ long: "--frontmatter", help: "Include frontmatter in markdown backups" ] ] end # Normalizes legacy flags and help invocation forms. @spec normalize_args([String.t()]) :: [String.t()] defp normalize_args(args) do args |> normalize_legacy_command_forms() |> Enum.map(fn "--dry_run" -> "--dry-run" "--instance_url" -> "--instance-url" "--access_token" -> "--access-token" "--delay_ms" -> "--delay-ms" other -> other end) |> normalize_subcommand_help() end @spec normalize_legacy_command_forms([String.t()]) :: [String.t()] defp normalize_legacy_command_forms(["thread", subcommand | _] = args) when subcommand in ["see", "export", "--help", "-h"] do args end defp normalize_legacy_command_forms(["thread", source]) when source not in ["--help", "-h"] do ["thread", "see", source] end defp normalize_legacy_command_forms(["thread" | rest]) do ["thread", "see" | rest] end defp normalize_legacy_command_forms(["delete", subcommand | _] = args) when subcommand in ["all", "thread", "--help", "-h"] do args end defp normalize_legacy_command_forms(["delete", "--thread-source", source | rest]) do ["delete", "thread", source | rest] end defp normalize_legacy_command_forms(["delete"]) do ["help", "delete"] end defp normalize_legacy_command_forms(["delete" | rest]) do ["delete", "all" | rest] end defp normalize_legacy_command_forms(args), do: args # Rewrites subcommand help into Optimus-compatible help commands. @spec normalize_subcommand_help([String.t()]) :: [String.t()] defp normalize_subcommand_help(["thread", "--help"]), do: ["help", "thread"] defp normalize_subcommand_help(["thread", "-h"]), do: ["help", "thread"] defp normalize_subcommand_help(["thread", "see", "--help"]), do: ["help", "thread", "see"] defp normalize_subcommand_help(["thread", "see", "-h"]), do: ["help", "thread", "see"] defp normalize_subcommand_help(["thread", "export", "--help"]), do: ["help", "thread", "export"] defp normalize_subcommand_help(["thread", "export", "-h"]), do: ["help", "thread", "export"] defp normalize_subcommand_help(["delete", "--help"]), do: ["help", "delete"] defp normalize_subcommand_help(["delete", "-h"]), do: ["help", "delete"] defp normalize_subcommand_help(["delete", "all", "--help"]), do: ["help", "delete", "all"] defp normalize_subcommand_help(["delete", "all", "-h"]), do: ["help", "delete", "all"] defp normalize_subcommand_help(["delete", "thread", "--help"]), do: ["help", "delete", "thread"] defp normalize_subcommand_help(["delete", "thread", "-h"]), do: ["help", "delete", "thread"] defp normalize_subcommand_help(["notes", "--help"]), do: ["help", "notes"] defp normalize_subcommand_help(["notes", "-h"]), do: ["help", "notes"] defp normalize_subcommand_help(["notes", "download", "--help"]), do: ["help", "notes", "download"] defp normalize_subcommand_help(["notes", "download", "-h"]), do: ["help", "notes", "download"] defp normalize_subcommand_help(["notes", "search", "--help"]), do: ["help", "notes", "search"] defp normalize_subcommand_help(["notes", "search", "-h"]), do: ["help", "notes", "search"] defp normalize_subcommand_help(args), do: args end