From 57979da28c0af4bc14787575d94308d5762e7dc6 Mon Sep 17 00:00:00 2001
From: Andrei Vasiliu <whyte.vuhuni@gmail.com>
Date: Fri, 21 Jan 2022 17:34:21 +0200
Subject: [PATCH] Change structopt to clap, remove markdown dependency

---
 Cargo.lock            |  75 +++++++++++++++++++++++-
 Cargo.toml            |   3 +-
 src/database/admin.rs | 131 ++++++++++++++++++++++++++----------------
 3 files changed, 156 insertions(+), 53 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 5be10f14..ae385fe6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -269,6 +269,33 @@ dependencies = [
  "libloading",
 ]
 
+[[package]]
+name = "clap"
+version = "3.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a30c3bf9ff12dfe5dae53f0a96e0febcd18420d1c0e7fad77796d9d5c4b5375"
+dependencies = [
+ "bitflags",
+ "clap_derive",
+ "indexmap",
+ "lazy_static",
+ "os_str_bytes",
+ "textwrap",
+]
+
+[[package]]
+name = "clap_derive"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "517358c28fcef6607bf6f76108e02afad7e82297d132a6b846dcc1fc3efcd153"
+dependencies = [
+ "heck 0.4.0",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "color_quant"
 version = "1.1.0"
@@ -281,6 +308,7 @@ version = "0.2.0"
 dependencies = [
  "base64 0.13.0",
  "bytes",
+ "clap",
  "crossbeam",
  "directories",
  "heed",
@@ -630,7 +658,7 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595"
 dependencies = [
- "heck",
+ "heck 0.3.3",
  "proc-macro2",
  "quote",
  "syn",
@@ -902,6 +930,12 @@ dependencies = [
  "unicode-segmentation",
 ]
 
+[[package]]
+name = "heck"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
+
 [[package]]
 name = "heed"
 version = "0.10.6"
@@ -1570,6 +1604,15 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "os_str_bytes"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "page_size"
 version = "0.4.2"
@@ -1728,6 +1771,30 @@ dependencies = [
  "toml",
 ]
 
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
 [[package]]
 name = "proc-macro-hack"
 version = "0.5.19"
@@ -2863,6 +2930,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "textwrap"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
+
 [[package]]
 name = "thiserror"
 version = "1.0.30"
diff --git a/Cargo.toml b/Cargo.toml
index 9a2d2fdb..3f8677d6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -86,8 +86,7 @@ thread_local = "1.1.3"
 hmac = "0.11.0"
 sha-1 = "0.9.8"
 # used for conduit's CLI and admin room command parsing
-structopt = { version = "0.3.25", default-features = false }
-pulldown-cmark = "0.9.1"
+clap = { version = "3.0.10", default-features = false, features = ["std", "derive"] }
 
 [features]
 default = ["conduit_bin", "backend_sqlite", "backend_rocksdb"]
diff --git a/src/database/admin.rs b/src/database/admin.rs
index 362ef294..59b8acdf 100644
--- a/src/database/admin.rs
+++ b/src/database/admin.rs
@@ -5,6 +5,7 @@ use crate::{
     pdu::PduBuilder,
     server_server, Database, PduEvent,
 };
+use clap::Parser;
 use regex::Regex;
 use rocket::{
     futures::{channel::mpsc, stream::StreamExt},
@@ -15,7 +16,6 @@ use ruma::{
     EventId, RoomId, RoomVersionId, UserId,
 };
 use serde_json::value::to_raw_value;
-use structopt::StructOpt;
 use tokio::sync::{MutexGuard, RwLock, RwLockReadGuard};
 use tracing::warn;
 
@@ -155,7 +155,7 @@ pub fn parse_admin_command(db: &Database, command_line: &str, body: Vec<&str>) -
         Some(command) => *command,
         None => {
             let markdown_message = "No command given. Use `help` for a list of commands.";
-            let html_message = markdown_to_html(&markdown_message);
+            let html_message = "No command given. Use <code>help</code> for a list of commands.";
 
             return AdminCommand::SendMessage(RoomMessageEventContent::text_html(
                 markdown_message,
@@ -164,10 +164,17 @@ pub fn parse_admin_command(db: &Database, command_line: &str, body: Vec<&str>) -
         }
     };
 
+    // Replace `help command` with `command --help`
+    // Clap has a help subcommand, but it omits the long help description.
+    if argv[0] == "help" {
+        argv.remove(0);
+        argv.push("--help");
+    }
+
     // Backwards compatibility with `register_appservice`-style commands
     let command_with_dashes;
-    if command_line.contains("_") {
-        command_with_dashes = command_name.replace("_", "-");
+    if argv[0].contains("_") {
+        command_with_dashes = argv[0].replace("_", "-");
         argv[0] = &command_with_dashes;
     }
 
@@ -179,7 +186,11 @@ pub fn parse_admin_command(db: &Database, command_line: &str, body: Vec<&str>) -
                 ```\n{}\n```",
                 command_name, error,
             );
-            let html_message = markdown_to_html(&markdown_message);
+            let html_message = format!(
+                "Encountered an error while handling the <code>{}</code> command:\n\
+                <pre>\n{}\n</pre>",
+                command_name, error,
+            );
 
             AdminCommand::SendMessage(RoomMessageEventContent::text_html(
                 markdown_message,
@@ -189,9 +200,10 @@ pub fn parse_admin_command(db: &Database, command_line: &str, body: Vec<&str>) -
     }
 }
 
-#[derive(StructOpt)]
+#[derive(Parser)]
+#[clap(name = "@conduit:example.com", version = env!("CARGO_PKG_VERSION"))]
 enum AdminCommands {
-    #[structopt(verbatim_doc_comment)]
+    #[clap(verbatim_doc_comment)]
     /// Register an appservice using its registration YAML
     ///
     /// This command needs a YAML generated by an appservice (such as a bridge),
@@ -200,25 +212,25 @@ enum AdminCommands {
     /// Registering a new bridge using the ID of an existing bridge will replace
     /// the old one.
     ///
-    /// Example:
-    /// ````
-    /// @conduit:example.com: register-appservice
-    /// ```
-    /// yaml content here
-    /// ```
-    /// ````
+    /// [add-yaml-block-to-usage]
     RegisterAppservice,
 
     /// Unregister an appservice using its ID
-    /// 
+    ///
     /// You can find the ID using the `list-appservices` command.
-    UnregisterAppservice { appservice_identifier: String },
+    UnregisterAppservice {
+        /// The appservice to unregister
+        appservice_identifier: String,
+    },
 
     /// List all the currently registered appservices
     ListAppservices,
 
     /// Get the auth_chain of a PDU
-    GetAuthChain { event_id: Box<EventId> },
+    GetAuthChain {
+        /// An event ID (the $ character followed by the base64 reference hash)
+        event_id: Box<EventId>,
+    },
 
     /// Parse and print a PDU from a JSON
     ///
@@ -227,7 +239,10 @@ enum AdminCommands {
     ParsePdu,
 
     /// Retrieve and print a PDU by ID from the Conduit database
-    GetPdu { event_id: Box<EventId> },
+    GetPdu {
+        /// An event ID (a $ followed by the base64 reference hash)
+        event_id: Box<EventId>,
+    },
 
     /// Print database memory usage statistics
     DatabaseMemoryUsage,
@@ -239,16 +254,16 @@ pub fn try_parse_admin_command(
     body: Vec<&str>,
 ) -> Result<AdminCommand> {
     argv.insert(0, "@conduit:example.com:");
-    let command = match AdminCommands::from_iter_safe(argv) {
+    let command = match AdminCommands::try_parse_from(argv) {
         Ok(command) => command,
         Err(error) => {
-            println!("Before:\n{}\n", error.to_string());
-            let markdown_message = usage_to_markdown(&error.to_string())
+            let message = error
+                .to_string()
                 .replace("example.com", db.globals.server_name().as_str());
-            let html_message = markdown_to_html(&markdown_message);
+            let html_message = usage_to_html(&message);
 
             return Ok(AdminCommand::SendMessage(
-                RoomMessageEventContent::text_html(markdown_message, html_message),
+                RoomMessageEventContent::text_html(message, html_message),
             ));
         }
     };
@@ -380,42 +395,58 @@ pub fn try_parse_admin_command(
     Ok(admin_command)
 }
 
-// Utility to turn structopt's `--help` text to markdown.
-fn usage_to_markdown(text: &str) -> String {
+// Utility to turn clap's `--help` text to HTML.
+fn usage_to_html(text: &str) -> String {
     // For the conduit admin room, subcommands become main commands
     let text = text.replace("SUBCOMMAND", "COMMAND");
     let text = text.replace("subcommand", "command");
 
-    // Put the first line (command name and version text) on its own paragraph
+    // Escape option names (e.g. `<element-id>`) since they look like HTML tags
+    let text = text.replace("<", "&lt;").replace(">", "&gt;");
+
+    // Italicize the first line (command name and version text)
     let re = Regex::new("^(.*?)\n").expect("Regex compilation should not fail");
-    let text = re.replace_all(&text, "*$1*\n\n");
+    let text = re.replace_all(&text, "<em>$1</em>\n");
 
-    // Wrap command names in backticks
+    // Unmerge wrapped lines
+    let text = text.replace("\n            ", "  ");
+
+    // Wrap option names in backticks. The lines look like:
+    //     -V, --version  Prints version information
+    // And are converted to:
+    // <code>-V, --version</code>: Prints version information
     // (?m) enables multi-line mode for ^ and $
-    let re = Regex::new("(?m)^    ([a-z-]+)  +(.*)$").expect("Regex compilation should not fail");
-    let text = re.replace_all(&text, "    `$1`: $2");
+    let re = Regex::new("(?m)^    (([a-zA-Z_&;-]+(, )?)+)  +(.*)$")
+        .expect("Regex compilation should not fail");
+    let text = re.replace_all(&text, "<code>$1</code>: $4");
 
-    // Add * to list items
-    let re = Regex::new("(?m)^    (.*)$").expect("Regex compilation should not fail");
-    let text = re.replace_all(&text, "* $1");
+    // // Enclose examples in code blocks
+    // // (?ms) enables multi-line mode and dot-matches-all
+    // let re =
+    //     Regex::new("(?ms)^Example:\n(.*?)\nUSAGE:$").expect("Regex compilation should not fail");
+    // let text = re.replace_all(&text, "EXAMPLE:\n<pre>$1</pre>\nUSAGE:");
 
-    // Turn section names to headings
-    let re = Regex::new("(?m)^([A-Z-]+):$").expect("Regex compilation should not fail");
-    let text = re.replace_all(&text, "#### $1");
+    let has_yaml_block_marker = text.contains("\n[add-yaml-block-to-usage]\n");
+    let text = text.replace("\n[add-yaml-block-to-usage]\n", "");
+
+    // Add HTML line-breaks
+    let text = text.replace("\n", "<br>\n");
+
+    let text = if !has_yaml_block_marker {
+        // Wrap the usage line in code tags
+        let re = Regex::new("(?m)^USAGE:<br>\n    (@conduit:.*)<br>$")
+            .expect("Regex compilation should not fail");
+        re.replace_all(&text, "USAGE:<br>\n<code>$1</code><br>")
+    } else {
+        // Wrap the usage line in a code block, and add a yaml block example
+        // This makes the usage of e.g. `register-appservice` more accurate
+        let re = Regex::new("(?m)^USAGE:<br>\n    (.*?)<br>\n<br>\n")
+            .expect("Regex compilation should not fail");
+        re.replace_all(
+            &text,
+            "USAGE:<br>\n<pre>$1\n```\nyaml content here\n```</pre>",
+        )
+    };
 
     text.to_string()
 }
-
-// Convert markdown to HTML using the CommonMark flavor
-fn markdown_to_html(text: &str) -> String {
-    // CommonMark's spec allows HTML tags; however, CLI required arguments look
-    // very much like tags so escape them.
-    let text = text.replace("<", "&lt;").replace(">", "&gt;");
-
-    let mut html_output = String::new();
-
-    let parser = pulldown_cmark::Parser::new(&text);
-    pulldown_cmark::html::push_html(&mut html_output, parser);
-
-    html_output
-}