Skip to main content

lean_ctx/core/
context_overlay.rs

1//! Reversible context overlays — user/policy manipulations that modify
2//! context items without changing source files ("synaptic modulation").
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::path::Path;
8
9use super::context_field::{ContextItemId, ContextState, ViewKind};
10
11// ---------------------------------------------------------------------------
12// Core types
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct OverlayId(pub String);
17
18impl OverlayId {
19    pub fn generate(target: &ContextItemId) -> Self {
20        Self(format!(
21            "ov_{}_{}",
22            target.as_str(),
23            Utc::now().timestamp_millis()
24        ))
25    }
26
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30}
31
32impl fmt::Display for OverlayId {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(&self.0)
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum OverlayOp {
41    Include,
42    Exclude { reason: String },
43    Pin { verbatim: bool },
44    Unpin,
45    Rewrite { content: String },
46    SetView(ViewKind),
47    SetPriority { set_priority: f64 },
48    MarkOutdated,
49    Expire { after_secs: u64 },
50}
51
52impl OverlayOp {
53    fn discriminant(&self) -> &'static str {
54        match self {
55            Self::Include => "include",
56            Self::Exclude { .. } => "exclude",
57            Self::Pin { .. } => "pin",
58            Self::Unpin => "unpin",
59            Self::Rewrite { .. } => "rewrite",
60            Self::SetView(_) => "set_view",
61            Self::SetPriority { .. } => "set_priority",
62            Self::MarkOutdated => "mark_outdated",
63            Self::Expire { .. } => "expire",
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum OverlayScope {
71    Call,
72    Session,
73    Project,
74    Agent(String),
75    Global,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum OverlayAuthor {
81    User,
82    Policy(String),
83    Agent(String),
84}
85
86// ---------------------------------------------------------------------------
87// ContextOverlay
88// ---------------------------------------------------------------------------
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ContextOverlay {
92    pub id: OverlayId,
93    pub target: ContextItemId,
94    pub operation: OverlayOp,
95    pub scope: OverlayScope,
96    pub before_hash: String,
97    pub author: OverlayAuthor,
98    pub created_at: DateTime<Utc>,
99    pub stale: bool,
100}
101
102impl ContextOverlay {
103    pub fn new(
104        target: ContextItemId,
105        operation: OverlayOp,
106        scope: OverlayScope,
107        before_hash: String,
108        author: OverlayAuthor,
109    ) -> Self {
110        Self {
111            id: OverlayId::generate(&target),
112            target,
113            operation,
114            scope,
115            before_hash,
116            author,
117            created_at: Utc::now(),
118            stale: false,
119        }
120    }
121
122    fn is_expired(&self) -> bool {
123        if let OverlayOp::Expire { after_secs } = &self.operation {
124            let elapsed = Utc::now()
125                .signed_duration_since(self.created_at)
126                .num_seconds();
127            elapsed >= *after_secs as i64
128        } else {
129            false
130        }
131    }
132}
133
134// ---------------------------------------------------------------------------
135// OverlayStore
136// ---------------------------------------------------------------------------
137
138const OVERLAY_FILE: &str = ".lean-ctx/overlays.json";
139
140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141pub struct OverlayStore {
142    overlays: Vec<ContextOverlay>,
143}
144
145impl OverlayStore {
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Adds an overlay, replacing any existing overlay with the same
151    /// target + operation discriminant.
152    pub fn add(&mut self, overlay: ContextOverlay) {
153        let disc = overlay.operation.discriminant();
154        self.overlays.retain(|existing| {
155            !(existing.target == overlay.target && existing.operation.discriminant() == disc)
156        });
157        self.overlays.push(overlay);
158    }
159
160    pub fn remove(&mut self, id: &OverlayId) {
161        self.overlays.retain(|o| o.id != *id);
162    }
163
164    pub fn for_item(&self, target: &ContextItemId) -> Vec<&ContextOverlay> {
165        self.overlays
166            .iter()
167            .filter(|o| o.target == *target)
168            .collect()
169    }
170
171    pub fn active_for_scope(&self, scope: &OverlayScope) -> Vec<&ContextOverlay> {
172        self.overlays.iter().filter(|o| o.scope == *scope).collect()
173    }
174
175    /// Applies all overlays for `target` to `current_state`, returning the
176    /// effective state. Later overlays take precedence.
177    pub fn apply_to_state(
178        &self,
179        target: &ContextItemId,
180        current_state: ContextState,
181    ) -> ContextState {
182        let mut state = current_state;
183        for overlay in self.overlays.iter().filter(|o| o.target == *target) {
184            state = match &overlay.operation {
185                OverlayOp::Include => ContextState::Included,
186                OverlayOp::Exclude { .. } => ContextState::Excluded,
187                OverlayOp::Pin { .. } => ContextState::Pinned,
188                OverlayOp::Unpin => ContextState::Candidate,
189                OverlayOp::MarkOutdated => ContextState::Stale,
190                _ => state,
191            };
192        }
193        state
194    }
195
196    /// Marks overlays as stale when the source hash has changed.
197    pub fn mark_stale_by_hash(&mut self, target: &ContextItemId, new_hash: &str) {
198        for overlay in self.overlays.iter_mut().filter(|o| o.target == *target) {
199            if overlay.before_hash != new_hash {
200                overlay.stale = true;
201            }
202        }
203    }
204
205    /// Removes overlays whose `Expire` operation has elapsed.
206    pub fn prune_expired(&mut self) {
207        self.overlays.retain(|o| !o.is_expired());
208    }
209
210    /// Returns all overlays for `target`, ordered by creation time.
211    pub fn history(&self, target: &ContextItemId) -> Vec<&ContextOverlay> {
212        let mut items: Vec<&ContextOverlay> = self.for_item(target);
213        items.sort_by_key(|o| o.created_at);
214        items
215    }
216
217    pub fn remove_for_item(&mut self, target: &ContextItemId) {
218        self.overlays.retain(|o| o.target != *target);
219    }
220
221    pub fn all(&self) -> &[ContextOverlay] {
222        &self.overlays
223    }
224
225    pub fn save_project(&self, project_root: &Path) -> Result<(), String> {
226        let project_dir = crate::core::pathutil::safe_project_data_dir(project_root)?;
227        let path = project_dir.join("overlays.json");
228        let json =
229            serde_json::to_string_pretty(self).map_err(|e| format!("serialize overlays: {e}"))?;
230        crate::config_io::write_atomic(&path, &json)
231    }
232
233    pub fn load_project(project_root: &Path) -> Self {
234        if crate::core::pathutil::is_data_dir_collision(project_root) {
235            tracing::debug!(
236                "Skipping overlay load: project root {} collides with data directory",
237                project_root.display()
238            );
239            return Self::default();
240        }
241        let path = project_root.join(OVERLAY_FILE);
242        let mut store: Self = std::fs::read_to_string(&path)
243            .ok()
244            .and_then(|s| serde_json::from_str(&s).ok())
245            .unwrap_or_default();
246        let now = chrono::Utc::now();
247        let session_ttl = chrono::Duration::hours(24);
248        store.overlays.retain(|o| match &o.scope {
249            OverlayScope::Session | OverlayScope::Call => {
250                now.signed_duration_since(o.created_at) < session_ttl
251            }
252            _ => true,
253        });
254        store
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Tests
260// ---------------------------------------------------------------------------
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn make_target() -> ContextItemId {
267        ContextItemId::from_file("src/main.rs")
268    }
269
270    fn make_overlay(op: OverlayOp) -> ContextOverlay {
271        ContextOverlay::new(
272            make_target(),
273            op,
274            OverlayScope::Session,
275            "abc123".into(),
276            OverlayAuthor::User,
277        )
278    }
279
280    // -- State transitions ---------------------------------------------------
281
282    #[test]
283    fn exclude_sets_excluded_state() {
284        let mut store = OverlayStore::new();
285        store.add(make_overlay(OverlayOp::Exclude {
286            reason: "too large".into(),
287        }));
288        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
289        assert_eq!(state, ContextState::Excluded);
290    }
291
292    #[test]
293    fn include_sets_included_state() {
294        let mut store = OverlayStore::new();
295        store.add(make_overlay(OverlayOp::Include));
296        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
297        assert_eq!(state, ContextState::Included);
298    }
299
300    #[test]
301    fn pin_sets_pinned_state() {
302        let mut store = OverlayStore::new();
303        store.add(make_overlay(OverlayOp::Pin { verbatim: true }));
304        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
305        assert_eq!(state, ContextState::Pinned);
306    }
307
308    #[test]
309    fn unpin_resets_to_candidate() {
310        let mut store = OverlayStore::new();
311        store.add(make_overlay(OverlayOp::Unpin));
312        let state = store.apply_to_state(&make_target(), ContextState::Pinned);
313        assert_eq!(state, ContextState::Candidate);
314    }
315
316    #[test]
317    fn mark_outdated_sets_stale_state() {
318        let mut store = OverlayStore::new();
319        store.add(make_overlay(OverlayOp::MarkOutdated));
320        let state = store.apply_to_state(&make_target(), ContextState::Included);
321        assert_eq!(state, ContextState::Stale);
322    }
323
324    #[test]
325    fn non_state_ops_preserve_current_state() {
326        let mut store = OverlayStore::new();
327        store.add(make_overlay(OverlayOp::SetPriority { set_priority: 0.9 }));
328        let state = store.apply_to_state(&make_target(), ContextState::Included);
329        assert_eq!(state, ContextState::Included);
330    }
331
332    // -- Staleness -----------------------------------------------------------
333
334    #[test]
335    fn mark_stale_when_hash_changes() {
336        let mut store = OverlayStore::new();
337        store.add(make_overlay(OverlayOp::Include));
338        assert!(!store.overlays[0].stale);
339
340        store.mark_stale_by_hash(&make_target(), "different_hash");
341        assert!(store.overlays[0].stale);
342    }
343
344    #[test]
345    fn no_stale_when_hash_matches() {
346        let mut store = OverlayStore::new();
347        store.add(make_overlay(OverlayOp::Include));
348        store.mark_stale_by_hash(&make_target(), "abc123");
349        assert!(!store.overlays[0].stale);
350    }
351
352    // -- Scope filtering -----------------------------------------------------
353
354    #[test]
355    fn active_for_scope_filters_correctly() {
356        let mut store = OverlayStore::new();
357        store.add(make_overlay(OverlayOp::Include));
358        store.add(ContextOverlay::new(
359            ContextItemId::from_file("other.rs"),
360            OverlayOp::Include,
361            OverlayScope::Project,
362            "xyz".into(),
363            OverlayAuthor::User,
364        ));
365
366        let session = store.active_for_scope(&OverlayScope::Session);
367        assert_eq!(session.len(), 1);
368
369        let project = store.active_for_scope(&OverlayScope::Project);
370        assert_eq!(project.len(), 1);
371
372        let global = store.active_for_scope(&OverlayScope::Global);
373        assert!(global.is_empty());
374    }
375
376    // -- Expiry pruning ------------------------------------------------------
377
378    #[test]
379    fn prune_removes_expired_overlays() {
380        let mut store = OverlayStore::new();
381        let mut expired = make_overlay(OverlayOp::Expire { after_secs: 0 });
382        expired.created_at = Utc::now() - chrono::Duration::seconds(10);
383        store.add(expired);
384        store.add(make_overlay(OverlayOp::Include));
385
386        assert_eq!(store.overlays.len(), 2);
387        store.prune_expired();
388        assert_eq!(store.overlays.len(), 1);
389    }
390
391    #[test]
392    fn prune_keeps_unexpired_overlays() {
393        let mut store = OverlayStore::new();
394        store.add(make_overlay(OverlayOp::Expire { after_secs: 99999 }));
395        store.prune_expired();
396        assert_eq!(store.overlays.len(), 1);
397    }
398
399    // -- Persistence roundtrip -----------------------------------------------
400
401    #[test]
402    fn save_and_load_roundtrip() {
403        let dir = tempfile::tempdir().expect("tmp dir");
404        let root = dir.path();
405
406        let mut store = OverlayStore::new();
407        store.add(make_overlay(OverlayOp::Include));
408        store.add(make_overlay(OverlayOp::Exclude {
409            reason: "noise".into(),
410        }));
411        store.add(make_overlay(OverlayOp::SetView(ViewKind::Signatures)));
412
413        store.save_project(root).expect("save");
414        let loaded = OverlayStore::load_project(root);
415        assert_eq!(loaded.overlays.len(), store.overlays.len());
416    }
417
418    #[test]
419    fn load_missing_file_returns_empty() {
420        let dir = tempfile::tempdir().expect("tmp dir");
421        let store = OverlayStore::load_project(dir.path());
422        assert!(store.overlays.is_empty());
423    }
424
425    // -- Override semantics --------------------------------------------------
426
427    #[test]
428    fn newer_overlay_replaces_same_target_and_op() {
429        let mut store = OverlayStore::new();
430        store.add(make_overlay(OverlayOp::Exclude {
431            reason: "first".into(),
432        }));
433        assert_eq!(store.overlays.len(), 1);
434        assert_eq!(
435            store.overlays[0].operation,
436            OverlayOp::Exclude {
437                reason: "first".into()
438            }
439        );
440
441        store.add(make_overlay(OverlayOp::Exclude {
442            reason: "second".into(),
443        }));
444        assert_eq!(store.overlays.len(), 1);
445        assert_eq!(
446            store.overlays[0].operation,
447            OverlayOp::Exclude {
448                reason: "second".into()
449            }
450        );
451    }
452
453    #[test]
454    fn different_ops_coexist_for_same_target() {
455        let mut store = OverlayStore::new();
456        store.add(make_overlay(OverlayOp::Include));
457        store.add(make_overlay(OverlayOp::SetPriority { set_priority: 0.8 }));
458        assert_eq!(store.overlays.len(), 2);
459    }
460
461    // -- History order -------------------------------------------------------
462
463    #[test]
464    fn history_returns_chronological_order() {
465        let mut store = OverlayStore::new();
466        let mut older = make_overlay(OverlayOp::Include);
467        older.created_at = Utc::now() - chrono::Duration::seconds(60);
468        store.overlays.push(older);
469
470        let newer = make_overlay(OverlayOp::SetPriority { set_priority: 0.5 });
471        store.overlays.push(newer);
472
473        let hist = store.history(&make_target());
474        assert_eq!(hist.len(), 2);
475        assert!(hist[0].created_at <= hist[1].created_at);
476    }
477
478    // -- Remove --------------------------------------------------------------
479
480    #[test]
481    fn remove_deletes_by_id() {
482        let mut store = OverlayStore::new();
483        let ov = make_overlay(OverlayOp::Include);
484        let id = ov.id.clone();
485        store.add(ov);
486        assert_eq!(store.overlays.len(), 1);
487
488        store.remove(&id);
489        assert!(store.overlays.is_empty());
490    }
491}