lean_ctx/core/savings_ledger/
push.rs1use super::SignedSavingsBatchV1;
12
13#[derive(Debug, Clone, Copy)]
15pub struct PushOutcome {
16 pub net_saved_tokens: u64,
17 pub saved_usd: f64,
18}
19
20#[derive(Debug)]
22pub enum PushError {
23 Empty,
25 Sign(String),
27 Serialize(String),
29 Unauthorized,
31 Rejected { status: u16, body: String },
33 Unreachable(String),
35}
36
37impl std::fmt::Display for PushError {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::Empty => write!(f, "Savings ledger is empty — nothing to push."),
41 Self::Sign(e) => write!(f, "Signing failed: {e}"),
42 Self::Serialize(e) => write!(f, "Serialization failed: {e}"),
43 Self::Unauthorized => write!(f, "Team server denied the push (HTTP 401/403)."),
44 Self::Rejected { status, body } => {
45 write!(f, "Team server rejected the batch (HTTP {status}): {body}")
46 }
47 Self::Unreachable(e) => write!(f, "Failed to reach team server: {e}"),
48 }
49 }
50}
51
52impl std::error::Error for PushError {}
53
54pub fn agent_id() -> String {
56 std::env::var("LEAN_CTX_AGENT_ID")
57 .or_else(|_| std::env::var("LCTX_AGENT_ID"))
58 .unwrap_or_else(|_| "local".to_string())
59}
60
61pub fn ingest_endpoint(url: &str) -> String {
63 format!("{}/api/v1/savings/ingest", url.trim_end_matches('/'))
64}
65
66pub fn push_batch(url: &str, token: Option<&str>) -> Result<PushOutcome, PushError> {
71 let agent = agent_id();
72 let mut batch = SignedSavingsBatchV1::build_all(&agent);
73 if batch.totals.total_events == 0 {
74 return Err(PushError::Empty);
75 }
76 batch.sign(&agent).map_err(PushError::Sign)?;
77
78 let endpoint = ingest_endpoint(url);
79 let body = serde_json::to_vec(&batch).map_err(|e| PushError::Serialize(e.to_string()))?;
80
81 let mut request = ureq::post(&endpoint).header("Content-Type", "application/json");
82 if let Some(tok) = token {
83 request = request.header("Authorization", &format!("Bearer {tok}"));
84 }
85
86 match request.send(&body[..]) {
87 Ok(resp) => {
88 let status = resp.status().as_u16();
89 if status == 401 || status == 403 {
90 return Err(PushError::Unauthorized);
91 }
92 if status == 200 {
93 Ok(PushOutcome {
94 net_saved_tokens: batch.totals.net_saved_tokens,
95 saved_usd: batch.totals.saved_usd,
96 })
97 } else {
98 let body = resp.into_body().read_to_string().unwrap_or_default();
99 Err(PushError::Rejected { status, body })
100 }
101 }
102 Err(e) => Err(PushError::Unreachable(e.to_string())),
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn ingest_endpoint_trims_trailing_slash() {
112 assert_eq!(
113 ingest_endpoint("https://team.example.com/"),
114 "https://team.example.com/api/v1/savings/ingest"
115 );
116 assert_eq!(
117 ingest_endpoint("https://team.example.com"),
118 "https://team.example.com/api/v1/savings/ingest"
119 );
120 }
121
122 #[test]
123 fn push_error_display_is_actionable() {
124 assert!(PushError::Empty.to_string().contains("empty"));
125 assert!(PushError::Unauthorized.to_string().contains("401/403"));
126 assert!(PushError::Rejected {
127 status: 500,
128 body: "boom".into()
129 }
130 .to_string()
131 .contains("500"));
132 }
133}