1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::{
4 GroupDetail, GroupDetailResponse, GroupMember, GroupMembersResponse, GroupSummary,
5};
6use anyhow::{Context, Result, anyhow};
7use reqwest::StatusCode;
8use serde_json::Value;
9use std::collections::HashSet;
10
11impl DiscourseClient {
12 pub fn fetch_groups(&self) -> Result<Vec<GroupSummary>> {
14 if let Some(groups) = self.fetch_groups_admin()? {
15 return Ok(groups);
16 }
17 self.fetch_groups_paginated("/groups.json")
18 }
19
20 pub fn fetch_group_detail(
22 &self,
23 group_id: u64,
24 group_name: Option<&str>,
25 ) -> Result<GroupDetail> {
26 let id_path = format!("/groups/{}.json", group_id);
27 if let Some(detail) = self.fetch_group_detail_by_path(&id_path)? {
28 return Ok(detail);
29 }
30 if let Some(name) = group_name {
31 let name_path = format!("/groups/{}.json", name);
32 if let Some(detail) = self.fetch_group_detail_by_path(&name_path)? {
33 return Ok(detail);
34 }
35 }
36 Err(anyhow!("group not found: {}", group_id))
37 }
38
39 pub fn fetch_group_members(
40 &self,
41 group_id: u64,
42 group_name: Option<&str>,
43 ) -> Result<Vec<GroupMember>> {
44 let id_path = format!("/groups/{}/members.json", group_id);
45 if let Some(members) = self.fetch_group_members_by_path(&id_path)? {
46 return Ok(members);
47 }
48 if let Some(name) = group_name {
49 let name_path = format!("/groups/{}/members.json", name);
50 if let Some(members) = self.fetch_group_members_by_path(&name_path)? {
51 return Ok(members);
52 }
53 }
54 Err(anyhow!("group not found: {}", group_id))
55 }
56
57 pub fn create_group(&self, group: &GroupDetail) -> Result<u64> {
59 let mut payload: Vec<(String, String)> = Vec::new();
60 payload.push(("group[name]".to_string(), group.name.clone()));
61 if let Some(full_name) = group.full_name.clone() {
62 payload.push(("group[full_name]".to_string(), full_name));
63 }
64 push_opt(&mut payload, "group[title]", group.title.as_deref());
65 push_opt(
66 &mut payload,
67 "group[grant_trust_level]",
68 group
69 .grant_trust_level
70 .as_ref()
71 .map(|v| v.to_string())
72 .as_deref(),
73 );
74 push_opt(
75 &mut payload,
76 "group[visibility_level]",
77 group
78 .visibility_level
79 .as_ref()
80 .map(|v| v.to_string())
81 .as_deref(),
82 );
83 push_opt(
84 &mut payload,
85 "group[mentionable_level]",
86 group
87 .mentionable_level
88 .as_ref()
89 .map(|v| v.to_string())
90 .as_deref(),
91 );
92 push_opt(
93 &mut payload,
94 "group[messageable_level]",
95 group
96 .messageable_level
97 .as_ref()
98 .map(|v| v.to_string())
99 .as_deref(),
100 );
101 push_opt(
102 &mut payload,
103 "group[default_notification_level]",
104 group
105 .default_notification_level
106 .as_ref()
107 .map(|v| v.to_string())
108 .as_deref(),
109 );
110 push_opt(
111 &mut payload,
112 "group[members_visibility_level]",
113 group
114 .members_visibility_level
115 .as_ref()
116 .map(|v| v.to_string())
117 .as_deref(),
118 );
119 push_opt(
120 &mut payload,
121 "group[primary_group]",
122 group
123 .primary_group
124 .as_ref()
125 .map(|v| v.to_string())
126 .as_deref(),
127 );
128 push_opt(
129 &mut payload,
130 "group[public_admission]",
131 group
132 .public_admission
133 .as_ref()
134 .map(|v| v.to_string())
135 .as_deref(),
136 );
137 push_opt(
138 &mut payload,
139 "group[public_exit]",
140 group.public_exit.as_ref().map(|v| v.to_string()).as_deref(),
141 );
142 push_opt(
143 &mut payload,
144 "group[allow_membership_requests]",
145 group
146 .allow_membership_requests
147 .as_ref()
148 .map(|v| v.to_string())
149 .as_deref(),
150 );
151 push_opt(
152 &mut payload,
153 "group[automatic_membership_email_domains]",
154 group.automatic_membership_email_domains.as_deref(),
155 );
156 push_opt(
157 &mut payload,
158 "group[automatic_membership_retroactive]",
159 group
160 .automatic_membership_retroactive
161 .as_ref()
162 .map(|v| v.to_string())
163 .as_deref(),
164 );
165 push_opt(
166 &mut payload,
167 "group[membership_request_template]",
168 group.membership_request_template.as_deref(),
169 );
170 push_opt(
171 &mut payload,
172 "group[flair_icon]",
173 group.flair_icon.as_deref(),
174 );
175 push_opt(
176 &mut payload,
177 "group[flair_upload_id]",
178 group
179 .flair_upload_id
180 .as_ref()
181 .map(|v| v.to_string())
182 .as_deref(),
183 );
184 push_opt(
185 &mut payload,
186 "group[flair_color]",
187 group.flair_color.as_deref(),
188 );
189 push_opt(
190 &mut payload,
191 "group[flair_background_color]",
192 group.flair_background_color.as_deref(),
193 );
194 push_opt(&mut payload, "group[bio_raw]", group.bio_raw.as_deref());
195 let response = self.send_retrying(|| Ok(self.post("/admin/groups")?.form(&payload)))?;
196 let status = response.status();
197 let text = response.text().context("reading group response body")?;
198 if !status.is_success() {
199 return Err(http_error("create group request", status, &text));
200 }
201 let value: Value = serde_json::from_str(&text).context("parsing group response json")?;
202 let id = value
203 .get("group")
204 .and_then(|group| group.get("id"))
205 .and_then(|id| id.as_u64())
206 .or_else(|| {
207 value
208 .get("basic_group")
209 .and_then(|g| g.get("id"))
210 .and_then(|id| id.as_u64())
211 })
212 .or_else(|| value.get("id").and_then(|id| id.as_u64()))
213 .ok_or_else(|| anyhow!("missing group id in response: {}", text))?;
214 Ok(id)
215 }
216
217 fn fetch_group_detail_by_path(&self, path: &str) -> Result<Option<GroupDetail>> {
218 let response = self.get(path)?;
219 let status = response.status();
220 let text = response.text().context("reading group detail body")?;
221 if !status.is_success() {
222 if status == StatusCode::NOT_FOUND {
223 return Ok(None);
224 }
225 return Err(http_error("group detail request", status, &text));
226 }
227 let body: GroupDetailResponse =
228 serde_json::from_str(&text).context("parsing group detail json")?;
229 Ok(Some(body.group))
230 }
231
232 fn fetch_group_members_by_path(&self, path: &str) -> Result<Option<Vec<GroupMember>>> {
233 let response = self.get(path)?;
234 let status = response.status();
235 let text = response.text().context("reading group members body")?;
236 if !status.is_success() {
237 if status == StatusCode::NOT_FOUND {
238 return Ok(None);
239 }
240 return Err(http_error("group members request", status, &text));
241 }
242 let body: GroupMembersResponse =
243 serde_json::from_str(&text).context("parsing group members json")?;
244 Ok(Some(body.members))
245 }
246
247 fn fetch_groups_admin(&self) -> Result<Option<Vec<GroupSummary>>> {
248 let response = self.get("/admin/groups.json")?;
249 let status = response.status();
250 let text = response.text().context("reading groups response body")?;
251 if status.is_success() {
252 if text.trim().is_empty() {
253 return Ok(None);
254 }
255 let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
256 return Ok(Some(extract_groups_from_value(&value)?));
257 }
258 if status == StatusCode::NOT_FOUND {
259 return Ok(None);
260 }
261 Err(http_error("groups request", status, &text))
262 }
263
264 fn fetch_groups_paginated(&self, path: &str) -> Result<Vec<GroupSummary>> {
265 let mut out = Vec::new();
266 let mut seen = HashSet::new();
267 let mut next_path = Some(path.to_string());
268
269 while let Some(path) = next_path.take() {
270 let path = self.normalize_groups_path(&path);
271 if !seen.insert(path.clone()) {
272 return Err(anyhow!("groups request loop detected at {}", path));
273 }
274 let response = self.get(&path)?;
275 let status = response.status();
276 let text = response.text().context("reading groups response body")?;
277 if !status.is_success() {
278 return Err(http_error("groups request", status, &text));
279 }
280 if text.trim().is_empty() {
281 return Err(anyhow!(
282 "groups request failed with {} (empty response)",
283 status
284 ));
285 }
286 let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
287 let page_groups = extract_groups_from_value(&value)?;
288 if page_groups.is_empty() {
289 break;
290 }
291 out.extend(page_groups);
292 next_path = extract_next_groups_path(&value);
293 }
294
295 Ok(out)
296 }
297
298 fn normalize_groups_path(&self, path: &str) -> String {
299 let mut path = path.to_string();
300 if let Some(stripped) = path.strip_prefix(self.baseurl()) {
301 path = stripped.to_string();
302 }
303 if !path.starts_with('/') {
304 path = format!("/{}", path);
305 }
306 if path.contains(".json") {
307 return path;
308 }
309 if let Some((base, query)) = path.split_once('?') {
310 format!("{}.json?{}", base, query)
311 } else {
312 format!("{}.json", path)
313 }
314 }
315}
316
317fn push_opt(payload: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
318 if let Some(value) = value {
319 payload.push((key.to_string(), value.to_string()));
320 }
321}
322
323fn extract_groups_from_value(value: &Value) -> Result<Vec<GroupSummary>> {
324 let groups = if let Some(arr) = value.as_array() {
325 arr
326 } else {
327 value
328 .get("groups")
329 .and_then(|v| v.as_array())
330 .ok_or_else(|| anyhow!("groups response missing groups array"))?
331 };
332 let mut out = Vec::with_capacity(groups.len());
333 for group in groups {
334 let parsed: GroupSummary =
335 serde_json::from_value(group.clone()).context("parsing group summary")?;
336 out.push(parsed);
337 }
338 Ok(out)
339}
340
341fn extract_next_groups_path(value: &Value) -> Option<String> {
342 let direct = value
343 .get("load_more_groups")
344 .and_then(|v| v.as_str())
345 .map(|s| s.to_string());
346 if direct
347 .as_deref()
348 .map(|s| !s.trim().is_empty())
349 .unwrap_or(false)
350 {
351 return direct;
352 }
353 value
354 .get("extras")
355 .and_then(|extras| extras.get("load_more_groups"))
356 .and_then(|v| v.as_str())
357 .map(|s| s.to_string())
358 .filter(|s| !s.trim().is_empty())
359}