Skip to main content

cairn_mod/cli/
report.rs

1//! `cairn report {create, list, view, resolve, flag, unflag}` — thin
2//! wrappers over the moderation + admin XRPC endpoints Cairn exposes
3//! (#7 + #16).
4//!
5//! Every subcommand follows the same flow:
6//! 1. Load the session file (required).
7//! 2. Mint a fresh service-auth JWT via PDS `getServiceAuth`. On
8//!    401, call `refreshSession` once, persist rotated tokens
9//!    atomically (§5.3 auto-refresh path), retry.
10//! 3. Issue the HTTP call to Cairn with the service-auth token.
11//! 4. Return a typed response the CLI dispatcher turns into
12//!    human or JSON output via the per-subcommand `format_*_{human, json}`
13//!    pairs.
14//!
15//! Each orchestrator function's doc-comment cites the handler it
16//! wraps so a future reader can jump from CLI code to server code
17//! without grep.
18
19use std::path::Path;
20use std::time::Duration;
21
22use reqwest::Client;
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25
26use super::auth::acquire_service_auth;
27use super::error::CliError;
28use super::output::truncate;
29use super::pds::PdsClient;
30use super::session::SessionFile;
31
32const CREATE_REPORT_LXM: &str = "com.atproto.moderation.createReport";
33
34/// Input to `cairn report create` — wire-agnostic so the CLI flags
35/// + the tests can drive it the same way.
36#[derive(Debug, Clone)]
37pub struct ReportCreateInput {
38    /// Subject identifier: `did:*` for an account, `at://...` for a
39    /// record. Must be paired with `cid` for record subjects.
40    pub subject: String,
41    /// Only meaningful for `at://` subjects — pins the report to a
42    /// record version per §F11's strongRef shape.
43    pub cid: Option<String>,
44    /// Lexicon-spec reason type (e.g.,
45    /// `com.atproto.moderation.defs#reasonSpam`).
46    pub reason_type: String,
47    /// Optional free-text body (≤2KB at Cairn per §F11, enforced
48    /// server-side; the CLI does not pre-truncate).
49    pub reason: Option<String>,
50    /// Per-invocation override of the session's stored Cairn URL.
51    pub cairn_server_override: Option<String>,
52}
53
54/// Successful `createReport` response — only the fields the CLI
55/// displays. Extra fields are ignored by serde.
56#[derive(Debug, Deserialize, Serialize)]
57pub struct CreateReportResponse {
58    /// Report row primary key assigned by Cairn's writer.
59    pub id: i64,
60    /// RFC-3339 Z timestamp when the report row was committed.
61    #[serde(rename = "createdAt")]
62    pub created_at: String,
63    /// Lexicon-spec reason type Cairn echoed back (`com.atproto.
64    /// moderation.defs#reason*`).
65    #[serde(rename = "reasonType")]
66    pub reason_type: String,
67    /// Authenticated reporter DID — the `iss` from the
68    /// service-auth JWT the CLI minted.
69    #[serde(rename = "reportedBy")]
70    pub reported_by: String,
71    /// Subject union per §F11 — either a
72    /// `com.atproto.admin.defs#repoRef` or a
73    /// `com.atproto.repo.strongRef`. Opaque on the CLI side;
74    /// displayed as JSON when `--json` is set.
75    pub subject: Value,
76}
77
78/// Build the `subject` union payload per §F11. Returns
79/// `CliError::Config` for malformed input — `main.rs` maps that to
80/// the usage exit code (argument-shape problems surface here, not
81/// as Cairn 400s).
82fn build_subject(subject: &str, cid: Option<&str>) -> Result<Value, CliError> {
83    if let Some(rest) = subject.strip_prefix("at://") {
84        // `rest` is retained only to force validation — an empty
85        // body after the prefix means the user passed literally
86        // `at://`.
87        if rest.is_empty() {
88            return Err(CliError::Config(
89                "--subject at://... must include a repo and path".into(),
90            ));
91        }
92        let cid =
93            cid.ok_or_else(|| CliError::Config("record subjects (at://...) require --cid".into()))?;
94        Ok(json!({
95            "$type": "com.atproto.repo.strongRef",
96            "uri": subject,
97            "cid": cid,
98        }))
99    } else if subject.starts_with("did:") {
100        if cid.is_some() {
101            return Err(CliError::Config(
102                "--cid is not meaningful for account (did:) subjects".into(),
103            ));
104        }
105        Ok(json!({
106            "$type": "com.atproto.admin.defs#repoRef",
107            "did": subject,
108        }))
109    } else {
110        Err(CliError::Config(format!(
111            "--subject must start with `did:` or `at://`; got {subject}"
112        )))
113    }
114}
115
116/// Submit the report. Mutates `session` in place on a mid-call
117/// token refresh and persists the rotation atomically before
118/// returning. `session_path` is the SAME path the caller loaded
119/// from, so the file on disk stays in sync.
120pub async fn create(
121    session: &mut SessionFile,
122    session_path: &Path,
123    input: ReportCreateInput,
124) -> Result<CreateReportResponse, CliError> {
125    let subject = build_subject(&input.subject, input.cid.as_deref())?;
126    let cairn_server = input
127        .cairn_server_override
128        .as_deref()
129        .unwrap_or(&session.cairn_server_url)
130        .to_string();
131
132    let pds = PdsClient::new(&session.pds_url)?;
133    let token = acquire_service_auth(&pds, session, session_path, CREATE_REPORT_LXM).await?;
134
135    let url = format!(
136        "{}/xrpc/com.atproto.moderation.createReport",
137        cairn_server.trim_end_matches('/')
138    );
139    let body = json!({
140        "reasonType": input.reason_type,
141        "reason": input.reason,
142        "subject": subject,
143    });
144
145    let client = Client::builder()
146        .timeout(Duration::from_secs(30))
147        .build()
148        .expect("reqwest build");
149    let resp = client
150        .post(&url)
151        .bearer_auth(&token)
152        .json(&body)
153        .send()
154        .await
155        .map_err(|source| CliError::Http {
156            url: url.clone(),
157            source,
158        })?;
159
160    if !resp.status().is_success() {
161        let status = resp.status().as_u16();
162        let body = resp.text().await.unwrap_or_default();
163        return Err(CliError::CairnStatus { url, status, body });
164    }
165    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
166        url: url.clone(),
167        source,
168    })?;
169    serde_json::from_slice::<CreateReportResponse>(&bytes)
170        .map_err(|source| CliError::MalformedResponse { url, source })
171}
172
173/// Machine-readable `--json` renderer for `cairn report create`.
174///
175/// Renamed from `format_json` to `format_create_json` ahead of the
176/// multi-subcommand expansion in this commit — the bare name would
177/// collide with `format_list_json` / `format_view_json` added below.
178pub fn format_create_json(resp: &CreateReportResponse) -> String {
179    serde_json::to_string_pretty(resp).expect("CreateReportResponse serializes")
180}
181
182/// Human-readable one-liner for `cairn report create` — default
183/// output when `--json` is not set.
184///
185/// Renamed from `format_human` to `format_create_human` ahead of the
186/// multi-subcommand expansion.
187pub fn format_create_human(resp: &CreateReportResponse) -> String {
188    format!("Report {} created at {}", resp.id, resp.created_at)
189}
190
191// ============================================================
192// `cairn report list` — wraps tools.cairn.admin.listReports
193// (src/server/admin/list_reports.rs). Admin OR moderator role.
194// ============================================================
195
196const LIST_REPORTS_LXM: &str = "tools.cairn.admin.listReports";
197
198/// Subject-union wire shape. Deserializes the server's
199/// `{ $type: ..., ... }` tagged representation.
200#[derive(Debug, Clone, Deserialize, Serialize)]
201#[serde(tag = "$type")]
202pub enum ReportSubject {
203    /// `com.atproto.admin.defs#repoRef` — account subject.
204    #[serde(rename = "com.atproto.admin.defs#repoRef")]
205    Repo {
206        /// Subject DID.
207        did: String,
208    },
209    /// `com.atproto.repo.strongRef` — record subject, CID-pinned.
210    #[serde(rename = "com.atproto.repo.strongRef")]
211    Strong {
212        /// Subject AT-URI.
213        uri: String,
214        /// Content-address hash at report time.
215        cid: String,
216    },
217}
218
219/// One row in a `listReports` response. Deliberately omits the
220/// `reason` field — the server's `ReportListEntry` projection drops
221/// it per §F11 and the CLI's shape preserves that invariant
222/// type-level.
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct ReportListEntry {
225    /// Report row primary key.
226    pub id: i64,
227    /// RFC-3339 timestamp the report was filed.
228    #[serde(rename = "createdAt")]
229    pub created_at: String,
230    /// Lexicon-spec reason type.
231    #[serde(rename = "reasonType")]
232    pub reason_type: String,
233    /// Subject the report targets.
234    pub subject: ReportSubject,
235    /// Authenticated reporter DID.
236    #[serde(rename = "reportedBy")]
237    pub reported_by: String,
238    /// `"pending"` or `"resolved"`.
239    pub status: String,
240    /// When the report was resolved, if resolved.
241    #[serde(
242        rename = "resolvedAt",
243        skip_serializing_if = "Option::is_none",
244        default
245    )]
246    pub resolved_at: Option<String>,
247    /// Admin / moderator DID that resolved the report.
248    #[serde(
249        rename = "resolvedBy",
250        skip_serializing_if = "Option::is_none",
251        default
252    )]
253    pub resolved_by: Option<String>,
254    /// Label value applied on resolution, if any.
255    #[serde(
256        rename = "resolutionLabel",
257        skip_serializing_if = "Option::is_none",
258        default
259    )]
260    pub resolution_label: Option<String>,
261    /// Free-text resolution rationale, if provided.
262    #[serde(
263        rename = "resolutionReason",
264        skip_serializing_if = "Option::is_none",
265        default
266    )]
267    pub resolution_reason: Option<String>,
268}
269
270/// Input to `cairn report list`.
271#[derive(Debug, Clone, Default)]
272pub struct ReportListInput {
273    /// Filter by status (`"pending"` or `"resolved"`). Passed
274    /// through unchanged; server rejects unknown values.
275    pub status: Option<String>,
276    /// Filter by reporter DID.
277    pub reported_by: Option<String>,
278    /// Max rows to return. Server clamps to [1, 250]; default 50.
279    pub limit: Option<i64>,
280    /// Opaque pagination cursor from a prior response.
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
286/// `listReports` response envelope.
287#[derive(Debug, Deserialize, Serialize)]
288pub struct ReportListResponse {
289    /// Matched reports, newest first.
290    pub reports: Vec<ReportListEntry>,
291    /// Opaque next-page cursor. Present iff more results available.
292    #[serde(skip_serializing_if = "Option::is_none", default)]
293    pub cursor: Option<String>,
294}
295
296/// Query the reports table via the admin HTTP endpoint.
297///
298/// Wraps the `tools.cairn.admin.listReports` handler at
299/// [src/server/admin/list_reports.rs](..) — GET with query-string
300/// filters; server enforces role (`verify_and_authorize`, mod OR
301/// admin) and the reason-leak invariant via the `ReportListEntry`
302/// projection.
303pub async fn list(
304    session: &mut SessionFile,
305    session_path: &Path,
306    input: ReportListInput,
307) -> Result<ReportListResponse, CliError> {
308    let cairn_server = input
309        .cairn_server_override
310        .as_deref()
311        .unwrap_or(&session.cairn_server_url)
312        .trim_end_matches('/')
313        .to_string();
314    let pds = PdsClient::new(&session.pds_url)?;
315    let token = acquire_service_auth(&pds, session, session_path, LIST_REPORTS_LXM).await?;
316
317    let url = format!("{cairn_server}/xrpc/{LIST_REPORTS_LXM}");
318    // Build query params. Owned strings for limit because reqwest
319    // wants &str bindings.
320    let limit_owned = input.limit.map(|n| n.to_string());
321    let mut query: Vec<(&str, &str)> = Vec::new();
322    if let Some(s) = &input.status {
323        query.push(("status", s.as_str()));
324    }
325    if let Some(r) = &input.reported_by {
326        query.push(("reportedBy", r.as_str()));
327    }
328    if let Some(n) = &limit_owned {
329        query.push(("limit", n.as_str()));
330    }
331    if let Some(c) = &input.cursor {
332        query.push(("cursor", c.as_str()));
333    }
334
335    let client = build_client();
336    let resp = client
337        .get(&url)
338        .bearer_auth(&token)
339        .query(&query)
340        .send()
341        .await
342        .map_err(|source| CliError::Http {
343            url: url.clone(),
344            source,
345        })?;
346    cairn_response::<ReportListResponse>(url, resp).await
347}
348
349/// Tabular human output for `cairn report list`. Columns: id,
350/// status, reporter (truncated), subject (summary), reason-type
351/// (truncated), created_at. Trailing `next cursor: …` line when
352/// a next page exists.
353pub fn format_list_human(resp: &ReportListResponse) -> String {
354    use std::fmt::Write;
355    if resp.reports.is_empty() {
356        let mut s = "(no reports)".to_string();
357        if let Some(c) = &resp.cursor {
358            let _ = write!(s, "\nnext cursor: {c}");
359        }
360        return s;
361    }
362    let id_w = resp
363        .reports
364        .iter()
365        .map(|e| e.id.to_string().len())
366        .max()
367        .unwrap_or(2)
368        .max(2);
369    let reporter_w = resp
370        .reports
371        .iter()
372        .map(|e| e.reported_by.len().min(40))
373        .max()
374        .unwrap_or(8)
375        .max(8);
376    let subject_w = resp
377        .reports
378        .iter()
379        .map(|e| subject_summary(&e.subject).len().min(40))
380        .max()
381        .unwrap_or(8)
382        .max(8);
383    let mut s = String::new();
384    let _ = writeln!(
385        s,
386        "{:>id_w$}  {:<9}  {:<reporter_w$}  {:<subject_w$}  {:<20}",
387        "ID",
388        "STATUS",
389        "REPORTER",
390        "SUBJECT",
391        "CREATED_AT",
392        id_w = id_w,
393        reporter_w = reporter_w,
394        subject_w = subject_w,
395    );
396    for e in &resp.reports {
397        let reporter = truncate(&e.reported_by, 40);
398        let subj = truncate(&subject_summary(&e.subject), 40);
399        let _ = writeln!(
400            s,
401            "{:>id_w$}  {:<9}  {:<reporter_w$}  {:<subject_w$}  {:<20}",
402            e.id,
403            e.status,
404            reporter,
405            subj,
406            e.created_at,
407            id_w = id_w,
408            reporter_w = reporter_w,
409            subject_w = subject_w,
410        );
411    }
412    if let Some(c) = &resp.cursor {
413        let _ = write!(s, "next cursor: {c}");
414    } else if s.ends_with('\n') {
415        s.pop();
416    }
417    s
418}
419
420/// JSON envelope for `cairn report list`.
421pub fn format_list_json(resp: &ReportListResponse) -> String {
422    serde_json::to_string_pretty(resp).expect("ReportListResponse serializes")
423}
424
425// ============================================================
426// `cairn report view` — wraps tools.cairn.admin.getReport
427// (src/server/admin/get_report.rs). Admin OR moderator role.
428// ============================================================
429
430const GET_REPORT_LXM: &str = "tools.cairn.admin.getReport";
431
432/// Full report record — includes `reason` body (admin-authenticated
433/// only, per §F11).
434#[derive(Debug, Clone, Deserialize, Serialize)]
435pub struct ReportDetail {
436    /// Report row primary key.
437    pub id: i64,
438    /// RFC-3339 timestamp the report was filed.
439    #[serde(rename = "createdAt")]
440    pub created_at: String,
441    /// Lexicon-spec reason type.
442    #[serde(rename = "reasonType")]
443    pub reason_type: String,
444    /// Free-text body. Server permits this field only for admin
445    /// fetches (`getReport` / `resolveReport` response).
446    #[serde(skip_serializing_if = "Option::is_none", default)]
447    pub reason: Option<String>,
448    /// Subject the report targets.
449    pub subject: ReportSubject,
450    /// Authenticated reporter DID.
451    #[serde(rename = "reportedBy")]
452    pub reported_by: String,
453    /// `"pending"` or `"resolved"`.
454    pub status: String,
455    /// When the report was resolved, if resolved.
456    #[serde(
457        rename = "resolvedAt",
458        skip_serializing_if = "Option::is_none",
459        default
460    )]
461    pub resolved_at: Option<String>,
462    /// Admin / moderator DID that resolved the report.
463    #[serde(
464        rename = "resolvedBy",
465        skip_serializing_if = "Option::is_none",
466        default
467    )]
468    pub resolved_by: Option<String>,
469    /// Label value applied on resolution, if any.
470    #[serde(
471        rename = "resolutionLabel",
472        skip_serializing_if = "Option::is_none",
473        default
474    )]
475    pub resolution_label: Option<String>,
476    /// Free-text resolution rationale, if provided.
477    #[serde(
478        rename = "resolutionReason",
479        skip_serializing_if = "Option::is_none",
480        default
481    )]
482    pub resolution_reason: Option<String>,
483}
484
485/// Input to `cairn report view`.
486#[derive(Debug, Clone)]
487pub struct ReportViewInput {
488    /// Report row primary key to fetch.
489    pub id: i64,
490    /// Per-invocation override of the session's stored Cairn URL.
491    pub cairn_server_override: Option<String>,
492}
493
494/// Fetch a single report record (with reason body).
495///
496/// Wraps the `tools.cairn.admin.getReport` handler at
497/// [src/server/admin/get_report.rs](..) — GET with `id` query
498/// param; server enforces role (`verify_and_authorize`, mod OR
499/// admin) and returns `ReportDetail` (full body included, §F11
500/// permitted for admin-authenticated access).
501pub async fn view(
502    session: &mut SessionFile,
503    session_path: &Path,
504    input: ReportViewInput,
505) -> Result<ReportDetail, CliError> {
506    let cairn_server = input
507        .cairn_server_override
508        .as_deref()
509        .unwrap_or(&session.cairn_server_url)
510        .trim_end_matches('/')
511        .to_string();
512    let pds = PdsClient::new(&session.pds_url)?;
513    let token = acquire_service_auth(&pds, session, session_path, GET_REPORT_LXM).await?;
514
515    let url = format!("{cairn_server}/xrpc/{GET_REPORT_LXM}");
516    let id_str = input.id.to_string();
517    let client = build_client();
518    let resp = client
519        .get(&url)
520        .bearer_auth(&token)
521        .query(&[("id", id_str.as_str())])
522        .send()
523        .await
524        .map_err(|source| CliError::Http {
525            url: url.clone(),
526            source,
527        })?;
528    cairn_response::<ReportDetail>(url, resp).await
529}
530
531/// Multi-line field/value output for `cairn report view`.
532pub fn format_view_human(detail: &ReportDetail) -> String {
533    use std::fmt::Write;
534    let mut s = String::new();
535    let _ = writeln!(s, "Report {}", detail.id);
536    let _ = writeln!(s, "  status:         {}", detail.status);
537    let _ = writeln!(s, "  created_at:     {}", detail.created_at);
538    let _ = writeln!(s, "  reported_by:    {}", detail.reported_by);
539    let _ = writeln!(s, "  reason_type:    {}", detail.reason_type);
540    if let Some(r) = &detail.reason {
541        let _ = writeln!(s, "  reason:         {r}");
542    }
543    let _ = writeln!(s, "  subject:        {}", subject_summary(&detail.subject));
544    if let Some(t) = &detail.resolved_at {
545        let _ = writeln!(s, "  resolved_at:    {t}");
546    }
547    if let Some(by) = &detail.resolved_by {
548        let _ = writeln!(s, "  resolved_by:    {by}");
549    }
550    if let Some(lab) = &detail.resolution_label {
551        let _ = writeln!(s, "  resolution_label: {lab}");
552    }
553    if let Some(r) = &detail.resolution_reason {
554        let _ = writeln!(s, "  resolution_reason: {r}");
555    }
556    if s.ends_with('\n') {
557        s.pop();
558    }
559    s
560}
561
562/// JSON output for `cairn report view`.
563pub fn format_view_json(detail: &ReportDetail) -> String {
564    serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
565}
566
567// ============================================================
568// `cairn report resolve` — wraps tools.cairn.admin.resolveReport
569// (src/server/admin/resolve_report.rs). Admin OR moderator role.
570// Resolve with `apply_label = Some(...)` to apply a label and
571// resolve in one transaction; `apply_label = None` resolves
572// without applying (the "dismiss" semantic in operator UX terms).
573// ============================================================
574
575const RESOLVE_REPORT_LXM: &str = "tools.cairn.admin.resolveReport";
576
577/// Optional label-application sub-object on `cairn report resolve`.
578/// Mirrors the server handler's `InputApplyLabel` shape.
579#[derive(Debug, Clone, Serialize)]
580pub struct ApplyLabelArg {
581    /// AT-URI or DID the label targets. Server enforces
582    /// `at://` or `did:` prefix.
583    pub uri: String,
584    /// CID pin for record subjects. Required iff `uri` is `at://`
585    /// per §F11 strongRef shape.
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub cid: Option<String>,
588    /// Label value. Server clamps to 1..=128 bytes and (when
589    /// configured) checks against the labeler's allowlist.
590    pub val: String,
591    /// Optional RFC-3339 expiration for the applied label.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub exp: Option<String>,
594}
595
596/// Input to `cairn report resolve`.
597#[derive(Debug, Clone)]
598pub struct ReportResolveInput {
599    /// Report row primary key to resolve.
600    pub id: i64,
601    /// Optional inline label application. `None` resolves without
602    /// applying a label (the "dismiss" workflow); `Some(_)` resolves
603    /// AND applies in one server transaction.
604    pub apply_label: Option<ApplyLabelArg>,
605    /// Operator-facing rationale. Stored on the report row and
606    /// echoed back in the response.
607    pub reason: Option<String>,
608    /// Per-invocation override of the session's stored Cairn URL.
609    pub cairn_server_override: Option<String>,
610}
611
612/// Resolve a report.
613///
614/// Wraps the `tools.cairn.admin.resolveReport` handler at
615/// [src/server/admin/resolve_report.rs](..) — POST body shape:
616/// ```json
617/// { "id": <i64>, "applyLabel": { ... }?, "reason": <string>? }
618/// ```
619/// Server enforces role (`verify_and_authorize`, mod OR admin),
620/// validates `applyLabel.val` length + URI prefix + label-allowlist
621/// pre-dispatch, and returns the resolved `ReportDetail`. Audit
622/// attribution is the JWT iss the CLI's session-auth produces.
623pub async fn resolve(
624    session: &mut SessionFile,
625    session_path: &Path,
626    input: ReportResolveInput,
627) -> Result<ReportDetail, CliError> {
628    let cairn_server = input
629        .cairn_server_override
630        .as_deref()
631        .unwrap_or(&session.cairn_server_url)
632        .trim_end_matches('/')
633        .to_string();
634    let pds = PdsClient::new(&session.pds_url)?;
635    let token = acquire_service_auth(&pds, session, session_path, RESOLVE_REPORT_LXM).await?;
636
637    // Build the wire body. `applyLabel` is camelCase per the
638    // lexicon; serde's rename + the Serialize derive on
639    // `ApplyLabelArg` handle the rest.
640    let mut body = json!({ "id": input.id });
641    if let Some(apply) = &input.apply_label {
642        body["applyLabel"] = serde_json::to_value(apply).expect("ApplyLabelArg serializes");
643    }
644    if let Some(r) = &input.reason {
645        body["reason"] = json!(r);
646    }
647
648    let url = format!("{cairn_server}/xrpc/{RESOLVE_REPORT_LXM}");
649    let client = build_client();
650    let resp = client
651        .post(&url)
652        .bearer_auth(&token)
653        .json(&body)
654        .send()
655        .await
656        .map_err(|source| CliError::Http {
657            url: url.clone(),
658            source,
659        })?;
660    cairn_response::<ReportDetail>(url, resp).await
661}
662
663/// Human one-liner for `cairn report resolve`. Names whether a
664/// label was applied so the operator sees the side-effect at a
665/// glance.
666pub fn format_resolve_human(detail: &ReportDetail) -> String {
667    let label = detail
668        .resolution_label
669        .as_deref()
670        .map(|v| format!(" with label {v}"))
671        .unwrap_or_default();
672    format!("Resolved report {}{}", detail.id, label)
673}
674
675/// JSON output for `cairn report resolve` — the resolved
676/// [`ReportDetail`].
677pub fn format_resolve_json(detail: &ReportDetail) -> String {
678    serde_json::to_string_pretty(detail).expect("ReportDetail serializes")
679}
680
681// ============================================================
682// `cairn report flag` / `cairn report unflag` — both wrap
683// tools.cairn.admin.flagReporter
684// (src/server/admin/flag_reporter.rs). Admin OR moderator role.
685// One handler, two CLI subcommands distinguished by the
686// `suppressed` body param: true → flag, false → unflag. Server
687// audits as `reporter_flagged` / `reporter_unflagged` accordingly.
688// ============================================================
689
690const FLAG_REPORTER_LXM: &str = "tools.cairn.admin.flagReporter";
691
692/// Input to `cairn report flag` / `cairn report unflag`.
693#[derive(Debug, Clone)]
694pub struct ReportFlagInput {
695    /// Reporter DID to flag or unflag. Server requires `did:`
696    /// prefix; CLI echoes the same constraint.
697    pub did: String,
698    /// `true` = flag (suppress future reports from this DID).
699    /// `false` = unflag (remove suppression). Idempotent in either
700    /// direction per the handler doc — repeating the same call
701    /// produces another audit row but no row-state change.
702    pub suppressed: bool,
703    /// Optional moderator-facing rationale stored in the audit
704    /// row's reason payload.
705    pub reason: Option<String>,
706    /// Per-invocation override of the session's stored Cairn URL.
707    pub cairn_server_override: Option<String>,
708}
709
710/// Synthetic response for `cairn report {flag, unflag}`. The
711/// server's response body is `{}` — operationally we only care
712/// that the call succeeded (2xx). The struct echoes the input
713/// fields the CLI prints.
714#[derive(Debug, Clone, Serialize)]
715pub struct ReportFlagResponse {
716    /// Reporter DID acted on.
717    pub did: String,
718    /// `true` if the call flagged, `false` if it unflagged.
719    pub suppressed: bool,
720}
721
722/// Flag or unflag a reporter.
723///
724/// Wraps the `tools.cairn.admin.flagReporter` handler at
725/// [src/server/admin/flag_reporter.rs](..) — POST body shape:
726/// ```json
727/// { "did": "...", "suppressed": true | false, "reason": "..."? }
728/// ```
729/// Server enforces role (`verify_and_authorize`, mod OR admin),
730/// validates the `did:` prefix, writes the
731/// `suppressed_reporters` row + `reporter_{flagged,unflagged}`
732/// audit entry in one transaction, and returns `{}`. The
733/// [`ReportFlagResponse`] returned here is synthetic — the
734/// handler's empty body carries no fields, so the CLI echoes the
735/// input did + suppressed for the format_* renderers to use.
736pub async fn flag(
737    session: &mut SessionFile,
738    session_path: &Path,
739    input: ReportFlagInput,
740) -> Result<ReportFlagResponse, CliError> {
741    if !input.did.starts_with("did:") {
742        return Err(CliError::Config(format!(
743            "DID must start with 'did:'; got {:?}",
744            input.did
745        )));
746    }
747    let cairn_server = input
748        .cairn_server_override
749        .as_deref()
750        .unwrap_or(&session.cairn_server_url)
751        .trim_end_matches('/')
752        .to_string();
753    let pds = PdsClient::new(&session.pds_url)?;
754    let token = acquire_service_auth(&pds, session, session_path, FLAG_REPORTER_LXM).await?;
755
756    let body = json!({
757        "did": input.did,
758        "suppressed": input.suppressed,
759        "reason": input.reason,
760    });
761    let url = format!("{cairn_server}/xrpc/{FLAG_REPORTER_LXM}");
762    let client = build_client();
763    let resp = client
764        .post(&url)
765        .bearer_auth(&token)
766        .json(&body)
767        .send()
768        .await
769        .map_err(|source| CliError::Http {
770            url: url.clone(),
771            source,
772        })?;
773    // Discard the empty `{}` response body once 2xx is confirmed —
774    // we don't need to parse it. cairn_response<Value> parses any
775    // JSON; if the server returns a non-2xx, the error path runs
776    // and we get a CairnStatus. On success we synthesize the
777    // response shape from the input.
778    let _: serde_json::Value = cairn_response::<serde_json::Value>(url, resp).await?;
779    Ok(ReportFlagResponse {
780        did: input.did,
781        suppressed: input.suppressed,
782    })
783}
784
785/// Human one-liner for `cairn report flag` / `cairn report unflag`.
786pub fn format_flag_human(resp: &ReportFlagResponse) -> String {
787    let verb = if resp.suppressed {
788        "Flagged"
789    } else {
790        "Unflagged"
791    };
792    format!("{verb} reporter {}", resp.did)
793}
794
795/// JSON output for `cairn report flag` / `cairn report unflag`.
796/// Includes an `action` discriminator so scripts can branch
797/// without re-reading `suppressed`.
798pub fn format_flag_json(resp: &ReportFlagResponse) -> String {
799    let action = if resp.suppressed { "flag" } else { "unflag" };
800    let body = json!({
801        "action": action,
802        "did": resp.did,
803        "suppressed": resp.suppressed,
804    });
805    serde_json::to_string_pretty(&body).expect("flag JSON serializes")
806}
807
808// ============================================================
809// Shared helpers
810// ============================================================
811
812/// Build the shared reqwest client. Centralized so the 30s
813/// timeout is applied uniformly to every subcommand.
814fn build_client() -> Client {
815    Client::builder()
816        .timeout(Duration::from_secs(30))
817        .build()
818        .expect("reqwest build")
819}
820
821/// Parse a Cairn HTTP response into a typed body, mapping non-2xx
822/// to [`CliError::CairnStatus`] and JSON-parse failures to
823/// [`CliError::MalformedResponse`].
824async fn cairn_response<T: serde::de::DeserializeOwned>(
825    url: String,
826    resp: reqwest::Response,
827) -> Result<T, CliError> {
828    if !resp.status().is_success() {
829        let status = resp.status().as_u16();
830        let body = resp.text().await.unwrap_or_default();
831        return Err(CliError::CairnStatus { url, status, body });
832    }
833    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
834        url: url.clone(),
835        source,
836    })?;
837    serde_json::from_slice::<T>(&bytes)
838        .map_err(|source| CliError::MalformedResponse { url, source })
839}
840
841/// Human-display summary of a report subject. Returns either the
842/// DID (account subject) or `"<uri>@<cid>"` (record subject).
843fn subject_summary(s: &ReportSubject) -> String {
844    match s {
845        ReportSubject::Repo { did } => did.clone(),
846        ReportSubject::Strong { uri, cid } => format!("{uri}@{cid}"),
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    #[test]
855    fn subject_did_maps_to_repo_ref() {
856        let v = build_subject("did:plc:example", None).unwrap();
857        assert_eq!(v["$type"], "com.atproto.admin.defs#repoRef");
858        assert_eq!(v["did"], "did:plc:example");
859    }
860
861    #[test]
862    fn subject_at_uri_requires_cid() {
863        let err = build_subject("at://did:plc:x/col/r", None).unwrap_err();
864        assert!(matches!(err, CliError::Config(_)));
865    }
866
867    #[test]
868    fn subject_at_uri_with_cid_maps_to_strong_ref() {
869        let v = build_subject("at://did:plc:x/col/r", Some("bafy")).unwrap();
870        assert_eq!(v["$type"], "com.atproto.repo.strongRef");
871        assert_eq!(v["uri"], "at://did:plc:x/col/r");
872        assert_eq!(v["cid"], "bafy");
873    }
874
875    #[test]
876    fn subject_did_with_cid_rejected() {
877        let err = build_subject("did:plc:x", Some("bafy")).unwrap_err();
878        assert!(matches!(err, CliError::Config(_)));
879    }
880
881    #[test]
882    fn subject_unknown_shape_rejected() {
883        let err = build_subject("bsky.example/profile", None).unwrap_err();
884        assert!(matches!(err, CliError::Config(_)));
885    }
886}