Skip to main content

yt/api/
commands.rs

1use serde::Deserialize;
2use serde_json::{Value, json};
3
4use crate::api::{Client, Projection};
5use crate::error::Result;
6
7/// How YouTrack reads one command, from `commands/assist`.
8#[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
39/// Applies `command` to every issue in one request.
40pub async fn apply(client: &Client, issues: &[String], command: &str) -> Result<()> {
41    client.post_unit("commands", &body(issues, command)).await
42}
43
44/// How YouTrack would read `command`, without applying it.
45pub 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
63/// Removes the markup YouTrack puts in command descriptions (`Set State <span class="bold">Fixed</span>`).
64pub 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("&lt;", "<")
77        .replace("&gt;", ">")
78        .replace("&quot;", "\"")
79        .replace("&#39;", "'")
80        .replace("&nbsp;", " ")
81        .replace("&amp;", "&")
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>&lt;b&gt;</b> &amp; c"), "a <b> & c");
151    }
152}