Skip to main content

cairn_mod/cli/
moderator_action.rs

1//! `cairn moderator {action, warn, note, revoke}` (#51) — HTTP-routed
2//! moderator-tier CLIs that hit `tools.cairn.admin.recordAction` and
3//! `tools.cairn.admin.revokeAction`.
4//!
5//! Same wire-side pattern as `cairn report {flag, resolve}` from
6//! v1.2 — each subcommand:
7//! 1. Loads the moderator session file.
8//! 2. Mints a fresh service-auth JWT bound to the lexicon method,
9//!    refreshing on a 401 via the shared `acquire_service_auth`
10//!    helper.
11//! 3. POSTs the lexicon-shaped body to the running Cairn instance.
12//! 4. Returns a typed [`RecordResponse`] / [`RevokeResponse`] for
13//!    the dispatcher's human / JSON formatters.
14//!
15//! Distinct from the v1.1-era `cairn moderator {add, remove, list}`
16//! in `cli/moderator.rs` — those are operator-tier (direct-DB,
17//! lease-aware). The new write commands here are moderator-tier
18//! (HTTP-routed, daemon must be up) — they DO moderation actions,
19//! the operator-tier CLI in `cli/moderator.rs` manages WHO can.
20//! See the architectural note in MEMORY.md (feedback memory) for
21//! the full split.
22
23use std::path::Path;
24use std::time::Duration;
25
26use reqwest::Client;
27use serde::{Deserialize, Serialize};
28use serde_json::json;
29
30use super::auth::acquire_service_auth;
31use super::error::CliError;
32use super::pds::PdsClient;
33use super::session::SessionFile;
34
35const RECORD_ACTION_LXM: &str = "tools.cairn.admin.recordAction";
36const REVOKE_ACTION_LXM: &str = "tools.cairn.admin.revokeAction";
37const GET_SUBJECT_HISTORY_LXM: &str = "tools.cairn.admin.getSubjectHistory";
38const GET_SUBJECT_STRIKES_LXM: &str = "tools.cairn.admin.getSubjectStrikes";
39
40// ============================================================
41// `cairn moderator action` — record a graduated-action moderation
42// event. Backs `recordAction` admin XRPC (#51). Wraps both the
43// generic --type form and the warn / note shorthands (which are
44// implemented as preset-type calls into this same orchestrator).
45// ============================================================
46
47/// Input to `cairn moderator action / warn / note`.
48#[derive(Debug, Clone)]
49pub struct RecordActionInput {
50    /// Subject — `did:*` for an account, `at://...` for a record.
51    /// Server auto-routes to `subject_did` vs `subject_uri`.
52    pub subject: String,
53    /// Graduated-action category as the wire string
54    /// (`warning` / `note` / `temp_suspension` / `indef_suspension`
55    /// / `takedown`).
56    pub action_type: String,
57    /// One or more reason identifiers from the operator's
58    /// `[moderation_reasons]` vocabulary. Multi-reason: server
59    /// resolves severe-wins / highest-base-weight.
60    pub reasons: Vec<String>,
61    /// ISO-8601 duration (e.g. `P7D`). Required iff `action_type
62    /// == "temp_suspension"`; rejected for other types.
63    pub duration: Option<String>,
64    /// Optional moderator-facing note stored on the row.
65    pub note: Option<String>,
66    /// Optional list of report row ids that motivated this action.
67    pub report_ids: Vec<i64>,
68    /// Per-invocation override of the session's stored Cairn URL.
69    pub cairn_server_override: Option<String>,
70}
71
72/// Wire-shaped response from `tools.cairn.admin.recordAction`.
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct RecordResponse {
75    /// Inserted subject_actions row id.
76    #[serde(rename = "actionId")]
77    pub action_id: i64,
78    /// Reason's base_weight before dampening.
79    #[serde(rename = "strikeValueBase")]
80    pub strike_value_base: u32,
81    /// Strike weight actually applied after dampening.
82    #[serde(rename = "strikeValueApplied")]
83    pub strike_value_applied: u32,
84    /// `true` iff the curve was consulted at action time.
85    #[serde(rename = "wasDampened")]
86    pub was_dampened: bool,
87    /// Subject's strike count BEFORE this action — frozen for
88    /// forensic history.
89    #[serde(rename = "strikesAtTimeOfAction")]
90    pub strikes_at_time_of_action: u32,
91}
92
93/// Submit a recordAction request. Wraps the
94/// `tools.cairn.admin.recordAction` handler at
95/// [src/server/admin/record_action.rs](..).
96pub async fn record(
97    session: &mut SessionFile,
98    session_path: &Path,
99    input: RecordActionInput,
100) -> Result<RecordResponse, CliError> {
101    if input.reasons.is_empty() {
102        return Err(CliError::Config("at least one --reason is required".into()));
103    }
104    if !input.subject.starts_with("did:") && !input.subject.starts_with("at://") {
105        return Err(CliError::Config(format!(
106            "subject must start with `did:` or `at://`; got {:?}",
107            input.subject
108        )));
109    }
110
111    let cairn_server = input
112        .cairn_server_override
113        .as_deref()
114        .unwrap_or(&session.cairn_server_url)
115        .trim_end_matches('/')
116        .to_string();
117    let pds = PdsClient::new(&session.pds_url)?;
118    let token = acquire_service_auth(&pds, session, session_path, RECORD_ACTION_LXM).await?;
119
120    let mut body = json!({
121        "subject": input.subject,
122        "type": input.action_type,
123        "reasons": input.reasons,
124    });
125    if let Some(d) = &input.duration {
126        body["duration"] = json!(d);
127    }
128    if let Some(n) = &input.note {
129        body["note"] = json!(n);
130    }
131    if !input.report_ids.is_empty() {
132        body["reportIds"] = json!(input.report_ids);
133    }
134
135    let url = format!("{cairn_server}/xrpc/{RECORD_ACTION_LXM}");
136    let client = build_client();
137    let resp = client
138        .post(&url)
139        .bearer_auth(&token)
140        .json(&body)
141        .send()
142        .await
143        .map_err(|source| CliError::Http {
144            url: url.clone(),
145            source,
146        })?;
147    cairn_response::<RecordResponse>(url, resp).await
148}
149
150/// Human-readable one-liner for `cairn moderator action / warn / note`.
151pub fn format_record_human(resp: &RecordResponse) -> String {
152    if resp.strike_value_applied == 0 {
153        format!("Recorded action {} (no strikes)", resp.action_id)
154    } else if resp.was_dampened {
155        format!(
156            "Recorded action {} (+{} strike{}, dampened from {})",
157            resp.action_id,
158            resp.strike_value_applied,
159            if resp.strike_value_applied == 1 {
160                ""
161            } else {
162                "s"
163            },
164            resp.strike_value_base,
165        )
166    } else {
167        format!(
168            "Recorded action {} (+{} strike{})",
169            resp.action_id,
170            resp.strike_value_applied,
171            if resp.strike_value_applied == 1 {
172                ""
173            } else {
174                "s"
175            },
176        )
177    }
178}
179
180/// JSON output for `cairn moderator action / warn / note`.
181pub fn format_record_json(resp: &RecordResponse) -> String {
182    serde_json::to_string_pretty(resp).expect("RecordResponse serializes")
183}
184
185// ============================================================
186// `cairn moderator revoke` — revoke a previously-recorded action.
187// Backs `revokeAction` admin XRPC.
188// ============================================================
189
190/// Input to `cairn moderator revoke`.
191#[derive(Debug, Clone)]
192pub struct RevokeActionInput {
193    /// subject_actions.id to revoke.
194    pub action_id: i64,
195    /// Optional rationale stored on the row's revoked_reason column.
196    pub reason: Option<String>,
197    /// Per-invocation override of the session's stored Cairn URL.
198    pub cairn_server_override: Option<String>,
199}
200
201/// Wire-shaped response from `tools.cairn.admin.revokeAction`.
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct RevokeResponse {
204    /// The revoked row's id.
205    #[serde(rename = "actionId")]
206    pub action_id: i64,
207    /// RFC-3339 wall-clock the revocation took effect.
208    #[serde(rename = "revokedAt")]
209    pub revoked_at: String,
210}
211
212/// Submit a revokeAction request.
213pub async fn revoke(
214    session: &mut SessionFile,
215    session_path: &Path,
216    input: RevokeActionInput,
217) -> Result<RevokeResponse, CliError> {
218    let cairn_server = input
219        .cairn_server_override
220        .as_deref()
221        .unwrap_or(&session.cairn_server_url)
222        .trim_end_matches('/')
223        .to_string();
224    let pds = PdsClient::new(&session.pds_url)?;
225    let token = acquire_service_auth(&pds, session, session_path, REVOKE_ACTION_LXM).await?;
226
227    let mut body = json!({"actionId": input.action_id});
228    if let Some(r) = &input.reason {
229        body["reason"] = json!(r);
230    }
231
232    let url = format!("{cairn_server}/xrpc/{REVOKE_ACTION_LXM}");
233    let client = build_client();
234    let resp = client
235        .post(&url)
236        .bearer_auth(&token)
237        .json(&body)
238        .send()
239        .await
240        .map_err(|source| CliError::Http {
241            url: url.clone(),
242            source,
243        })?;
244    cairn_response::<RevokeResponse>(url, resp).await
245}
246
247/// Human-readable one-liner for `cairn moderator revoke`.
248pub fn format_revoke_human(resp: &RevokeResponse) -> String {
249    format!("Revoked action {} at {}", resp.action_id, resp.revoked_at)
250}
251
252/// JSON output for `cairn moderator revoke`.
253pub fn format_revoke_json(resp: &RevokeResponse) -> String {
254    serde_json::to_string_pretty(resp).expect("RevokeResponse serializes")
255}
256
257// ============================================================
258// `cairn moderator history` — list subject_actions for a subject.
259// Backs `getSubjectHistory` admin XRPC (#52 / read-half of #53).
260// ============================================================
261
262/// Input to `cairn moderator history`.
263#[derive(Debug, Clone)]
264pub struct HistoryInput {
265    /// Subject DID. AT-URIs are normalized to the parent DID
266    /// before sending; this matches the lexicon's account-rollup
267    /// invariant (strike accounting is always at the account
268    /// level).
269    pub subject: String,
270    /// Optional AT-URI filter — when set, narrows to record-level
271    /// actions on that URI.
272    pub subject_uri: Option<String>,
273    /// `false` excludes revoked actions from the response. Default
274    /// `true` (lexicon default).
275    pub include_revoked: bool,
276    /// RFC-3339 timestamp lower bound on `effective_at`.
277    pub since: Option<String>,
278    /// Page size. Server caps at 250; default 50.
279    pub limit: Option<i64>,
280    /// Opaque pagination cursor.
281    pub cursor: Option<String>,
282    /// Per-invocation override of the session's stored Cairn URL.
283    pub cairn_server_override: Option<String>,
284}
285
286impl Default for HistoryInput {
287    fn default() -> Self {
288        Self {
289            subject: String::new(),
290            subject_uri: None,
291            include_revoked: true,
292            since: None,
293            limit: None,
294            cursor: None,
295            cairn_server_override: None,
296        }
297    }
298}
299
300/// One row in a `getSubjectHistory` response. Field set tracks
301/// `tools.cairn.admin.defs#subjectAction`; optional fields use
302/// `serde(default)` so deserialization tolerates server-side
303/// projections that drop them.
304#[derive(Debug, Clone, Deserialize, Serialize)]
305pub struct HistoryEntry {
306    /// `subject_actions.id` — primary key.
307    pub id: i64,
308    /// Account DID this action attributes to. Always the parent
309    /// DID for record-level actions.
310    #[serde(rename = "subjectDid")]
311    pub subject_did: String,
312    /// AT-URI for record-level actions; absent for account-level.
313    #[serde(
314        rename = "subjectUri",
315        skip_serializing_if = "Option::is_none",
316        default
317    )]
318    pub subject_uri: Option<String>,
319    /// DID of the moderator/admin that recorded the action.
320    #[serde(rename = "actorDid")]
321    pub actor_did: String,
322    /// `warning` / `note` / `temp_suspension` / `indef_suspension` /
323    /// `takedown`.
324    #[serde(rename = "actionType")]
325    pub action_type: String,
326    /// Reason identifiers from the `[moderation_reasons]` vocabulary.
327    #[serde(rename = "reasonCodes")]
328    pub reason_codes: Vec<String>,
329    /// Original ISO-8601 duration string (e.g. `P7D`); only present
330    /// for `temp_suspension`.
331    #[serde(skip_serializing_if = "Option::is_none", default)]
332    pub duration: Option<String>,
333    /// RFC-3339 wall-clock the action took effect.
334    #[serde(rename = "effectiveAt")]
335    pub effective_at: String,
336    /// RFC-3339 wall-clock the action ends; only `temp_suspension`.
337    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
338    pub expires_at: Option<String>,
339    /// Moderator-facing rationale stored on the row.
340    #[serde(skip_serializing_if = "Option::is_none", default)]
341    pub notes: Option<String>,
342    /// Report ids that motivated this action, if any.
343    #[serde(rename = "reportIds", skip_serializing_if = "Option::is_none", default)]
344    pub report_ids: Option<Vec<i64>>,
345    /// Reason's `base_weight` before dampening.
346    #[serde(rename = "strikeValueBase")]
347    pub strike_value_base: i64,
348    /// Strike weight actually applied after dampening.
349    #[serde(rename = "strikeValueApplied")]
350    pub strike_value_applied: i64,
351    /// `true` iff the dampening curve was consulted at action time.
352    #[serde(rename = "wasDampened")]
353    pub was_dampened: bool,
354    /// Subject's `current_strike_count` BEFORE this action.
355    #[serde(rename = "strikesAtTimeOfAction")]
356    pub strikes_at_time_of_action: i64,
357    /// RFC-3339 revocation wall-clock; absent if not revoked.
358    #[serde(rename = "revokedAt", skip_serializing_if = "Option::is_none", default)]
359    pub revoked_at: Option<String>,
360    /// DID of the moderator/admin who revoked this action; absent
361    /// if not revoked.
362    #[serde(
363        rename = "revokedByDid",
364        skip_serializing_if = "Option::is_none",
365        default
366    )]
367    pub revoked_by_did: Option<String>,
368    /// Free-text revocation rationale; absent if not provided.
369    #[serde(
370        rename = "revokedReason",
371        skip_serializing_if = "Option::is_none",
372        default
373    )]
374    pub revoked_reason: Option<String>,
375    /// `audit_log.id` of the row this action's intent was attested
376    /// as.
377    #[serde(
378        rename = "auditLogId",
379        skip_serializing_if = "Option::is_none",
380        default
381    )]
382    pub audit_log_id: Option<i64>,
383    /// RFC-3339 wall-clock at INSERT.
384    #[serde(rename = "createdAt")]
385    pub created_at: String,
386    /// `"moderator"` for moderator-recorded actions and confirmed-
387    /// pending materializations; `"policy"` for actions the policy
388    /// automation engine recorded directly (§F22). Always populated
389    /// in v1.6+ wire responses; defaults to `"moderator"` if a
390    /// pre-v1.6 server omits the field.
391    #[serde(rename = "actorKind", default = "default_actor_kind")]
392    pub actor_kind: String,
393    /// Name of the `[policy_automation.<rule>]` sub-block that
394    /// produced this action. Present when `actor_kind == "policy"`
395    /// and on moderator-confirmed pending materializations
396    /// (forensic provenance); absent otherwise.
397    #[serde(
398        rename = "triggeredByPolicyRule",
399        skip_serializing_if = "Option::is_none",
400        default
401    )]
402    pub triggered_by_policy_rule: Option<String>,
403}
404
405fn default_actor_kind() -> String {
406    "moderator".to_string()
407}
408
409/// Wire-shaped response from `tools.cairn.admin.getSubjectHistory`.
410#[derive(Debug, Clone, Deserialize, Serialize)]
411pub struct HistoryResponse {
412    /// Matched actions, newest-first.
413    pub actions: Vec<HistoryEntry>,
414    /// Opaque pagination cursor; absent when this is the final page.
415    #[serde(skip_serializing_if = "Option::is_none", default)]
416    pub cursor: Option<String>,
417}
418
419/// Fetch one page of history. Pagination across pages is the
420/// caller's job — the dispatcher loops on the returned cursor.
421/// Mirrors `cli/report.rs::list` posture.
422pub async fn history(
423    session: &mut SessionFile,
424    session_path: &Path,
425    input: HistoryInput,
426) -> Result<HistoryResponse, CliError> {
427    if !input.subject.starts_with("did:") {
428        return Err(CliError::Config(format!(
429            "subject must be a DID (`did:...`); got {:?}",
430            input.subject
431        )));
432    }
433
434    let cairn_server = input
435        .cairn_server_override
436        .as_deref()
437        .unwrap_or(&session.cairn_server_url)
438        .trim_end_matches('/')
439        .to_string();
440    let pds = PdsClient::new(&session.pds_url)?;
441    let token = acquire_service_auth(&pds, session, session_path, GET_SUBJECT_HISTORY_LXM).await?;
442
443    let url = format!("{cairn_server}/xrpc/{GET_SUBJECT_HISTORY_LXM}");
444    let limit_owned = input.limit.map(|n| n.to_string());
445    let mut query: Vec<(&str, &str)> = vec![("subject", input.subject.as_str())];
446    if let Some(u) = &input.subject_uri {
447        query.push(("subjectUri", u.as_str()));
448    }
449    if !input.include_revoked {
450        query.push(("includeRevoked", "false"));
451    }
452    if let Some(s) = &input.since {
453        query.push(("since", s.as_str()));
454    }
455    if let Some(n) = &limit_owned {
456        query.push(("limit", n.as_str()));
457    }
458    if let Some(c) = &input.cursor {
459        query.push(("cursor", c.as_str()));
460    }
461
462    let client = build_client();
463    let resp = client
464        .get(&url)
465        .bearer_auth(&token)
466        .query(&query)
467        .send()
468        .await
469        .map_err(|source| CliError::Http {
470            url: url.clone(),
471            source,
472        })?;
473    cairn_response::<HistoryResponse>(url, resp).await
474}
475
476/// Tabular human renderer for `cairn moderator history`. Columns:
477/// id | when | type | actor | reasons | applied (vs base) | dampened
478/// | revoked. The ACTOR column surfaces the v1.6 actor_kind so
479/// operators can visually distinguish moderator-recorded actions
480/// from policy-automation-recorded ones (§F22). Empty result
481/// renders as a friendly "no actions" line rather than an empty
482/// table — matches the listReports posture from v1.2.
483pub fn format_history_human(resp: &HistoryResponse, subject: &str) -> String {
484    use std::fmt::Write;
485    if resp.actions.is_empty() {
486        let mut s = format!("No actions recorded for {subject}");
487        if let Some(c) = &resp.cursor {
488            let _ = write!(s, "\nnext cursor: {c}");
489        }
490        return s;
491    }
492
493    let id_w = resp
494        .actions
495        .iter()
496        .map(|e| e.id.to_string().len())
497        .max()
498        .unwrap_or(2)
499        .max(2);
500    let type_w = resp
501        .actions
502        .iter()
503        .map(|e| e.action_type.len())
504        .max()
505        .unwrap_or(4)
506        .max(4);
507    let actor_w = resp
508        .actions
509        .iter()
510        .map(|e| e.actor_kind.len())
511        .max()
512        .unwrap_or(5)
513        .max(5);
514    let reasons_w = resp
515        .actions
516        .iter()
517        .map(|e| e.reason_codes.join(",").len().min(40))
518        .max()
519        .unwrap_or(7)
520        .max(7);
521
522    let mut s = String::new();
523    let _ = writeln!(
524        s,
525        "{:>id_w$}  {:<24}  {:<type_w$}  {:<actor_w$}  {:<reasons_w$}  {:>9}  {:<8}  {:<8}",
526        "ID",
527        "EFFECTIVE_AT",
528        "TYPE",
529        "ACTOR",
530        "REASONS",
531        "APPLIED",
532        "DAMPENED",
533        "REVOKED",
534        id_w = id_w,
535        type_w = type_w,
536        actor_w = actor_w,
537        reasons_w = reasons_w,
538    );
539    for e in &resp.actions {
540        let reasons = e.reason_codes.join(",");
541        let reasons = if reasons.len() > 40 {
542            format!("{}…", &reasons[..39])
543        } else {
544            reasons
545        };
546        let applied = if e.strike_value_applied != e.strike_value_base {
547            format!("{}/{}", e.strike_value_applied, e.strike_value_base)
548        } else {
549            e.strike_value_applied.to_string()
550        };
551        let dampened = if e.was_dampened { "yes" } else { "no" };
552        let revoked = if e.revoked_at.is_some() { "yes" } else { "no" };
553        let _ = writeln!(
554            s,
555            "{:>id_w$}  {:<24}  {:<type_w$}  {:<actor_w$}  {:<reasons_w$}  {:>9}  {:<8}  {:<8}",
556            e.id,
557            e.effective_at,
558            e.action_type,
559            e.actor_kind,
560            reasons,
561            applied,
562            dampened,
563            revoked,
564            id_w = id_w,
565            type_w = type_w,
566            actor_w = actor_w,
567            reasons_w = reasons_w,
568        );
569    }
570    if let Some(c) = &resp.cursor {
571        let _ = write!(s, "next cursor: {c}");
572    } else if s.ends_with('\n') {
573        s.pop();
574    }
575    s
576}
577
578/// JSON renderer for `cairn moderator history`. The full
579/// [`HistoryResponse`] verbatim.
580pub fn format_history_json(resp: &HistoryResponse) -> String {
581    serde_json::to_string_pretty(resp).expect("HistoryResponse serializes")
582}
583
584// ============================================================
585// `cairn moderator strikes` — current strike state for a subject.
586// Backs `getSubjectStrikes` admin XRPC (#52 / read-half of #53).
587// ============================================================
588
589/// Input to `cairn moderator strikes`.
590#[derive(Debug, Clone)]
591pub struct StrikesInput {
592    /// Subject DID. The endpoint enforces the DID-prefix shape;
593    /// the CLI checks early so a bad shape doesn't cost an HTTP
594    /// round-trip.
595    pub subject: String,
596    /// Per-invocation override of the session's stored Cairn URL.
597    pub cairn_server_override: Option<String>,
598}
599
600/// Wire-shaped response from `tools.cairn.admin.getSubjectStrikes`.
601/// Mirrors `tools.cairn.admin.defs#subjectStrikeState`.
602#[derive(Debug, Clone, Deserialize, Serialize)]
603pub struct StrikesResponse {
604    /// Active strike total after decay and revocation.
605    #[serde(rename = "currentStrikeCount")]
606    pub current_strike_count: u32,
607    /// Lifetime sum of strike_value_applied (ignores decay + revoke).
608    #[serde(rename = "rawTotal")]
609    pub raw_total: u32,
610    /// Strikes lost to time-based decay across unrevoked actions.
611    #[serde(rename = "decayedCount")]
612    pub decayed_count: u32,
613    /// Strikes that have been revoked.
614    #[serde(rename = "revokedCount")]
615    pub revoked_count: u32,
616    /// `true` iff `currentStrikeCount <= policy.good_standing_threshold`.
617    #[serde(rename = "goodStanding")]
618    pub good_standing: bool,
619    /// Currently-active suspension if any.
620    #[serde(
621        rename = "activeSuspension",
622        skip_serializing_if = "Option::is_none",
623        default
624    )]
625    pub active_suspension: Option<ActiveSuspensionView>,
626    /// Days until the most recent strike-bearing action falls out
627    /// of the decay window. Omitted when `currentStrikeCount == 0`.
628    #[serde(
629        rename = "decayWindowRemainingDays",
630        skip_serializing_if = "Option::is_none",
631        default
632    )]
633    pub decay_window_remaining_days: Option<u32>,
634    /// RFC-3339 effective_at of the most recent strike-bearing
635    /// unrevoked action. Absent if none.
636    #[serde(
637        rename = "lastActionAt",
638        skip_serializing_if = "Option::is_none",
639        default
640    )]
641    pub last_action_at: Option<String>,
642    /// ATProto labels cairn-mod is currently emitting against the
643    /// subject (#65, v1.5). One entry per non-revoked, non-negated
644    /// action that emitted labels, ordered most-recent-first.
645    /// Always present; empty array when nothing is active. Surfaced
646    /// here so `cairn moderator strikes --json` carries the same
647    /// envelope as the wire response, and so `cairn moderator
648    /// labels` can render the same field as its primary output.
649    #[serde(rename = "activeLabels", default)]
650    pub active_labels: Vec<ActiveLabelView>,
651}
652
653/// Per-action active-label entry returned on
654/// [`StrikesResponse::active_labels`]. Mirrors
655/// `tools.cairn.admin.defs#activeLabel`.
656#[derive(Debug, Clone, Deserialize, Serialize)]
657pub struct ActiveLabelView {
658    /// Action label `val` (e.g., `!takedown`).
659    pub val: String,
660    /// `subject_actions.id` of the source action.
661    #[serde(rename = "actionId")]
662    pub action_id: i64,
663    /// `subject_actions.action_type` for the source row.
664    #[serde(rename = "actionType")]
665    pub action_type: String,
666    /// Reason codes whose reason-labels were emitted alongside the
667    /// action label. Always present (may be empty).
668    #[serde(rename = "reasonCodes")]
669    pub reason_codes: Vec<String>,
670    /// RFC-3339 expiry of the action's emitted labels; absent for
671    /// non-temp_suspension actions.
672    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
673    pub expires_at: Option<String>,
674}
675
676/// Active-suspension sub-object surfaced on a [`StrikesResponse`].
677#[derive(Debug, Clone, Deserialize, Serialize)]
678pub struct ActiveSuspensionView {
679    /// `temp_suspension` or `indef_suspension`.
680    #[serde(rename = "actionType")]
681    pub action_type: String,
682    /// RFC-3339 wall-clock the suspension took effect.
683    #[serde(rename = "effectiveAt")]
684    pub effective_at: String,
685    /// RFC-3339 wall-clock the suspension ends; absent for indef.
686    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
687    pub expires_at: Option<String>,
688}
689
690/// Fetch the subject's current strike state.
691pub async fn strikes(
692    session: &mut SessionFile,
693    session_path: &Path,
694    input: StrikesInput,
695) -> Result<StrikesResponse, CliError> {
696    if !input.subject.starts_with("did:") {
697        return Err(CliError::Config(format!(
698            "subject must be a DID (`did:...`); got {:?}",
699            input.subject
700        )));
701    }
702    let cairn_server = input
703        .cairn_server_override
704        .as_deref()
705        .unwrap_or(&session.cairn_server_url)
706        .trim_end_matches('/')
707        .to_string();
708    let pds = PdsClient::new(&session.pds_url)?;
709    let token = acquire_service_auth(&pds, session, session_path, GET_SUBJECT_STRIKES_LXM).await?;
710
711    let url = format!("{cairn_server}/xrpc/{GET_SUBJECT_STRIKES_LXM}");
712    let client = build_client();
713    let resp = client
714        .get(&url)
715        .bearer_auth(&token)
716        .query(&[("subject", input.subject.as_str())])
717        .send()
718        .await
719        .map_err(|source| CliError::Http {
720            url: url.clone(),
721            source,
722        })?;
723    cairn_response::<StrikesResponse>(url, resp).await
724}
725
726/// Multi-line human renderer for `cairn moderator strikes`. Sections:
727/// summary numbers, suspension state (if any), trajectory.
728pub fn format_strikes_human(resp: &StrikesResponse, subject: &str) -> String {
729    use std::fmt::Write;
730    let mut s = String::new();
731    let _ = writeln!(s, "Strike state for {subject}");
732    let _ = writeln!(s, "  current strikes:    {}", resp.current_strike_count);
733    let _ = writeln!(
734        s,
735        "  good standing:      {}",
736        if resp.good_standing { "yes" } else { "no" }
737    );
738    let _ = writeln!(s, "  raw total:          {}", resp.raw_total);
739    let _ = writeln!(s, "  decayed:            {}", resp.decayed_count);
740    let _ = writeln!(s, "  revoked:            {}", resp.revoked_count);
741    match &resp.active_suspension {
742        None => {
743            let _ = writeln!(s, "  active suspension:  none");
744        }
745        Some(susp) => {
746            let when = match &susp.expires_at {
747                None => format!("{} (indefinite)", susp.effective_at),
748                Some(e) => format!("{} → {}", susp.effective_at, e),
749            };
750            let _ = writeln!(s, "  active suspension:  {} {}", susp.action_type, when);
751        }
752    }
753    if let Some(t) = &resp.last_action_at {
754        let _ = writeln!(s, "  last action:        {t}");
755    }
756    if let Some(d) = resp.decay_window_remaining_days {
757        let _ = writeln!(s, "  returns to good standing in: {d} day(s)");
758    }
759    if s.ends_with('\n') {
760        s.pop();
761    }
762    s
763}
764
765/// JSON renderer for `cairn moderator strikes`.
766pub fn format_strikes_json(resp: &StrikesResponse) -> String {
767    serde_json::to_string_pretty(resp).expect("StrikesResponse serializes")
768}
769
770// ============================================================
771// `cairn moderator labels <subject>` (#66, v1.5).
772//
773// Reads the same `tools.cairn.admin.getSubjectStrikes` envelope
774// as `cairn moderator strikes`, but renders only the `active_labels`
775// field. The fetch path is shared with `strikes` (single source of
776// HTTP code); divergence is at the formatting layer.
777// ============================================================
778
779/// Input for [`labels`]. Mirrors [`StrikesInput`] one-to-one — the
780/// CLI surfaces them as separate subcommands for UX clarity even
781/// though the underlying XRPC request is identical.
782#[derive(Debug, Clone)]
783pub struct LabelsInput {
784    /// Subject DID. Pre-validated for the `did:` prefix; the
785    /// endpoint also enforces this.
786    pub subject: String,
787    /// Per-invocation override of the session's stored Cairn URL.
788    pub cairn_server_override: Option<String>,
789}
790
791/// Fetch the subject's active-label state. Internally calls the
792/// same getSubjectStrikes XRPC as [`strikes`]; renderers below
793/// pull the `active_labels` field off the envelope.
794pub async fn labels(
795    session: &mut SessionFile,
796    session_path: &Path,
797    input: LabelsInput,
798) -> Result<StrikesResponse, CliError> {
799    strikes(
800        session,
801        session_path,
802        StrikesInput {
803            subject: input.subject,
804            cairn_server_override: input.cairn_server_override,
805        },
806    )
807    .await
808}
809
810/// Tabular human renderer for `cairn moderator labels`.
811///
812/// One row per emitted label: each [`ActiveLabelView`] expands to
813/// the action label's row plus one row per reason code. The action
814/// context (id, type, reasons, expiry) repeats across every row
815/// belonging to the same action so an operator scanning the table
816/// can see every label's provenance in one line.
817///
818/// Reason-label `val`s are reconstructed by prefixing each
819/// `reason_code` with the default `reason-` prefix. The wire shape
820/// doesn't carry the operator's configured prefix, so a custom
821/// `[label_emission].reason_label_prefix` in operator config will
822/// drift here. Tracked for follow-up if a real deployment surfaces
823/// a non-default prefix.
824pub fn format_labels_human(resp: &StrikesResponse, subject: &str) -> String {
825    use std::fmt::Write;
826    if resp.active_labels.is_empty() {
827        return format!("No active labels for {subject}");
828    }
829
830    let mut s = String::new();
831    let _ = writeln!(s, "Active labels for {subject}");
832
833    // Two-pass render: pass 1 collects rows so column widths come
834    // from real content; pass 2 prints with consistent spacing.
835    let mut rows: Vec<[String; 5]> = Vec::new();
836    for entry in &resp.active_labels {
837        let reasons_joined = if entry.reason_codes.is_empty() {
838            "-".to_string()
839        } else {
840            entry.reason_codes.join(",")
841        };
842        let expires = entry.expires_at.as_deref().unwrap_or("-").to_string();
843        // Action label first.
844        rows.push([
845            entry.val.clone(),
846            entry.action_id.to_string(),
847            entry.action_type.clone(),
848            reasons_joined.clone(),
849            expires.clone(),
850        ]);
851        // Then one row per reason code, val=`reason-<code>`.
852        for code in &entry.reason_codes {
853            rows.push([
854                format!("reason-{code}"),
855                entry.action_id.to_string(),
856                entry.action_type.clone(),
857                reasons_joined.clone(),
858                expires.clone(),
859            ]);
860        }
861    }
862
863    let headers = [
864        "LABEL_VAL",
865        "ACTION_ID",
866        "ACTION_TYPE",
867        "REASONS",
868        "EXPIRES_AT",
869    ];
870    let mut widths = [0usize; 5];
871    for (i, h) in headers.iter().enumerate() {
872        widths[i] = h.len();
873    }
874    for row in &rows {
875        for (i, cell) in row.iter().enumerate() {
876            if cell.len() > widths[i] {
877                widths[i] = cell.len();
878            }
879        }
880    }
881    let _ = writeln!(
882        s,
883        "  {h0:<w0$}  {h1:<w1$}  {h2:<w2$}  {h3:<w3$}  {h4:<w4$}",
884        h0 = headers[0],
885        h1 = headers[1],
886        h2 = headers[2],
887        h3 = headers[3],
888        h4 = headers[4],
889        w0 = widths[0],
890        w1 = widths[1],
891        w2 = widths[2],
892        w3 = widths[3],
893        w4 = widths[4],
894    );
895    for row in &rows {
896        let _ = writeln!(
897            s,
898            "  {c0:<w0$}  {c1:<w1$}  {c2:<w2$}  {c3:<w3$}  {c4:<w4$}",
899            c0 = row[0],
900            c1 = row[1],
901            c2 = row[2],
902            c3 = row[3],
903            c4 = row[4],
904            w0 = widths[0],
905            w1 = widths[1],
906            w2 = widths[2],
907            w3 = widths[3],
908            w4 = widths[4],
909        );
910    }
911    if s.ends_with('\n') {
912        s.pop();
913    }
914    s
915}
916
917/// JSON renderer for `cairn moderator labels`. Returns just the
918/// `activeLabels` array — the subcommand's job is "show me labels,"
919/// so the JSON output mirrors that. Operators wanting the full
920/// envelope use `cairn moderator strikes --json`.
921pub fn format_labels_json(resp: &StrikesResponse) -> String {
922    serde_json::to_string_pretty(&resp.active_labels).expect("Vec<ActiveLabelView> serializes")
923}
924
925// ============================================================
926// Shared helpers (mirrors the cli/report.rs pattern).
927// ============================================================
928
929fn build_client() -> Client {
930    Client::builder()
931        .timeout(Duration::from_secs(30))
932        .build()
933        .expect("reqwest build")
934}
935
936async fn cairn_response<T: serde::de::DeserializeOwned>(
937    url: String,
938    resp: reqwest::Response,
939) -> Result<T, CliError> {
940    if !resp.status().is_success() {
941        let status = resp.status().as_u16();
942        let body = resp.text().await.unwrap_or_default();
943        return Err(CliError::CairnStatus { url, status, body });
944    }
945    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
946        url: url.clone(),
947        source,
948    })?;
949    serde_json::from_slice::<T>(&bytes)
950        .map_err(|source| CliError::MalformedResponse { url, source })
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956
957    #[test]
958    fn record_input_rejects_bad_subject() {
959        let mut session = SessionFile {
960            version: 1,
961            pds_url: "https://pds.example".into(),
962            moderator_handle: "mod.example".into(),
963            moderator_did: "did:plc:m1".into(),
964            access_jwt: "x".into(),
965            refresh_jwt: "x".into(),
966            cairn_server_url: "https://cairn.example".into(),
967            cairn_service_did: "did:web:cairn.example".into(),
968        };
969        // synchronous validation runs before any HTTP — no runtime needed.
970        let path = std::path::PathBuf::from("/tmp/nonexistent-session");
971        let rt = tokio::runtime::Builder::new_current_thread()
972            .enable_all()
973            .build()
974            .unwrap();
975        let err = rt
976            .block_on(record(
977                &mut session,
978                &path,
979                RecordActionInput {
980                    subject: "bad-shape".into(),
981                    action_type: "takedown".into(),
982                    reasons: vec!["spam".into()],
983                    duration: None,
984                    note: None,
985                    report_ids: vec![],
986                    cairn_server_override: None,
987                },
988            ))
989            .unwrap_err();
990        assert!(matches!(err, CliError::Config(_)));
991    }
992
993    #[test]
994    fn record_input_rejects_empty_reasons() {
995        let mut session = SessionFile {
996            version: 1,
997            pds_url: "https://pds.example".into(),
998            moderator_handle: "mod.example".into(),
999            moderator_did: "did:plc:m1".into(),
1000            access_jwt: "x".into(),
1001            refresh_jwt: "x".into(),
1002            cairn_server_url: "https://cairn.example".into(),
1003            cairn_service_did: "did:web:cairn.example".into(),
1004        };
1005        let path = std::path::PathBuf::from("/tmp/nonexistent-session");
1006        let rt = tokio::runtime::Builder::new_current_thread()
1007            .enable_all()
1008            .build()
1009            .unwrap();
1010        let err = rt
1011            .block_on(record(
1012                &mut session,
1013                &path,
1014                RecordActionInput {
1015                    subject: "did:plc:abc".into(),
1016                    action_type: "takedown".into(),
1017                    reasons: vec![],
1018                    duration: None,
1019                    note: None,
1020                    report_ids: vec![],
1021                    cairn_server_override: None,
1022                },
1023            ))
1024            .unwrap_err();
1025        assert!(matches!(err, CliError::Config(_)));
1026    }
1027
1028    #[test]
1029    fn format_record_human_dampened_includes_base() {
1030        let r = RecordResponse {
1031            action_id: 7,
1032            strike_value_base: 4,
1033            strike_value_applied: 1,
1034            was_dampened: true,
1035            strikes_at_time_of_action: 0,
1036        };
1037        let s = format_record_human(&r);
1038        assert!(s.contains("dampened"));
1039        assert!(s.contains("from 4"));
1040    }
1041
1042    #[test]
1043    fn format_record_human_zero_strikes_says_no_strikes() {
1044        let r = RecordResponse {
1045            action_id: 7,
1046            strike_value_base: 0,
1047            strike_value_applied: 0,
1048            was_dampened: false,
1049            strikes_at_time_of_action: 0,
1050        };
1051        let s = format_record_human(&r);
1052        assert!(s.contains("no strikes"));
1053    }
1054
1055    #[test]
1056    fn format_revoke_human_shows_id_and_timestamp() {
1057        let r = RevokeResponse {
1058            action_id: 7,
1059            revoked_at: "2026-04-26T12:00:00.000Z".into(),
1060        };
1061        let s = format_revoke_human(&r);
1062        assert!(s.contains("7"));
1063        assert!(s.contains("2026-04-26"));
1064    }
1065
1066    // ---------- labels (#66) ----------
1067
1068    fn empty_strikes_response() -> StrikesResponse {
1069        StrikesResponse {
1070            current_strike_count: 0,
1071            raw_total: 0,
1072            decayed_count: 0,
1073            revoked_count: 0,
1074            good_standing: true,
1075            active_suspension: None,
1076            decay_window_remaining_days: None,
1077            last_action_at: None,
1078            active_labels: vec![],
1079        }
1080    }
1081
1082    #[test]
1083    fn format_labels_human_empty_says_no_active_labels() {
1084        let resp = empty_strikes_response();
1085        let s = format_labels_human(&resp, "did:plc:abc");
1086        assert_eq!(s, "No active labels for did:plc:abc");
1087    }
1088
1089    #[test]
1090    fn format_labels_human_takedown_no_reasons_emits_one_row() {
1091        // emit_reason_labels=false at recording time → emitted action
1092        // label only, reason_codes empty.
1093        let mut resp = empty_strikes_response();
1094        resp.active_labels.push(ActiveLabelView {
1095            val: "!takedown".into(),
1096            action_id: 42,
1097            action_type: "takedown".into(),
1098            reason_codes: vec![],
1099            expires_at: None,
1100        });
1101        let s = format_labels_human(&resp, "did:plc:abc");
1102        assert!(s.contains("Active labels for did:plc:abc"));
1103        assert!(s.contains("LABEL_VAL"));
1104        assert!(s.contains("!takedown"));
1105        assert!(s.contains("42"));
1106        assert!(s.contains("takedown"));
1107        // No reason-* rows when reason_codes is empty.
1108        assert!(!s.contains("reason-"));
1109        // REASONS column carries `-` placeholder.
1110        let row_count = s.lines().filter(|l| l.contains("!takedown")).count();
1111        assert_eq!(row_count, 1);
1112    }
1113
1114    #[test]
1115    fn format_labels_human_takedown_with_two_reasons_emits_three_rows() {
1116        let mut resp = empty_strikes_response();
1117        resp.active_labels.push(ActiveLabelView {
1118            val: "!takedown".into(),
1119            action_id: 42,
1120            action_type: "takedown".into(),
1121            reason_codes: vec!["harassment".into(), "hate-speech".into()],
1122            expires_at: None,
1123        });
1124        let s = format_labels_human(&resp, "did:plc:abc");
1125        assert!(s.contains("!takedown"));
1126        assert!(s.contains("reason-harassment"));
1127        assert!(s.contains("reason-hate-speech"));
1128        // REASONS column: comma-joined.
1129        assert!(s.contains("harassment,hate-speech"));
1130    }
1131
1132    #[test]
1133    fn format_labels_human_temp_suspension_shows_expires_at_column() {
1134        let mut resp = empty_strikes_response();
1135        resp.active_labels.push(ActiveLabelView {
1136            val: "!hide".into(),
1137            action_id: 38,
1138            action_type: "temp_suspension".into(),
1139            reason_codes: vec!["spam".into()],
1140            expires_at: Some("2026-05-04T12:00:00.000Z".into()),
1141        });
1142        let s = format_labels_human(&resp, "did:plc:abc");
1143        assert!(s.contains("temp_suspension"));
1144        assert!(s.contains("2026-05-04T12:00:00.000Z"));
1145        // Both the action-label row and the reason-label row carry
1146        // the same expiry (each row is fully self-describing).
1147        let with_exp = s
1148            .lines()
1149            .filter(|l| l.contains("2026-05-04T12:00:00.000Z"))
1150            .count();
1151        assert_eq!(with_exp, 2, "action row + reason row both carry expiry");
1152    }
1153
1154    #[test]
1155    fn format_labels_human_indef_suspension_shows_dash_in_expires_at() {
1156        let mut resp = empty_strikes_response();
1157        resp.active_labels.push(ActiveLabelView {
1158            val: "!hide".into(),
1159            action_id: 38,
1160            action_type: "indef_suspension".into(),
1161            reason_codes: vec![],
1162            expires_at: None,
1163        });
1164        let s = format_labels_human(&resp, "did:plc:abc");
1165        assert!(s.contains("indef_suspension"));
1166        // The action-label data row's trailing column is `-`.
1167        let action_row = s
1168            .lines()
1169            .find(|l| l.contains("!hide") && l.contains("38"))
1170            .expect("action row present");
1171        assert!(action_row.trim_end().ends_with('-'));
1172    }
1173
1174    #[test]
1175    fn format_labels_human_multiple_actions_renders_all() {
1176        let mut resp = empty_strikes_response();
1177        // Most-recent first per #65's ordering.
1178        resp.active_labels.push(ActiveLabelView {
1179            val: "!hide".into(),
1180            action_id: 50,
1181            action_type: "temp_suspension".into(),
1182            reason_codes: vec!["spam".into()],
1183            expires_at: Some("2026-05-04T12:00:00.000Z".into()),
1184        });
1185        resp.active_labels.push(ActiveLabelView {
1186            val: "!takedown".into(),
1187            action_id: 42,
1188            action_type: "takedown".into(),
1189            reason_codes: vec!["hate-speech".into()],
1190            expires_at: None,
1191        });
1192        let s = format_labels_human(&resp, "did:plc:abc");
1193        // Both actions present.
1194        assert!(s.contains("!hide"));
1195        assert!(s.contains("!takedown"));
1196        assert!(s.contains("reason-spam"));
1197        assert!(s.contains("reason-hate-speech"));
1198        // Order: action_id 50 (newer) appears before 42 (older) in
1199        // the rendered output.
1200        let pos_50 = s.find("50").expect("action 50 row present");
1201        let pos_42 = s.find(" 42 ").expect("action 42 row present");
1202        assert!(pos_50 < pos_42, "most-recent action renders first");
1203    }
1204
1205    #[test]
1206    fn format_labels_json_returns_only_active_labels_array() {
1207        // The JSON output is the activeLabels array, not the full
1208        // strikes envelope. Operators wanting the full state use
1209        // `cairn moderator strikes --json`.
1210        let mut resp = empty_strikes_response();
1211        resp.current_strike_count = 7; // would-be-noisy field in full envelope
1212        resp.active_labels.push(ActiveLabelView {
1213            val: "!takedown".into(),
1214            action_id: 42,
1215            action_type: "takedown".into(),
1216            reason_codes: vec!["spam".into()],
1217            expires_at: None,
1218        });
1219        let json = format_labels_json(&resp);
1220        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1221        assert!(v.is_array(), "labels JSON is the array, not the envelope");
1222        assert_eq!(v[0]["val"], "!takedown");
1223        assert_eq!(v[0]["actionId"], 42);
1224        assert_eq!(v[0]["actionType"], "takedown");
1225        assert_eq!(v[0]["reasonCodes"], serde_json::json!(["spam"]));
1226        // currentStrikeCount must NOT appear in the labels JSON.
1227        let s = json.as_str();
1228        assert!(
1229            !s.contains("currentStrikeCount"),
1230            "labels --json drops the strikes envelope fields"
1231        );
1232    }
1233
1234    #[test]
1235    fn format_labels_json_empty_active_labels_is_empty_array() {
1236        let resp = empty_strikes_response();
1237        let json = format_labels_json(&resp);
1238        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1239        assert_eq!(v, serde_json::json!([]));
1240    }
1241}