lean_ctx/core/
editor_signal.rs1use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16
17const SIGNAL_FILE: &str = "editor_signal.json";
18pub const FRESHNESS_SECS: u64 = 120;
21const MAX_RECENT: usize = 10;
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24pub struct EditorSignal {
25 pub active_file: Option<String>,
26 #[serde(default)]
28 pub recent_files: Vec<(String, u64)>,
29 pub updated_at: u64,
30}
31
32fn signal_path() -> PathBuf {
33 crate::core::data_dir::lean_ctx_data_dir()
34 .unwrap_or_else(|_| PathBuf::from("."))
35 .join(SIGNAL_FILE)
36}
37
38fn now_unix() -> u64 {
39 std::time::SystemTime::now()
40 .duration_since(std::time::UNIX_EPOCH)
41 .map_or(0, |d| d.as_secs())
42}
43
44pub fn record_focus(path: &str) -> Result<(), String> {
46 let norm = crate::core::pathutil::normalize_tool_path(path);
47 let now = now_unix();
48
49 let mut signal = load_raw().unwrap_or_default();
50 signal.recent_files.retain(|(p, _)| p != &norm);
51 if let Some(prev) = signal.active_file.take() {
52 if prev != norm {
53 signal.recent_files.insert(0, (prev, signal.updated_at));
54 }
55 }
56 signal.recent_files.truncate(MAX_RECENT);
57 signal.active_file = Some(norm);
58 signal.updated_at = now;
59
60 save(&signal)
61}
62
63fn save(signal: &EditorSignal) -> Result<(), String> {
64 let path = signal_path();
65 if let Some(parent) = path.parent() {
66 std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
67 }
68 let json = serde_json::to_string(signal).map_err(|e| format!("serialize: {e}"))?;
69 let tmp = path.with_extension("tmp");
70 std::fs::write(&tmp, json).map_err(|e| format!("write: {e}"))?;
71 std::fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))
72}
73
74fn load_raw() -> Option<EditorSignal> {
75 let raw = std::fs::read_to_string(signal_path()).ok()?;
76 serde_json::from_str(&raw).ok()
77}
78
79pub fn load_fresh(max_age_secs: u64) -> Option<EditorSignal> {
82 let signal = load_raw()?;
83 if now_unix().saturating_sub(signal.updated_at) > max_age_secs {
84 return None;
85 }
86 Some(signal)
87}
88
89pub fn load_raw_for_status() -> Option<EditorSignal> {
92 load_raw()
93}
94
95pub fn boost_for(signal: &EditorSignal, path: &str) -> f64 {
97 let norm = crate::core::pathutil::normalize_tool_path(path);
98 if let Some(active) = &signal.active_file {
99 if paths_match(active, &norm) {
100 return 0.30;
101 }
102 }
103 if signal
104 .recent_files
105 .iter()
106 .any(|(p, _)| paths_match(p, &norm))
107 {
108 return 0.10;
109 }
110 0.0
111}
112
113fn paths_match(a: &str, b: &str) -> bool {
116 a == b || a.ends_with(b) || b.ends_with(a)
117}
118
119pub fn apply_boost(scores: &mut [crate::core::task_relevance::RelevanceScore]) {
121 let Some(signal) = load_fresh(FRESHNESS_SECS) else {
122 return;
123 };
124 let mut changed = false;
125 for s in scores.iter_mut() {
126 let boost = boost_for(&signal, &s.path);
127 if boost > 0.0 {
128 s.score = (s.score + boost).min(1.0);
129 changed = true;
130 }
131 }
132 if changed {
133 scores.sort_by(|a, b| {
134 b.score
135 .partial_cmp(&a.score)
136 .unwrap_or(std::cmp::Ordering::Equal)
137 });
138 }
139}
140
141pub fn is_active(path: &str) -> bool {
143 load_fresh(FRESHNESS_SECS).is_some_and(|s| boost_for(&s, path) >= 0.30)
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn boost_active_beats_recent() {
152 let signal = EditorSignal {
153 active_file: Some("/repo/src/auth.rs".into()),
154 recent_files: vec![("/repo/src/db.rs".into(), 100)],
155 updated_at: 100,
156 };
157 assert!((boost_for(&signal, "/repo/src/auth.rs") - 0.30).abs() < f64::EPSILON);
158 assert!((boost_for(&signal, "/repo/src/db.rs") - 0.10).abs() < f64::EPSILON);
159 assert!((boost_for(&signal, "/repo/src/other.rs")).abs() < f64::EPSILON);
160 }
161
162 #[test]
163 fn relative_paths_match_absolute_signal() {
164 let signal = EditorSignal {
165 active_file: Some("/repo/src/auth.rs".into()),
166 recent_files: vec![],
167 updated_at: 100,
168 };
169 assert!((boost_for(&signal, "src/auth.rs") - 0.30).abs() < f64::EPSILON);
170 }
171
172 #[test]
173 fn stale_signal_is_ignored() {
174 let signal = EditorSignal {
175 active_file: Some("a.rs".into()),
176 recent_files: vec![],
177 updated_at: 0, };
179 assert!(now_unix().saturating_sub(signal.updated_at) > FRESHNESS_SECS);
181 }
182
183 #[test]
184 fn focus_rotation_keeps_window_bounded() {
185 let mut signal = EditorSignal::default();
186 for i in 0..15 {
187 let norm = format!("f{i}.rs");
188 signal.recent_files.retain(|(p, _)| p != &norm);
189 if let Some(prev) = signal.active_file.take() {
190 if prev != norm {
191 signal.recent_files.insert(0, (prev, signal.updated_at));
192 }
193 }
194 signal.recent_files.truncate(MAX_RECENT);
195 signal.active_file = Some(norm);
196 signal.updated_at = 1000 + i;
197 }
198 assert_eq!(signal.active_file.as_deref(), Some("f14.rs"));
199 assert_eq!(signal.recent_files.len(), MAX_RECENT);
200 assert_eq!(signal.recent_files[0].0, "f13.rs");
201 }
202}