1use 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#[derive(Debug, Clone)]
49pub struct RecordActionInput {
50 pub subject: String,
53 pub action_type: String,
57 pub reasons: Vec<String>,
61 pub duration: Option<String>,
64 pub note: Option<String>,
66 pub report_ids: Vec<i64>,
68 pub cairn_server_override: Option<String>,
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct RecordResponse {
75 #[serde(rename = "actionId")]
77 pub action_id: i64,
78 #[serde(rename = "strikeValueBase")]
80 pub strike_value_base: u32,
81 #[serde(rename = "strikeValueApplied")]
83 pub strike_value_applied: u32,
84 #[serde(rename = "wasDampened")]
86 pub was_dampened: bool,
87 #[serde(rename = "strikesAtTimeOfAction")]
90 pub strikes_at_time_of_action: u32,
91}
92
93pub 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
150pub 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
180pub fn format_record_json(resp: &RecordResponse) -> String {
182 serde_json::to_string_pretty(resp).expect("RecordResponse serializes")
183}
184
185#[derive(Debug, Clone)]
192pub struct RevokeActionInput {
193 pub action_id: i64,
195 pub reason: Option<String>,
197 pub cairn_server_override: Option<String>,
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct RevokeResponse {
204 #[serde(rename = "actionId")]
206 pub action_id: i64,
207 #[serde(rename = "revokedAt")]
209 pub revoked_at: String,
210}
211
212pub 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
247pub fn format_revoke_human(resp: &RevokeResponse) -> String {
249 format!("Revoked action {} at {}", resp.action_id, resp.revoked_at)
250}
251
252pub fn format_revoke_json(resp: &RevokeResponse) -> String {
254 serde_json::to_string_pretty(resp).expect("RevokeResponse serializes")
255}
256
257#[derive(Debug, Clone)]
264pub struct HistoryInput {
265 pub subject: String,
270 pub subject_uri: Option<String>,
273 pub include_revoked: bool,
276 pub since: Option<String>,
278 pub limit: Option<i64>,
280 pub cursor: Option<String>,
282 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#[derive(Debug, Clone, Deserialize, Serialize)]
305pub struct HistoryEntry {
306 pub id: i64,
308 #[serde(rename = "subjectDid")]
311 pub subject_did: String,
312 #[serde(
314 rename = "subjectUri",
315 skip_serializing_if = "Option::is_none",
316 default
317 )]
318 pub subject_uri: Option<String>,
319 #[serde(rename = "actorDid")]
321 pub actor_did: String,
322 #[serde(rename = "actionType")]
325 pub action_type: String,
326 #[serde(rename = "reasonCodes")]
328 pub reason_codes: Vec<String>,
329 #[serde(skip_serializing_if = "Option::is_none", default)]
332 pub duration: Option<String>,
333 #[serde(rename = "effectiveAt")]
335 pub effective_at: String,
336 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
338 pub expires_at: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none", default)]
341 pub notes: Option<String>,
342 #[serde(rename = "reportIds", skip_serializing_if = "Option::is_none", default)]
344 pub report_ids: Option<Vec<i64>>,
345 #[serde(rename = "strikeValueBase")]
347 pub strike_value_base: i64,
348 #[serde(rename = "strikeValueApplied")]
350 pub strike_value_applied: i64,
351 #[serde(rename = "wasDampened")]
353 pub was_dampened: bool,
354 #[serde(rename = "strikesAtTimeOfAction")]
356 pub strikes_at_time_of_action: i64,
357 #[serde(rename = "revokedAt", skip_serializing_if = "Option::is_none", default)]
359 pub revoked_at: Option<String>,
360 #[serde(
363 rename = "revokedByDid",
364 skip_serializing_if = "Option::is_none",
365 default
366 )]
367 pub revoked_by_did: Option<String>,
368 #[serde(
370 rename = "revokedReason",
371 skip_serializing_if = "Option::is_none",
372 default
373 )]
374 pub revoked_reason: Option<String>,
375 #[serde(
378 rename = "auditLogId",
379 skip_serializing_if = "Option::is_none",
380 default
381 )]
382 pub audit_log_id: Option<i64>,
383 #[serde(rename = "createdAt")]
385 pub created_at: String,
386 #[serde(rename = "actorKind", default = "default_actor_kind")]
392 pub actor_kind: String,
393 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
411pub struct HistoryResponse {
412 pub actions: Vec<HistoryEntry>,
414 #[serde(skip_serializing_if = "Option::is_none", default)]
416 pub cursor: Option<String>,
417}
418
419pub 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
476pub 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
578pub fn format_history_json(resp: &HistoryResponse) -> String {
581 serde_json::to_string_pretty(resp).expect("HistoryResponse serializes")
582}
583
584#[derive(Debug, Clone)]
591pub struct StrikesInput {
592 pub subject: String,
596 pub cairn_server_override: Option<String>,
598}
599
600#[derive(Debug, Clone, Deserialize, Serialize)]
603pub struct StrikesResponse {
604 #[serde(rename = "currentStrikeCount")]
606 pub current_strike_count: u32,
607 #[serde(rename = "rawTotal")]
609 pub raw_total: u32,
610 #[serde(rename = "decayedCount")]
612 pub decayed_count: u32,
613 #[serde(rename = "revokedCount")]
615 pub revoked_count: u32,
616 #[serde(rename = "goodStanding")]
618 pub good_standing: bool,
619 #[serde(
621 rename = "activeSuspension",
622 skip_serializing_if = "Option::is_none",
623 default
624 )]
625 pub active_suspension: Option<ActiveSuspensionView>,
626 #[serde(
629 rename = "decayWindowRemainingDays",
630 skip_serializing_if = "Option::is_none",
631 default
632 )]
633 pub decay_window_remaining_days: Option<u32>,
634 #[serde(
637 rename = "lastActionAt",
638 skip_serializing_if = "Option::is_none",
639 default
640 )]
641 pub last_action_at: Option<String>,
642 #[serde(rename = "activeLabels", default)]
650 pub active_labels: Vec<ActiveLabelView>,
651}
652
653#[derive(Debug, Clone, Deserialize, Serialize)]
657pub struct ActiveLabelView {
658 pub val: String,
660 #[serde(rename = "actionId")]
662 pub action_id: i64,
663 #[serde(rename = "actionType")]
665 pub action_type: String,
666 #[serde(rename = "reasonCodes")]
669 pub reason_codes: Vec<String>,
670 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
673 pub expires_at: Option<String>,
674}
675
676#[derive(Debug, Clone, Deserialize, Serialize)]
678pub struct ActiveSuspensionView {
679 #[serde(rename = "actionType")]
681 pub action_type: String,
682 #[serde(rename = "effectiveAt")]
684 pub effective_at: String,
685 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none", default)]
687 pub expires_at: Option<String>,
688}
689
690pub 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
726pub 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
765pub fn format_strikes_json(resp: &StrikesResponse) -> String {
767 serde_json::to_string_pretty(resp).expect("StrikesResponse serializes")
768}
769
770#[derive(Debug, Clone)]
783pub struct LabelsInput {
784 pub subject: String,
787 pub cairn_server_override: Option<String>,
789}
790
791pub 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
810pub 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 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 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 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
917pub fn format_labels_json(resp: &StrikesResponse) -> String {
922 serde_json::to_string_pretty(&resp.active_labels).expect("Vec<ActiveLabelView> serializes")
923}
924
925fn 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 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 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 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 assert!(!s.contains("reason-"));
1109 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 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 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 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 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 assert!(s.contains("!hide"));
1195 assert!(s.contains("!takedown"));
1196 assert!(s.contains("reason-spam"));
1197 assert!(s.contains("reason-hate-speech"));
1198 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 let mut resp = empty_strikes_response();
1211 resp.current_strike_count = 7; 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 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}