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
89            .text()
90            .context("reading tag groups response body")?;
91        if !status.is_success() {
92            return Err(http_error("tag groups request", status, &text));
93        }
94        let body: TagGroupsResponse =
95            serde_json::from_str(&text).context("parsing tag groups response json")?;
96        Ok(Some(body.tag_groups))
97    }
98
99    /// Create a tag group. Returns the created group's ID.
100    pub fn create_tag_group(&self, payload: &Value) -> Result<u64> {
101        let response = self.send_retrying(|| Ok(self.post("/tag_groups.json")?.json(payload)))?;
102        let status = response.status();
103        let text = response
104            .text()
105            .context("reading create tag group response")?;
106        if !status.is_success() {
107            return Err(http_error("create tag group", status, &text));
108        }
109        let value: Value =
110            serde_json::from_str(&text).context("parsing create tag group response")?;
111        let id = value
112            .pointer("/tag_group/id")
113            .and_then(|v| v.as_u64())
114            .ok_or_else(|| anyhow::anyhow!("tag group creation response missing id"))?;
115        Ok(id)
116    }
117
118    /// Update an existing tag group.
119    pub fn update_tag_group(&self, group_id: u64, payload: &Value) -> Result<()> {
120        let path = format!("/tag_groups/{}.json", group_id);
121        let response = self.send_retrying(|| Ok(self.put(&path)?.json(payload)))?;
122        let status = response.status();
123        let text = response
124            .text()
125            .context("reading update tag group response")?;
126        if !status.is_success() {
127            return Err(http_error("update tag group", status, &text));
128        }
129        Ok(())
130    }
131
132    /// Delete a tag group.
133    pub fn delete_tag_group(&self, group_id: u64) -> Result<()> {
134        let path = format!("/tag_groups/{}.json", group_id);
135        let response = self.delete(&path)?;
136        let status = response.status();
137        if !status.is_success() {
138            let text = response.text().unwrap_or_default();
139            return Err(http_error("delete tag group", status, &text));
140        }
141        Ok(())
142    }
143
144    /// Update an existing tag's metadata (description).
145    ///
146    /// Note: this does NOT create a tag. Discourse exposes no standalone
147    /// create-tag endpoint to an admin API key - `PUT /tag/{name}.json` returns
148    /// 404 for a tag that does not yet exist (verified against 2026.7.0). Tags
149    /// are materialised only implicitly: by a tag group (`POST /tag_groups.json`
150    /// with `tag_names`) or by being assigned to a topic. Call this only for a
151    /// tag that already exists (see `tag_push`, which reconciles groups first).
152    pub fn update_tag(&self, tag_name: &str, description: Option<&str>) -> Result<()> {
153        let path = format!("/tag/{}.json", tag_name);
154        let mut payload = serde_json::Map::new();
155        if let Some(desc) = description {
156            payload.insert("tag".to_string(), serde_json::json!({"description": desc}));
157        }
158        let response = self.send_retrying(|| Ok(self.put(&path)?.json(&payload)))?;
159        let status = response.status();
160        let text = response.text().context("reading update tag response")?;
161        if !status.is_success() {
162            return Err(http_error("update tag", status, &text));
163        }
164        Ok(())
165    }
166
167    /// Rename a tag, preserving topic associations. Discourse accepts a new
168    /// `id` (slug) on the tag-update endpoint and reassigns every topic
169    /// in-place.
170    pub fn rename_tag(&self, old_name: &str, new_name: &str) -> Result<()> {
171        let path = format!("/tag/{}.json", old_name);
172        let payload = serde_json::json!({ "tag": { "id": new_name } });
173        let response = self.send_retrying(|| Ok(self.put(&path)?.json(&payload)))?;
174        let status = response.status();
175        let text = response.text().context("reading rename tag response")?;
176        if !status.is_success() {
177            return Err(http_error("rename tag", status, &text));
178        }
179        Ok(())
180    }
181
182    /// Delete a tag.
183    ///
184    /// The endpoint is singular `/tag/{name}.json` - the plural `/tags/...`
185    /// form returns 404 and silently leaves the tag in place (verified against
186    /// 2026.7.0).
187    pub fn delete_tag(&self, tag_name: &str) -> Result<()> {
188        let path = format!("/tag/{}.json", tag_name);
189        let response = self.delete(&path)?;
190        let status = response.status();
191        if !status.is_success() {
192            let text = response.text().unwrap_or_default();
193            return Err(http_error("delete tag", status, &text));
194        }
195        Ok(())
196    }
197
198    /// Fetch the current tag list for a topic.
199    pub fn fetch_topic_tags(&self, topic_id: u64) -> Result<Vec<String>> {
200        let path = format!("/t/{}.json", topic_id);
201        let response = self.get(&path)?;
202        let status = response.status();
203        let text = response.text().context("reading topic response body")?;
204        if !status.is_success() {
205            return Err(http_error("topic request", status, &text));
206        }
207        let value: Value = serde_json::from_str(&text).context("parsing topic response json")?;
208        let tags = value
209            .get("tags")
210            .and_then(|v| v.as_array())
211            .map(|arr| {
212                arr.iter()
213                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
214                    .collect()
215            })
216            .unwrap_or_default();
217        Ok(tags)
218    }
219
220    /// Replace the full tag list on a topic. Returns the resulting tag set.
221    pub fn set_topic_tags(&self, topic_id: u64, tags: &[String]) -> Result<Vec<String>> {
222        let path = format!("/t/{}.json", topic_id);
223        let payload: Vec<(&str, &str)> = if tags.is_empty() {
224            // Discourse needs at least one form field to clear tags;
225            // sending an empty `tags[]` removes them all.
226            vec![("tags[]", "")]
227        } else {
228            tags.iter().map(|t| ("tags[]", t.as_str())).collect()
229        };
230        let response = self.send_retrying(|| Ok(self.put(&path)?.form(&payload)))?;
231        let status = response.status();
232        let text = response.text().context("reading set-tags response body")?;
233        if !status.is_success() {
234            return Err(http_error("set tags request", status, &text));
235        }
236        // Confirm the post-update state by re-reading the topic; the PUT response
237        // shape is awkward to depend on across versions.
238        self.fetch_topic_tags(topic_id)
239    }
240}