1use crate::api::{DiscourseClient, UserAction};
2use crate::cli::ListFormat;
3use crate::commands::common::{ensure_api_credentials, select_discourse};
4use crate::config::Config;
5use crate::utils::{normalize_baseurl, parse_since_cutoff};
6use anyhow::{Context, Result, anyhow};
7use std::io::{self, Read};
8
9pub fn user_list(
10 config: &Config,
11 discourse_name: &str,
12 listing: &str,
13 page: u32,
14 format: ListFormat,
15) -> Result<()> {
16 let discourse = select_discourse(config, Some(discourse_name))?;
17 ensure_api_credentials(discourse)?;
18 let client = DiscourseClient::new(discourse)?;
19 let users = client.admin_list_users(listing, page)?;
20
21 match format {
22 ListFormat::Text => {
23 if users.is_empty() {
24 println!("No users found in listing '{}'.", listing);
25 return Ok(());
26 }
27 let name_width = users
28 .iter()
29 .map(|u| u.username.len())
30 .max()
31 .unwrap_or(0)
32 .max(8);
33 for u in &users {
34 let flag = if u.admin.unwrap_or(false) {
35 "admin"
36 } else if u.moderator.unwrap_or(false) {
37 "mod"
38 } else if u.suspended.unwrap_or(false) {
39 "suspended"
40 } else if u.silenced.unwrap_or(false) {
41 "silenced"
42 } else {
43 "-"
44 };
45 let tl = u
46 .trust_level
47 .map(|t| t.to_string())
48 .unwrap_or_else(|| "?".to_string());
49 println!(
50 "{:<width$} id:{} tl:{} {}",
51 u.username,
52 u.id,
53 tl,
54 flag,
55 width = name_width
56 );
57 }
58 }
59 ListFormat::Json => {
60 println!("{}", serde_json::to_string_pretty(&users)?);
61 }
62 ListFormat::Yaml => {
63 println!("{}", serde_yaml::to_string(&users)?);
64 }
65 }
66
67 Ok(())
68}
69
70pub fn user_info(
71 config: &Config,
72 discourse_name: &str,
73 username: &str,
74 format: ListFormat,
75) -> Result<()> {
76 let discourse = select_discourse(config, Some(discourse_name))?;
77 ensure_api_credentials(discourse)?;
78 let client = DiscourseClient::new(discourse)?;
79 let detail = client.fetch_user_detail(username)?;
80
81 match format {
82 ListFormat::Text => {
83 println!("id: {}", detail.id);
84 println!("username: {}", detail.username);
85 if let Some(name) = &detail.name {
86 println!("name: {}", name);
87 }
88 if let Some(email) = &detail.email {
89 println!("email: {}", email);
90 }
91 if let Some(tl) = detail.trust_level {
92 println!("trust_level: {}", tl);
93 }
94 if detail.admin.unwrap_or(false) {
95 println!("role: admin");
96 } else if detail.moderator.unwrap_or(false) {
97 println!("role: moderator");
98 }
99 if let Some(until) = &detail.suspended_till {
100 println!("suspended: until {}", until);
101 }
102 if let Some(until) = &detail.silenced_till {
103 println!("silenced: until {}", until);
104 }
105 if let Some(last) = &detail.last_seen_at {
106 println!("last_seen: {}", last);
107 }
108 if let Some(created) = &detail.created_at {
109 println!("created: {}", created);
110 }
111 if let Some(posts) = detail.post_count {
112 println!("posts: {}", posts);
113 }
114 if !detail.groups.is_empty() {
115 println!("groups: {}", detail.groups.len());
116 }
117 }
118 ListFormat::Json => {
119 println!("{}", serde_json::to_string_pretty(&detail)?);
120 }
121 ListFormat::Yaml => {
122 println!("{}", serde_yaml::to_string(&detail)?);
123 }
124 }
125 Ok(())
126}
127
128pub fn user_suspend(
129 config: &Config,
130 discourse_name: &str,
131 username: &str,
132 until: &str,
133 reason: &str,
134 dry_run: bool,
135) -> Result<()> {
136 let discourse = select_discourse(config, Some(discourse_name))?;
137 ensure_api_credentials(discourse)?;
138 let client = DiscourseClient::new(discourse)?;
139
140 if dry_run {
141 println!(
142 "[dry-run] {}: would suspend {} until {} (reason: {})",
143 discourse.name,
144 username,
145 until,
146 if reason.is_empty() { "<none>" } else { reason }
147 );
148 return Ok(());
149 }
150
151 let detail = client.fetch_user_detail(username)?;
152 client.suspend_user(detail.id, until, reason)?;
153 println!("Suspended {} (id:{}) until {}", detail.username, detail.id, until);
154 Ok(())
155}
156
157pub fn user_unsuspend(
158 config: &Config,
159 discourse_name: &str,
160 username: &str,
161 dry_run: bool,
162) -> Result<()> {
163 let discourse = select_discourse(config, Some(discourse_name))?;
164 ensure_api_credentials(discourse)?;
165 let client = DiscourseClient::new(discourse)?;
166
167 if dry_run {
168 println!("[dry-run] {}: would unsuspend {}", discourse.name, username);
169 return Ok(());
170 }
171
172 let detail = client.fetch_user_detail(username)?;
173 client.unsuspend_user(detail.id)?;
174 println!("Unsuspended {} (id:{})", detail.username, detail.id);
175 Ok(())
176}
177
178pub fn user_silence(
179 config: &Config,
180 discourse_name: &str,
181 username: &str,
182 until: &str,
183 reason: &str,
184 dry_run: bool,
185) -> Result<()> {
186 let discourse = select_discourse(config, Some(discourse_name))?;
187 ensure_api_credentials(discourse)?;
188 let client = DiscourseClient::new(discourse)?;
189
190 if dry_run {
191 println!(
192 "[dry-run] {}: would silence {}{}{}",
193 discourse.name,
194 username,
195 if until.is_empty() {
196 String::new()
197 } else {
198 format!(" until {}", until)
199 },
200 if reason.is_empty() {
201 String::new()
202 } else {
203 format!(" (reason: {})", reason)
204 },
205 );
206 return Ok(());
207 }
208
209 let detail = client.fetch_user_detail(username)?;
210 client.silence_user(detail.id, until, reason)?;
211 println!("Silenced {} (id:{})", detail.username, detail.id);
212 Ok(())
213}
214
215pub fn user_unsilence(
216 config: &Config,
217 discourse_name: &str,
218 username: &str,
219 dry_run: bool,
220) -> Result<()> {
221 let discourse = select_discourse(config, Some(discourse_name))?;
222 ensure_api_credentials(discourse)?;
223 let client = DiscourseClient::new(discourse)?;
224
225 if dry_run {
226 println!("[dry-run] {}: would unsilence {}", discourse.name, username);
227 return Ok(());
228 }
229
230 let detail = client.fetch_user_detail(username)?;
231 client.unsilence_user(detail.id)?;
232 println!("Unsilenced {} (id:{})", detail.username, detail.id);
233 Ok(())
234}
235
236#[derive(Clone, Copy)]
237pub enum Role {
238 Admin,
239 Moderator,
240}
241
242pub fn user_promote(
243 config: &Config,
244 discourse_name: &str,
245 username: &str,
246 role: Role,
247 dry_run: bool,
248) -> Result<()> {
249 let discourse = select_discourse(config, Some(discourse_name))?;
250 ensure_api_credentials(discourse)?;
251 let client = DiscourseClient::new(discourse)?;
252
253 let role_label = match role {
254 Role::Admin => "admin",
255 Role::Moderator => "moderator",
256 };
257
258 if dry_run {
259 println!(
260 "[dry-run] {}: would grant {} to {}",
261 discourse.name, role_label, username
262 );
263 return Ok(());
264 }
265
266 let detail = client.fetch_user_detail(username)?;
267 match role {
268 Role::Admin => client.grant_admin(detail.id)?,
269 Role::Moderator => client.grant_moderation(detail.id)?,
270 }
271 println!("Granted {} to {} (id:{})", role_label, detail.username, detail.id);
272 Ok(())
273}
274
275pub fn user_demote(
276 config: &Config,
277 discourse_name: &str,
278 username: &str,
279 role: Role,
280 dry_run: bool,
281) -> Result<()> {
282 let discourse = select_discourse(config, Some(discourse_name))?;
283 ensure_api_credentials(discourse)?;
284 let client = DiscourseClient::new(discourse)?;
285
286 let role_label = match role {
287 Role::Admin => "admin",
288 Role::Moderator => "moderator",
289 };
290
291 if dry_run {
292 println!(
293 "[dry-run] {}: would revoke {} from {}",
294 discourse.name, role_label, username
295 );
296 return Ok(());
297 }
298
299 let detail = client.fetch_user_detail(username)?;
300 match role {
301 Role::Admin => client.revoke_admin(detail.id)?,
302 Role::Moderator => client.revoke_moderation(detail.id)?,
303 }
304 println!(
305 "Revoked {} from {} (id:{})",
306 role_label, detail.username, detail.id
307 );
308 Ok(())
309}
310
311pub fn user_groups_list(
312 config: &Config,
313 discourse_name: &str,
314 username: &str,
315 format: ListFormat,
316) -> Result<()> {
317 let discourse = select_discourse(config, Some(discourse_name))?;
318 ensure_api_credentials(discourse)?;
319 let client = DiscourseClient::new(discourse)?;
320
321 let mut groups = client.fetch_user_groups(username)?;
322 groups.sort_by(|a, b| a.name.cmp(&b.name));
323
324 match format {
325 ListFormat::Text => {
326 if groups.is_empty() {
327 println!("{} is not in any groups.", username);
328 return Ok(());
329 }
330 let name_width = groups
331 .iter()
332 .map(|g| g.name.len())
333 .max()
334 .unwrap_or(0)
335 .max(4);
336 for g in &groups {
337 println!("{:<width$} id:{}", g.name, g.id, width = name_width);
338 }
339 }
340 ListFormat::Json => {
341 println!("{}", serde_json::to_string_pretty(&groups)?);
342 }
343 ListFormat::Yaml => {
344 println!("{}", serde_yaml::to_string(&groups)?);
345 }
346 }
347
348 Ok(())
349}
350
351pub fn user_groups_add(
352 config: &Config,
353 discourse_name: &str,
354 username: &str,
355 group_id: u64,
356 notify: bool,
357 dry_run: bool,
358) -> Result<()> {
359 let discourse = select_discourse(config, Some(discourse_name))?;
360 ensure_api_credentials(discourse)?;
361 let client = DiscourseClient::new(discourse)?;
362
363 if dry_run {
364 println!(
365 "[dry-run] {}: would add {} to group {} (notify={})",
366 discourse.name, username, group_id, notify
367 );
368 return Ok(());
369 }
370
371 let usernames = vec![username.to_string()];
372 let outcome = client.add_group_members_by_username(group_id, &usernames, notify)?;
373 if outcome.added_usernames.is_empty() {
374 println!(
375 "{} was already a member of group {} (or Discourse reported no change)",
376 username, group_id
377 );
378 } else {
379 println!("Added {} to group {}", username, group_id);
380 }
381 if !outcome.errors.is_empty() {
382 eprintln!("Server notes:");
383 for msg in &outcome.errors {
384 eprintln!(" - {}", msg);
385 }
386 }
387 Ok(())
388}
389
390pub fn user_groups_remove(
391 config: &Config,
392 discourse_name: &str,
393 username: &str,
394 group_id: u64,
395 dry_run: bool,
396) -> Result<()> {
397 let discourse = select_discourse(config, Some(discourse_name))?;
398 ensure_api_credentials(discourse)?;
399 let client = DiscourseClient::new(discourse)?;
400
401 if dry_run {
402 println!(
403 "[dry-run] {}: would remove {} from group {}",
404 discourse.name, username, group_id
405 );
406 return Ok(());
407 }
408
409 let usernames = vec![username.to_string()];
410 client.remove_group_members_by_username(group_id, &usernames)?;
411 println!("Removed {} from group {}", username, group_id);
412 Ok(())
413}
414
415#[derive(Clone, Copy)]
418pub enum ActivityFormat {
419 Text,
420 Json,
421 Yaml,
422 Markdown,
423 Csv,
424}
425
426pub fn user_activity(
428 config: &Config,
429 discourse_name: &str,
430 username: &str,
431 type_names: &[String],
432 since: Option<&str>,
433 limit: Option<u32>,
434 format: ActivityFormat,
435) -> Result<()> {
436 let discourse = select_discourse(config, Some(discourse_name))?;
437 let client = DiscourseClient::new(discourse)?;
443
444 let filter_types = resolve_activity_types(type_names)?;
445 let cutoff = match since {
446 Some(raw) => Some(parse_since_cutoff(raw)?),
447 None => None,
448 };
449
450 let mut collected: Vec<UserAction> = Vec::new();
451 let mut offset: u32 = 0;
452 let page_hint: u32 = 30; let max = limit.unwrap_or(u32::MAX);
454 loop {
455 let page = client.fetch_user_actions(username, &filter_types, offset)?;
456 if page.is_empty() {
457 break;
458 }
459 let page_len = page.len() as u32;
460
461 let mut past_cutoff = false;
462 for action in page {
463 if let Some(cutoff) = cutoff {
464 if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&action.created_at) {
465 if created.with_timezone(&chrono::Utc) < cutoff {
466 past_cutoff = true;
467 continue;
468 }
469 }
470 }
471 collected.push(action);
472 if collected.len() as u32 >= max {
473 break;
474 }
475 }
476
477 if past_cutoff || collected.len() as u32 >= max {
478 break;
479 }
480 offset = offset.saturating_add(page_len.max(page_hint));
481 }
482
483 render_activity(&collected, &normalize_baseurl(&discourse.baseurl), format)
484}
485
486fn render_activity(
487 actions: &[UserAction],
488 baseurl: &str,
489 format: ActivityFormat,
490) -> Result<()> {
491 match format {
492 ActivityFormat::Text => {
493 if actions.is_empty() {
494 println!("No activity in that window.");
495 return Ok(());
496 }
497 for a in actions {
498 let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
499 let title = a.title.as_deref().unwrap_or("(untitled)");
500 let kind = action_type_label(a.action_type);
501 println!(
502 "{} [{:<6}] {} {}",
503 date,
504 kind,
505 title,
506 activity_url(baseurl, a)
507 );
508 }
509 }
510 ActivityFormat::Markdown => {
511 for a in actions {
512 let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
513 let title = a.title.as_deref().unwrap_or("(untitled)");
514 println!(
515 "- [{}]({}) — {}",
516 title,
517 activity_url(baseurl, a),
518 date
519 );
520 }
521 }
522 ActivityFormat::Csv => {
523 println!("date,type,title,url");
524 for a in actions {
525 let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
526 let title = a.title.as_deref().unwrap_or("").replace('"', "\"\"");
527 println!(
528 "{},{},\"{}\",{}",
529 date,
530 action_type_label(a.action_type),
531 title,
532 activity_url(baseurl, a)
533 );
534 }
535 }
536 ActivityFormat::Json => println!("{}", serde_json::to_string_pretty(&actions)?),
537 ActivityFormat::Yaml => println!("{}", serde_yaml::to_string(&actions)?),
538 }
539 Ok(())
540}
541
542pub(crate) fn activity_url(baseurl: &str, a: &UserAction) -> String {
544 let slug = a.slug.as_deref().unwrap_or("-");
545 match a.post_number {
546 Some(n) if n > 1 => format!("{}/t/{}/{}/{}", baseurl, slug, a.topic_id, n),
547 _ => format!("{}/t/{}/{}", baseurl, slug, a.topic_id),
548 }
549}
550
551const ACTION_LIKE: u32 = 1;
553const ACTION_NEW_TOPIC: u32 = 4;
554const ACTION_REPLY: u32 = 5;
555const ACTION_RESPONSE: u32 = 6;
556const ACTION_MENTION: u32 = 7;
557const ACTION_QUOTE: u32 = 9;
558const ACTION_EDIT: u32 = 11;
559
560fn action_type_label(n: u32) -> &'static str {
561 match n {
562 ACTION_LIKE => "like",
563 ACTION_NEW_TOPIC => "topic",
564 ACTION_REPLY => "reply",
565 ACTION_RESPONSE => "resp",
566 ACTION_MENTION => "@",
567 ACTION_QUOTE => "quote",
568 ACTION_EDIT => "edit",
569 _ => "?",
570 }
571}
572
573pub(crate) fn resolve_activity_types(names: &[String]) -> Result<Vec<u32>> {
575 if names.is_empty() {
576 return Ok(vec![ACTION_NEW_TOPIC, ACTION_REPLY]);
577 }
578 let mut out = Vec::new();
579 for raw in names {
580 for piece in raw.split(',') {
581 let piece = piece.trim().to_ascii_lowercase();
582 if piece.is_empty() {
583 continue;
584 }
585 let n = match piece.as_str() {
586 "topic" | "topics" | "new_topic" => ACTION_NEW_TOPIC,
587 "reply" | "replies" => ACTION_REPLY,
588 "response" | "responses" => ACTION_RESPONSE,
589 "mention" | "mentions" => ACTION_MENTION,
590 "quote" | "quotes" => ACTION_QUOTE,
591 "like" | "likes" => ACTION_LIKE,
592 "edit" | "edits" => ACTION_EDIT,
593 other => {
594 return Err(anyhow!(
595 "unknown activity type: {:?} (known: topics, replies, mentions, quotes, likes, edits, responses)",
596 other
597 ));
598 }
599 };
600 if !out.contains(&n) {
601 out.push(n);
602 }
603 }
604 }
605 Ok(out)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::api::UserAction;
612
613 #[test]
614 fn default_activity_types_are_topics_and_replies() {
615 assert_eq!(
616 resolve_activity_types(&[]).unwrap(),
617 vec![ACTION_NEW_TOPIC, ACTION_REPLY]
618 );
619 }
620
621 #[test]
622 fn activity_types_accept_plural_and_csv() {
623 let got = resolve_activity_types(&["topics,mentions".to_string()]).unwrap();
624 assert_eq!(got, vec![ACTION_NEW_TOPIC, ACTION_MENTION]);
625 }
626
627 #[test]
628 fn activity_types_dedupe() {
629 let got = resolve_activity_types(&["reply,reply,replies".to_string()]).unwrap();
630 assert_eq!(got, vec![ACTION_REPLY]);
631 }
632
633 #[test]
634 fn activity_types_reject_unknown() {
635 assert!(resolve_activity_types(&["nonsense".to_string()]).is_err());
636 }
637
638 #[test]
639 fn activity_url_for_reply_includes_post_number() {
640 let a = UserAction {
641 action_type: ACTION_REPLY,
642 created_at: "2026-04-15T12:00:00Z".to_string(),
643 title: Some("Hi".to_string()),
644 slug: Some("hi-there".to_string()),
645 topic_id: 42,
646 post_id: Some(999),
647 post_number: Some(3),
648 username: Some("alice".to_string()),
649 excerpt: None,
650 };
651 assert_eq!(
652 activity_url("https://f.example", &a),
653 "https://f.example/t/hi-there/42/3"
654 );
655 }
656
657 #[test]
658 fn activity_url_for_op_omits_post_number() {
659 let a = UserAction {
660 action_type: ACTION_NEW_TOPIC,
661 created_at: "2026-04-15T12:00:00Z".to_string(),
662 title: Some("Hi".to_string()),
663 slug: Some("hi-there".to_string()),
664 topic_id: 42,
665 post_id: Some(999),
666 post_number: Some(1),
667 username: Some("alice".to_string()),
668 excerpt: None,
669 };
670 assert_eq!(
671 activity_url("https://f.example", &a),
672 "https://f.example/t/hi-there/42"
673 );
674 }
675}
676
677pub fn user_create(
678 config: &Config,
679 discourse_name: &str,
680 email: &str,
681 username: &str,
682 name: Option<&str>,
683 password_from_stdin: bool,
684 approve: bool,
685 dry_run: bool,
686) -> Result<()> {
687 let discourse = select_discourse(config, Some(discourse_name))?;
688 ensure_api_credentials(discourse)?;
689 let client = DiscourseClient::new(discourse)?;
690
691 if !email.contains('@') {
692 return Err(anyhow!("invalid email: {:?}", email));
693 }
694 if username.trim().is_empty() {
695 return Err(anyhow!("username cannot be empty"));
696 }
697
698 let password = if password_from_stdin {
699 let mut buf = String::new();
700 io::stdin()
701 .read_to_string(&mut buf)
702 .context("reading password from stdin")?;
703 let trimmed = buf.trim_end_matches(['\r', '\n']).to_string();
704 if trimmed.is_empty() {
705 return Err(anyhow!("--password-stdin set but stdin was empty"));
706 }
707 Some(trimmed)
708 } else {
709 None
710 };
711
712 if dry_run {
713 println!(
714 "[dry-run] {}: would create user {} ({}){}{}{}",
715 discourse.name,
716 username,
717 email,
718 name.filter(|n| !n.is_empty())
719 .map(|n| format!(", name=\"{}\"", n))
720 .unwrap_or_default(),
721 if password.is_some() {
722 ", with password from stdin"
723 } else {
724 ", no password (triggers email reset flow)"
725 },
726 if approve { ", approved=true" } else { "" }
727 );
728 return Ok(());
729 }
730
731 let id = client.create_user(email, username, password.as_deref(), name, approve)?;
732 println!("Created user {} (id:{})", username, id);
733 if password.is_none() {
734 println!(
735 " no password set — send them a reset email with:\n dsc user password-reset {} {}",
736 discourse.name, username
737 );
738 }
739 Ok(())
740}
741
742pub fn user_password_reset(
743 config: &Config,
744 discourse_name: &str,
745 username: &str,
746 dry_run: bool,
747) -> Result<()> {
748 let discourse = select_discourse(config, Some(discourse_name))?;
749 ensure_api_credentials(discourse)?;
750 let client = DiscourseClient::new(discourse)?;
751
752 if dry_run {
753 println!(
754 "[dry-run] {}: would trigger password-reset email for {}",
755 discourse.name, username
756 );
757 return Ok(());
758 }
759
760 client.trigger_password_reset(username)?;
761 println!("Password reset request sent for {} (if that user exists).", username);
765 Ok(())
766}
767
768pub fn user_email_set(
769 config: &Config,
770 discourse_name: &str,
771 username: &str,
772 email: &str,
773 dry_run: bool,
774) -> Result<()> {
775 let discourse = select_discourse(config, Some(discourse_name))?;
776 ensure_api_credentials(discourse)?;
777 let client = DiscourseClient::new(discourse)?;
778
779 if !email.contains('@') {
780 return Err(anyhow!("invalid email: {:?}", email));
781 }
782
783 if dry_run {
784 println!(
785 "[dry-run] {}: would set {}'s email to {}",
786 discourse.name, username, email
787 );
788 return Ok(());
789 }
790
791 client.set_user_email(username, email)?;
792 println!("Set {}'s email to {}", username, email);
793 Ok(())
794}