use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::cloud_client;
use crate::core::wrapped::WrappedReport;
const MAX_LABEL_LEN: usize = 60;
#[derive(Serialize, Deserialize)]
struct PublishPayload {
period: String,
tokens_saved: i64,
cost_avoided_usd: f64,
pricing_estimated: bool,
compression_rate_pct: f64,
#[serde(skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
leaderboard_opt_in: bool,
}
fn build_payload(r: &WrappedReport, name: Option<&str>, leaderboard: bool) -> PublishPayload {
let display_name = name
.map(|s| sanitize(s.trim(), MAX_LABEL_LEN))
.filter(|s| !s.is_empty());
PublishPayload {
period: r.period.clone(),
tokens_saved: clamp_u64(r.tokens_saved),
cost_avoided_usd: r.cost_avoided_usd.max(0.0),
pricing_estimated: r.pricing_estimated,
compression_rate_pct: r.compression_rate_pct.clamp(0.0, 100.0),
display_name,
leaderboard_opt_in: leaderboard,
}
}
fn clamp_u64(v: u64) -> i64 {
i64::try_from(v).unwrap_or(i64::MAX)
}
fn shared_disclosure(has_name: bool) -> String {
let name = if has_name {
", and the display name you chose"
} else {
""
};
format!(
"Shared (aggregate numbers only): tokens saved, estimated USD, compression rate{name}.\n\
Never shared: your code, file contents, file paths, repo names, prompts or messages."
)
}
fn sanitize(s: &str, max: usize) -> String {
s.chars()
.filter(|c| !c.is_control() && *c != '<' && *c != '>')
.take(max)
.collect()
}
#[derive(Serialize, Deserialize, Clone)]
struct PublishedEntry {
id: String,
edit_token: String,
url: String,
period: String,
published_at: String,
#[serde(default)]
auto: bool,
}
#[derive(Serialize, Deserialize, Default)]
struct PublishedStore {
cards: Vec<PublishedEntry>,
}
fn store_path() -> Option<PathBuf> {
let base = std::env::var("LEAN_CTX_DATA_DIR")
.map(PathBuf::from)
.ok()
.or_else(|| dirs::home_dir().map(|h| h.join(".lean-ctx")))?;
Some(base.join("wrapped").join("published.json"))
}
impl PublishedStore {
fn load() -> Self {
store_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save(&self) -> std::io::Result<()> {
let Some(path) = store_path() else {
return Ok(());
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
std::fs::write(path, json)
}
}
fn publisher_agent_id() -> String {
std::env::var("LEAN_CTX_AGENT_ID")
.or_else(|_| std::env::var("LCTX_AGENT_ID"))
.unwrap_or_else(|_| "local".to_string())
}
fn publish_report(
report: &WrappedReport,
period: &str,
name: Option<&str>,
leaderboard: bool,
auto: bool,
) -> Result<cloud_client::PublishedCard, String> {
use crate::core::agent_identity;
let payload = build_payload(report, name, leaderboard);
let payload_json =
serde_json::to_string(&payload).map_err(|e| format!("could not build payload: {e}"))?;
let agent = publisher_agent_id();
let public_key = agent_identity::get_public_key(&agent)
.map(|k| agent_identity::hex_encode(&k.to_bytes()))
.map_err(|e| format!("could not load publish identity: {e}"))?;
let signature = agent_identity::sign_bytes(&agent, payload_json.as_bytes())
.map(|s| agent_identity::hex_encode(&s))
.map_err(|e| format!("could not sign payload: {e}"))?;
let envelope = serde_json::json!({
"payload_json": payload_json,
"public_key": public_key,
"signature": signature,
});
let card = cloud_client::publish_wrapped(&envelope)?;
record_published(&card, period, auto);
Ok(card)
}
fn record_published(card: &cloud_client::PublishedCard, period: &str, auto: bool) {
let mut store = PublishedStore::load();
for stale in store
.cards
.iter()
.filter(|c| c.period == period && c.id != card.id && !c.edit_token.is_empty())
{
let _ = cloud_client::unpublish_wrapped(&stale.id, &stale.edit_token);
}
let edit_token = card.edit_token.clone().unwrap_or_else(|| {
store
.cards
.iter()
.find(|c| c.id == card.id)
.map(|c| c.edit_token.clone())
.unwrap_or_default()
});
store.cards.retain(|c| c.period != period);
store.cards.push(PublishedEntry {
id: card.id.clone(),
edit_token,
url: card.url.clone(),
period: period.to_string(),
published_at: chrono::Utc::now().to_rfc3339(),
auto,
});
if let Err(e) = store.save() {
tracing::warn!("Published, but could not save local record: {e}");
}
}
pub(crate) fn publish(period: &str, name: Option<&str>, leaderboard: bool) {
let report = WrappedReport::generate(period);
if report.tokens_saved == 0 {
println!("Nothing to publish yet — use lean-ctx for a bit, then try again.");
return;
}
let mut cfg = crate::core::config::Config::load();
if let Some(n) = name.map(str::trim).filter(|n| !n.is_empty()) {
if cfg.gain.display_name.as_deref() != Some(n) {
cfg.gain.display_name = Some(n.to_string());
if let Err(e) = cfg.save() {
tracing::warn!("Could not save display name: {e}");
}
}
}
let effective_name = name
.map(str::to_string)
.or_else(|| cfg.gain.display_name.clone());
match publish_report(
&report,
period,
effective_name.as_deref(),
leaderboard,
false,
) {
Ok(card) => {
println!("Published: {}", card.url);
println!("{}", shared_disclosure(effective_name.is_some()));
if crate::core::share::copy_to_clipboard(&card.url) {
println!("URL copied to clipboard — paste it anywhere.");
}
if leaderboard {
if let Some(base) = card.url.split("/w/").next() {
println!("Listed on the community leaderboard: {base}/metrics#leaderboard");
}
}
println!(
"Remove anytime with: lean-ctx gain --unpublish={}",
card.id
);
}
Err(e) => {
eprintln!("Publish failed: {e}");
std::process::exit(1);
}
}
}
pub(crate) fn maybe_auto_publish(period: &str) {
let cfg = crate::core::config::Config::load();
let g = &cfg.gain;
if !g.auto_publish {
return;
}
if !auto_publish_due(
g.last_auto_publish.as_deref(),
g.auto_publish_interval_hours,
) {
return;
}
let report = WrappedReport::generate(period);
if report.tokens_saved == 0 {
return;
}
let disclose_name = g.display_name.is_some();
match publish_report(
&report,
period,
g.display_name.as_deref(),
g.leaderboard,
true,
) {
Ok(card) => {
let mut cfg = cfg;
cfg.gain.last_auto_publish = Some(chrono::Utc::now().to_rfc3339());
if let Err(e) = cfg.save() {
tracing::warn!("Auto-published, but could not record timestamp: {e}");
}
println!("\nAuto-published your recap: {}", card.url);
println!("{}", shared_disclosure(disclose_name));
println!(" (disable with: lean-ctx config set gain.auto_publish false)");
}
Err(e) => tracing::warn!("Auto-publish skipped: {e}"),
}
}
fn auto_publish_due(last: Option<&str>, interval_hours: u64) -> bool {
let Some(last) = last else {
return true;
};
let Ok(prev) = chrono::DateTime::parse_from_rfc3339(last) else {
return true;
};
let elapsed = chrono::Utc::now().signed_duration_since(prev.with_timezone(&chrono::Utc));
let interval = i64::try_from(interval_hours.max(1)).unwrap_or(i64::MAX);
elapsed.num_hours() >= interval
}
pub(crate) fn unpublish(id: Option<&str>) {
let mut store = PublishedStore::load();
let entry = match id {
Some(id) => store.cards.iter().find(|c| c.id == id).cloned(),
None => store.cards.last().cloned(),
};
let Some(entry) = entry else {
match id {
Some(id) => println!("No published card with id {id} found locally."),
None => println!("No published cards found. Publish one with: lean-ctx gain --publish"),
}
return;
};
match cloud_client::unpublish_wrapped(&entry.id, &entry.edit_token) {
Ok(()) => {
store.cards.retain(|c| c.id != entry.id);
let _ = store.save();
println!("Unpublished {} ({})", entry.id, entry.url);
}
Err(e) => {
eprintln!("Unpublish failed: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn report() -> WrappedReport {
WrappedReport {
period: "week".into(),
tokens_saved: 480_600_000,
tokens_input: 600_000_000,
cost_avoided_usd: 1441.79,
total_commands: 1234,
sessions_count: 56,
top_commands: vec![
("ctx_search".into(), 100, 60.0),
("ctx_read".into(), 80, 40.0),
],
compression_rate_pct: 91.2,
files_touched: 789,
daily_savings: vec![1, 2, 3],
bounce_tokens: 100,
model_key: "claude-opus".into(),
pricing_estimated: true,
}
}
#[test]
fn payload_carries_only_minimal_aggregates() {
let p = build_payload(&report(), Some("yvesg"), false);
let v = serde_json::to_value(&p).unwrap();
let obj = v.as_object().unwrap();
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"compression_rate_pct",
"cost_avoided_usd",
"display_name",
"leaderboard_opt_in",
"period",
"pricing_estimated",
"tokens_saved",
]
);
}
#[test]
fn no_name_omits_display_name() {
let p = build_payload(&report(), None, false);
assert!(p.display_name.is_none());
let v = serde_json::to_value(&p).unwrap();
assert!(v.as_object().unwrap().get("display_name").is_none());
}
#[test]
fn leaderboard_flag_sets_opt_in() {
assert!(!build_payload(&report(), None, false).leaderboard_opt_in);
assert!(build_payload(&report(), None, true).leaderboard_opt_in);
}
#[test]
fn auto_publish_due_throttle() {
assert!(auto_publish_due(None, 24));
assert!(auto_publish_due(Some("not-a-timestamp"), 24));
let now = chrono::Utc::now().to_rfc3339();
assert!(!auto_publish_due(Some(&now), 24));
let two_days_ago = (chrono::Utc::now() - chrono::Duration::hours(48)).to_rfc3339();
assert!(auto_publish_due(Some(&two_days_ago), 24));
assert!(!auto_publish_due(Some(&now), 0));
}
#[test]
fn sanitizes_markup_and_truncates() {
assert_eq!(sanitize("ctx_search", MAX_LABEL_LEN), "ctx_search");
assert_eq!(sanitize("<script>", MAX_LABEL_LEN), "script");
assert_eq!(
sanitize(&"a".repeat(100), MAX_LABEL_LEN).chars().count(),
MAX_LABEL_LEN
);
}
#[test]
fn display_name_is_sanitized_and_capped() {
let p = build_payload(&report(), Some(" <b>hi</b> "), false);
let name = p.display_name.unwrap();
assert!(!name.contains('<') && !name.contains('>'));
assert!(name.chars().count() <= MAX_LABEL_LEN);
}
#[test]
fn compression_is_clamped_into_range() {
let mut r = report();
r.compression_rate_pct = 250.0;
let p = build_payload(&r, None, false);
assert!((0.0..=100.0).contains(&p.compression_rate_pct));
}
}