lean_ctx/core/
edit_metering.rs1use std::sync::atomic::{AtomicUsize, Ordering};
26use std::sync::{Mutex, OnceLock};
27
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
31#[allow(dead_code)]
32pub struct EditMetrics {
33 pub lines_added: u64,
34 pub lines_removed: u64,
35 pub net_loc_delta: i64,
36 pub edits_count: u32,
37 pub files_touched: u32,
38}
39
40const STORE_FILE: &str = "edit_metering.json";
41const FLUSH_EVERY: usize = 5;
42
43static STORE: OnceLock<Mutex<EditMeteringStore>> = OnceLock::new();
44static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51#[serde(default)]
52pub(crate) struct EditMeteringStore {
53 pub anchored_calls: u64,
55 pub anchored_ops: u64,
57 pub anchored_avoided_output_tokens: u64,
59 pub anchored_conflicts: u64,
61 pub str_replace_calls: u64,
63 pub str_replace_old_string_tokens: u64,
65 pub str_replace_misses: u64,
67 pub loc_added: u64,
69 pub loc_removed: u64,
71 #[serde(skip)]
72 dirty: bool,
73}
74
75impl EditMeteringStore {
76 fn load_from_disk() -> Self {
77 let Ok(raw) = std::fs::read_to_string(store_path()) else {
78 return Self::default();
79 };
80 serde_json::from_str(&raw).unwrap_or_default()
81 }
82
83 pub(crate) fn save(&self) -> std::io::Result<()> {
84 let path = store_path();
85 if let Some(parent) = path.parent() {
86 std::fs::create_dir_all(parent)?;
87 }
88 let json = serde_json::to_string(self)?;
89 let tmp = path.with_extension("tmp");
90 std::fs::write(&tmp, json)?;
91 std::fs::rename(&tmp, &path)
92 }
93}
94
95fn store_path() -> std::path::PathBuf {
96 crate::core::data_dir::lean_ctx_data_dir()
97 .unwrap_or_else(|_| std::path::PathBuf::from("."))
98 .join(STORE_FILE)
99}
100
101fn global() -> &'static Mutex<EditMeteringStore> {
102 STORE.get_or_init(|| Mutex::new(EditMeteringStore::load_from_disk()))
103}
104
105fn with_store(f: impl FnOnce(&mut EditMeteringStore)) {
106 let Ok(mut store) = global().lock() else {
107 return;
108 };
109 f(&mut store);
110 store.dirty = true;
111 let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
112 if n.is_multiple_of(FLUSH_EVERY) && store.save().is_ok() {
113 store.dirty = false;
114 }
115}
116
117pub fn record_anchored_success(ops: u64, avoided_tokens: u64) {
120 with_store(|s| {
121 s.anchored_calls = s.anchored_calls.saturating_add(1);
122 s.anchored_ops = s.anchored_ops.saturating_add(ops);
123 s.anchored_avoided_output_tokens = s
124 .anchored_avoided_output_tokens
125 .saturating_add(avoided_tokens);
126 });
127}
128
129pub fn record_anchored_conflict() {
131 with_store(|s| s.anchored_conflicts = s.anchored_conflicts.saturating_add(1));
132}
133
134pub fn record_str_replace_success(old_string_tokens: u64) {
136 with_store(|s| {
137 s.str_replace_calls = s.str_replace_calls.saturating_add(1);
138 s.str_replace_old_string_tokens = s
139 .str_replace_old_string_tokens
140 .saturating_add(old_string_tokens);
141 });
142}
143
144pub fn record_str_replace_miss() {
146 with_store(|s| s.str_replace_misses = s.str_replace_misses.saturating_add(1));
147}
148
149#[allow(dead_code)]
150pub fn record_loc_change(path: Option<&str>, added: u64, removed: u64) {
151 with_store(|s| {
152 s.loc_added = s.loc_added.saturating_add(added);
153 s.loc_removed = s.loc_removed.saturating_add(removed);
154 });
155 crate::core::solution_tracker::record_loc_change(added, removed);
156 if let Some(path) = path {
157 crate::core::savings_ledger::record_edit_event(path, added, removed);
158 }
159}
160
161pub fn metrics_snapshot() -> serde_json::Value {
163 let Ok(store) = global().lock() else {
164 return serde_json::json!({});
165 };
166 serde_json::json!({
167 "anchored_calls": store.anchored_calls,
168 "anchored_ops": store.anchored_ops,
169 "anchored_avoided_output_tokens": store.anchored_avoided_output_tokens,
170 "anchored_conflicts": store.anchored_conflicts,
171 "str_replace_calls": store.str_replace_calls,
172 "str_replace_old_string_tokens": store.str_replace_old_string_tokens,
173 "str_replace_misses": store.str_replace_misses,
174 "loc_added": store.loc_added,
175 "loc_removed": store.loc_removed,
176 })
177}
178
179pub(crate) fn flush() {
180 if let Ok(store) = global().lock()
181 && store.dirty
182 {
183 let _ = store.save();
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn counters_accumulate_and_saturate() {
193 let mut s = EditMeteringStore::default();
194 s.anchored_calls = u64::MAX;
195 s.anchored_calls = s.anchored_calls.saturating_add(1);
196 assert_eq!(s.anchored_calls, u64::MAX, "saturating, never wraps");
197 }
198
199 #[test]
200 fn roundtrip_serialization() {
201 let s = EditMeteringStore {
202 anchored_calls: 3,
203 anchored_ops: 7,
204 anchored_avoided_output_tokens: 412,
205 anchored_conflicts: 1,
206 str_replace_calls: 2,
207 str_replace_old_string_tokens: 260,
208 str_replace_misses: 4,
209 loc_added: 15,
210 loc_removed: 9,
211 dirty: false,
212 };
213 let json = serde_json::to_string(&s).unwrap();
214 let back: EditMeteringStore = serde_json::from_str(&json).unwrap();
215 assert_eq!(back.anchored_ops, 7);
216 assert_eq!(back.anchored_avoided_output_tokens, 412);
217 assert_eq!(back.str_replace_misses, 4);
218 assert_eq!(back.loc_added, 15);
219 assert_eq!(back.loc_removed, 9);
220 }
221
222 #[test]
223 fn legacy_or_empty_store_deserializes() {
224 let s: EditMeteringStore = serde_json::from_str("{}").unwrap();
225 assert_eq!(s.anchored_calls, 0);
226 assert_eq!(s.str_replace_old_string_tokens, 0);
227 assert_eq!(s.loc_added, 0);
228 assert_eq!(s.loc_removed, 0);
229 }
230}