Skip to main content

cairn_mod/cli/
moderator.rs

1//! `cairn moderator {add, remove, list}` — orchestrators (#24).
2//!
3//! Thin wrapper over `crate::moderators`: translates CLI input
4//! shapes into helper calls, maps helper outcomes to typed
5//! results + [`CliError`], and exposes pure `format_*` functions
6//! that turn results into stdout strings (human or JSON).
7//!
8//! Pattern matches `cli/report.rs`: orchestrator returns a typed
9//! value; formatters are pure functions of that value; the
10//! `main.rs` dispatcher composes the two and prints. Tests can
11//! assert on the typed outcome (logic) and on `format_*` strings
12//! (output contract) independently, without capturing stdout.
13//!
14//! Input validation is intentionally minimal: DID format is a
15//! prefix check matching the rest of the codebase
16//! (see `crate::server::create_report` and peers). Further
17//! DID-shape validation lives at the DB CHECK + real-use layer.
18
19use 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
28// =================== Inputs ===================
29
30/// Input shape for `cairn moderator add`.
31pub struct AddInput {
32    /// DID of the moderator to add (or update via
33    /// `update_role=true`).
34    pub did: String,
35    /// Role to assign.
36    pub role: Role,
37    /// If `true`, an existing DID's role is overwritten when it
38    /// differs. If `false`, an existing-DID-with-different-role
39    /// returns a USAGE-coded [`CliError`].
40    pub update_role: bool,
41}
42
43/// Input shape for `cairn moderator remove`.
44pub struct RemoveInput {
45    /// DID of the moderator to remove.
46    pub did: String,
47    /// Skip the last-admin guard. Without this flag, removing the
48    /// only remaining admin returns a USAGE error.
49    pub force: bool,
50}
51
52/// Input shape for `cairn moderator list`.
53pub struct ListInput {
54    /// Optional role filter; `None` lists all moderators.
55    pub role: Option<Role>,
56}
57
58// =================== Outcomes ===================
59
60/// Successful outcome of `cairn moderator add`. Failure cases
61/// (`DuplicateBlocked`, DB errors) flow through [`CliError`] and
62/// don't appear here.
63#[derive(Debug, PartialEq, Eq)]
64pub enum AddResult {
65    /// New row created.
66    Inserted {
67        /// DID that was added.
68        did: String,
69        /// Role assigned.
70        role: Role,
71    },
72    /// Existing DID's role was changed via `--update-role`.
73    RoleUpdated {
74        /// DID whose row was updated.
75        did: String,
76        /// Role the row held before this call.
77        previous: Role,
78        /// New role.
79        role: Role,
80    },
81    /// DID already had the requested role; nothing to do.
82    Unchanged {
83        /// DID inspected.
84        did: String,
85        /// Role on the row (matches the requested role).
86        role: Role,
87    },
88}
89
90/// Successful outcome of `cairn moderator remove`. The
91/// not-found / last-admin / DB-error cases flow through
92/// [`CliError`].
93#[derive(Debug, PartialEq, Eq)]
94pub struct RemoveResult {
95    /// DID that was removed.
96    pub did: String,
97}
98
99// =================== Orchestrators ===================
100
101/// `cairn moderator add` — insert a moderator row, or update an
102/// existing role when `input.update_role` is set. Same-role
103/// re-invocation is [`AddResult::Unchanged`], never an error.
104/// `DuplicateBlocked` from the underlying helper returns a
105/// USAGE-coded [`CliError`].
106pub async fn add(pool: &Pool<Sqlite>, input: AddInput) -> Result<AddResult, CliError> {
107    validate_did(&input.did)?;
108
109    // CLI inserts have no attested caller identity; added_by is
110    // set only for HTTP-admin attribution via JWT iss (#24
111    // decision C).
112    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
137/// `cairn moderator remove` — delete a moderator row. Errors with
138/// USAGE exit code if the DID isn't a moderator. Refuses to remove
139/// the last admin unless `input.force` is set.
140pub async fn remove(pool: &Pool<Sqlite>, input: RemoveInput) -> Result<RemoveResult, CliError> {
141    validate_did(&input.did)?;
142
143    // Last-admin guard: block if the target is the only remaining
144    // admin and --force wasn't passed. The check races between
145    // SELECT and DELETE in theory, but the CLI is a one-shot
146    // operator tool — the window isn't exploitable in practice and
147    // a transactional guard would be overkill for v1.1.
148    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
177/// `cairn moderator list` — return all moderators (optionally
178/// filtered by role). Caller chooses [`format_list_human`] or
179/// [`format_list_json`] for stdout; the orchestrator does no
180/// printing of its own.
181pub 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
187// =================== Formatters: human ===================
188
189/// Human-one-liner for the `add` outcome. UTF-8 arrow used in
190/// `RoleUpdated` matches the pattern in `cli/report.rs`.
191pub 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
205/// Human one-liner for the `remove` outcome.
206pub fn format_remove_human(result: &RemoveResult) -> String {
207    format!("removed moderator {}", result.did)
208}
209
210/// Human-readable table for `list`. Column widths sized to
211/// content for `DID` and `ADDED_BY`; `ROLE` and `ADDED_AT` are
212/// fixed (5 chars and the RFC-3339-Z width respectively).
213/// Returns `(no moderators)` for an empty list rather than an
214/// empty string so the caller's `println!` produces visible
215/// output.
216pub 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    // Trim the trailing newline; the caller appends its own via println!.
256    if s.ends_with('\n') {
257        s.pop();
258    }
259    s
260}
261
262// =================== Formatters: JSON ===================
263
264#[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
274/// JSON one-line for the `add` outcome. Stable field names
275/// (`action`, `did`, `role`, `result`, optional `previous_role`).
276pub 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
307/// JSON for `add --with-xrpc-callers`. Identical to
308/// [`format_add_json`] when `xrpc_caller_added` is `false`; adds
309/// the boolean flag when `true` so tooling can branch on whether
310/// the second-table write fired.
311pub 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    // Splice the flag into the JSON object. The base format_add_json
317    // emits a single-object line; we re-parse and re-emit so the
318    // shape stays a single flat object (consumers don't need
319    // structured JSON parsing).
320    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
332/// JSON one-line for the `remove` outcome.
333pub 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
350/// JSON array for `list`. Each element carries `did`, `role`,
351/// nullable `added_by`, and an RFC-3339 UTC `added_at`.
352pub 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
365// =================== shared helpers ===================
366
367fn 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
376/// Convert `moderators::Error` to the CLI's taxonomy. DB errors
377/// map to [`CliError::Startup`] (INTERNAL exit code) matching the
378/// pattern used in `publish_service_record`; `CorruptRole`
379/// likewise — it signals a schema-level corruption that the
380/// operator needs to diagnose manually.
381fn map_db_error(e: moderators::Error) -> CliError {
382    CliError::Startup(e.to_string())
383}
384
385/// Format an epoch-ms value as RFC 3339 UTC
386/// (e.g. `2026-04-24T12:34:56Z`). Falls back to
387/// `"ms=<raw>"` on out-of-range inputs; the fallback keeps tabular
388/// output aligned without introducing a second error path.
389fn 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}