Skip to main content

cairn_mod/cli/
retention.rs

1//! `cairn retention sweep` — admin-side trigger for the §F4
2//! retention sweep (#12).
3//!
4//! Wraps `tools.cairn.admin.retentionSweep`
5//! (src/server/admin/retention_sweep.rs). **Admin role only** —
6//! the server's auth check uses `verify_and_authorize_admin_only`,
7//! so a moderator-role session file produces a 403 here.
8//!
9//! Pattern matches `cli/audit.rs` and `cli/report.rs` exactly:
10//! typed `Input` → orchestrator (`sweep`) → typed `Response` →
11//! pure `format_*` functions.
12
13use std::path::Path;
14use std::time::Duration;
15
16use reqwest::Client;
17use serde::{Deserialize, Serialize};
18
19use super::auth::acquire_service_auth;
20use super::error::CliError;
21use super::pds::PdsClient;
22use super::session::SessionFile;
23
24const RETENTION_SWEEP_LXM: &str = "tools.cairn.admin.retentionSweep";
25
26/// Wire-shape of a `retentionSweep` response. Mirrors the
27/// server's `Output` struct (camelCase JSON, snake_case Rust).
28#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
29pub struct SweepResponse {
30    /// Total rows deleted across all batches.
31    #[serde(rename = "rowsDeleted")]
32    pub rows_deleted: i64,
33    /// Number of batched DELETE round-trips issued.
34    pub batches: u64,
35    /// Wall-clock duration of the full sweep, in milliseconds.
36    #[serde(rename = "durationMs")]
37    pub duration_ms: u64,
38    /// Cutoff days actually applied. `None` when the labeler was
39    /// started with no retention cutoff configured (sweep is a
40    /// no-op and the server omits the field).
41    #[serde(
42        rename = "retentionDaysApplied",
43        skip_serializing_if = "Option::is_none",
44        default
45    )]
46    pub retention_days_applied: Option<u32>,
47}
48
49/// Input to `cairn retention sweep`. The HTTP endpoint takes no
50/// per-call parameters — the cutoff is whatever `retention_days`
51/// the labeler was started with — but we keep the typed input so
52/// session-related overrides have a place to live.
53#[derive(Debug, Clone, Default)]
54pub struct SweepInput {
55    /// Per-invocation override of the session's stored Cairn URL.
56    pub cairn_server_override: Option<String>,
57}
58
59/// Trigger a retention sweep via the admin HTTP endpoint.
60///
61/// Wraps the `tools.cairn.admin.retentionSweep` handler at
62/// [src/server/admin/retention_sweep.rs](..) — POST with empty
63/// JSON body; server enforces ADMIN role
64/// (`verify_and_authorize_admin_only`), runs `WriterHandle::sweep`
65/// in the writer task, writes one audit row, and returns the
66/// aggregate `SweepResponse`.
67pub async fn sweep(
68    session: &mut SessionFile,
69    session_path: &Path,
70    input: SweepInput,
71) -> Result<SweepResponse, CliError> {
72    let cairn_server = input
73        .cairn_server_override
74        .as_deref()
75        .unwrap_or(&session.cairn_server_url)
76        .trim_end_matches('/')
77        .to_string();
78    let pds = PdsClient::new(&session.pds_url)?;
79    let token = acquire_service_auth(&pds, session, session_path, RETENTION_SWEEP_LXM).await?;
80
81    let url = format!("{cairn_server}/xrpc/{RETENTION_SWEEP_LXM}");
82    let client = Client::builder()
83        .timeout(Duration::from_secs(300))
84        .build()
85        .expect("reqwest build");
86    let resp = client
87        .post(&url)
88        .bearer_auth(&token)
89        .header("content-type", "application/json")
90        .body("{}")
91        .send()
92        .await
93        .map_err(|source| CliError::Http {
94            url: url.clone(),
95            source,
96        })?;
97    if !resp.status().is_success() {
98        let status = resp.status().as_u16();
99        let body = resp.text().await.unwrap_or_default();
100        return Err(CliError::CairnStatus { url, status, body });
101    }
102    let bytes = resp.bytes().await.map_err(|source| CliError::Http {
103        url: url.clone(),
104        source,
105    })?;
106    serde_json::from_slice::<SweepResponse>(&bytes)
107        .map_err(|source| CliError::MalformedResponse { url, source })
108}
109
110/// Human-readable single-line summary for `cairn retention sweep`.
111/// Renders an explicit "no cutoff configured" note when the server
112/// omits `retentionDaysApplied`, since "0 rows deleted" otherwise
113/// hides the difference between "all rows in retention" and
114/// "retention is off".
115pub fn format_sweep_human(resp: &SweepResponse) -> String {
116    use std::fmt::Write;
117    let mut s = String::new();
118    let _ = write!(
119        s,
120        "rows_deleted={} batches={} duration_ms={}",
121        resp.rows_deleted, resp.batches, resp.duration_ms,
122    );
123    match resp.retention_days_applied {
124        Some(days) => {
125            let _ = write!(s, " retention_days={days}");
126        }
127        None => {
128            let _ = write!(s, " (no retention cutoff configured; sweep was a no-op)");
129        }
130    }
131    s
132}
133
134/// JSON envelope for `cairn retention sweep`.
135pub fn format_sweep_json(resp: &SweepResponse) -> String {
136    serde_json::to_string_pretty(resp).expect("SweepResponse serializes")
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn format_human_shows_retention_days_when_present() {
145        let resp = SweepResponse {
146            rows_deleted: 42,
147            batches: 3,
148            duration_ms: 87,
149            retention_days_applied: Some(180),
150        };
151        let s = format_sweep_human(&resp);
152        assert!(s.contains("rows_deleted=42"));
153        assert!(s.contains("batches=3"));
154        assert!(s.contains("retention_days=180"));
155    }
156
157    #[test]
158    fn format_human_notes_no_cutoff_when_field_absent() {
159        let resp = SweepResponse {
160            rows_deleted: 0,
161            batches: 1,
162            duration_ms: 0,
163            retention_days_applied: None,
164        };
165        let s = format_sweep_human(&resp);
166        assert!(s.contains("rows_deleted=0"));
167        assert!(s.contains("no retention cutoff configured"));
168    }
169
170    #[test]
171    fn format_json_omits_retention_days_when_none() {
172        let resp = SweepResponse {
173            rows_deleted: 0,
174            batches: 1,
175            duration_ms: 0,
176            retention_days_applied: None,
177        };
178        let json = format_sweep_json(&resp);
179        assert!(
180            !json.contains("retentionDaysApplied"),
181            "skip_serializing_if drops the field"
182        );
183    }
184
185    #[test]
186    fn deserializes_server_camelcase_shape() {
187        let body = r#"{"rowsDeleted":7,"batches":2,"durationMs":15,"retentionDaysApplied":30}"#;
188        let r: SweepResponse = serde_json::from_str(body).unwrap();
189        assert_eq!(r.rows_deleted, 7);
190        assert_eq!(r.batches, 2);
191        assert_eq!(r.duration_ms, 15);
192        assert_eq!(r.retention_days_applied, Some(30));
193    }
194}