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