1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct PendingActionEntry {
36 pub id: i64,
38 #[serde(rename = "subjectDid")]
40 pub subject_did: String,
41 #[serde(
44 rename = "subjectUri",
45 skip_serializing_if = "Option::is_none",
46 default
47 )]
48 pub subject_uri: Option<String>,
49 #[serde(rename = "actionType")]
52 pub action_type: String,
53 #[serde(
56 rename = "durationIso",
57 skip_serializing_if = "Option::is_none",
58 default
59 )]
60 pub duration_iso: Option<String>,
61 #[serde(rename = "reasonCodes")]
64 pub reason_codes: Vec<String>,
65 #[serde(rename = "triggeredByPolicyRule")]
68 pub triggered_by_policy_rule: String,
69 #[serde(rename = "triggeredAt")]
71 pub triggered_at: String,
72 #[serde(rename = "triggeringActionId")]
75 pub triggering_action_id: i64,
76 pub resolution: String,
78 #[serde(
81 rename = "resolvedAt",
82 skip_serializing_if = "Option::is_none",
83 default
84 )]
85 pub resolved_at: Option<String>,
86 #[serde(
90 rename = "resolvedByDid",
91 skip_serializing_if = "Option::is_none",
92 default
93 )]
94 pub resolved_by_did: Option<String>,
95 #[serde(
99 rename = "confirmedActionId",
100 skip_serializing_if = "Option::is_none",
101 default
102 )]
103 pub confirmed_action_id: Option<i64>,
104}
105
106#[derive(Debug, Clone, Default)]
112pub struct ListPendingInput {
113 pub subject: Option<String>,
116 pub limit: Option<i64>,
118 pub cursor: Option<String>,
120 pub cairn_server_override: Option<String>,
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
126pub struct ListPendingResponse {
127 pub actions: Vec<PendingActionEntry>,
129 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub cursor: Option<String>,
133}
134
135pub 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
186pub 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 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
295pub fn format_list_json(resp: &ListPendingResponse) -> String {
298 serde_json::to_string_pretty(resp).expect("ListPendingResponse serializes")
299}
300
301#[derive(Debug, Clone)]
307pub struct ViewPendingInput {
308 pub pending_id: i64,
310 pub cairn_server_override: Option<String>,
312}
313
314pub 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
345pub 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
378pub fn format_view_json(entry: &PendingActionEntry) -> String {
380 serde_json::to_string_pretty(entry).expect("PendingActionEntry serializes")
381}
382
383#[derive(Debug, Clone)]
389pub struct ConfirmPendingInput {
390 pub pending_id: i64,
392 pub reason: Option<String>,
397 pub cairn_server_override: Option<String>,
399}
400
401#[derive(Debug, Clone, Deserialize, Serialize)]
403pub struct ConfirmPendingResponse {
404 #[serde(rename = "actionId")]
406 pub action_id: i64,
407 #[serde(rename = "pendingId")]
409 pub pending_id: i64,
410 #[serde(rename = "resolvedAt")]
412 pub resolved_at: String,
413}
414
415pub 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 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
453pub fn format_confirm_human(resp: &ConfirmPendingResponse) -> String {
455 format!(
456 "Confirmed pending {} → action {} recorded.",
457 resp.pending_id, resp.action_id
458 )
459}
460
461pub fn format_confirm_json(resp: &ConfirmPendingResponse) -> String {
465 serde_json::to_string_pretty(resp).expect("ConfirmPendingResponse serializes")
466}
467
468#[derive(Debug, Clone)]
474pub struct DismissPendingInput {
475 pub pending_id: i64,
477 pub reason: Option<String>,
481 pub cairn_server_override: Option<String>,
483}
484
485#[derive(Debug, Clone, Deserialize, Serialize)]
487pub struct DismissPendingResponse {
488 #[serde(rename = "pendingId")]
490 pub pending_id: i64,
491 #[serde(rename = "resolvedAt")]
493 pub resolved_at: String,
494}
495
496pub 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
531pub fn format_dismiss_human(resp: &DismissPendingResponse) -> String {
533 format!("Dismissed pending {}.", resp.pending_id)
534}
535
536pub fn format_dismiss_json(resp: &DismissPendingResponse) -> String {
539 serde_json::to_string_pretty(resp).expect("DismissPendingResponse serializes")
540}
541
542fn 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
570fn 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 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 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 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 assert_eq!(lines.len(), 3);
691 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 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}