use super::SignedSavingsBatchV1;
#[derive(Debug, Clone, Copy)]
pub struct PushOutcome {
pub net_saved_tokens: u64,
pub saved_usd: f64,
}
#[derive(Debug)]
pub enum PushError {
Empty,
Sign(String),
Serialize(String),
Unauthorized,
Rejected { status: u16, body: String },
Unreachable(String),
}
impl std::fmt::Display for PushError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "Savings ledger is empty — nothing to push."),
Self::Sign(e) => write!(f, "Signing failed: {e}"),
Self::Serialize(e) => write!(f, "Serialization failed: {e}"),
Self::Unauthorized => write!(f, "Team server denied the push (HTTP 401/403)."),
Self::Rejected { status, body } => {
write!(f, "Team server rejected the batch (HTTP {status}): {body}")
}
Self::Unreachable(e) => write!(f, "Failed to reach team server: {e}"),
}
}
}
impl std::error::Error for PushError {}
pub fn agent_id() -> String {
std::env::var("LEAN_CTX_AGENT_ID")
.or_else(|_| std::env::var("LCTX_AGENT_ID"))
.unwrap_or_else(|_| "local".to_string())
}
pub fn ingest_endpoint(url: &str) -> String {
format!("{}/api/v1/savings/ingest", url.trim_end_matches('/'))
}
pub fn push_batch(url: &str, token: Option<&str>) -> Result<PushOutcome, PushError> {
let agent = agent_id();
let mut batch = SignedSavingsBatchV1::build_all(&agent);
if batch.totals.total_events == 0 {
return Err(PushError::Empty);
}
batch.sign(&agent).map_err(PushError::Sign)?;
let endpoint = ingest_endpoint(url);
let body = serde_json::to_vec(&batch).map_err(|e| PushError::Serialize(e.to_string()))?;
let mut request = ureq::post(&endpoint).header("Content-Type", "application/json");
if let Some(tok) = token {
request = request.header("Authorization", &format!("Bearer {tok}"));
}
match request.send(&body[..]) {
Ok(resp) => {
let status = resp.status().as_u16();
if status == 401 || status == 403 {
return Err(PushError::Unauthorized);
}
if status == 200 {
Ok(PushOutcome {
net_saved_tokens: batch.totals.net_saved_tokens,
saved_usd: batch.totals.saved_usd,
})
} else {
let body = resp.into_body().read_to_string().unwrap_or_default();
Err(PushError::Rejected { status, body })
}
}
Err(e) => Err(PushError::Unreachable(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ingest_endpoint_trims_trailing_slash() {
assert_eq!(
ingest_endpoint("https://team.example.com/"),
"https://team.example.com/api/v1/savings/ingest"
);
assert_eq!(
ingest_endpoint("https://team.example.com"),
"https://team.example.com/api/v1/savings/ingest"
);
}
#[test]
fn push_error_display_is_actionable() {
assert!(PushError::Empty.to_string().contains("empty"));
assert!(PushError::Unauthorized.to_string().contains("401/403"));
assert!(PushError::Rejected {
status: 500,
body: "boom".into()
}
.to_string()
.contains("500"));
}
}