lean_ctx/core/addons/
meter.rs1use std::collections::BTreeMap;
14use std::path::PathBuf;
15use std::sync::Mutex;
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ToolStat {
22 pub calls: u64,
24 pub errors: u64,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct ServerUsage {
31 pub calls: u64,
32 pub errors: u64,
33 #[serde(default)]
35 pub tools: BTreeMap<String, ToolStat>,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub struct UsageLedger {
41 #[serde(default)]
43 pub servers: BTreeMap<String, ServerUsage>,
44}
45
46static WRITE_LOCK: Mutex<()> = Mutex::new(());
49
50fn ledger_path() -> Result<PathBuf, String> {
51 Ok(crate::core::data_dir::lean_ctx_data_dir()?
52 .join("addons")
53 .join("usage.json"))
54}
55
56impl UsageLedger {
57 #[must_use]
59 pub fn load() -> Self {
60 let Ok(path) = ledger_path() else {
61 return Self::default();
62 };
63 match std::fs::read_to_string(&path) {
64 Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
65 _ => Self::default(),
66 }
67 }
68
69 pub fn save(&self) -> Result<(), String> {
71 let path = ledger_path()?;
72 if let Some(parent) = path.parent() {
73 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
74 }
75 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
76 std::fs::write(&path, json).map_err(|e| e.to_string())
77 }
78
79 pub fn record_into(&mut self, server: &str, tool: &str, ok: bool) {
82 let su = self.servers.entry(server.to_string()).or_default();
83 su.calls += 1;
84 let ts = su.tools.entry(tool.to_string()).or_default();
85 ts.calls += 1;
86 if !ok {
87 su.errors += 1;
88 ts.errors += 1;
89 }
90 }
91
92 #[must_use]
95 pub fn by_usage(&self) -> Vec<(&String, &ServerUsage)> {
96 let mut v: Vec<_> = self.servers.iter().collect();
97 v.sort_by(|a, b| b.1.calls.cmp(&a.1.calls).then_with(|| a.0.cmp(b.0)));
98 v
99 }
100}
101
102pub fn record(server: &str, tool: &str, ok: bool) {
106 if !crate::core::config::Config::load().addons.metering {
107 return;
108 }
109 let Ok(_guard) = WRITE_LOCK.lock() else {
110 return;
111 };
112 let mut ledger = UsageLedger::load();
113 ledger.record_into(server, tool, ok);
114 if let Err(e) = ledger.save() {
115 tracing::debug!("[addon-meter] could not persist usage: {e}");
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::core::data_dir::isolated_data_dir;
123
124 #[test]
125 fn record_into_counts_calls_and_errors() {
126 let mut l = UsageLedger::default();
127 l.record_into("git", "commit", true);
128 l.record_into("git", "commit", false);
129 l.record_into("git", "status", true);
130
131 let git = &l.servers["git"];
132 assert_eq!(git.calls, 3);
133 assert_eq!(git.errors, 1);
134 assert_eq!(git.tools["commit"].calls, 2);
135 assert_eq!(git.tools["commit"].errors, 1);
136 assert_eq!(git.tools["status"].calls, 1);
137 assert_eq!(git.tools["status"].errors, 0);
138 }
139
140 #[test]
141 fn by_usage_is_descending_and_deterministic() {
142 let mut l = UsageLedger::default();
143 for _ in 0..5 {
144 l.record_into("busy", "t", true);
145 }
146 l.record_into("quiet", "t", true);
147 let order: Vec<&str> = l.by_usage().iter().map(|(n, _)| n.as_str()).collect();
148 assert_eq!(order, vec!["busy", "quiet"]);
149 }
150
151 #[test]
152 fn round_trips_through_disk() {
153 let _iso = isolated_data_dir();
154 record("demo", "tool", true);
155 record("demo", "tool", false);
156 let reloaded = UsageLedger::load();
157 assert_eq!(reloaded.servers["demo"].calls, 2);
158 assert_eq!(reloaded.servers["demo"].errors, 1);
159 }
160}