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
196 .post("/admin/groups")?
197 .form(&payload)
198 .send()
199 .context("creating group")?;
200 let status = response.status();
201 let text = response.text().context("reading group response body")?;
202 if !status.is_success() {
203 return Err(http_error("create group request", status, &text));
204 }
205 let value: Value = serde_json::from_str(&text).context("parsing group response json")?;
206 let id = value
207 .get("group")
208 .and_then(|group| group.get("id"))
209 .and_then(|id| id.as_u64())
210 .or_else(|| {
211 value
212 .get("basic_group")
213 .and_then(|g| g.get("id"))
214 .and_then(|id| id.as_u64())
215 })
216 .or_else(|| value.get("id").and_then(|id| id.as_u64()))
217 .ok_or_else(|| anyhow!("missing group id in response: {}", text))?;
218 Ok(id)
219 }
220
221 fn fetch_group_detail_by_path(&self, path: &str) -> Result<Option<GroupDetail>> {
222 let response = self.get(path)?;
223 let status = response.status();
224 let text = response.text().context("reading group detail body")?;
225 if !status.is_success() {
226 if status == StatusCode::NOT_FOUND {
227 return Ok(None);
228 }
229 return Err(http_error("group detail request", status, &text));
230 }
231 let body: GroupDetailResponse =
232 serde_json::from_str(&text).context("parsing group detail json")?;
233 Ok(Some(body.group))
234 }
235
236 fn fetch_group_members_by_path(&self, path: &str) -> Result<Option<Vec<GroupMember>>> {
237 let response = self.get(path)?;
238 let status = response.status();
239 let text = response.text().context("reading group members body")?;
240 if !status.is_success() {
241 if status == StatusCode::NOT_FOUND {
242 return Ok(None);
243 }
244 return Err(http_error("group members request", status, &text));
245 }
246 let body: GroupMembersResponse =
247 serde_json::from_str(&text).context("parsing group members json")?;
248 Ok(Some(body.members))
249 }
250
251 fn fetch_groups_admin(&self) -> Result<Option<Vec<GroupSummary>>> {
252 let response = self.get("/admin/groups.json")?;
253 let status = response.status();
254 let text = response.text().context("reading groups response body")?;
255 if status.is_success() {
256 if text.trim().is_empty() {
257 return Ok(None);
258 }
259 let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
260 return Ok(Some(extract_groups_from_value(&value)?));
261 }
262 if status == StatusCode::NOT_FOUND {
263 return Ok(None);
264 }
265 Err(http_error("groups request", status, &text))
266 }
267
268 fn fetch_groups_paginated(&self, path: &str) -> Result<Vec<GroupSummary>> {
269 let mut out = Vec::new();
270 let mut seen = HashSet::new();
271 let mut next_path = Some(path.to_string());
272
273 while let Some(path) = next_path.take() {
274 let path = self.normalize_groups_path(&path);
275 if !seen.insert(path.clone()) {
276 return Err(anyhow!("groups request loop detected at {}", path));
277 }
278 let response = self.get(&path)?;
279 let status = response.status();
280 let text = response.text().context("reading groups response body")?;
281 if !status.is_success() {
282 return Err(http_error("groups request", status, &text));
283 }
284 if text.trim().is_empty() {
285 return Err(anyhow!(
286 "groups request failed with {} (empty response)",
287 status
288 ));
289 }
290 let value: Value = serde_json::from_str(&text).context("parsing groups json")?;
291 let page_groups = extract_groups_from_value(&value)?;
292 if page_groups.is_empty() {
293 break;
294 }
295 out.extend(page_groups);
296 next_path = extract_next_groups_path(&value);
297 }
298
299 Ok(out)
300 }
301
302 fn normalize_groups_path(&self, path: &str) -> String {
303 let mut path = path.to_string();
304 if let Some(stripped) = path.strip_prefix(self.baseurl()) {
305 path = stripped.to_string();
306 }
307 if !path.starts_with('/') {
308 path = format!("/{}", path);
309 }
310 if path.contains(".json") {
311 return path;
312 }
313 if let Some((base, query)) = path.split_once('?') {
314 format!("{}.json?{}", base, query)
315 } else {
316 format!("{}.json", path)
317 }
318 }
319}
320
321fn push_opt(payload: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
322 if let Some(value) = value {
323 payload.push((key.to_string(), value.to_string()));
324 }
325}
326
327fn extract_groups_from_value(value: &Value) -> Result<Vec<GroupSummary>> {
328 let groups = if let Some(arr) = value.as_array() {
329 arr
330 } else {
331 value
332 .get("groups")
333 .and_then(|v| v.as_array())
334 .ok_or_else(|| anyhow!("groups response missing groups array"))?
335 };
336 let mut out = Vec::with_capacity(groups.len());
337 for group in groups {
338 let parsed: GroupSummary =
339 serde_json::from_value(group.clone()).context("parsing group summary")?;
340 out.push(parsed);
341 }
342 Ok(out)
343}
344
345fn extract_next_groups_path(value: &Value) -> Option<String> {
346 let direct = value
347 .get("load_more_groups")
348 .and_then(|v| v.as_str())
349 .map(|s| s.to_string());
350 if direct
351 .as_deref()
352 .map(|s| !s.trim().is_empty())
353 .unwrap_or(false)
354 {
355 return direct;
356 }
357 value
358 .get("extras")
359 .and_then(|extras| extras.get("load_more_groups"))
360 .and_then(|v| v.as_str())
361 .map(|s| s.to_string())
362 .filter(|s| !s.trim().is_empty())
363}