1use serde::Serialize;
20use sqlx::{Pool, Sqlite};
21use time::OffsetDateTime;
22use time::format_description::well_known::Rfc3339;
23
24use crate::moderators::{self, AddOutcome, Moderator, RemoveOutcome, Role};
25
26use super::error::CliError;
27
28pub struct AddInput {
32 pub did: String,
35 pub role: Role,
37 pub update_role: bool,
41}
42
43pub struct RemoveInput {
45 pub did: String,
47 pub force: bool,
50}
51
52pub struct ListInput {
54 pub role: Option<Role>,
56}
57
58#[derive(Debug, PartialEq, Eq)]
64pub enum AddResult {
65 Inserted {
67 did: String,
69 role: Role,
71 },
72 RoleUpdated {
74 did: String,
76 previous: Role,
78 role: Role,
80 },
81 Unchanged {
83 did: String,
85 role: Role,
87 },
88}
89
90#[derive(Debug, PartialEq, Eq)]
94pub struct RemoveResult {
95 pub did: String,
97}
98
99pub async fn add(pool: &Pool<Sqlite>, input: AddInput) -> Result<AddResult, CliError> {
107 validate_did(&input.did)?;
108
109 let outcome = moderators::add(pool, &input.did, input.role, None, input.update_role)
113 .await
114 .map_err(map_db_error)?;
115
116 match outcome {
117 AddOutcome::Inserted => Ok(AddResult::Inserted {
118 did: input.did,
119 role: input.role,
120 }),
121 AddOutcome::RoleUpdated { previous } => Ok(AddResult::RoleUpdated {
122 did: input.did,
123 previous,
124 role: input.role,
125 }),
126 AddOutcome::Unchanged => Ok(AddResult::Unchanged {
127 did: input.did,
128 role: input.role,
129 }),
130 AddOutcome::DuplicateBlocked { current_role } => Err(CliError::Config(format!(
131 "{} is already a moderator (role {current_role}); pass --update-role to change role",
132 input.did
133 ))),
134 }
135}
136
137pub async fn remove(pool: &Pool<Sqlite>, input: RemoveInput) -> Result<RemoveResult, CliError> {
141 validate_did(&input.did)?;
142
143 let existing_role: Option<Role> =
149 sqlx::query_scalar!("SELECT role FROM moderators WHERE did = ?1", input.did)
150 .fetch_optional(pool)
151 .await
152 .map_err(|e| CliError::Startup(format!("moderator lookup: {e}")))?
153 .and_then(|s| Role::from_db_str(&s));
154
155 if existing_role == Some(Role::Admin) && !input.force {
156 let admin_count = moderators::count_admins(pool).await.map_err(map_db_error)?;
157 if admin_count <= 1 {
158 return Err(CliError::Config(format!(
159 "{} is the last admin; pass --force to remove anyway",
160 input.did
161 )));
162 }
163 }
164
165 match moderators::remove(pool, &input.did)
166 .await
167 .map_err(map_db_error)?
168 {
169 RemoveOutcome::Removed => Ok(RemoveResult { did: input.did }),
170 RemoveOutcome::NotFound => Err(CliError::Config(format!(
171 "{} is not a moderator",
172 input.did
173 ))),
174 }
175}
176
177pub async fn list(pool: &Pool<Sqlite>, input: ListInput) -> Result<Vec<Moderator>, CliError> {
182 moderators::list(pool, input.role)
183 .await
184 .map_err(map_db_error)
185}
186
187pub fn format_add_human(result: &AddResult) -> String {
192 match result {
193 AddResult::Inserted { did, role } => format!("added {did} as {role}"),
194 AddResult::RoleUpdated {
195 did,
196 previous,
197 role,
198 } => format!("updated {did}: {previous} → {role}"),
199 AddResult::Unchanged { did, role } => {
200 format!("{did} already has role {role}; no change")
201 }
202 }
203}
204
205pub fn format_remove_human(result: &RemoveResult) -> String {
207 format!("removed moderator {}", result.did)
208}
209
210pub fn format_list_human(mods: &[Moderator]) -> String {
217 use std::fmt::Write;
218
219 if mods.is_empty() {
220 return "(no moderators)".to_string();
221 }
222 let did_w = mods.iter().map(|m| m.did.len()).max().unwrap_or(3).max(3);
223 let added_by_w = mods
224 .iter()
225 .map(|m| m.added_by.as_deref().unwrap_or("-").len())
226 .max()
227 .unwrap_or(8)
228 .max(8);
229
230 let mut s = String::new();
231 let _ = writeln!(
232 s,
233 "{:<did_w$} {:<5} {:<20} {:<added_by_w$}",
234 "DID",
235 "ROLE",
236 "ADDED_AT",
237 "ADDED_BY",
238 did_w = did_w,
239 added_by_w = added_by_w
240 );
241 for m in mods {
242 let added_at = format_rfc3339(m.added_at);
243 let added_by = m.added_by.as_deref().unwrap_or("-");
244 let _ = writeln!(
245 s,
246 "{:<did_w$} {:<5} {:<20} {:<added_by_w$}",
247 m.did,
248 m.role.as_str(),
249 added_at,
250 added_by,
251 did_w = did_w,
252 added_by_w = added_by_w
253 );
254 }
255 if s.ends_with('\n') {
257 s.pop();
258 }
259 s
260}
261
262#[derive(Serialize)]
265struct AddJson<'a> {
266 action: &'a str,
267 did: &'a str,
268 role: &'a str,
269 result: &'a str,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 previous_role: Option<&'a str>,
272}
273
274pub fn format_add_json(result: &AddResult) -> String {
277 let body = match result {
278 AddResult::Inserted { did, role } => AddJson {
279 action: "add",
280 did,
281 role: role.as_str(),
282 result: "inserted",
283 previous_role: None,
284 },
285 AddResult::RoleUpdated {
286 did,
287 previous,
288 role,
289 } => AddJson {
290 action: "add",
291 did,
292 role: role.as_str(),
293 result: "role_updated",
294 previous_role: Some(previous.as_str()),
295 },
296 AddResult::Unchanged { did, role } => AddJson {
297 action: "add",
298 did,
299 role: role.as_str(),
300 result: "unchanged",
301 previous_role: None,
302 },
303 };
304 serde_json::to_string(&body).expect("AddJson serializes")
305}
306
307pub fn format_add_json_with_xrpc(result: &AddResult, xrpc_caller_added: bool) -> String {
312 let core = format_add_json(result);
313 if !xrpc_caller_added {
314 return core;
315 }
316 let mut v: serde_json::Value = serde_json::from_str(&core).expect("AddJson re-parses");
321 v["xrpc_caller_added"] = serde_json::json!(true);
322 v.to_string()
323}
324
325#[derive(Serialize)]
326struct RemoveJson<'a> {
327 action: &'a str,
328 did: &'a str,
329 result: &'a str,
330}
331
332pub fn format_remove_json(result: &RemoveResult) -> String {
334 let body = RemoveJson {
335 action: "remove",
336 did: &result.did,
337 result: "removed",
338 };
339 serde_json::to_string(&body).expect("RemoveJson serializes")
340}
341
342#[derive(Serialize)]
343struct ListEntryJson<'a> {
344 did: &'a str,
345 role: &'a str,
346 added_by: Option<&'a str>,
347 added_at: String,
348}
349
350pub fn format_list_json(mods: &[Moderator]) -> String {
353 let entries: Vec<ListEntryJson> = mods
354 .iter()
355 .map(|m| ListEntryJson {
356 did: &m.did,
357 role: m.role.as_str(),
358 added_by: m.added_by.as_deref(),
359 added_at: format_rfc3339(m.added_at),
360 })
361 .collect();
362 serde_json::to_string(&entries).expect("ListEntryJson serializes")
363}
364
365fn validate_did(did: &str) -> Result<(), CliError> {
368 if !did.starts_with("did:") || did.len() <= "did:".len() {
369 return Err(CliError::Config(format!(
370 "DID must start with 'did:' and include an identifier; got {did:?}"
371 )));
372 }
373 Ok(())
374}
375
376fn map_db_error(e: moderators::Error) -> CliError {
382 CliError::Startup(e.to_string())
383}
384
385fn format_rfc3339(epoch_ms: i64) -> String {
390 let seconds = epoch_ms / 1000;
391 let nanos = ((epoch_ms % 1000).unsigned_abs() * 1_000_000) as u32;
392 let Ok(dt) = OffsetDateTime::from_unix_timestamp(seconds) else {
393 return format!("ms={epoch_ms}");
394 };
395 let dt = dt.replace_nanosecond(nanos).unwrap_or(dt);
396 dt.format(&Rfc3339)
397 .unwrap_or_else(|_| format!("ms={epoch_ms}"))
398}