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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}