Skip to main content

dsc/api/
search.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6/// One result row in a search response — distilled from the topic stanza of
7/// `/search.json` (which contains far more than we need).
8#[derive(Debug, Deserialize, Serialize, Clone)]
9pub struct SearchHit {
10    pub id: u64,
11    pub title: String,
12    #[serde(default)]
13    pub slug: String,
14    #[serde(default)]
15    pub posts_count: u64,
16    #[serde(default)]
17    pub category_id: Option<u64>,
18    #[serde(default)]
19    pub tags: Option<Vec<String>>,
20}
21
22#[derive(Debug, Deserialize)]
23struct RawSearchResponse {
24    #[serde(default)]
25    topics: Vec<SearchHit>,
26}
27
28impl DiscourseClient {
29    /// Search for topics. The `query` is passed through to Discourse verbatim
30    /// (so callers can use `category:`, `status:`, `@user`, etc. filters).
31    pub fn search_topics(&self, query: &str) -> Result<Vec<SearchHit>> {
32        let path = format!("/search.json?q={}", urlencode_form(query));
33        let response = self.get(&path)?;
34        let status = response.status();
35        let text = response.text().context("reading search response body")?;
36        if !status.is_success() {
37            return Err(http_error("search request", status, &text));
38        }
39        let body: RawSearchResponse =
40            serde_json::from_str(&text).context("parsing search response json")?;
41        Ok(body.topics)
42    }
43}
44
45/// Minimal `application/x-www-form-urlencoded` encoder for the query string.
46/// Avoids pulling in an extra crate just for one field.
47fn urlencode_form(input: &str) -> String {
48    let mut out = String::with_capacity(input.len());
49    for byte in input.as_bytes() {
50        let b = *byte;
51        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
52            out.push(b as char);
53        } else if b == b' ' {
54            out.push('+');
55        } else {
56            out.push_str(&format!("%{:02X}", b));
57        }
58    }
59    out
60}
61
62#[cfg(test)]
63mod tests {
64    use super::urlencode_form;
65
66    #[test]
67    fn encodes_spaces_as_plus() {
68        assert_eq!(urlencode_form("hello world"), "hello+world");
69    }
70
71    #[test]
72    fn encodes_special_chars_percent() {
73        assert_eq!(urlencode_form("a&b=c"), "a%26b%3Dc");
74    }
75
76    #[test]
77    fn passes_alnum_unchanged() {
78        assert_eq!(urlencode_form("Topic42"), "Topic42");
79    }
80
81    #[test]
82    fn passes_unreserved_unchanged() {
83        assert_eq!(urlencode_form("a-b_c.d~e"), "a-b_c.d~e");
84    }
85
86    #[test]
87    fn encodes_discourse_filter_syntax() {
88        // Things like `category:foo @user` should round-trip through Discourse fine.
89        assert_eq!(
90            urlencode_form("hello category:foo @bob"),
91            "hello+category%3Afoo+%40bob"
92        );
93    }
94}