Skip to main content

cairn_mod/cli/
moderator_pending.rs

1//! `cairn moderator pending {list, view, confirm, dismiss}` (#78)
2//! — HTTP-routed moderator-tier CLIs that wrap the
3//! `tools.cairn.admin.{listPendingActions, getPendingAction,
4//! confirmPendingAction, dismissPendingAction}` admin XRPC
5//! endpoints from #74 / #75 / #77.
6//!
7//! Same wire-side pattern as [`super::moderator_action`]: each
8//! subcommand loads the moderator session file, mints a fresh
9//! service-auth JWT bound to the lexicon method, sends the
10//! lexicon-shaped request, and returns a typed response for the
11//! dispatcher's human / JSON formatters.
12
13use std::path::Path;
14use std::time::Duration;
15
16use reqwest::Client;
17use serde::{Deserialize, Serialize};
18use serde_json::json;
19
20use super::auth::acquire_service_auth;
21use super::error::CliError;
22use super::pds::PdsClient;
23use super::session::SessionFile;
24
25const LIST_PENDING_LXM: &str = "tools.cairn.admin.listPendingActions";
26const GET_PENDING_LXM: &str = "tools.cairn.admin.getPendingAction";
27const CONFIRM_PENDING_LXM: &str = "tools.cairn.admin.confirmPendingAction";
28const DISMISS_PENDING_LXM: &str = "tools.cairn.admin.dismissPendingAction";
29
30/// One pending row from a list/get response. Tracks
31/// `tools.cairn.admin.defs#pendingAction`. Optional fields use
32/// `serde(default)` so deserialization tolerates server-side
33/// projections that omit them.
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct PendingActionEntry {
36    /// `pending_policy_actions.id` — primary key.
37    pub id: i64,
38    /// Account DID the proposed action would target.
39    #[serde(rename = "subjectDid")]
40    pub subject_did: String,
41    /// AT-URI for record-level proposed actions; absent for
42    /// account-level.
43    #[serde(
44        rename = "subjectUri",
45        skip_serializing_if = "Option::is_none",
46        default
47    )]
48    pub subject_uri: Option<String>,
49    /// Proposed graduated-action category (`warning` / `note` /
50    /// `temp_suspension` / `indef_suspension` / `takedown`).
51    #[serde(rename = "actionType")]
52    pub action_type: String,
53    /// ISO-8601 duration (canonical `PT<seconds>S` form). Only
54    /// present when `action_type == "temp_suspension"`.
55    #[serde(
56        rename = "durationIso",
57        skip_serializing_if = "Option::is_none",
58        default
59    )]
60    pub duration_iso: Option<String>,
61    /// Reason identifiers from the operator's `[moderation_reasons]`
62    /// vocabulary, frozen at proposal time.
63    #[serde(rename = "reasonCodes")]
64    pub reason_codes: Vec<String>,
65    /// Name of the `[policy_automation.<rule>]` sub-block that
66    /// fired and queued this pending.
67    #[serde(rename = "triggeredByPolicyRule")]
68    pub triggered_by_policy_rule: String,
69    /// RFC-3339 wall-clock the rule fired.
70    #[serde(rename = "triggeredAt")]
71    pub triggered_at: String,
72    /// `subject_actions.id` of the precipitating action whose
73    /// strike contribution caused the threshold crossing.
74    #[serde(rename = "triggeringActionId")]
75    pub triggering_action_id: i64,
76    /// Always one of `"pending"`, `"confirmed"`, `"dismissed"`.
77    pub resolution: String,
78    /// RFC-3339 wall-clock the pending was resolved; absent while
79    /// resolution is `"pending"`.
80    #[serde(
81        rename = "resolvedAt",
82        skip_serializing_if = "Option::is_none",
83        default
84    )]
85    pub resolved_at: Option<String>,
86    /// DID of the moderator (or synthetic policy DID for
87    /// takedown-cascade auto-dismissals per #76) that resolved the
88    /// pending. Absent while resolution is `"pending"`.
89    #[serde(
90        rename = "resolvedByDid",
91        skip_serializing_if = "Option::is_none",
92        default
93    )]
94    pub resolved_by_did: Option<String>,
95    /// `subject_actions.id` of the materialized action created
96    /// when this pending was confirmed (#74). Only present when
97    /// resolution is `"confirmed"`.
98    #[serde(
99        rename = "confirmedActionId",
100        skip_serializing_if = "Option::is_none",
101        default
102    )]
103    pub confirmed_action_id: Option<i64>,
104}
105
106// ============================================================
107// `cairn moderator pending list` — paginated list (#77).
108// ============================================================
109
110/// Input to `cairn moderator pending list`.
111#[derive(Debug, Clone, Default)]
112pub struct ListPendingInput {
113    /// Subject DID filter. Server returns SubjectNotFound (404) if
114    /// the subject has never had a pending row.
115    pub subject: Option<String>,
116    /// Page size. Server caps at 250; default 50.
117    pub limit: Option<i64>,
118    /// Opaque pagination cursor.
119    pub cursor: Option<String>,
120    /// Per-invocation override of the session's stored Cairn URL.
121    pub cairn_server_override: Option<String>,
122}
123
124/// Wire-shaped response from `tools.cairn.admin.listPendingActions`.
125#[derive(Debug, Clone, Deserialize, Serialize)]
126pub struct ListPendingResponse {
127    /// Matched pending rows, newest-first.
128    pub actions: Vec<PendingActionEntry>,
129    /// Opaque pagination cursor; absent when this is the final
130    /// page.
131    #[serde(skip_serializing_if = "Option::is_none", default)]
132    pub cursor: Option<String>,
133}
134
135/// Fetch one page of pending actions. Pagination across pages is
136/// the caller's job.
137pub async fn list(
138    session: &mut SessionFile,
139    session_path: &Path,
140    input: ListPendingInput,
141) -> Result<ListPendingResponse, CliError> {
142    if let Some(s) = &input.subject
143        && !s.starts_with("did:")
144    {
145        return Err(CliError::Config(format!(
146            "subject must be a DID (`did:...`); got {s:?}"
147        )));
148    }
149
150    let cairn_server = input
151        .cairn_server_override
152        .as_deref()
153        .unwrap_or(&session.cairn_server_url)
154        .trim_end_matches('/')
155        .to_string();
156    let pds = PdsClient::new(&session.pds_url)?;
157    let token = acquire_service_auth(&pds, session, session_path, LIST_PENDING_LXM).await?;
158
159    let url = format!("{cairn_server}/xrpc/{LIST_PENDING_LXM}");
160    let limit_owned = input.limit.map(|n| n.to_string());
161    let mut query: Vec<(&str, &str)> = Vec::new();
162    if let Some(s) = &input.subject {
163        query.push(("subject", s.as_str()));
164    }
165    if let Some(n) = &limit_owned {
166        query.push(("limit", n.as_str()));
167    }
168    if let Some(c) = &input.cursor {
169        query.push(("cursor", c.as_str()));
170    }
171
172    let client = build_client();
173    let resp = client
174        .get(&url)
175        .bearer_auth(&token)
176        .query(&query)
177        .send()
178        .await
179        .map_err(|source| CliError::Http {
180            url: url.clone(),
181            source,
182        })?;
183    cairn_response::<ListPendingResponse>(url, resp).await
184}
185
186/// Tabular human renderer for `cairn moderator pending list`.
187/// Columns: id | subject_did | action_type | rule_name |
188/// triggered_at | days. Empty result renders as a friendly
189/// "no pendings" line.
190///
191/// `today_rfc3339` is the wall-clock to compute the "days since
192/// triggered" column against. Passed in (rather than read from
193/// `OffsetDateTime::now_utc()` directly) so tests can pin the
194/// expected output.
195pub fn format_list_human(resp: &ListPendingResponse, today_rfc3339: &str) -> String {
196    use std::fmt::Write;
197    if resp.actions.is_empty() {
198        let mut s = "No pending actions found.".to_string();
199        if let Some(c) = &resp.cursor {
200            let _ = write!(s, "\nnext cursor: {c}");
201        }
202        return s;
203    }
204
205    // Compute days-since for each entry. Falls back to "-" when
206    // either timestamp is unparseable — the wire shape is
207    // server-controlled, so a parse failure is unexpected but
208    // shouldn't crash the CLI.
209    let today_ms = parse_rfc3339_to_ms(today_rfc3339);
210    let rows: Vec<[String; 6]> = resp
211        .actions
212        .iter()
213        .map(|e| {
214            let days = match (today_ms, parse_rfc3339_to_ms(&e.triggered_at)) {
215                (Some(t), Some(when)) => {
216                    let delta_days = (t - when).max(0) / 86_400_000;
217                    delta_days.to_string()
218                }
219                _ => "-".to_string(),
220            };
221            [
222                e.id.to_string(),
223                e.subject_did.clone(),
224                e.action_type.clone(),
225                e.triggered_by_policy_rule.clone(),
226                e.triggered_at.clone(),
227                days,
228            ]
229        })
230        .collect();
231
232    let headers = [
233        "ID",
234        "SUBJECT",
235        "ACTION_TYPE",
236        "RULE",
237        "TRIGGERED_AT",
238        "DAYS",
239    ];
240    let mut widths = [0usize; 6];
241    for (i, h) in headers.iter().enumerate() {
242        widths[i] = h.len();
243    }
244    for row in &rows {
245        for (i, cell) in row.iter().enumerate() {
246            if cell.len() > widths[i] {
247                widths[i] = cell.len();
248            }
249        }
250    }
251
252    let mut s = String::new();
253    let _ = writeln!(
254        s,
255        "{h0:<w0$}  {h1:<w1$}  {h2:<w2$}  {h3:<w3$}  {h4:<w4$}  {h5:>w5$}",
256        h0 = headers[0],
257        h1 = headers[1],
258        h2 = headers[2],
259        h3 = headers[3],
260        h4 = headers[4],
261        h5 = headers[5],
262        w0 = widths[0],
263        w1 = widths[1],
264        w2 = widths[2],
265        w3 = widths[3],
266        w4 = widths[4],
267        w5 = widths[5],
268    );
269    for row in &rows {
270        let _ = writeln!(
271            s,
272            "{c0:<w0$}  {c1:<w1$}  {c2:<w2$}  {c3:<w3$}  {c4:<w4$}  {c5:>w5$}",
273            c0 = row[0],
274            c1 = row[1],
275            c2 = row[2],
276            c3 = row[3],
277            c4 = row[4],
278            c5 = row[5],
279            w0 = widths[0],
280            w1 = widths[1],
281            w2 = widths[2],
282            w3 = widths[3],
283            w4 = widths[4],
284            w5 = widths[5],
285        );
286    }
287    if let Some(c) = &resp.cursor {
288        let _ = write!(s, "next cursor: {c}");
289    } else if s.ends_with('\n') {
290        s.pop();
291    }
292    s
293}
294
295/// JSON renderer for `cairn moderator pending list`. The full
296/// [`ListPendingResponse`] verbatim.
297pub fn format_list_json(resp: &ListPendingResponse) -> String {
298    serde_json::to_string_pretty(resp).expect("ListPendingResponse serializes")
299}
300
301// ============================================================
302// `cairn moderator pending view` — single-id detail (#77).
303// ============================================================
304
305/// Input to `cairn moderator pending view`.
306#[derive(Debug, Clone)]
307pub struct ViewPendingInput {
308    /// `pending_policy_actions.id` to fetch.
309    pub pending_id: i64,
310    /// Per-invocation override of the session's stored Cairn URL.
311    pub cairn_server_override: Option<String>,
312}
313
314/// Fetch a single pending action by id.
315pub async fn view(
316    session: &mut SessionFile,
317    session_path: &Path,
318    input: ViewPendingInput,
319) -> Result<PendingActionEntry, CliError> {
320    let cairn_server = input
321        .cairn_server_override
322        .as_deref()
323        .unwrap_or(&session.cairn_server_url)
324        .trim_end_matches('/')
325        .to_string();
326    let pds = PdsClient::new(&session.pds_url)?;
327    let token = acquire_service_auth(&pds, session, session_path, GET_PENDING_LXM).await?;
328
329    let url = format!("{cairn_server}/xrpc/{GET_PENDING_LXM}");
330    let pending_id_str = input.pending_id.to_string();
331    let client = build_client();
332    let resp = client
333        .get(&url)
334        .bearer_auth(&token)
335        .query(&[("pendingId", pending_id_str.as_str())])
336        .send()
337        .await
338        .map_err(|source| CliError::Http {
339            url: url.clone(),
340            source,
341        })?;
342    cairn_response::<PendingActionEntry>(url, resp).await
343}
344
345/// Multi-line human renderer for `cairn moderator pending view`.
346pub fn format_view_human(entry: &PendingActionEntry) -> String {
347    use std::fmt::Write;
348    let mut s = String::new();
349    let _ = writeln!(s, "Pending action {}", entry.id);
350    let _ = writeln!(s, "  Subject:           {}", entry.subject_did);
351    if let Some(uri) = &entry.subject_uri {
352        let _ = writeln!(s, "  Subject URI:       {uri}");
353    }
354    let _ = writeln!(s, "  Rule:              {}", entry.triggered_by_policy_rule);
355    let _ = writeln!(s, "  Action type:       {}", entry.action_type);
356    if let Some(d) = &entry.duration_iso {
357        let _ = writeln!(s, "  Duration:          {d}");
358    }
359    let _ = writeln!(s, "  Reason codes:      {}", entry.reason_codes.join(", "));
360    let _ = writeln!(s, "  Triggered at:      {}", entry.triggered_at);
361    let _ = writeln!(s, "  Triggering action: {}", entry.triggering_action_id);
362    let _ = writeln!(s, "  Resolution:        {}", entry.resolution);
363    if let Some(t) = &entry.resolved_at {
364        let _ = writeln!(s, "  Resolved at:       {t}");
365    }
366    if let Some(d) = &entry.resolved_by_did {
367        let _ = writeln!(s, "  Resolved by:       {d}");
368    }
369    if let Some(id) = entry.confirmed_action_id {
370        let _ = writeln!(s, "  Confirmed action:  {id}");
371    }
372    if s.ends_with('\n') {
373        s.pop();
374    }
375    s
376}
377
378/// JSON renderer for `cairn moderator pending view`.
379pub fn format_view_json(entry: &PendingActionEntry) -> String {
380    serde_json::to_string_pretty(entry).expect("PendingActionEntry serializes")
381}
382
383// ============================================================
384// `cairn moderator pending confirm` — promote pending to action (#74).
385// ============================================================
386
387/// Input to `cairn moderator pending confirm`.
388#[derive(Debug, Clone)]
389pub struct ConfirmPendingInput {
390    /// `pending_policy_actions.id` to confirm.
391    pub pending_id: i64,
392    /// Optional moderator-facing rationale. Maps to the
393    /// confirmPendingAction lexicon's `note` input field, which
394    /// the writer stores on `subject_actions.notes` for the
395    /// materialized action.
396    pub reason: Option<String>,
397    /// Per-invocation override of the session's stored Cairn URL.
398    pub cairn_server_override: Option<String>,
399}
400
401/// Wire-shaped response from `tools.cairn.admin.confirmPendingAction`.
402#[derive(Debug, Clone, Deserialize, Serialize)]
403pub struct ConfirmPendingResponse {
404    /// Inserted subject_actions row id.
405    #[serde(rename = "actionId")]
406    pub action_id: i64,
407    /// The pending row that was just resolved.
408    #[serde(rename = "pendingId")]
409    pub pending_id: i64,
410    /// RFC-3339 wall-clock the confirmation took effect.
411    #[serde(rename = "resolvedAt")]
412    pub resolved_at: String,
413}
414
415/// Submit a confirmPendingAction request.
416pub async fn confirm(
417    session: &mut SessionFile,
418    session_path: &Path,
419    input: ConfirmPendingInput,
420) -> Result<ConfirmPendingResponse, CliError> {
421    let cairn_server = input
422        .cairn_server_override
423        .as_deref()
424        .unwrap_or(&session.cairn_server_url)
425        .trim_end_matches('/')
426        .to_string();
427    let pds = PdsClient::new(&session.pds_url)?;
428    let token = acquire_service_auth(&pds, session, session_path, CONFIRM_PENDING_LXM).await?;
429
430    let mut body = json!({"pendingId": input.pending_id});
431    // Lexicon field name is `note` (it lands on
432    // subject_actions.notes); CLI flag is `--reason` for
433    // ergonomic symmetry with dismiss + revoke.
434    if let Some(r) = &input.reason {
435        body["note"] = json!(r);
436    }
437
438    let url = format!("{cairn_server}/xrpc/{CONFIRM_PENDING_LXM}");
439    let client = build_client();
440    let resp = client
441        .post(&url)
442        .bearer_auth(&token)
443        .json(&body)
444        .send()
445        .await
446        .map_err(|source| CliError::Http {
447            url: url.clone(),
448            source,
449        })?;
450    cairn_response::<ConfirmPendingResponse>(url, resp).await
451}
452
453/// Human-readable one-liner for `cairn moderator pending confirm`.
454pub fn format_confirm_human(resp: &ConfirmPendingResponse) -> String {
455    format!(
456        "Confirmed pending {} → action {} recorded.",
457        resp.pending_id, resp.action_id
458    )
459}
460
461/// JSON renderer for `cairn moderator pending confirm`. Full wire
462/// envelope (`{actionId, pendingId, resolvedAt}`) — already small;
463/// no projection.
464pub fn format_confirm_json(resp: &ConfirmPendingResponse) -> String {
465    serde_json::to_string_pretty(resp).expect("ConfirmPendingResponse serializes")
466}
467
468// ============================================================
469// `cairn moderator pending dismiss` — close without action (#75).
470// ============================================================
471
472/// Input to `cairn moderator pending dismiss`.
473#[derive(Debug, Clone)]
474pub struct DismissPendingInput {
475    /// `pending_policy_actions.id` to dismiss.
476    pub pending_id: i64,
477    /// Optional moderator-facing rationale. Lands on the audit
478    /// row's `moderator_reason` field per #75 (the pending table
479    /// itself has no resolved_reason column).
480    pub reason: Option<String>,
481    /// Per-invocation override of the session's stored Cairn URL.
482    pub cairn_server_override: Option<String>,
483}
484
485/// Wire-shaped response from `tools.cairn.admin.dismissPendingAction`.
486#[derive(Debug, Clone, Deserialize, Serialize)]
487pub struct DismissPendingResponse {
488    /// The pending row that was just resolved.
489    #[serde(rename = "pendingId")]
490    pub pending_id: i64,
491    /// RFC-3339 wall-clock the dismissal took effect.
492    #[serde(rename = "resolvedAt")]
493    pub resolved_at: String,
494}
495
496/// Submit a dismissPendingAction request.
497pub async fn dismiss(
498    session: &mut SessionFile,
499    session_path: &Path,
500    input: DismissPendingInput,
501) -> Result<DismissPendingResponse, CliError> {
502    let cairn_server = input
503        .cairn_server_override
504        .as_deref()
505        .unwrap_or(&session.cairn_server_url)
506        .trim_end_matches('/')
507        .to_string();
508    let pds = PdsClient::new(&session.pds_url)?;
509    let token = acquire_service_auth(&pds, session, session_path, DISMISS_PENDING_LXM).await?;
510
511    let mut body = json!({"pendingId": input.pending_id});
512    if let Some(r) = &input.reason {
513        body["reason"] = json!(r);
514    }
515
516    let url = format!("{cairn_server}/xrpc/{DISMISS_PENDING_LXM}");
517    let client = build_client();
518    let resp = client
519        .post(&url)
520        .bearer_auth(&token)
521        .json(&body)
522        .send()
523        .await
524        .map_err(|source| CliError::Http {
525            url: url.clone(),
526            source,
527        })?;
528    cairn_response::<DismissPendingResponse>(url, resp).await
529}
530
531/// Human-readable one-liner for `cairn moderator pending dismiss`.
532pub fn format_dismiss_human(resp: &DismissPendingResponse) -> String {
533    format!("Dismissed pending {}.", resp.pending_id)
534}
535
536/// JSON renderer for `cairn moderator pending dismiss`. Full wire
537/// envelope (`{pendingId, resolvedAt}`).
538pub fn format_dismiss_json(resp: &DismissPendingResponse) -> String {
539    serde_json::to_string_pretty(resp).expect("DismissPendingResponse serializes")
540}
541
542// ============================================================
543// Shared helpers.
544// ============================================================
545
546fn build_client() -> Client {
547    Client::builder()
548        .timeout(Duration::from_secs(30))
549        .build()
550        .expect("reqwest build")
551}
552
553async fn cairn_response<T: serde::de::DeserializeOwned>(
554    url: String,
555    resp: reqwest::Response,
556) -> Result<T, CliError> {
557    if !resp.status().is_success() {
558        let status = resp.status().as_u16();
559        let body = resp.text().await.unwrap_or_default();
560        return Err(CliError::CairnStatus { url, status, body });
561    }
562    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
563        url: url.clone(),
564        source,
565    })?;
566    serde_json::from_slice::<T>(&bytes)
567        .map_err(|source| CliError::MalformedResponse { url, source })
568}
569
570/// Parse an RFC-3339-with-Z timestamp to epoch milliseconds.
571/// Returns `None` on parse failure — the formatter falls back to
572/// "-" rather than crashing.
573fn parse_rfc3339_to_ms(s: &str) -> Option<i64> {
574    use time::OffsetDateTime;
575    use time::format_description::well_known::Rfc3339;
576    OffsetDateTime::parse(s, &Rfc3339)
577        .ok()
578        .map(|dt| dt.unix_timestamp_nanos() / 1_000_000)
579        .and_then(|nanos_ms| i64::try_from(nanos_ms).ok())
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    fn sample_unresolved() -> PendingActionEntry {
587        PendingActionEntry {
588            id: 42,
589            subject_did: "did:plc:offender".into(),
590            subject_uri: None,
591            action_type: "indef_suspension".into(),
592            duration_iso: None,
593            reason_codes: vec!["spam".into()],
594            triggered_by_policy_rule: "indef_at_25".into(),
595            triggered_at: "2026-04-25T12:00:00.000Z".into(),
596            triggering_action_id: 187,
597            resolution: "pending".into(),
598            resolved_at: None,
599            resolved_by_did: None,
600            confirmed_action_id: None,
601        }
602    }
603
604    fn sample_confirmed() -> PendingActionEntry {
605        PendingActionEntry {
606            id: 42,
607            subject_did: "did:plc:offender".into(),
608            subject_uri: None,
609            action_type: "indef_suspension".into(),
610            duration_iso: None,
611            reason_codes: vec!["spam".into()],
612            triggered_by_policy_rule: "indef_at_25".into(),
613            triggered_at: "2026-04-25T12:00:00.000Z".into(),
614            triggering_action_id: 187,
615            resolution: "confirmed".into(),
616            resolved_at: Some("2026-04-27T09:00:00.000Z".into()),
617            resolved_by_did: Some("did:plc:moderator".into()),
618            confirmed_action_id: Some(203),
619        }
620    }
621
622    #[test]
623    fn format_list_empty_says_no_pendings() {
624        let resp = ListPendingResponse {
625            actions: vec![],
626            cursor: None,
627        };
628        let s = format_list_human(&resp, "2026-04-27T12:00:00.000Z");
629        assert_eq!(s, "No pending actions found.");
630    }
631
632    #[test]
633    fn format_list_empty_with_cursor_appends_cursor_line() {
634        // Defense-in-depth: an empty page WITH a cursor is
635        // structurally unreachable from the server (cursor is set
636        // only when `rows.len() > limit`), but the formatter
637        // shouldn't lose the cursor if the wire violates that
638        // invariant.
639        let resp = ListPendingResponse {
640            actions: vec![],
641            cursor: Some("opaque-cursor".into()),
642        };
643        let s = format_list_human(&resp, "2026-04-27T12:00:00.000Z");
644        assert!(s.contains("No pending actions found."));
645        assert!(s.contains("next cursor: opaque-cursor"));
646    }
647
648    #[test]
649    fn format_list_single_entry_renders_columns() {
650        let resp = ListPendingResponse {
651            actions: vec![sample_unresolved()],
652            cursor: None,
653        };
654        let s = format_list_human(&resp, "2026-04-27T12:00:00.000Z");
655        // Header + one data row.
656        assert!(s.contains("ID"));
657        assert!(s.contains("SUBJECT"));
658        assert!(s.contains("ACTION_TYPE"));
659        assert!(s.contains("RULE"));
660        assert!(s.contains("TRIGGERED_AT"));
661        assert!(s.contains("DAYS"));
662        assert!(s.contains("42"));
663        assert!(s.contains("did:plc:offender"));
664        assert!(s.contains("indef_suspension"));
665        assert!(s.contains("indef_at_25"));
666        // 2026-04-25 → 2026-04-27 = 2 days.
667        let lines: Vec<&str> = s.lines().collect();
668        let data_line = lines[1];
669        assert!(
670            data_line.trim_end().ends_with('2'),
671            "data line: {data_line:?}"
672        );
673    }
674
675    #[test]
676    fn format_list_multi_entry_orders_by_input() {
677        let mut second = sample_unresolved();
678        second.id = 38;
679        second.subject_did = "did:plc:badactor".into();
680        second.triggered_by_policy_rule = "temp_at_10".into();
681        second.action_type = "temp_suspension".into();
682        second.triggered_at = "2026-04-26T12:00:00.000Z".into();
683        let resp = ListPendingResponse {
684            actions: vec![sample_unresolved(), second],
685            cursor: None,
686        };
687        let s = format_list_human(&resp, "2026-04-27T12:00:00.000Z");
688        let lines: Vec<&str> = s.lines().collect();
689        // Header + 2 data rows.
690        assert_eq!(lines.len(), 3);
691        // First data row = 42 (input order preserved).
692        assert!(lines[1].contains("42"));
693        assert!(lines[2].contains("38"));
694    }
695
696    #[test]
697    fn format_list_with_cursor_appends_next_cursor_line() {
698        let resp = ListPendingResponse {
699            actions: vec![sample_unresolved()],
700            cursor: Some("opaque".into()),
701        };
702        let s = format_list_human(&resp, "2026-04-27T12:00:00.000Z");
703        assert!(s.contains("next cursor: opaque"));
704    }
705
706    #[test]
707    fn format_list_json_emits_full_envelope() {
708        let resp = ListPendingResponse {
709            actions: vec![sample_unresolved()],
710            cursor: Some("opaque".into()),
711        };
712        let s = format_list_json(&resp);
713        assert!(s.contains("\"actions\""));
714        assert!(s.contains("\"cursor\": \"opaque\""));
715    }
716
717    #[test]
718    fn format_view_unresolved_omits_resolution_lines() {
719        let entry = sample_unresolved();
720        let s = format_view_human(&entry);
721        assert!(s.contains("Pending action 42"));
722        assert!(s.contains("Subject:"));
723        assert!(s.contains("did:plc:offender"));
724        assert!(s.contains("Rule:"));
725        assert!(s.contains("indef_at_25"));
726        assert!(s.contains("Action type:"));
727        assert!(s.contains("indef_suspension"));
728        assert!(s.contains("Resolution:"));
729        assert!(s.contains("pending"));
730        // Unresolved → no resolution-side lines.
731        assert!(!s.contains("Resolved at:"));
732        assert!(!s.contains("Resolved by:"));
733        assert!(!s.contains("Confirmed action:"));
734    }
735
736    #[test]
737    fn format_view_confirmed_includes_resolution_and_confirmed_action() {
738        let entry = sample_confirmed();
739        let s = format_view_human(&entry);
740        assert!(s.contains("Resolution:"));
741        assert!(s.contains("confirmed"));
742        assert!(s.contains("Resolved at:"));
743        assert!(s.contains("2026-04-27"));
744        assert!(s.contains("Resolved by:"));
745        assert!(s.contains("did:plc:moderator"));
746        assert!(s.contains("Confirmed action:"));
747        assert!(s.contains("203"));
748    }
749
750    #[test]
751    fn format_view_dismissed_omits_confirmed_action_id() {
752        let mut entry = sample_confirmed();
753        entry.resolution = "dismissed".into();
754        entry.confirmed_action_id = None;
755        let s = format_view_human(&entry);
756        assert!(s.contains("dismissed"));
757        assert!(s.contains("Resolved at:"));
758        assert!(s.contains("Resolved by:"));
759        assert!(!s.contains("Confirmed action:"));
760    }
761
762    #[test]
763    fn format_view_temp_suspension_renders_duration() {
764        let mut entry = sample_unresolved();
765        entry.action_type = "temp_suspension".into();
766        entry.duration_iso = Some("PT86400S".into());
767        let s = format_view_human(&entry);
768        assert!(s.contains("Duration:"));
769        assert!(s.contains("PT86400S"));
770    }
771
772    #[test]
773    fn format_view_subject_uri_present_when_record_level() {
774        let mut entry = sample_unresolved();
775        entry.subject_uri = Some("at://did:plc:offender/app.bsky.feed.post/abc".into());
776        let s = format_view_human(&entry);
777        assert!(s.contains("Subject URI:"));
778        assert!(s.contains("at://did:plc:offender/app.bsky.feed.post/abc"));
779    }
780
781    #[test]
782    fn format_confirm_human_says_action_recorded() {
783        let resp = ConfirmPendingResponse {
784            action_id: 203,
785            pending_id: 42,
786            resolved_at: "2026-04-27T09:00:00.000Z".into(),
787        };
788        let s = format_confirm_human(&resp);
789        assert!(s.contains("42"));
790        assert!(s.contains("203"));
791        assert!(s.to_lowercase().contains("confirmed"));
792    }
793
794    #[test]
795    fn format_confirm_json_emits_full_envelope() {
796        let resp = ConfirmPendingResponse {
797            action_id: 203,
798            pending_id: 42,
799            resolved_at: "2026-04-27T09:00:00.000Z".into(),
800        };
801        let s = format_confirm_json(&resp);
802        assert!(s.contains("\"actionId\": 203"));
803        assert!(s.contains("\"pendingId\": 42"));
804        assert!(s.contains("\"resolvedAt\""));
805    }
806
807    #[test]
808    fn format_dismiss_human_says_dismissed() {
809        let resp = DismissPendingResponse {
810            pending_id: 42,
811            resolved_at: "2026-04-27T09:00:00.000Z".into(),
812        };
813        let s = format_dismiss_human(&resp);
814        assert!(s.contains("42"));
815        assert!(s.to_lowercase().contains("dismissed"));
816    }
817
818    #[test]
819    fn format_dismiss_json_emits_full_envelope() {
820        let resp = DismissPendingResponse {
821            pending_id: 42,
822            resolved_at: "2026-04-27T09:00:00.000Z".into(),
823        };
824        let s = format_dismiss_json(&resp);
825        assert!(s.contains("\"pendingId\": 42"));
826        assert!(s.contains("\"resolvedAt\""));
827    }
828
829    #[test]
830    fn list_input_rejects_non_did_subject() {
831        let mut session = SessionFile {
832            version: 1,
833            pds_url: "https://pds.example".into(),
834            moderator_handle: "mod.example".into(),
835            moderator_did: "did:plc:m1".into(),
836            access_jwt: "x".into(),
837            refresh_jwt: "x".into(),
838            cairn_server_url: "https://cairn.example".into(),
839            cairn_service_did: "did:web:cairn.example".into(),
840        };
841        let path = std::path::PathBuf::from("/tmp/nonexistent-session");
842        let rt = tokio::runtime::Builder::new_current_thread()
843            .enable_all()
844            .build()
845            .unwrap();
846        let err = rt
847            .block_on(list(
848                &mut session,
849                &path,
850                ListPendingInput {
851                    subject: Some("not-a-did".into()),
852                    ..Default::default()
853                },
854            ))
855            .unwrap_err();
856        assert!(matches!(err, CliError::Config(_)));
857    }
858}