/* ** Copyright (C) 2022-2026 Dirk-Jan C. Binnema ** ** This program is free software; you can redistribute it and/or modify it ** under the terms of the GNU General Public License as published by the ** Free Software Foundation; either version 3, or (at your option) any ** later version. ** ** This program is distributed in the hope that it will be useful, ** but WITHOUT ANY WARRANTY; without even the implied warranty of ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ** GNU General Public License for more details. ** ** You should have received a copy of the GNU General Public License ** along with this program; if not, write to the Free Software Foundation, ** Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ** */ /** * @brief Command-line handling * * Here we implement mu's command-line parsing based on the CLI11 library. * * At the time of writing, that library seems to be the best based on the criteria * that it supports the features we need and is available as a header-only * include. * * CLI11 can do quite a bit, and we're only scratching the surface here, * plan is to slowly improve things. */ #include #include #include #include #include #include #include #include #include #include #include #include "utils/mu-test-utils.hh" #include "mu-options.hh" #include "mu-script.hh" #ifdef USE_EMBEDDED_CLI11 #include "CLI11.hpp" #else #include "CLI/CLI.hpp" #endif using namespace Mu; /* * helpers */ namespace { /** * Element of an array of associated pair elements -- like an alist * entry; make the array with std::to_array so its size is deduced. */ template using AssocPair = std::pair; /** * Get the first value of the pair where the second element is @param s. * * @param p AssocPairs * @param s some second pair value * * @return the matching first pair value, or Nothing if not found. */ template constexpr Option to_first(const P& p, typename P::value_type::second_type s) { for (const auto& item: p) if (item.second == s) return item.first; return Nothing; } /** * Get the second value of the pair where the first element is @param f. * * @param p AssocPairs * @param f some first pair value * * @return the matching second pair value, or Nothing if not found. */ template constexpr Option to_second(const P& p, typename P::value_type::first_type f) { for (const auto& item: p) if (item.first == f) return item.second; return Nothing; } /** * Options-specific pair type that maps some enum to a pair */ template using InfoPair = AssocPair>; /** * Get map from string->type */ template std::unordered_map options_map(const IE& ie) { std::unordered_map map; for (auto&& item : ie) map.emplace(std::string{item.second.first}, item.first); return map; } /** * Get the list of option names (shortnames), in declaration order */ template std::vector options_names(const IE& ie) { std::vector names; names.reserve(ie.size()); for (auto&& item : ie) names.emplace_back(item.second.first); return names; } /** * Friendly error messages for some options (see add_choice_option, * add_number_option), used for rewriting CLI11's rather terse errors. */ struct FriendlyError { const CLI::App *sub; /**< subcommand owning the option */ const CLI::Option *opt; /**< the option */ std::string errmsg; /**< friendly error message */ }; std::vector friendly_errors; /** * Get a friendly error message for some error. * * @param err a parse error * * @return the error message */ std::string friendly_error(const CLI::ParseError& err) { const std::string what{err.what()}; for (const auto& info: friendly_errors) { if (!info.sub->parsed()) continue; const auto name{info.opt->get_name()}; // the CLI11 messages we know how to improve upon for (const auto pat: { ": 1 required", // no value given ": requires one of", // bad choice value ": Value " }) // bad numeric value if (what.starts_with(name + pat)) return info.errmsg; } return what; } /** * Add an option to sub that takes its value from a fixed set of choices. * * Like CLI11's CheckedTransformer, but with friendlier help and error * messages, e.g. * error: --format requires one of { plain, links }; default is plain * * @param sub command to add the option to * @param name option name(s), e.g. "--format,-o" * @param value target for the parsed value * @param help help text; the choices and default are appended * @param type_name name for the value in the help, e.g. "" * @param choices map from choice-name to value * @param choice_names the choice names; displayed in alphabetical order * @param default_name name of the default choice; must occur in @p choices * * @return the newly added option */ template CLI::Option* add_choice_option(CLI::App& sub, const std::string& name, T& value, const std::string& help, const std::string& type_name, std::unordered_map choices, std::vector choice_names, const std::string& default_name) { std::ranges::sort(choice_names); const auto choices_str{"{ " + join(choice_names, ", ") + " }"}; auto opt = sub.add_option(name, value, mu_format("{}; one of {}; default is {}", help, choices_str, default_name)) ->type_name(type_name) ->default_val(choices.at(default_name)) ->default_str(default_name); // add the transform only after default_val(), so the default does not // go through the transform (CLI11 applies it at default_val() time). opt->transform([choices = std::move(choices), errmsg = mu_format("requires one of {}; default is {}", choices_str, default_name)] (const std::string& val) -> std::string { if (const auto it = choices.find(val); it != choices.end()) return std::to_string( static_cast>(it->second)); // normally rewritten by friendly_choice_error() throw CLI::ValidationError{errmsg}; }); friendly_errors.push_back( {&sub, opt, mu_format("{} requires one of {}; default is {}", opt->get_name(), choices_str, default_name)}); return opt; } /** The kind of number a numeric option accepts */ enum struct Numeric { Positive, NonNegative }; /** * Add an option to @p sub that takes a numeric value. * * Like CLI11's PositiveNumber/NonNegativeNumber checks, but with * friendlier error messages, e.g. * error: --maxnum requires a positive number * * @param sub command to add the option to * @param name option name(s), e.g. "--maxnum,-n" * @param value target for the parsed value * @param help help text * @param type_name name for the value in the help, e.g. "" * @param kind the kind of number the option accepts * * @return the newly added option */ template CLI::Option* add_number_option(CLI::App& sub, const std::string& name, T& value, const std::string& help, const std::string& type_name, Numeric kind) { const auto positive{kind == Numeric::Positive}; auto opt = sub.add_option(name, value, help) ->type_name(type_name) ->check(positive ? CLI::PositiveNumber : CLI::NonNegativeNumber); friendly_errors.push_back( {&sub, opt, mu_format("{} requires a {} number", opt->get_name(), positive ? "positive" : "non-negative")}); return opt; } // transformers // Expand the path using wordexp const std::function ExpandPath = [](const std::string& path)->std::string { if (auto&& res{expand_path(path)}; !res) throw CLI::ValidationError{res.error().what()}; else return res.value(); }; // Canonicalize path const std::function CanonicalizePath = [](const std::string& path)->std::string { return canonicalize_filename(path); }; } // end of anonymous namespace /* * common */ template static void sub_crypto(CLI::App& sub, T& opts) { sub.add_flag("--auto-retrieve,-r", opts.auto_retrieve, "Attempt to automatically retrieve online keys"); sub.add_flag("--decrypt", opts.decrypt, "Attempt to decrypt"); } static void add_muhome_option(CLI::App& sub, Options& opts) { sub.add_option("--muhome", opts.muhome, "Specify alternative mu directory") ->envname("MUHOME") ->type_name("") ->transform(ExpandPath, "expand muhome path"); } /* * subcommands */ static void sub_add(CLI::App& sub, Options& opts) { sub.add_option("files", opts.add.files, "Path(s) to message files(s)") ->required(); } static void sub_cfind(CLI::App& sub, Options& opts) { using Format = Options::Cfind::Format; static constexpr auto FormatInfos = std::to_array>({ { Format::Plain, {"plain", "Plain output"} }, { Format::MuttAlias, {"mutt-alias", "Mutt alias"} }, { Format::MuttAddressBook, {"mutt-ab", "Mutt address book"}}, { Format::Wanderlust, {"wl", "Wanderlust"}}, { Format::OrgContact, {"org-contact", "org-contact"}}, { Format::Bbdb, {"bbdb", "Emacs BBDB"}}, { Format::Csv, {"csv", "comma-separated values"}}, { Format::Json, {"json", "format as json array"}}, }); add_choice_option(sub, "--format,-o", opts.cfind.format, "Output format", "", options_map(FormatInfos), options_names(FormatInfos), "plain"); sub.add_option("pattern", opts.cfind.rx_pattern, "Regular expression pattern to match"); sub.add_flag("--personal,-p", opts.cfind.personal, "Only show 'personal' contacts"); add_number_option(sub, "--after", opts.cfind.after, "Only show results after some timestamps", "", Numeric::NonNegative); add_number_option(sub, "--maxnum,-n", opts.cfind.maxnum, "Maximum number of results", "", Numeric::Positive); } static void sub_extract(CLI::App& sub, Options& opts) { sub_crypto(sub, opts.extract); sub.add_flag("--save-attachments,-a", opts.extract.save_attachments, "Save all attachments"); sub.add_flag("--save-all", opts.extract.save_all, "Save all MIME parts") ->excludes("--save-attachments"); sub.add_flag("--overwrite", opts.extract.overwrite, "Overwrite existing files"); sub.add_flag("--play", opts.extract.play, "Attempt to open the extracted parts"); sub.add_option("--parts", opts.extract.parts, "Save specific parts (comma-sep'd list)") ->type_name("")->delimiter(','); sub.add_option("--target-dir", opts.extract.targetdir, "Target directory for saving") ->type_name("") ->transform(ExpandPath, "expand target path") ->default_str("") ->default_val("."); sub.add_flag("--uncooked,-u", opts.extract.uncooked, "Avoid massaging extracted file-names"); // optional; otherwise use standard-input sub.add_option("message-path", opts.extract.message, "Path to message file") ->type_name(""); sub.add_option("--matches", opts.extract.filename_rx, "Regular expression for files to save") ->type_name("") ->excludes("--parts") ->excludes("--save-attachments") ->excludes("--save-all"); // backward compat: filename-rx as non-option sub.add_option("filename-rx", opts.extract.filename_rx, "Regular expression for files to save") ->type_name("") ->excludes("--parts") ->excludes("--save-attachments") ->excludes("--matches") ->excludes("--save-all"); } static void sub_fields(CLI::App& sub, Options& opts) { // nothing to do. } static void sub_find(CLI::App& sub, Options& opts) { using Format = Options::Find::Format; static constexpr auto FormatInfos = std::to_array>({ { Format::Plain, {"plain", "Plain output"} }, { Format::Links, {"links", "Maildir with symbolic links"} }, { Format::Xml, {"xml", "XML"} }, { Format::Sexp, {"sexp", "S-expressions"} }, { Format::Json, {"json", "JSON"} }, { Format::Json2, {"json2", "more idiomatic JSON"} }, }); sub.add_flag("--threads,-t", opts.find.threads, "Show message threads"); sub.add_flag("--skip-dups,-u", opts.find.skip_dups, "Show only one of messages with same message-id"); sub.add_flag("--include-related,-r", opts.find.include_related, "Include related messages in results"); sub.add_flag("--analyze,-a", opts.find.analyze, "Analyze the query"); add_choice_option(sub, "--format,-o", opts.find.format, "Output format", "", options_map(FormatInfos), options_names(FormatInfos), "plain"); add_number_option(sub, "--maxnum,-n", opts.find.maxnum, "Maximum number of results", "", Numeric::Positive); sub.add_option("--fields,-f", opts.find.fields, "Fields to display") ->default_val("d f s"); std::unordered_map smap; std::vector snames; field_for_each([&](auto&& field){ if (field.is_sortable()) { smap.emplace(std::string(field.name), field.id); smap.emplace(std::string(1, field.shortcut), field.id); snames.emplace_back(mu_format("{}|{}", field.name, field.shortcut)); } }); add_choice_option(sub, "--sortfield,-s", opts.find.sortfield, "Field to sort the results by", "", std::move(smap), std::move(snames), "date"); sub.add_flag("--reverse,-z", opts.find.reverse, "Sort in descending order"); sub.add_option("--bookmark,-b", opts.find.bookmark, "Use bookmarked query") ->type_name(""); sub.add_flag("--clearlinks", opts.find.clearlinks, "Clear old links first"); sub.add_option("--linksdir", opts.find.linksdir, "Target directory for symlinks") ->type_name("") ->transform(ExpandPath, "expand linksdir path"); add_number_option(sub, "--after", opts.find.after, "Only show messages whose message file was changed " "after some timestamp", "", Numeric::NonNegative); add_number_option(sub, "--summary-len", opts.find.summary_len, "Use up to so many lines for the summary", "", Numeric::Positive); sub.add_option("--exec", opts.find.exec, "Command to execute on message file") ->type_name(""); sub.add_option("query", opts.find.query, "Search query pattern(s)") ->type_name(""); } static void sub_help(CLI::App& sub, Options& opts) { sub.add_option("command", opts.help.command, "Command to request help for") ->type_name(""); } static void sub_index(CLI::App& sub, Options& opts) { sub.add_flag("--lazy-check", opts.index.lazycheck, "Skip based on dir-timestamps"); sub.add_flag("--nocleanup", opts.index.nocleanup, "Don't clean up database after indexing"); sub.add_flag("--reindex", opts.index.reindex, "Perform a complete reindexing"); } static void sub_info(CLI::App& sub, Options& opts) { sub.add_option("topic", opts.info.topic, "Information topic") ->type_name("") ; } static void sub_init(CLI::App& sub, Options& opts) { const auto default_mdir = std::invoke([]()->std::string { if (const auto mdir_env{::getenv("MAILDIR")}; mdir_env) return mdir_env; else if (const auto mdir_home = ::join_paths(g_get_home_dir(), "Maildir"); check_dir(mdir_home)) return mdir_home; else return {}; }); sub.add_option("--maildir,-m", opts.init.maildir, "Root maildir") ->type_name("") ->default_val(default_mdir) ->transform(ExpandPath, "expand maildir path"); // don't attempt to canonicalize; in bash this breaks together with // expand path. sub.add_option("--personal-address,--my-address", opts.init.personal_addresses, "Personal e-mail address or regexp (can be used multiple times)") ->type_name("
"); sub.add_option("--ignored-address", opts.init.ignored_addresses, "Ignored e-mail address or regexp") ->type_name("
"); sub.add_option("--max-message-size", opts.init.max_msg_size, "Maximum allowed message size in bytes"); sub.add_option("--batch-size", opts.init.batch_size, "Maximum size of database transaction"); sub.add_flag("--support-ngrams", opts.init.support_ngrams, "Support CJK n-grams for querying/indexing"); sub.add_flag("--reinit", opts.init.reinit, "Re-initialize database with current settings") ->excludes("--maildir") ->excludes("--personal-address") ->excludes("--my-address") ->excludes("--ignored-address") ->excludes("--max-message-size") ->excludes("--batch-size") ->excludes("--support-ngrams"); } static void sub_labels(CLI::App& sub, Options& opts) { sub.require_subcommand(0, 1); auto update{sub.add_subcommand("update", "update labels")}; update->add_option("--labels", opts.labels.delta_labels, "One or more comma-separated +label,-label") ->delimiter(',') ->type_name("") ->required(); update->add_flag("-n,--dry-run", opts.labels.dry_run, "Output what would change without changing anything"); update->add_option("query", opts.labels.query, "Query for messages to update") ->required(); add_muhome_option(*update, opts); auto clear = sub.add_subcommand("clear", "clear all labels from matched messages"); clear ->add_option("query", opts.labels.query, "Query for messages to clear of labels") ->required(); clear->add_flag("-n,--dry-run", opts.labels.dry_run, "Output what would change without changing anything"); add_muhome_option(*clear, opts); [[maybe_unused]] auto list = sub.add_subcommand("list", "list labels in the store"); add_muhome_option(*list, opts); [[maybe_unused]] auto restore_list = sub.add_subcommand( "restore-list", "restore the labels cache"); add_muhome_option(*restore_list, opts); [[maybe_unused]] auto exportsub = sub.add_subcommand("export", "export labels to a file"); add_muhome_option(*exportsub, opts); exportsub->add_option("output", opts.labels.file, "File to export labels to") ->type_name(""); auto importsub = sub.add_subcommand("import", "import labels from a file"); importsub->add_flag("-n,--dry-run", opts.labels.dry_run, "Output what would change without changing anything"); importsub->add_option("input", opts.labels.file, "File with labels to import") ->required() ->type_name(""); add_muhome_option(*importsub, opts); sub.final_callback([&](){ if (sub.got_subcommand("list")) { opts.labels.sub = Options::Labels::Sub::List; opts.labels.read_only = true; } else if (sub.got_subcommand("restore-list")) { opts.labels.sub = Options::Labels::Sub::RestoreList; opts.labels.read_only = false;/*opts.labels.dry_run*/ } else if (sub.got_subcommand("clear")) { opts.labels.sub = Options::Labels::Sub::Clear; opts.labels.read_only = opts.labels.dry_run; } else if (sub.got_subcommand("update")){ opts.labels.sub = Options::Labels::Sub::Update; opts.labels.read_only = opts.labels.dry_run; } else if (sub.got_subcommand("export")){ opts.labels.sub = Options::Labels::Sub::Export; opts.labels.read_only = true; } else if (sub.got_subcommand("import")){ opts.labels.sub = Options::Labels::Sub::Import; opts.labels.read_only = opts.labels.dry_run; } else { mu_println("{}", sub.help()); opts.labels.read_only = true; } }); } static void sub_mkdir(CLI::App& sub, Options& opts) { sub.add_option("--mode", opts.mkdir.mode, "Set the access mode (octal)") ->default_val(0755) ->type_name(""); sub.add_option("dirs", opts.mkdir.dirs, "Path to directory/ies") ->type_name("") ->required(); } static void sub_move(CLI::App& sub, Options& opts) { sub.add_flag("--change-name", opts.move.change_name, "Change name of target file"); sub.add_flag("--update-dups", opts.move.update_dups, "Update duplicate messages too"); sub.add_flag("--dry-run,-n", opts.move.dry_run, "Print target name, but do not change anything"); sub.add_option("--flags", opts.move.flags, "Target flags") ->type_name(""); sub.add_option("source", opts.move.src, "Message file to move") ->type_name("") ->transform(ExpandPath, "expand source path") ->required(); sub.add_option("destination", opts.move.dest, "Destination maildir") ->type_name(""); } static void sub_remove(CLI::App& sub, Options& opts) { sub.add_option("files", opts.remove.files, "Paths to message files to remove") ->type_name(""); } static std::string concoct_socket_path() { return join_paths(g_get_user_runtime_dir(), mu_format("mu-scm-{}.sock", ::getpid())); } static void add_listen_flag(CLI::App& sub, Options& opts) { sub.add_flag("--listen", opts.scm.listen, "Start SCM REPL on a domain socket"); sub.callback([&opts]{ if (opts.scm.listen) opts.scm.socket_path = concoct_socket_path(); }); } static void sub_server(CLI::App& sub, Options& opts) { sub.add_flag("--commands", opts.server.commands, "List available commands"); sub.add_option("--eval", opts.server.eval, "Evaluate mu server expression") ->excludes("--commands"); sub.add_flag("--allow-temp-file", opts.server.allow_temp_file, "Allow for the temp-file optimization") ->excludes("--commands"); #if BUILD_SCM add_listen_flag(sub, opts); #endif/*BUILD_SCM*/ } static void sub_scm(CLI::App& sub, Options& opts) { add_listen_flag(sub, opts); sub.add_option("script-path", opts.scm.script_path, "Path to script") ->type_name("") ->excludes("--listen"); sub.add_option("script-args", opts.scm.params, "Parameters for script") ->type_name(""); sub.add_option("--eval", opts.scm.eval, "Expression to evaluate") ->excludes("--listen") ->excludes("script-path"); } static void sub_verify(CLI::App& sub, Options& opts) { sub_crypto(sub, opts.verify); // optional; otherwise use standard-input sub.add_option("message-paths", opts.verify.files, "Message files to verify") ->type_name(""); } static void sub_view(CLI::App& sub, Options& opts) { using Format = Options::View::Format; static constexpr auto FormatInfos = std::to_array>({ { Format::Plain, {"plain", "Plain output"} }, { Format::Html, {"html", "Plain output with HTML body"} }, { Format::Sexp, {"sexp", "S-expressions"} }, }); add_choice_option(sub, "--format,-o", opts.view.format, "Output format", "", options_map(FormatInfos), options_names(FormatInfos), "plain"); sub_crypto(sub, opts.view); add_number_option(sub, "--summary-len", opts.view.summary_len, "Use up to so many lines for the summary", "", Numeric::Positive); sub.add_flag("--terminate", opts.view.terminate, "Insert form-feed after each message"); // optional; otherwise use standard-input sub.add_option("message-paths", opts.view.files, "Message files to view") ->type_name(""); } using SubCommand = Options::SubCommand; using Category = Options::Category; struct CommandInfo { Category category; std::string_view name; std::string_view help; // std::function is not constexp-friendly using setup_func_t = void(*)(CLI::App&, Options&); setup_func_t setup_func{}; }; static constexpr auto SubCommandInfos = std::to_array>({ { SubCommand::Add, { Category::NeedsWritableStore, "add", "Add messages to the database", sub_add} }, { SubCommand::Cfind, { Category::NeedsReadOnlyStore, "cfind", "Find contacts matching some pattern", sub_cfind} }, { SubCommand::Extract, {Category::None, "extract", "Extract attachments and other MIME-parts", sub_extract} }, { SubCommand::Fields, {Category::None, "fields", "Superseded by 'mu info'", sub_fields} }, { SubCommand::Find, {Category::NeedsReadOnlyStore, "find", "Find messages matching some query", sub_find } }, { SubCommand::Help, {Category::None, "help", "Show help information", sub_help } }, { SubCommand::Index, {Category::NeedsWritableStore, "index", "Scan maildirs and store information", sub_index } }, { SubCommand::Info, {Category::NeedsReadOnlyStore, "info", "Show information about mu", sub_info } }, { SubCommand::Init, {Category::NeedsWritableStore, "init", "Initialize the mu database", sub_init } }, { SubCommand::Labels, {Category::None, // note Store handled on sub-subcommmands "labels", "Manage message labels", sub_labels } }, { SubCommand::Mkdir, {Category::None, "mkdir", "Create a new Maildir", sub_mkdir } }, { SubCommand::Move, {Category::NeedsWritableStore, "move", "Move a message or change its flags", sub_move } }, { SubCommand::Remove, {Category::NeedsWritableStore, "remove", "Remove message from file-system and database", sub_remove } }, { SubCommand::Scm, {Category::NeedsReadOnlyStore, "scm", "Start Guile/Scheme shell or run script",sub_scm} }, { SubCommand::Script, // Note: SubCommand::Script is special; there's no literal // "script" subcommand, there are subcommands for all the scripts. {Category::None, "script", "Invoke a script", {}} }, { SubCommand::Server, {Category::NeedsWritableStore, "server", "Start a mu server (for mu4e)", sub_server} }, { SubCommand::Verify, {Category::None, "verify", "Verify cryptographic signatures", sub_verify} }, { SubCommand::View, {Category::None, "view", "View specific messages", sub_view} }, }); static_assert(SubCommandInfos.size() == Options::SubCommandNum, "SubCommandInfos must have an entry for each subcommand"); static ScriptInfos add_scripts(CLI::App& app, Options& opts) { #ifndef BUILD_GUILE return {}; #else ScriptPaths paths = { MU_SCRIPTS_DIR }; auto scriptinfos{script_infos(paths)}; for (auto&& script: scriptinfos) { auto&& sub = app.add_subcommand(script.name)->group("Scripts") ->description(script.oneline); sub->add_option("params", opts.script.params, "Parameter to script") ->type_name(""); } return scriptinfos; #endif /*BUILD_GUILE*/ } static Result show_manpage(Options& opts, const std::string& name) { const auto manprog{program_in_path("man")}; if (!manprog) return Err(Error::Code::Command, "cannot find 'man' program"); GError* err{}; const auto cmd{mu_format("{} {}", *manprog, shell_quote(name))}; // run_command0 doesn't work here. int wait_status{}; auto res = g_spawn_command_line_sync(cmd.c_str(), {}, {}, &wait_status, &err); if (!res || !g_spawn_check_wait_status(wait_status, &err)) return Err(Error::Code::Command, &err, "error running man command"); return Ok(std::move(opts)); } static Result cmd_help(const CLI::App& app, Options& opts) { if (opts.help.command.empty()) { mu_println("{}", app.help()); return Ok(std::move(opts)); } for (auto&& item: SubCommandInfos) { if (item.second.name == opts.help.command) return show_manpage(opts, "mu-" + opts.help.command); } for (auto&& item: {"query", "easy"}) if (item == opts.help.command) return show_manpage(opts, "mu-" + opts.help.command); return Err(Error::Code::Command, "no help available for '{}'", opts.help.command); } bool Options::default_no_color() { static const auto no_color = !::isatty(::fileno(stdout)) || !::isatty(::fileno(stderr)) || ::getenv("NO_COLOR") != NULL; return no_color; } static void add_global_options(CLI::App& cli, Options& opts) { opts.nocolor = Options::default_no_color(); cli.add_flag("-q,--quiet", opts.quiet, "Hide non-essential output"); cli.add_flag("-v,--verbose", opts.verbose, "Show verbose output"); cli.add_flag("--log-stderr", opts.log_stderr, "Log to stderr") ->group(""/*always hide*/); cli.add_flag("--nocolor", opts.nocolor, "Don't show ANSI colors") ->default_val(Options::default_no_color()) ->default_str(Options::default_no_color() ? "" : ""); cli.add_flag("-d,--debug", opts.debug, "Run in debug mode") ->group(""/*always hide*/); } Result Options::make(int argc, char *argv[]) { Options opts{}; CLI::App app{"mu mail indexer/searcher " PACKAGE_VERSION, "mu"}; friendly_errors.clear(); // entries refer to the previous app, if any. app.description(R"(mu mail indexer/searcher Copyright (C) 2008-2025 Dirk-Jan C. Binnema License GPLv3+: GNU GPL version 3 or later . This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.)"); app.set_version_flag("-V,--version", PACKAGE_VERSION); app.set_help_flag("-h,--help", "Show help information"); app.set_help_all_flag("--help-all"); app.require_subcommand(0, 1); add_global_options(app, opts); /* * subcommands * * we keep around a map of the subcommand pointers, so we can * easily find the chosen one (if any) later. */ for (auto&& cmdinfo: SubCommandInfos) { //const auto cmdtype = cmdinfo.first; const auto name{std::string{cmdinfo.second.name}}; const auto help{std::string{cmdinfo.second.help}}; const auto setup{cmdinfo.second.setup_func}; const auto cat{category(cmdinfo.first)}; if (!setup) continue; auto sub = app.add_subcommand(name, help); setup(*sub, opts); /* allow global options _after_ subcommand as well; * this is for backward compat with the older * command-line parsing */ sub->fallthrough(true); /* store commands get the '--muhome' parameter as well */ if (cat == Category::NeedsReadOnlyStore || cat == Category::NeedsWritableStore) add_muhome_option(*sub, opts); } /* add scripts (if supported) as semi-subcommands as well */ const auto scripts = add_scripts(app, opts); try { app.parse(argc, argv); // find the chosen sub command, if any. for (auto&& cmdinfo: SubCommandInfos) { if (cmdinfo.first == SubCommand::Script) continue; // not a _real_ subcommand. const auto name{std::string{cmdinfo.second.name}}; if (app.got_subcommand(name)) { opts.sub_command = cmdinfo.first; } } // otherwise, perhaps it's a script? if (!opts.sub_command) { for (auto&& info: scripts) { // find the chosen script, if any. if (app.got_subcommand(info.name)) { opts.sub_command = SubCommand::Script; opts.script.name = info.name; } } } // if nothing else, try "help" if (opts.sub_command.value_or(SubCommand::Help) == SubCommand::Help) return cmd_help(app, opts); } catch (const CLI::CallForHelp& cfh) { mu_println("{}", app.help()); } catch (const CLI::CallForAllHelp& cfah) { mu_println("{}", app.help("", CLI::AppFormatMode::All)); } catch (const CLI::CallForVersion&) { mu_println("version {}", PACKAGE_VERSION); } catch (const CLI::ExtrasError& xe) { /* a first unexpected non-option argument without any subcommand * is (likely) a mistyped or unknown command */ if (const auto extras{app.remaining()}; app.get_subcommands().empty() && !extras.empty() && !extras.front().starts_with('-')) return Err(Error::Code::InvalidArgument, "'{}' is not a mu command. See 'mu --help'", extras.front()); return Err(Error::Code::InvalidArgument, "{}", xe.what()); } catch (const CLI::ParseError& pe) { return Err(Error::Code::InvalidArgument, "{}", friendly_error(pe)); } catch (...) { return Err(Error::Code::Internal, "error parsing arguments"); } return Ok(std::move(opts)); } Category Options::category(Options::SubCommand sub) { for (auto&& item: SubCommandInfos) if (item.first == sub) return item.second.category; return Category::None; } /* * trust but verify */ static constexpr bool validate_subcommand_ids() { size_t val{}; for (auto& cmd: Options::SubCommands) if (static_cast(cmd) != val++) return false; for (auto u = 0U; u != SubCommandInfos.size(); ++u) if (static_cast(SubCommandInfos.at(u).first) != u) return false; return true; } /* * tests... also build as runtime-tests, so we can get coverage info */ #ifdef BUILD_TESTS #define static_assert g_assert_true #endif /*BUILD_TESTS*/ [[maybe_unused]] static void test_ids() { static_assert(validate_subcommand_ids()); } #ifdef BUILD_TESTS enum struct TestEnum { A, B, C }; constexpr auto test_epairs = std::to_array>({ {TestEnum::A, "a"}, {TestEnum::B, "b"}, {TestEnum::C, "c"}, }); static constexpr Option to_name(TestEnum te) { return to_second(test_epairs, te); } static constexpr Option to_type(std::string_view name) { return to_first(test_epairs, name); } static void test_enum_pairs(void) { assert_equal(to_name(TestEnum::A).value(), "a"); g_assert_true(to_type("c").value() == TestEnum::C); } static Result test_make_options(std::vector args) { std::vector argv; argv.reserve(args.size()); for (auto& arg: args) argv.push_back(arg.data()); return Options::make(static_cast(argv.size()), argv.data()); } static void test_choice_option(void) { // an explicit value const auto explicit_fmt = test_make_options({"mu", "find", "--format", "sexp", "x"}); g_assert_true(!!explicit_fmt); g_assert_true(explicit_fmt->sub_command == Options::SubCommand::Find); g_assert_true(explicit_fmt->find.format == Options::Find::Format::Sexp); // the default const auto default_fmt = test_make_options({"mu", "find", "x"}); g_assert_true(!!default_fmt); g_assert_true(default_fmt->find.format == Options::Find::Format::Plain); g_assert_true(default_fmt->find.sortfield == Field::Id::Date); } static void test_choice_option_errors(void) { constexpr auto errmsg = "--format requires one of { json, json2, links, plain, sexp, xml }; " "default is plain"; // bad value const auto bad = test_make_options({"mu", "find", "--format=nope", "x"}); g_assert_false(!!bad); assert_equal(bad.error().what(), errmsg); // no value const auto missing = test_make_options({"mu", "find", "x", "--format"}); g_assert_false(!!missing); assert_equal(missing.error().what(), errmsg); // another subcommand's --format gets its own choices const auto view = test_make_options({"mu", "view", "--format=nope", "x"}); g_assert_false(!!view); assert_equal(view.error().what(), "--format requires one of { html, plain, sexp }; " "default is plain"); } static void test_sortfield_option(void) { // by name and by shortcut const auto by_name = test_make_options({"mu", "find", "--sortfield=subject", "x"}); g_assert_true(!!by_name); g_assert_true(by_name->find.sortfield == Field::Id::Subject); const auto by_shortcut = test_make_options({"mu", "find", "-s", "s", "x"}); g_assert_true(!!by_shortcut); g_assert_true(by_shortcut->find.sortfield == Field::Id::Subject); const auto bad = test_make_options({"mu", "find", "-s", "nope", "x"}); g_assert_false(!!bad); g_assert_true(std::string{bad.error().what()} .starts_with("--sortfield requires one of {")); } static void test_number_option(void) { // valid values; --after=0 is allowed (non-negative) const auto ok = test_make_options({"mu", "find", "--maxnum=10", "--after=0", "x"}); g_assert_true(!!ok); g_assert_cmpuint(ok->find.maxnum.value(), ==, 10); g_assert_cmpuint(ok->find.after.value(), ==, 0); // bad values for (auto&& val: {"-5", "abc"}) { const auto bad = test_make_options({"mu", "find", mu_format("--maxnum={}", val), "x"}); g_assert_false(!!bad); assert_equal(bad.error().what(), "--maxnum requires a positive number"); } const auto negative = test_make_options({"mu", "find", "--after=-1", "x"}); g_assert_false(!!negative); assert_equal(negative.error().what(), "--after requires a non-negative number"); } static void test_unknown_command(void) { constexpr auto errmsg = "'flimflam' is not a mu command. See 'mu --help'"; const auto unknown = test_make_options({"mu", "flimflam"}); g_assert_false(!!unknown); assert_equal(unknown.error().what(), errmsg); // also with a global option in front const auto with_opt = test_make_options({"mu", "--quiet", "flimflam"}); g_assert_false(!!with_opt); assert_equal(with_opt.error().what(), errmsg); // an unknown _option_ keeps CLI11's message const auto unknown_opt = test_make_options({"mu", "--flimflam"}); g_assert_false(!!unknown_opt); g_assert_true(std::string{unknown_opt.error().what()} .find("--flimflam") != std::string::npos); g_assert_true(std::string{unknown_opt.error().what()} .find("is not a mu command") == std::string::npos); // extra arguments to a real subcommand are not rewritten const auto sub_extra = test_make_options({"mu", "index", "extra"}); g_assert_false(!!sub_extra); g_assert_true(std::string{sub_extra.error().what()} .find("is not a mu command") == std::string::npos); } int main(int argc, char* argv[]) { mu_test_init(&argc, &argv); g_test_add_func("/options/ids", test_ids); g_test_add_func("/option/enum-pairs", test_enum_pairs); g_test_add_func("/options/choice", test_choice_option); g_test_add_func("/options/choice-errors", test_choice_option_errors); g_test_add_func("/options/sortfield", test_sortfield_option); g_test_add_func("/options/number", test_number_option); g_test_add_func("/options/unknown-command", test_unknown_command); return g_test_run(); } #endif /*BUILD_TESTS*/