Skip to main content

lean_ctx/core/
editor_signal.rs

1//! Editor focus signal (#500).
2//!
3//! The file open in the editor is the strongest available relevance signal —
4//! the developer is literally looking at it. The VS Code extension reports
5//! tab changes via `lean-ctx editor-signal --file <path>`; this module stores
6//! the signal in `~/.lean-ctx/editor_signal.json` so the MCP server, the CLI
7//! and the dashboard (all separate processes) can read it without a daemon
8//! or socket.
9//!
10//! Privacy: paths only, never content; the file stays local and is excluded
11//! from any cloud-sync artifact set.
12
13use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16
17const SIGNAL_FILE: &str = "editor_signal.json";
18/// A signal older than this is stale — the developer moved on or closed
19/// the editor; ranking must not be steered by it anymore.
20pub 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    /// Most-recently-focused files: `(path, unix_ts)`, newest first.
27    #[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
44/// Record a focus change (called by the `editor-signal` CLI subcommand).
45pub 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        && prev != norm
53    {
54        signal.recent_files.insert(0, (prev, signal.updated_at));
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
79/// Load the signal if it is fresh enough to steer ranking.
80/// Broken/missing files are silently `None` — the read path never fails.
81pub 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
89/// Load regardless of freshness — for status surfaces (Live Signals panel)
90/// that want to show a stale signal *as stale* instead of hiding it (#505).
91pub fn load_raw_for_status() -> Option<EditorSignal> {
92    load_raw()
93}
94
95/// Ranking boost for a path: 0.30 for the active file, 0.10 for recent tabs.
96pub 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        && paths_match(active, &norm)
100    {
101        return 0.30;
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
113/// Graph stores may hold relative paths while the editor reports absolute
114/// ones (or vice versa) — suffix matching bridges both.
115fn paths_match(a: &str, b: &str) -> bool {
116    a == b || a.ends_with(b) || b.ends_with(a)
117}
118
119/// Apply the editor boost to a relevance ranking and re-sort.
120pub 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
141/// Is `path` the currently focused editor file (fresh signal only)?
142pub 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, // 1970 — definitely stale
178        };
179        // load_fresh path can't be exercised without disk; verify the age rule.
180        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                && prev != norm
191            {
192                signal.recent_files.insert(0, (prev, signal.updated_at));
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}