Skip to main content

ytcli/api/
query.rs

1//! Building Tracker query strings from filter flags.
2//!
3//! Flags and `--yql` compile to the same thing — a query string — which keeps
4//! one code path on the wire and makes the escape hatch nothing more than
5//! skipping this module. Both are read-only: a query decides what can be seen,
6//! never what changes (ADR 1).
7
8/// Everything the search flags can express.
9#[derive(Debug, Default, Clone)]
10pub struct Filter {
11    pub queue: Option<String>,
12    pub assignee: Option<String>,
13    pub status: Option<String>,
14    pub tags: Vec<String>,
15}
16
17impl Filter {
18    #[must_use]
19    pub fn is_empty(&self) -> bool {
20        self.queue.is_none()
21            && self.assignee.is_none()
22            && self.status.is_none()
23            && self.tags.is_empty()
24    }
25
26    /// Compile to a query string.
27    #[must_use]
28    pub fn to_query(&self) -> String {
29        let mut clauses: Vec<String> = Vec::new();
30
31        if let Some(queue) = &self.queue {
32            clauses.push(format!("Queue: {}", value(queue)));
33        }
34        if let Some(assignee) = &self.assignee {
35            clauses.push(format!("Assignee: {}", assignee_value(assignee)));
36        }
37        if let Some(status) = &self.status {
38            clauses.push(format!("Status: {}", value(status)));
39        }
40        if !self.tags.is_empty() {
41            let tags: Vec<String> = self.tags.iter().map(|tag| value(tag)).collect();
42            clauses.push(format!("Tags: {}", tags.join(", ")));
43        }
44
45        clauses.join(" AND ")
46    }
47}
48
49/// `me` resolves server-side through Tracker's own `me()`, so the convenience
50/// costs nothing: no extra request to find out who the token belongs to.
51fn assignee_value(assignee: &str) -> String {
52    if assignee.eq_ignore_ascii_case("me") {
53        return "me()".to_owned();
54    }
55    value(assignee)
56}
57
58/// Quote a value.
59///
60/// Always quoted, never conditionally: a status like `In Progress` needs it, and
61/// deciding per value would mean two shapes to reason about and one of them
62/// wrong. Embedded quotes are escaped rather than stripped — a query that
63/// silently drops part of what was asked for is worse than one that fails.
64fn value(raw: &str) -> String {
65    format!("\"{}\"", raw.replace('\\', "\\\\").replace('"', "\\\""))
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn filter() -> Filter {
73        Filter {
74            queue: Some("PROJ".to_owned()),
75            assignee: Some("ilubenets".to_owned()),
76            status: Some("In Progress".to_owned()),
77            tags: vec!["regression".to_owned()],
78        }
79    }
80
81    #[test]
82    fn clauses_join_with_and_in_a_fixed_order() {
83        assert_eq!(
84            filter().to_query(),
85            r#"Queue: "PROJ" AND Assignee: "ilubenets" AND Status: "In Progress" AND Tags: "regression""#
86        );
87    }
88
89    #[test]
90    fn me_becomes_trackers_own_function_rather_than_a_lookup() {
91        let filter = Filter {
92            assignee: Some("me".to_owned()),
93            ..Filter::default()
94        };
95        assert_eq!(filter.to_query(), "Assignee: me()");
96    }
97
98    #[test]
99    fn me_is_matched_regardless_of_case() {
100        let filter = Filter {
101            assignee: Some("ME".to_owned()),
102            ..Filter::default()
103        };
104        assert_eq!(filter.to_query(), "Assignee: me()");
105    }
106
107    /// A quote inside a value must not be able to end the quoted string and
108    /// change what the query means.
109    #[test]
110    fn embedded_quotes_are_escaped_not_dropped() {
111        let filter = Filter {
112            status: Some(r#"say "hi""#.to_owned()),
113            ..Filter::default()
114        };
115        assert_eq!(filter.to_query(), r#"Status: "say \"hi\"""#);
116    }
117
118    #[test]
119    fn several_tags_become_one_clause() {
120        let filter = Filter {
121            tags: vec!["a".to_owned(), "b".to_owned()],
122            ..Filter::default()
123        };
124        assert_eq!(filter.to_query(), r#"Tags: "a", "b""#);
125    }
126
127    #[test]
128    fn an_empty_filter_is_recognisable() {
129        assert!(Filter::default().is_empty());
130        assert!(!filter().is_empty());
131    }
132}