use crate::api::{DiscourseClient, UserAction};
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, select_discourse};
use crate::config::Config;
use crate::utils::{normalize_baseurl, parse_since_cutoff};
use anyhow::{Context, Result, anyhow};
use std::io::{self, Read};
pub fn user_list(
config: &Config,
discourse_name: &str,
listing: &str,
page: u32,
format: ListFormat,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let users = client.admin_list_users(listing, page)?;
match format {
ListFormat::Text => {
if users.is_empty() {
println!("No users found in listing '{}'.", listing);
return Ok(());
}
let name_width = users
.iter()
.map(|u| u.username.len())
.max()
.unwrap_or(0)
.max(8);
for u in &users {
let flag = if u.admin.unwrap_or(false) {
"admin"
} else if u.moderator.unwrap_or(false) {
"mod"
} else if u.suspended.unwrap_or(false) {
"suspended"
} else if u.silenced.unwrap_or(false) {
"silenced"
} else {
"-"
};
let tl = u
.trust_level
.map(|t| t.to_string())
.unwrap_or_else(|| "?".to_string());
println!(
"{:<width$} id:{} tl:{} {}",
u.username,
u.id,
tl,
flag,
width = name_width
);
}
}
ListFormat::Json => {
println!("{}", serde_json::to_string_pretty(&users)?);
}
ListFormat::Yaml => {
println!("{}", serde_yaml::to_string(&users)?);
}
}
Ok(())
}
pub fn user_info(
config: &Config,
discourse_name: &str,
username: &str,
format: ListFormat,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let detail = client.fetch_user_detail(username)?;
match format {
ListFormat::Text => {
println!("id: {}", detail.id);
println!("username: {}", detail.username);
if let Some(name) = &detail.name {
println!("name: {}", name);
}
if let Some(email) = &detail.email {
println!("email: {}", email);
}
if let Some(tl) = detail.trust_level {
println!("trust_level: {}", tl);
}
if detail.admin.unwrap_or(false) {
println!("role: admin");
} else if detail.moderator.unwrap_or(false) {
println!("role: moderator");
}
if let Some(until) = &detail.suspended_till {
println!("suspended: until {}", until);
}
if let Some(until) = &detail.silenced_till {
println!("silenced: until {}", until);
}
if let Some(last) = &detail.last_seen_at {
println!("last_seen: {}", last);
}
if let Some(created) = &detail.created_at {
println!("created: {}", created);
}
if let Some(posts) = detail.post_count {
println!("posts: {}", posts);
}
if !detail.groups.is_empty() {
println!("groups: {}", detail.groups.len());
}
}
ListFormat::Json => {
println!("{}", serde_json::to_string_pretty(&detail)?);
}
ListFormat::Yaml => {
println!("{}", serde_yaml::to_string(&detail)?);
}
}
Ok(())
}
pub fn user_suspend(
config: &Config,
discourse_name: &str,
username: &str,
until: &str,
reason: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would suspend {} until {} (reason: {})",
discourse.name,
username,
until,
if reason.is_empty() { "<none>" } else { reason }
);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
client.suspend_user(detail.id, until, reason)?;
println!("Suspended {} (id:{}) until {}", detail.username, detail.id, until);
Ok(())
}
pub fn user_unsuspend(
config: &Config,
discourse_name: &str,
username: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!("[dry-run] {}: would unsuspend {}", discourse.name, username);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
client.unsuspend_user(detail.id)?;
println!("Unsuspended {} (id:{})", detail.username, detail.id);
Ok(())
}
pub fn user_silence(
config: &Config,
discourse_name: &str,
username: &str,
until: &str,
reason: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would silence {}{}{}",
discourse.name,
username,
if until.is_empty() {
String::new()
} else {
format!(" until {}", until)
},
if reason.is_empty() {
String::new()
} else {
format!(" (reason: {})", reason)
},
);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
client.silence_user(detail.id, until, reason)?;
println!("Silenced {} (id:{})", detail.username, detail.id);
Ok(())
}
pub fn user_unsilence(
config: &Config,
discourse_name: &str,
username: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!("[dry-run] {}: would unsilence {}", discourse.name, username);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
client.unsilence_user(detail.id)?;
println!("Unsilenced {} (id:{})", detail.username, detail.id);
Ok(())
}
#[derive(Clone, Copy)]
pub enum Role {
Admin,
Moderator,
}
pub fn user_promote(
config: &Config,
discourse_name: &str,
username: &str,
role: Role,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let role_label = match role {
Role::Admin => "admin",
Role::Moderator => "moderator",
};
if dry_run {
println!(
"[dry-run] {}: would grant {} to {}",
discourse.name, role_label, username
);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
match role {
Role::Admin => client.grant_admin(detail.id)?,
Role::Moderator => client.grant_moderation(detail.id)?,
}
println!("Granted {} to {} (id:{})", role_label, detail.username, detail.id);
Ok(())
}
pub fn user_demote(
config: &Config,
discourse_name: &str,
username: &str,
role: Role,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let role_label = match role {
Role::Admin => "admin",
Role::Moderator => "moderator",
};
if dry_run {
println!(
"[dry-run] {}: would revoke {} from {}",
discourse.name, role_label, username
);
return Ok(());
}
let detail = client.fetch_user_detail(username)?;
match role {
Role::Admin => client.revoke_admin(detail.id)?,
Role::Moderator => client.revoke_moderation(detail.id)?,
}
println!(
"Revoked {} from {} (id:{})",
role_label, detail.username, detail.id
);
Ok(())
}
pub fn user_groups_list(
config: &Config,
discourse_name: &str,
username: &str,
format: ListFormat,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let mut groups = client.fetch_user_groups(username)?;
groups.sort_by(|a, b| a.name.cmp(&b.name));
match format {
ListFormat::Text => {
if groups.is_empty() {
println!("{} is not in any groups.", username);
return Ok(());
}
let name_width = groups
.iter()
.map(|g| g.name.len())
.max()
.unwrap_or(0)
.max(4);
for g in &groups {
println!("{:<width$} id:{}", g.name, g.id, width = name_width);
}
}
ListFormat::Json => {
println!("{}", serde_json::to_string_pretty(&groups)?);
}
ListFormat::Yaml => {
println!("{}", serde_yaml::to_string(&groups)?);
}
}
Ok(())
}
pub fn user_groups_add(
config: &Config,
discourse_name: &str,
username: &str,
group_id: u64,
notify: bool,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would add {} to group {} (notify={})",
discourse.name, username, group_id, notify
);
return Ok(());
}
let usernames = vec![username.to_string()];
let outcome = client.add_group_members_by_username(group_id, &usernames, notify)?;
if outcome.added_usernames.is_empty() {
println!(
"{} was already a member of group {} (or Discourse reported no change)",
username, group_id
);
} else {
println!("Added {} to group {}", username, group_id);
}
if !outcome.errors.is_empty() {
eprintln!("Server notes:");
for msg in &outcome.errors {
eprintln!(" - {}", msg);
}
}
Ok(())
}
pub fn user_groups_remove(
config: &Config,
discourse_name: &str,
username: &str,
group_id: u64,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would remove {} from group {}",
discourse.name, username, group_id
);
return Ok(());
}
let usernames = vec![username.to_string()];
client.remove_group_members_by_username(group_id, &usernames)?;
println!("Removed {} from group {}", username, group_id);
Ok(())
}
#[derive(Clone, Copy)]
pub enum ActivityFormat {
Text,
Json,
Yaml,
Markdown,
Csv,
}
pub fn user_activity(
config: &Config,
discourse_name: &str,
username: &str,
type_names: &[String],
since: Option<&str>,
limit: Option<u32>,
format: ActivityFormat,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
let client = DiscourseClient::new(discourse)?;
let filter_types = resolve_activity_types(type_names)?;
let cutoff = match since {
Some(raw) => Some(parse_since_cutoff(raw)?),
None => None,
};
let mut collected: Vec<UserAction> = Vec::new();
let mut offset: u32 = 0;
let page_hint: u32 = 30; let max = limit.unwrap_or(u32::MAX);
loop {
let page = client.fetch_user_actions(username, &filter_types, offset)?;
if page.is_empty() {
break;
}
let page_len = page.len() as u32;
let mut past_cutoff = false;
for action in page {
if let Some(cutoff) = cutoff {
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&action.created_at) {
if created.with_timezone(&chrono::Utc) < cutoff {
past_cutoff = true;
continue;
}
}
}
collected.push(action);
if collected.len() as u32 >= max {
break;
}
}
if past_cutoff || collected.len() as u32 >= max {
break;
}
offset = offset.saturating_add(page_len.max(page_hint));
}
render_activity(&collected, &normalize_baseurl(&discourse.baseurl), format)
}
fn render_activity(
actions: &[UserAction],
baseurl: &str,
format: ActivityFormat,
) -> Result<()> {
match format {
ActivityFormat::Text => {
if actions.is_empty() {
println!("No activity in that window.");
return Ok(());
}
for a in actions {
let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
let title = a.title.as_deref().unwrap_or("(untitled)");
let kind = action_type_label(a.action_type);
println!(
"{} [{:<6}] {} {}",
date,
kind,
title,
activity_url(baseurl, a)
);
}
}
ActivityFormat::Markdown => {
for a in actions {
let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
let title = a.title.as_deref().unwrap_or("(untitled)");
println!(
"- [{}]({}) — {}",
title,
activity_url(baseurl, a),
date
);
}
}
ActivityFormat::Csv => {
println!("date,type,title,url");
for a in actions {
let date = a.created_at.split('T').next().unwrap_or(&a.created_at);
let title = a.title.as_deref().unwrap_or("").replace('"', "\"\"");
println!(
"{},{},\"{}\",{}",
date,
action_type_label(a.action_type),
title,
activity_url(baseurl, a)
);
}
}
ActivityFormat::Json => println!("{}", serde_json::to_string_pretty(&actions)?),
ActivityFormat::Yaml => println!("{}", serde_yaml::to_string(&actions)?),
}
Ok(())
}
pub(crate) fn activity_url(baseurl: &str, a: &UserAction) -> String {
let slug = a.slug.as_deref().unwrap_or("-");
match a.post_number {
Some(n) if n > 1 => format!("{}/t/{}/{}/{}", baseurl, slug, a.topic_id, n),
_ => format!("{}/t/{}/{}", baseurl, slug, a.topic_id),
}
}
const ACTION_LIKE: u32 = 1;
const ACTION_NEW_TOPIC: u32 = 4;
const ACTION_REPLY: u32 = 5;
const ACTION_RESPONSE: u32 = 6;
const ACTION_MENTION: u32 = 7;
const ACTION_QUOTE: u32 = 9;
const ACTION_EDIT: u32 = 11;
fn action_type_label(n: u32) -> &'static str {
match n {
ACTION_LIKE => "like",
ACTION_NEW_TOPIC => "topic",
ACTION_REPLY => "reply",
ACTION_RESPONSE => "resp",
ACTION_MENTION => "@",
ACTION_QUOTE => "quote",
ACTION_EDIT => "edit",
_ => "?",
}
}
pub(crate) fn resolve_activity_types(names: &[String]) -> Result<Vec<u32>> {
if names.is_empty() {
return Ok(vec![ACTION_NEW_TOPIC, ACTION_REPLY]);
}
let mut out = Vec::new();
for raw in names {
for piece in raw.split(',') {
let piece = piece.trim().to_ascii_lowercase();
if piece.is_empty() {
continue;
}
let n = match piece.as_str() {
"topic" | "topics" | "new_topic" => ACTION_NEW_TOPIC,
"reply" | "replies" => ACTION_REPLY,
"response" | "responses" => ACTION_RESPONSE,
"mention" | "mentions" => ACTION_MENTION,
"quote" | "quotes" => ACTION_QUOTE,
"like" | "likes" => ACTION_LIKE,
"edit" | "edits" => ACTION_EDIT,
other => {
return Err(anyhow!(
"unknown activity type: {:?} (known: topics, replies, mentions, quotes, likes, edits, responses)",
other
));
}
};
if !out.contains(&n) {
out.push(n);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::UserAction;
#[test]
fn default_activity_types_are_topics_and_replies() {
assert_eq!(
resolve_activity_types(&[]).unwrap(),
vec![ACTION_NEW_TOPIC, ACTION_REPLY]
);
}
#[test]
fn activity_types_accept_plural_and_csv() {
let got = resolve_activity_types(&["topics,mentions".to_string()]).unwrap();
assert_eq!(got, vec![ACTION_NEW_TOPIC, ACTION_MENTION]);
}
#[test]
fn activity_types_dedupe() {
let got = resolve_activity_types(&["reply,reply,replies".to_string()]).unwrap();
assert_eq!(got, vec![ACTION_REPLY]);
}
#[test]
fn activity_types_reject_unknown() {
assert!(resolve_activity_types(&["nonsense".to_string()]).is_err());
}
#[test]
fn activity_url_for_reply_includes_post_number() {
let a = UserAction {
action_type: ACTION_REPLY,
created_at: "2026-04-15T12:00:00Z".to_string(),
title: Some("Hi".to_string()),
slug: Some("hi-there".to_string()),
topic_id: 42,
post_id: Some(999),
post_number: Some(3),
username: Some("alice".to_string()),
excerpt: None,
};
assert_eq!(
activity_url("https://f.example", &a),
"https://f.example/t/hi-there/42/3"
);
}
#[test]
fn activity_url_for_op_omits_post_number() {
let a = UserAction {
action_type: ACTION_NEW_TOPIC,
created_at: "2026-04-15T12:00:00Z".to_string(),
title: Some("Hi".to_string()),
slug: Some("hi-there".to_string()),
topic_id: 42,
post_id: Some(999),
post_number: Some(1),
username: Some("alice".to_string()),
excerpt: None,
};
assert_eq!(
activity_url("https://f.example", &a),
"https://f.example/t/hi-there/42"
);
}
}
pub fn user_create(
config: &Config,
discourse_name: &str,
email: &str,
username: &str,
name: Option<&str>,
password_from_stdin: bool,
approve: bool,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if !email.contains('@') {
return Err(anyhow!("invalid email: {:?}", email));
}
if username.trim().is_empty() {
return Err(anyhow!("username cannot be empty"));
}
let password = if password_from_stdin {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("reading password from stdin")?;
let trimmed = buf.trim_end_matches(['\r', '\n']).to_string();
if trimmed.is_empty() {
return Err(anyhow!("--password-stdin set but stdin was empty"));
}
Some(trimmed)
} else {
None
};
if dry_run {
println!(
"[dry-run] {}: would create user {} ({}){}{}{}",
discourse.name,
username,
email,
name.filter(|n| !n.is_empty())
.map(|n| format!(", name=\"{}\"", n))
.unwrap_or_default(),
if password.is_some() {
", with password from stdin"
} else {
", no password (triggers email reset flow)"
},
if approve { ", approved=true" } else { "" }
);
return Ok(());
}
let id = client.create_user(email, username, password.as_deref(), name, approve)?;
println!("Created user {} (id:{})", username, id);
if password.is_none() {
println!(
" no password set — send them a reset email with:\n dsc user password-reset {} {}",
discourse.name, username
);
}
Ok(())
}
pub fn user_password_reset(
config: &Config,
discourse_name: &str,
username: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if dry_run {
println!(
"[dry-run] {}: would trigger password-reset email for {}",
discourse.name, username
);
return Ok(());
}
client.trigger_password_reset(username)?;
println!("Password reset request sent for {} (if that user exists).", username);
Ok(())
}
pub fn user_email_set(
config: &Config,
discourse_name: &str,
username: &str,
email: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
if !email.contains('@') {
return Err(anyhow!("invalid email: {:?}", email));
}
if dry_run {
println!(
"[dry-run] {}: would set {}'s email to {}",
discourse.name, username, email
);
return Ok(());
}
client.set_user_email(username, email)?;
println!("Set {}'s email to {}", username, email);
Ok(())
}