use serde::Serialize;
use sqlx::{Pool, Sqlite};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::moderators::{self, AddOutcome, Moderator, RemoveOutcome, Role};
use super::error::CliError;
pub struct AddInput {
pub did: String,
pub role: Role,
pub update_role: bool,
}
pub struct RemoveInput {
pub did: String,
pub force: bool,
}
pub struct ListInput {
pub role: Option<Role>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum AddResult {
Inserted {
did: String,
role: Role,
},
RoleUpdated {
did: String,
previous: Role,
role: Role,
},
Unchanged {
did: String,
role: Role,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct RemoveResult {
pub did: String,
}
pub async fn add(pool: &Pool<Sqlite>, input: AddInput) -> Result<AddResult, CliError> {
validate_did(&input.did)?;
let outcome = moderators::add(pool, &input.did, input.role, None, input.update_role)
.await
.map_err(map_db_error)?;
match outcome {
AddOutcome::Inserted => Ok(AddResult::Inserted {
did: input.did,
role: input.role,
}),
AddOutcome::RoleUpdated { previous } => Ok(AddResult::RoleUpdated {
did: input.did,
previous,
role: input.role,
}),
AddOutcome::Unchanged => Ok(AddResult::Unchanged {
did: input.did,
role: input.role,
}),
AddOutcome::DuplicateBlocked { current_role } => Err(CliError::Config(format!(
"{} is already a moderator (role {current_role}); pass --update-role to change role",
input.did
))),
}
}
pub async fn remove(pool: &Pool<Sqlite>, input: RemoveInput) -> Result<RemoveResult, CliError> {
validate_did(&input.did)?;
let existing_role: Option<Role> =
sqlx::query_scalar!("SELECT role FROM moderators WHERE did = ?1", input.did)
.fetch_optional(pool)
.await
.map_err(|e| CliError::Startup(format!("moderator lookup: {e}")))?
.and_then(|s| Role::from_db_str(&s));
if existing_role == Some(Role::Admin) && !input.force {
let admin_count = moderators::count_admins(pool).await.map_err(map_db_error)?;
if admin_count <= 1 {
return Err(CliError::Config(format!(
"{} is the last admin; pass --force to remove anyway",
input.did
)));
}
}
match moderators::remove(pool, &input.did)
.await
.map_err(map_db_error)?
{
RemoveOutcome::Removed => Ok(RemoveResult { did: input.did }),
RemoveOutcome::NotFound => Err(CliError::Config(format!(
"{} is not a moderator",
input.did
))),
}
}
pub async fn list(pool: &Pool<Sqlite>, input: ListInput) -> Result<Vec<Moderator>, CliError> {
moderators::list(pool, input.role)
.await
.map_err(map_db_error)
}
pub fn format_add_human(result: &AddResult) -> String {
match result {
AddResult::Inserted { did, role } => format!("added {did} as {role}"),
AddResult::RoleUpdated {
did,
previous,
role,
} => format!("updated {did}: {previous} → {role}"),
AddResult::Unchanged { did, role } => {
format!("{did} already has role {role}; no change")
}
}
}
pub fn format_remove_human(result: &RemoveResult) -> String {
format!("removed moderator {}", result.did)
}
pub fn format_list_human(mods: &[Moderator]) -> String {
use std::fmt::Write;
if mods.is_empty() {
return "(no moderators)".to_string();
}
let did_w = mods.iter().map(|m| m.did.len()).max().unwrap_or(3).max(3);
let added_by_w = mods
.iter()
.map(|m| m.added_by.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(8)
.max(8);
let mut s = String::new();
let _ = writeln!(
s,
"{:<did_w$} {:<5} {:<20} {:<added_by_w$}",
"DID",
"ROLE",
"ADDED_AT",
"ADDED_BY",
did_w = did_w,
added_by_w = added_by_w
);
for m in mods {
let added_at = format_rfc3339(m.added_at);
let added_by = m.added_by.as_deref().unwrap_or("-");
let _ = writeln!(
s,
"{:<did_w$} {:<5} {:<20} {:<added_by_w$}",
m.did,
m.role.as_str(),
added_at,
added_by,
did_w = did_w,
added_by_w = added_by_w
);
}
if s.ends_with('\n') {
s.pop();
}
s
}
#[derive(Serialize)]
struct AddJson<'a> {
action: &'a str,
did: &'a str,
role: &'a str,
result: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
previous_role: Option<&'a str>,
}
pub fn format_add_json(result: &AddResult) -> String {
let body = match result {
AddResult::Inserted { did, role } => AddJson {
action: "add",
did,
role: role.as_str(),
result: "inserted",
previous_role: None,
},
AddResult::RoleUpdated {
did,
previous,
role,
} => AddJson {
action: "add",
did,
role: role.as_str(),
result: "role_updated",
previous_role: Some(previous.as_str()),
},
AddResult::Unchanged { did, role } => AddJson {
action: "add",
did,
role: role.as_str(),
result: "unchanged",
previous_role: None,
},
};
serde_json::to_string(&body).expect("AddJson serializes")
}
pub fn format_add_json_with_xrpc(result: &AddResult, xrpc_caller_added: bool) -> String {
let core = format_add_json(result);
if !xrpc_caller_added {
return core;
}
let mut v: serde_json::Value = serde_json::from_str(&core).expect("AddJson re-parses");
v["xrpc_caller_added"] = serde_json::json!(true);
v.to_string()
}
#[derive(Serialize)]
struct RemoveJson<'a> {
action: &'a str,
did: &'a str,
result: &'a str,
}
pub fn format_remove_json(result: &RemoveResult) -> String {
let body = RemoveJson {
action: "remove",
did: &result.did,
result: "removed",
};
serde_json::to_string(&body).expect("RemoveJson serializes")
}
#[derive(Serialize)]
struct ListEntryJson<'a> {
did: &'a str,
role: &'a str,
added_by: Option<&'a str>,
added_at: String,
}
pub fn format_list_json(mods: &[Moderator]) -> String {
let entries: Vec<ListEntryJson> = mods
.iter()
.map(|m| ListEntryJson {
did: &m.did,
role: m.role.as_str(),
added_by: m.added_by.as_deref(),
added_at: format_rfc3339(m.added_at),
})
.collect();
serde_json::to_string(&entries).expect("ListEntryJson serializes")
}
fn validate_did(did: &str) -> Result<(), CliError> {
if !did.starts_with("did:") || did.len() <= "did:".len() {
return Err(CliError::Config(format!(
"DID must start with 'did:' and include an identifier; got {did:?}"
)));
}
Ok(())
}
fn map_db_error(e: moderators::Error) -> CliError {
CliError::Startup(e.to_string())
}
fn format_rfc3339(epoch_ms: i64) -> String {
let seconds = epoch_ms / 1000;
let nanos = ((epoch_ms % 1000).unsigned_abs() * 1_000_000) as u32;
let Ok(dt) = OffsetDateTime::from_unix_timestamp(seconds) else {
return format!("ms={epoch_ms}");
};
let dt = dt.replace_nanosecond(nanos).unwrap_or(dt);
dt.format(&Rfc3339)
.unwrap_or_else(|_| format!("ms={epoch_ms}"))
}