1use serde::Deserialize;
2use serde_json::{Value, json};
3
4use crate::api::{Client, Projection};
5use crate::error::Result;
6
7#[derive(Clone, Debug, PartialEq)]
9pub struct ParsedCommand {
10 pub description: String,
11 pub error: bool,
12}
13
14#[derive(Deserialize)]
15struct Assist {
16 #[serde(default)]
17 commands: Vec<RawCommand>,
18}
19
20#[derive(Deserialize)]
21struct RawCommand {
22 #[serde(default)]
23 description: String,
24 #[serde(default)]
25 error: bool,
26}
27
28impl Projection for Assist {
29 const FIELDS: &'static str = "commands(description,error)";
30}
31
32fn body(issues: &[String], command: &str) -> Value {
33 json!({
34 "query": command,
35 "issues": issues.iter().map(|id| json!({"idReadable": id})).collect::<Vec<_>>(),
36 })
37}
38
39pub async fn apply(client: &Client, issues: &[String], command: &str) -> Result<()> {
41 client.post_unit("commands", &body(issues, command)).await
42}
43
44pub async fn assist(
46 client: &Client,
47 issues: &[String],
48 command: &str,
49) -> Result<Vec<ParsedCommand>> {
50 let mut request = body(issues, command);
51 request["caret"] = json!(command.chars().count());
52 let assist: Assist = client.post_query("commands/assist", &request).await?;
53 Ok(assist
54 .commands
55 .into_iter()
56 .map(|raw| ParsedCommand {
57 description: strip_html(&raw.description),
58 error: raw.error,
59 })
60 .collect())
61}
62
63pub fn strip_html(text: &str) -> String {
65 let mut plain = String::with_capacity(text.len());
66 let mut in_tag = false;
67 for c in text.chars() {
68 match c {
69 '<' => in_tag = true,
70 '>' if in_tag => in_tag = false,
71 _ if !in_tag => plain.push(c),
72 _ => {}
73 }
74 }
75 plain
76 .replace("<", "<")
77 .replace(">", ">")
78 .replace(""", "\"")
79 .replace("'", "'")
80 .replace(" ", " ")
81 .replace("&", "&")
82}
83
84#[cfg(test)]
85mod tests {
86 use serde_json::json;
87 use wiremock::matchers::{body_json, method, path};
88 use wiremock::{Mock, MockServer, ResponseTemplate};
89
90 use super::*;
91 use crate::api::test_client;
92
93 #[tokio::test]
94 async fn apply_sends_the_command_for_every_issue() {
95 let server = MockServer::start().await;
96 Mock::given(method("POST"))
97 .and(path("/api/commands"))
98 .and(body_json(json!({
99 "query": "State In Progress",
100 "issues": [{"idReadable": "WEB-1"}, {"idReadable": "WEB-2"}]
101 })))
102 .respond_with(ResponseTemplate::new(200))
103 .expect(1)
104 .mount(&server)
105 .await;
106 apply(
107 &test_client(&server.uri()),
108 &["WEB-1".into(), "WEB-2".into()],
109 "State In Progress",
110 )
111 .await
112 .unwrap();
113 }
114
115 #[tokio::test]
116 async fn assist_reports_how_the_command_reads() {
117 let server = MockServer::start().await;
118 Mock::given(method("POST"))
119 .and(path("/api/commands/assist"))
120 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"commands": [
121 {"description": "Set State <span class=\"bold\">In Progress</span>", "error": false},
122 {"description": "Unknown command: Stat", "error": true}
123 ]})))
124 .mount(&server)
125 .await;
126 let parsed = assist(
127 &test_client(&server.uri()),
128 &["WEB-1".into()],
129 "State In Progress",
130 )
131 .await
132 .unwrap();
133 assert_eq!(
134 parsed,
135 vec![
136 ParsedCommand {
137 description: "Set State In Progress".into(),
138 error: false
139 },
140 ParsedCommand {
141 description: "Unknown command: Stat".into(),
142 error: true
143 },
144 ]
145 );
146 }
147
148 #[test]
149 fn markup_and_entities_are_removed() {
150 assert_eq!(strip_html("a <b><b></b> & c"), "a <b> & c");
151 }
152}