lean_ctx/core/
solution_tracker.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Mutex, OnceLock};
4
5use chrono::Local;
6use serde::{Deserialize, Serialize};
7
8const STORE_FILE: &str = "solution_tracker.json";
9
10#[derive(Default, Deserialize, Serialize)]
11struct SolutionStore {
12 decisions_total: u64,
13 decisions_stdlib: u64,
14 decisions_native: u64,
15 decisions_reuse: u64,
16 decisions_yagni: u64,
17 decisions_oneline: u64,
18 decisions_debt: u64,
19 loc_added: u64,
20 loc_removed: u64,
21 loc_net_saved: i64,
22 output_tokens_baseline: u64,
23 output_tokens_actual: u64,
24 #[serde(default)]
25 daily_records: Vec<DayRecord>,
26}
27
28#[derive(Clone, Deserialize, Serialize)]
29struct DayRecord {
30 date: String,
31 decisions: u64,
32 loc_net_saved: i64,
33}
34
35#[derive(Clone, Serialize)]
36pub struct SolutionSnapshot {
37 pub decisions_total: u64,
38 pub decisions_by_kind: HashMap<String, u64>,
39 pub loc_added: u64,
40 pub loc_removed: u64,
41 pub loc_net_saved: i64,
42 pub output_tokens_baseline: u64,
43 pub output_tokens_actual: u64,
44 pub output_reduction_pct: u8,
45}
46
47fn store_path() -> PathBuf {
48 crate::core::paths::data_dir()
49 .unwrap_or_else(|_| {
50 std::env::var_os("HOME")
51 .map(PathBuf::from)
52 .unwrap_or_else(|| PathBuf::from("."))
53 .join(".lean-ctx")
54 })
55 .join(STORE_FILE)
56}
57
58static STORE: OnceLock<Mutex<SolutionStore>> = OnceLock::new();
59
60fn store() -> &'static Mutex<SolutionStore> {
61 STORE.get_or_init(|| {
62 let loaded = std::fs::read(store_path())
63 .ok()
64 .and_then(|contents| serde_json::from_slice(&contents).ok())
65 .unwrap_or_default();
66 Mutex::new(loaded)
67 })
68}
69
70fn reload_from_disk() {
73 if let Some(fresh) = read_store_file() {
74 if let Ok(mut guard) = store().lock() {
75 *guard = fresh;
76 }
77 }
78}
79
80fn read_store_file() -> Option<SolutionStore> {
81 let bytes = std::fs::read(store_path()).ok()?;
82 serde_json::from_slice(&bytes).ok()
83}
84
85fn lock_store_file() -> Option<std::fs::File> {
88 use fs2::FileExt;
89
90 let lock_path = store_path().with_extension("json.lock");
91 std::fs::create_dir_all(lock_path.parent()?).ok()?;
92 let lock = std::fs::OpenOptions::new()
93 .create(true)
94 .truncate(false)
95 .read(true)
96 .write(true)
97 .open(lock_path)
98 .ok()?;
99 lock.lock_exclusive().ok()?;
100 Some(lock)
101}
102
103fn flush(store: &SolutionStore) {
104 let path = store_path();
105 let Some(parent) = path.parent() else {
106 return;
107 };
108 if std::fs::create_dir_all(parent).is_err() {
109 return;
110 }
111
112 let Ok(contents) = serde_json::to_vec(store) else {
113 return;
114 };
115 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
116 if std::fs::write(&temporary, contents).is_ok() {
117 let _ = std::fs::rename(temporary, path);
118 }
119}
120
121fn with_store<F: FnOnce(&mut SolutionStore)>(f: F) {
125 let mut store = store()
126 .lock()
127 .unwrap_or_else(std::sync::PoisonError::into_inner);
128 let _file_lock = lock_store_file();
129 if let Some(fresh) = read_store_file() {
130 *store = fresh;
131 }
132 f(&mut store);
133 flush(&store);
134}
135
136fn current_day_record(store: &mut SolutionStore) -> &mut DayRecord {
137 let date = Local::now().format("%F").to_string();
138 if let Some(index) = store
139 .daily_records
140 .iter()
141 .position(|record| record.date == date)
142 {
143 return &mut store.daily_records[index];
144 }
145
146 store.daily_records.push(DayRecord {
147 date,
148 decisions: 0,
149 loc_net_saved: 0,
150 });
151 store
152 .daily_records
153 .last_mut()
154 .expect("daily record was just inserted")
155}
156
157pub fn record_decision(kind: &str) {
158 with_store(|store| {
159 store.decisions_total = store.decisions_total.saturating_add(1);
160 let record = current_day_record(store);
161 record.decisions = record.decisions.saturating_add(1);
162 match kind {
163 "stdlib" => store.decisions_stdlib = store.decisions_stdlib.saturating_add(1),
164 "native" => store.decisions_native = store.decisions_native.saturating_add(1),
165 "reuse" => store.decisions_reuse = store.decisions_reuse.saturating_add(1),
166 "yagni" => store.decisions_yagni = store.decisions_yagni.saturating_add(1),
167 "oneline" => store.decisions_oneline = store.decisions_oneline.saturating_add(1),
168 "debt" => store.decisions_debt = store.decisions_debt.saturating_add(1),
169 _ => {}
170 }
171 });
172}
173
174pub fn record_loc_change(added: u64, removed: u64) {
175 with_store(|store| {
176 store.loc_added = store.loc_added.saturating_add(added);
177 store.loc_removed = store.loc_removed.saturating_add(removed);
178 let added = i64::try_from(added).unwrap_or(i64::MAX);
179 let removed = i64::try_from(removed).unwrap_or(i64::MAX);
180 let loc_net_saved = removed.saturating_sub(added);
181 store.loc_net_saved = store.loc_net_saved.saturating_add(loc_net_saved);
182 let record = current_day_record(store);
183 record.loc_net_saved = record.loc_net_saved.saturating_add(loc_net_saved);
184 });
185}
186
187pub fn trend_7d() -> Vec<(String, u64, i64)> {
188 reload_from_disk();
189 let store = store()
190 .lock()
191 .unwrap_or_else(std::sync::PoisonError::into_inner);
192 let mut records = store.daily_records.clone();
193 records.sort_unstable_by(|left, right| left.date.cmp(&right.date));
194 let mut trend: Vec<_> = records
195 .into_iter()
196 .rev()
197 .take(7)
198 .map(|record| (record.date, record.decisions, record.loc_net_saved))
199 .collect();
200 trend.reverse();
201 trend
202}
203
204pub fn record_output_tokens(baseline: u64, actual: u64) {
205 with_store(|store| {
206 store.output_tokens_baseline = store.output_tokens_baseline.saturating_add(baseline);
207 store.output_tokens_actual = store.output_tokens_actual.saturating_add(actual);
208 });
209}
210
211pub fn snapshot() -> SolutionSnapshot {
212 reload_from_disk();
213 let store = store()
214 .lock()
215 .unwrap_or_else(std::sync::PoisonError::into_inner);
216 let mut decisions_by_kind = HashMap::new();
217 if store.decisions_stdlib > 0 {
218 decisions_by_kind.insert("stdlib".to_owned(), store.decisions_stdlib);
219 }
220 if store.decisions_native > 0 {
221 decisions_by_kind.insert("native".to_owned(), store.decisions_native);
222 }
223 if store.decisions_reuse > 0 {
224 decisions_by_kind.insert("reuse".to_owned(), store.decisions_reuse);
225 }
226 if store.decisions_yagni > 0 {
227 decisions_by_kind.insert("yagni".to_owned(), store.decisions_yagni);
228 }
229 if store.decisions_oneline > 0 {
230 decisions_by_kind.insert("oneline".to_owned(), store.decisions_oneline);
231 }
232 if store.decisions_debt > 0 {
233 decisions_by_kind.insert("debt".to_owned(), store.decisions_debt);
234 }
235 let output_reduction_pct = store
236 .output_tokens_baseline
237 .checked_sub(store.output_tokens_actual)
238 .and_then(|reduction| reduction.checked_mul(100))
239 .and_then(|reduction| reduction.checked_div(store.output_tokens_baseline))
240 .and_then(|reduction| u8::try_from(reduction).ok())
241 .unwrap_or(0);
242
243 SolutionSnapshot {
244 decisions_total: store.decisions_total,
245 decisions_by_kind,
246 loc_added: store.loc_added,
247 loc_removed: store.loc_removed,
248 loc_net_saved: store.loc_net_saved,
249 output_tokens_baseline: store.output_tokens_baseline,
250 output_tokens_actual: store.output_tokens_actual,
251 output_reduction_pct,
252 }
253}
254
255pub fn reset() {
256 with_store(|store| *store = SolutionStore::default());
257}
258
259pub fn gain_summary() -> String {
260 let snapshot = snapshot();
261 format!(
262 "{} decisions; {} net LOC saved; {}% output-token reduction",
263 snapshot.decisions_total, snapshot.loc_net_saved, snapshot.output_reduction_pct
264 )
265}