Skip to main content

lean_ctx/core/
solution_tracker.rs

1use 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
70/// Reload the store from disk. Called by `snapshot()` so the dashboard
71/// daemon sees changes written by short-lived `hook observe` processes.
72fn 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
85/// Exclusive lock on a stable companion file; released when the handle drops.
86/// Writes still happen without it if the lock cannot be taken, as before.
87fn 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
121/// Several long-lived MCP servers record into the same file. Each mutation is
122/// a locked read-modify-write so no process writes back a stale copy and
123/// drops counts the others recorded since it started.
124fn 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}