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!(
33            "/search.json?q={}",
34            urlencode_form(query)
35        );
36        let response = self.get(&path)?;
37        let status = response.status();
38        let text = response.text().context("reading search response body")?;
39        if !status.is_success() {
40            return Err(http_error("search request", status, &text));
41        }
42        let body: RawSearchResponse =
43            serde_json::from_str(&text).context("parsing search response json")?;
44        Ok(body.topics)
45    }
46}
47
48/// Minimal `application/x-www-form-urlencoded` encoder for the query string.
49/// Avoids pulling in an extra crate just for one field.
50fn urlencode_form(input: &str) -> String {
51    let mut out = String::with_capacity(input.len());
52    for byte in input.as_bytes() {
53        let b = *byte;
54        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
55            out.push(b as char);
56        } else if b == b' ' {
57            out.push('+');
58        } else {
59            out.push_str(&format!("%{:02X}", b));
60        }
61    }
62    out
63}
64
65#[cfg(test)]
66mod tests {
67    use super::urlencode_form;
68
69    #[test]
70    fn encodes_spaces_as_plus() {
71        assert_eq!(urlencode_form("hello world"), "hello+world");
72    }
73
74    #[test]
75    fn encodes_special_chars_percent() {
76        assert_eq!(urlencode_form("a&b=c"), "a%26b%3Dc");
77    }
78
79    #[test]
80    fn passes_alnum_unchanged() {
81        assert_eq!(urlencode_form("Topic42"), "Topic42");
82    }
83
84    #[test]
85    fn passes_unreserved_unchanged() {
86        assert_eq!(urlencode_form("a-b_c.d~e"), "a-b_c.d~e");
87    }
88
89    #[test]
90    fn encodes_discourse_filter_syntax() {
91        // Things like `category:foo @user` should round-trip through Discourse fine.
92        assert_eq!(
93            urlencode_form("hello category:foo @bob"),
94            "hello+category%3Afoo+%40bob"
95        );
96    }
97}