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 json =
227            serde_json::to_string_pretty(self).map_err(|e| format!("serialize overlays: {e}"))?;
228        crate::core::pathjail_write::write_project_data(
229            project_root,
230            Path::new("overlays.json"),
231            json.as_bytes(),
232        )
233        .map(|_| ())
234    }
235
236    pub fn load_project(project_root: &Path) -> Self {
237        if crate::core::pathutil::is_data_dir_collision(project_root) {
238            tracing::debug!(
239                "Skipping overlay load: project root {} collides with data directory",
240                project_root.display()
241            );
242            return Self::default();
243        }
244        let path = project_root.join(OVERLAY_FILE);
245        let mut store: Self = std::fs::read_to_string(&path)
246            .ok()
247            .and_then(|s| serde_json::from_str(&s).ok())
248            .unwrap_or_default();
249        let now = chrono::Utc::now();
250        let session_ttl = chrono::Duration::hours(24);
251        store.overlays.retain(|o| match &o.scope {
252            OverlayScope::Session | OverlayScope::Call => {
253                now.signed_duration_since(o.created_at) < session_ttl
254            }
255            _ => true,
256        });
257        store
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Tests
263// ---------------------------------------------------------------------------
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn make_target() -> ContextItemId {
270        ContextItemId::from_file("src/main.rs")
271    }
272
273    fn make_overlay(op: OverlayOp) -> ContextOverlay {
274        ContextOverlay::new(
275            make_target(),
276            op,
277            OverlayScope::Session,
278            "abc123".into(),
279            OverlayAuthor::User,
280        )
281    }
282
283    // -- State transitions ---------------------------------------------------
284
285    #[test]
286    fn exclude_sets_excluded_state() {
287        let mut store = OverlayStore::new();
288        store.add(make_overlay(OverlayOp::Exclude {
289            reason: "too large".into(),
290        }));
291        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
292        assert_eq!(state, ContextState::Excluded);
293    }
294
295    #[test]
296    fn include_sets_included_state() {
297        let mut store = OverlayStore::new();
298        store.add(make_overlay(OverlayOp::Include));
299        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
300        assert_eq!(state, ContextState::Included);
301    }
302
303    #[test]
304    fn pin_sets_pinned_state() {
305        let mut store = OverlayStore::new();
306        store.add(make_overlay(OverlayOp::Pin { verbatim: true }));
307        let state = store.apply_to_state(&make_target(), ContextState::Candidate);
308        assert_eq!(state, ContextState::Pinned);
309    }
310
311    #[test]
312    fn unpin_resets_to_candidate() {
313        let mut store = OverlayStore::new();
314        store.add(make_overlay(OverlayOp::Unpin));
315        let state = store.apply_to_state(&make_target(), ContextState::Pinned);
316        assert_eq!(state, ContextState::Candidate);
317    }
318
319    #[test]
320    fn mark_outdated_sets_stale_state() {
321        let mut store = OverlayStore::new();
322        store.add(make_overlay(OverlayOp::MarkOutdated));
323        let state = store.apply_to_state(&make_target(), ContextState::Included);
324        assert_eq!(state, ContextState::Stale);
325    }
326
327    #[test]
328    fn non_state_ops_preserve_current_state() {
329        let mut store = OverlayStore::new();
330        store.add(make_overlay(OverlayOp::SetPriority { set_priority: 0.9 }));
331        let state = store.apply_to_state(&make_target(), ContextState::Included);
332        assert_eq!(state, ContextState::Included);
333    }
334
335    // -- Staleness -----------------------------------------------------------
336
337    #[test]
338    fn mark_stale_when_hash_changes() {
339        let mut store = OverlayStore::new();
340        store.add(make_overlay(OverlayOp::Include));
341        assert!(!store.overlays[0].stale);
342
343        store.mark_stale_by_hash(&make_target(), "different_hash");
344        assert!(store.overlays[0].stale);
345    }
346
347    #[test]
348    fn no_stale_when_hash_matches() {
349        let mut store = OverlayStore::new();
350        store.add(make_overlay(OverlayOp::Include));
351        store.mark_stale_by_hash(&make_target(), "abc123");
352        assert!(!store.overlays[0].stale);
353    }
354
355    // -- Scope filtering -----------------------------------------------------
356
357    #[test]
358    fn active_for_scope_filters_correctly() {
359        let mut store = OverlayStore::new();
360        store.add(make_overlay(OverlayOp::Include));
361        store.add(ContextOverlay::new(
362            ContextItemId::from_file("other.rs"),
363            OverlayOp::Include,
364            OverlayScope::Project,
365            "xyz".into(),
366            OverlayAuthor::User,
367        ));
368
369        let session = store.active_for_scope(&OverlayScope::Session);
370        assert_eq!(session.len(), 1);
371
372        let project = store.active_for_scope(&OverlayScope::Project);
373        assert_eq!(project.len(), 1);
374
375        let global = store.active_for_scope(&OverlayScope::Global);
376        assert!(global.is_empty());
377    }
378
379    // -- Expiry pruning ------------------------------------------------------
380
381    #[test]
382    fn prune_removes_expired_overlays() {
383        let mut store = OverlayStore::new();
384        let mut expired = make_overlay(OverlayOp::Expire { after_secs: 0 });
385        expired.created_at = Utc::now() - chrono::Duration::seconds(10);
386        store.add(expired);
387        store.add(make_overlay(OverlayOp::Include));
388
389        assert_eq!(store.overlays.len(), 2);
390        store.prune_expired();
391        assert_eq!(store.overlays.len(), 1);
392    }
393
394    #[test]
395    fn prune_keeps_unexpired_overlays() {
396        let mut store = OverlayStore::new();
397        store.add(make_overlay(OverlayOp::Expire { after_secs: 99999 }));
398        store.prune_expired();
399        assert_eq!(store.overlays.len(), 1);
400    }
401
402    // -- Persistence roundtrip -----------------------------------------------
403
404    #[test]
405    fn save_and_load_roundtrip() {
406        let dir = tempfile::tempdir().expect("tmp dir");
407        let root = dir.path();
408
409        let mut store = OverlayStore::new();
410        store.add(make_overlay(OverlayOp::Include));
411        store.add(make_overlay(OverlayOp::Exclude {
412            reason: "noise".into(),
413        }));
414        store.add(make_overlay(OverlayOp::SetView(ViewKind::Signatures)));
415
416        store.save_project(root).expect("save");
417        let loaded = OverlayStore::load_project(root);
418        assert_eq!(loaded.overlays.len(), store.overlays.len());
419    }
420
421    #[test]
422    fn load_missing_file_returns_empty() {
423        let dir = tempfile::tempdir().expect("tmp dir");
424        let store = OverlayStore::load_project(dir.path());
425        assert!(store.overlays.is_empty());
426    }
427
428    // -- Override semantics --------------------------------------------------
429
430    #[test]
431    fn newer_overlay_replaces_same_target_and_op() {
432        let mut store = OverlayStore::new();
433        store.add(make_overlay(OverlayOp::Exclude {
434            reason: "first".into(),
435        }));
436        assert_eq!(store.overlays.len(), 1);
437        assert_eq!(
438            store.overlays[0].operation,
439            OverlayOp::Exclude {
440                reason: "first".into()
441            }
442        );
443
444        store.add(make_overlay(OverlayOp::Exclude {
445            reason: "second".into(),
446        }));
447        assert_eq!(store.overlays.len(), 1);
448        assert_eq!(
449            store.overlays[0].operation,
450            OverlayOp::Exclude {
451                reason: "second".into()
452            }
453        );
454    }
455
456    #[test]
457    fn different_ops_coexist_for_same_target() {
458        let mut store = OverlayStore::new();
459        store.add(make_overlay(OverlayOp::Include));
460        store.add(make_overlay(OverlayOp::SetPriority { set_priority: 0.8 }));
461        assert_eq!(store.overlays.len(), 2);
462    }
463
464    // -- History order -------------------------------------------------------
465
466    #[test]
467    fn history_returns_chronological_order() {
468        let mut store = OverlayStore::new();
469        let mut older = make_overlay(OverlayOp::Include);
470        older.created_at = Utc::now() - chrono::Duration::seconds(60);
471        store.overlays.push(older);
472
473        let newer = make_overlay(OverlayOp::SetPriority { set_priority: 0.5 });
474        store.overlays.push(newer);
475
476        let hist = store.history(&make_target());
477        assert_eq!(hist.len(), 2);
478        assert!(hist[0].created_at <= hist[1].created_at);
479    }
480
481    // -- Remove --------------------------------------------------------------
482
483    #[test]
484    fn remove_deletes_by_id() {
485        let mut store = OverlayStore::new();
486        let ov = make_overlay(OverlayOp::Include);
487        let id = ov.id.clone();
488        store.add(ov);
489        assert_eq!(store.overlays.len(), 1);
490
491        store.remove(&id);
492        assert!(store.overlays.is_empty());
493    }
494}