1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::path::Path;
8
9use super::context_field::{ContextItemId, ContextState, ViewKind};
10
11#[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#[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
134const 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 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 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 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 pub fn prune_expired(&mut self) {
207 self.overlays.retain(|o| !o.is_expired());
208 }
209
210 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}