Skip to main content

dsc/api/
tags.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Deserialize, Serialize, Clone)]
8pub struct TagInfo {
9    pub id: u64,
10    #[serde(default)]
11    pub text: String,
12    #[serde(default)]
13    pub count: u64,
14    #[serde(default)]
15    pub pm_count: u64,
16    #[serde(default)]
17    pub description: Option<String>,
18}
19
20#[derive(Debug, Deserialize)]
21struct TagsResponse {
22    #[serde(default)]
23    tags: Vec<TagInfo>,
24}
25
26/// A tag group as returned by the Discourse admin API.
27#[derive(Debug, Deserialize, Serialize, Clone)]
28pub struct TagGroupInfo {
29    pub id: u64,
30    pub name: String,
31    #[serde(default)]
32    pub tag_names: Vec<String>,
33    #[serde(default)]
34    pub one_per_topic: bool,
35    #[serde(default)]
36    pub parent_tag_name: Option<String>,
37    #[serde(default)]
38    pub permissions: Option<Value>,
39}
40
41#[derive(Debug, Deserialize)]
42struct TagGroupsResponse {
43    #[serde(default)]
44    tag_groups: Vec<TagGroupInfo>,
45}
46
47impl DiscourseClient {
48    /// List every tag visible to the authenticated user.
49    pub fn list_tags(&self) -> Result<Vec<TagInfo>> {
50        let response = self.get("/tags.json")?;
51        let status = response.status();
52        let text = response.text().context("reading tags response body")?;
53        if !status.is_success() {
54            return Err(http_error("tags request", status, &text));
55        }
56        let body: TagsResponse =
57            serde_json::from_str(&text).context("parsing tags response json")?;
58        Ok(body.tags)
59    }
60
61    /// Fetch tag description via the tag detail endpoint.
62    pub fn get_tag_description(&self, tag_name: &str) -> Result<Option<String>> {
63        let path = format!("/tag/{}.json", tag_name);
64        let response = self.get(&path)?;
65        let status = response.status();
66        let text = response.text().context("reading tag detail response")?;
67        if !status.is_success() {
68            return Ok(None);
69        }
70        let value: Value = serde_json::from_str(&text).unwrap_or_default();
71        let desc = value
72            .pointer("/topic_list/tags/0/description")
73            .or_else(|| value.pointer("/tag/description"))
74            .and_then(|v| v.as_str())
75            .filter(|s| !s.is_empty())
76            .map(|s| s.to_string());
77        Ok(desc)
78    }
79
80    /// List tag groups (admin endpoint). Returns Err on non-2xx other than 403;
81    /// returns Ok(None) on 403 (non-admin key).
82    pub fn list_tag_groups(&self) -> Result<Option<Vec<TagGroupInfo>>> {
83        let response = self.get("/tag_groups.json")?;
84        let status = response.status();
85        if status.as_u16() == 403 {
86            return Ok(None);
87        }
88        let text = response.text().context("reading tag groups response body")?;
89        if !status.is_success() {
90            return Err(http_error("tag groups request", status, &text));
91        }
92        let body: TagGroupsResponse =
93            serde_json::from_str(&text).context("parsing tag groups response json")?;
94        Ok(Some(body.tag_groups))
95    }
96
97    /// Create a tag group. Returns the created group's ID.
98    pub fn create_tag_group(&self, payload: &Value) -> Result<u64> {
99        let response = self.send_retrying(|| {
100            Ok(self.post("/tag_groups.json")?.json(payload))
101        })?;
102        let status = response.status();
103        let text = response.text().context("reading create tag group response")?;
104        if !status.is_success() {
105            return Err(http_error("create tag group", status, &text));
106        }
107        let value: Value = serde_json::from_str(&text)
108            .context("parsing create tag group response")?;
109        let id = value
110            .pointer("/tag_group/id")
111            .and_then(|v| v.as_u64())
112            .ok_or_else(|| anyhow::anyhow!("tag group creation response missing id"))?;
113        Ok(id)
114    }
115
116    /// Update an existing tag group.
117    pub fn update_tag_group(&self, group_id: u64, payload: &Value) -> Result<()> {
118        let path = format!("/tag_groups/{}.json", group_id);
119        let response = self.send_retrying(|| {
120            Ok(self.put(&path)?.json(payload))
121        })?;
122        let status = response.status();
123        let text = response.text().context("reading update tag group response")?;
124        if !status.is_success() {
125            return Err(http_error("update tag group", status, &text));
126        }
127        Ok(())
128    }
129
130    /// Delete a tag group.
131    pub fn delete_tag_group(&self, group_id: u64) -> Result<()> {
132        let path = format!("/tag_groups/{}.json", group_id);
133        let response = self.delete(&path)?;
134        let status = response.status();
135        if !status.is_success() {
136            let text = response.text().unwrap_or_default();
137            return Err(http_error("delete tag group", status, &text));
138        }
139        Ok(())
140    }
141
142    /// Update tag metadata (description). Creates the tag implicitly if it doesn't exist.
143    pub fn update_tag(&self, tag_name: &str, description: Option<&str>) -> Result<()> {
144        let path = format!("/tag/{}.json", tag_name);
145        let mut payload = serde_json::Map::new();
146        if let Some(desc) = description {
147            payload.insert("tag".to_string(), serde_json::json!({"description": desc}));
148        }
149        let response = self.send_retrying(|| {
150            Ok(self.put(&path)?.json(&payload))
151        })?;
152        let status = response.status();
153        let text = response.text().context("reading update tag response")?;
154        if !status.is_success() {
155            return Err(http_error("update tag", status, &text));
156        }
157        Ok(())
158    }
159
160    /// Rename a tag, preserving topic associations. Discourse accepts a new
161    /// `id` (slug) on the tag-update endpoint and reassigns every topic
162    /// in-place.
163    pub fn rename_tag(&self, old_name: &str, new_name: &str) -> Result<()> {
164        let path = format!("/tag/{}.json", old_name);
165        let payload = serde_json::json!({ "tag": { "id": new_name } });
166        let response = self.send_retrying(|| Ok(self.put(&path)?.json(&payload)))?;
167        let status = response.status();
168        let text = response.text().context("reading rename tag response")?;
169        if !status.is_success() {
170            return Err(http_error("rename tag", status, &text));
171        }
172        Ok(())
173    }
174
175    /// Delete a tag.
176    pub fn delete_tag(&self, tag_name: &str) -> Result<()> {
177        let path = format!("/tags/{}.json", tag_name);
178        let response = self.delete(&path)?;
179        let status = response.status();
180        if !status.is_success() {
181            let text = response.text().unwrap_or_default();
182            return Err(http_error("delete tag", status, &text));
183        }
184        Ok(())
185    }
186
187    /// Fetch the current tag list for a topic.
188    pub fn fetch_topic_tags(&self, topic_id: u64) -> Result<Vec<String>> {
189        let path = format!("/t/{}.json", topic_id);
190        let response = self.get(&path)?;
191        let status = response.status();
192        let text = response.text().context("reading topic response body")?;
193        if !status.is_success() {
194            return Err(http_error("topic request", status, &text));
195        }
196        let value: Value = serde_json::from_str(&text).context("parsing topic response json")?;
197        let tags = value
198            .get("tags")
199            .and_then(|v| v.as_array())
200            .map(|arr| {
201                arr.iter()
202                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
203                    .collect()
204            })
205            .unwrap_or_default();
206        Ok(tags)
207    }
208
209    /// Replace the full tag list on a topic. Returns the resulting tag set.
210    pub fn set_topic_tags(&self, topic_id: u64, tags: &[String]) -> Result<Vec<String>> {
211        let path = format!("/t/{}.json", topic_id);
212        let payload: Vec<(&str, &str)> = if tags.is_empty() {
213            // Discourse needs at least one form field to clear tags;
214            // sending an empty `tags[]` removes them all.
215            vec![("tags[]", "")]
216        } else {
217            tags.iter().map(|t| ("tags[]", t.as_str())).collect()
218        };
219        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
220        let status = response.status();
221        let text = response.text().context("reading set-tags response body")?;
222        if !status.is_success() {
223            return Err(http_error("set tags request", status, &text));
224        }
225        // Confirm the post-update state by re-reading the topic; the PUT response
226        // shape is awkward to depend on across versions.
227        self.fetch_topic_tags(topic_id)
228    }
229}