Skip to main content

git_lore/lore/
prism.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8use super::{LoreAtom, Workspace};
9
10pub const PRISM_STALE_TTL_SECONDS: u64 = 24 * 60 * 60;
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub struct PrismSignal {
15    pub session_id: String,
16    pub agent: Option<String>,
17    pub scope: Option<String>,
18    pub paths: Vec<String>,
19    pub assumptions: Vec<String>,
20    pub decision: Option<String>,
21    pub created_unix_seconds: u64,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PrismConflict {
26    pub session_id: String,
27    pub agent: Option<String>,
28    pub scope: Option<String>,
29    pub decision: Option<String>,
30    pub overlapping_paths: Vec<String>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct HardLockViolation {
35    pub session_id: String,
36    pub message: String,
37    pub atom_ids: Vec<String>,
38}
39
40impl PrismSignal {
41    pub fn new(
42        session_id: String,
43        agent: Option<String>,
44        scope: Option<String>,
45        mut paths: Vec<String>,
46        assumptions: Vec<String>,
47        decision: Option<String>,
48    ) -> Self {
49        paths.sort();
50        paths.dedup();
51
52        Self {
53            session_id,
54            agent,
55            scope,
56            paths,
57            assumptions,
58            decision,
59            created_unix_seconds: now_unix_seconds(),
60        }
61    }
62}
63
64impl Workspace {
65    pub fn write_prism_signal(&self, signal: &PrismSignal) -> Result<()> {
66        self.ensure_layout()?;
67        let signal_path = self.prism_signal_path(&signal.session_id);
68        self.write_json(&signal_path, signal)
69    }
70
71    pub fn remove_prism_signal(&self, session_id: &str) -> Result<bool> {
72        self.ensure_layout()?;
73        let signal_path = self.prism_signal_path(session_id);
74        if !signal_path.exists() {
75            return Ok(false);
76        }
77
78        fs::remove_file(signal_path)?;
79        Ok(true)
80    }
81
82    pub fn load_prism_signals(&self) -> Result<Vec<PrismSignal>> {
83        self.ensure_layout()?;
84
85        let mut signals = Vec::new();
86        for entry in fs::read_dir(self.prism_dir())? {
87            let entry = entry?;
88            let path = entry.path();
89            if path.extension().and_then(|value| value.to_str()) != Some("signal") {
90                continue;
91            }
92
93            let signal: PrismSignal = self.read_json(&path)?;
94            signals.push(signal);
95        }
96
97        signals.sort_by(|left, right| left.session_id.cmp(&right.session_id));
98        Ok(signals)
99    }
100
101    pub fn scan_prism_conflicts(&self, current_signal: &PrismSignal) -> Result<Vec<PrismConflict>> {
102        let mut conflicts = Vec::new();
103        let now = now_unix_seconds();
104
105        for signal in self.load_prism_signals()? {
106            if signal.session_id == current_signal.session_id {
107                continue;
108            }
109
110            if is_stale_signal(&signal, now, PRISM_STALE_TTL_SECONDS) {
111                continue;
112            }
113
114            let overlapping_paths = current_signal
115                .paths
116                .iter()
117                .flat_map(|current_path| {
118                    signal
119                        .paths
120                        .iter()
121                        .filter(move |other_path| patterns_may_overlap(current_path, other_path))
122                        .cloned()
123                })
124                .collect::<Vec<_>>();
125
126            if overlapping_paths.is_empty() {
127                continue;
128            }
129
130            conflicts.push(PrismConflict {
131                session_id: signal.session_id,
132                agent: signal.agent,
133                scope: signal.scope,
134                decision: signal.decision,
135                overlapping_paths,
136            });
137        }
138
139        Ok(conflicts)
140    }
141
142    pub fn scan_prism_hard_locks(&self, active_atoms: &[LoreAtom]) -> Result<Vec<HardLockViolation>> {
143        let mut violations = Vec::new();
144        let now = now_unix_seconds();
145
146        for signal in self.load_prism_signals()? {
147            if is_stale_signal(&signal, now, PRISM_STALE_TTL_SECONDS) {
148                continue;
149            }
150
151            let Some(decision) = signal.decision.as_deref() else {
152                continue;
153            };
154
155            let overlapping_atoms = active_atoms
156                .iter()
157                .filter(|atom| atom_overlaps_signal(atom, &signal))
158                .filter(|atom| atom.title != decision && atom.body.as_deref() != Some(decision))
159                .collect::<Vec<_>>();
160
161            if overlapping_atoms.is_empty() {
162                continue;
163            }
164
165            violations.push(HardLockViolation {
166                session_id: signal.session_id.clone(),
167                message: format!(
168                    "hard-lock from session {} blocks overlapping active lore",
169                    signal.session_id
170                ),
171                atom_ids: overlapping_atoms
172                    .into_iter()
173                    .map(|atom| atom.id.clone())
174                    .collect(),
175            });
176        }
177
178        Ok(violations)
179    }
180
181    fn prism_signal_path(&self, session_id: &str) -> PathBuf {
182        self.prism_dir().join(format!("{session_id}.signal"))
183    }
184
185    pub fn count_stale_prism_signals(&self, stale_ttl_seconds: u64) -> Result<usize> {
186        let now = now_unix_seconds();
187        Ok(self
188            .load_prism_signals()?
189            .into_iter()
190            .filter(|signal| is_stale_signal(signal, now, stale_ttl_seconds))
191            .count())
192    }
193
194    pub fn prune_stale_prism_signals(&self, stale_ttl_seconds: u64) -> Result<usize> {
195        self.ensure_layout()?;
196        let now = now_unix_seconds();
197        let mut pruned = 0usize;
198
199        for signal in self.load_prism_signals()? {
200            if !is_stale_signal(&signal, now, stale_ttl_seconds) {
201                continue;
202            }
203
204            let path = self.prism_signal_path(&signal.session_id);
205            if path.exists() {
206                fs::remove_file(&path)?;
207                pruned += 1;
208            }
209        }
210
211        Ok(pruned)
212    }
213}
214
215fn patterns_may_overlap(left: &str, right: &str) -> bool {
216    let left = normalize_pattern(left);
217    let right = normalize_pattern(right);
218
219    if left == right {
220        return true;
221    }
222
223    let left_has_glob = left.chars().any(is_glob_char);
224    let right_has_glob = right.chars().any(is_glob_char);
225
226    if !left_has_glob && !right_has_glob {
227        return path_prefix_overlap(&left, &right);
228    }
229
230    let left_prefix = literal_prefix(&left);
231    let right_prefix = literal_prefix(&right);
232
233    if left_prefix.is_empty() || right_prefix.is_empty() {
234        return true;
235    }
236
237    left_prefix == right_prefix || path_prefix_overlap(&left_prefix, &right_prefix)
238}
239
240fn literal_prefix(pattern: &str) -> String {
241    let mut segments = Vec::new();
242    for segment in pattern.split('/') {
243        if segment.chars().any(is_glob_char) {
244            break;
245        }
246        if segment.is_empty() {
247            continue;
248        }
249        segments.push(segment);
250    }
251
252    segments.join("/")
253}
254
255fn path_prefix_overlap(left: &str, right: &str) -> bool {
256    if left == right {
257        return true;
258    }
259
260    let left = left.trim_end_matches('/');
261    let right = right.trim_end_matches('/');
262
263    left.starts_with(&(right.to_string() + "/")) || right.starts_with(&(left.to_string() + "/"))
264}
265
266fn normalize_pattern(pattern: &str) -> String {
267    pattern.replace('\\', "/")
268}
269
270fn is_glob_char(character: char) -> bool {
271    matches!(character, '*' | '?' | '[' | ']')
272}
273
274fn atom_overlaps_signal(atom: &LoreAtom, signal: &PrismSignal) -> bool {
275    let atom_path = atom.path.as_ref().map(|path| path.to_string_lossy().replace('\\', "/"));
276    let atom_scope = atom.scope.as_deref();
277
278    signal.paths.iter().any(|signal_path| {
279        atom_path
280            .as_deref()
281            .map(|path| patterns_may_overlap(path, signal_path))
282            .unwrap_or(false)
283            || atom_scope
284                .zip(signal.scope.as_deref())
285                .map(|(left, right)| left == right)
286                .unwrap_or(false)
287    })
288}
289
290fn is_stale_signal(signal: &PrismSignal, now_unix_seconds: u64, stale_ttl_seconds: u64) -> bool {
291    now_unix_seconds.saturating_sub(signal.created_unix_seconds) > stale_ttl_seconds
292}
293
294fn now_unix_seconds() -> u64 {
295    SystemTime::now()
296        .duration_since(UNIX_EPOCH)
297        .map(|duration| duration.as_secs())
298        .unwrap_or(0)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use uuid::Uuid;
305    use std::path::PathBuf;
306
307    #[test]
308    fn overlapping_signals_are_reported() {
309        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-test-{}", Uuid::new_v4()));
310        fs::create_dir_all(&temp_root).unwrap();
311        let workspace = Workspace::init(&temp_root).unwrap();
312
313        let existing = PrismSignal::new(
314            "session-b".to_string(),
315            Some("agent-b".to_string()),
316            Some("db".to_string()),
317            vec!["src/db/**".to_string()],
318            vec!["Database layer is stable".to_string()],
319            Some("refactor db".to_string()),
320        );
321        workspace.write_prism_signal(&existing).unwrap();
322
323        let current = PrismSignal::new(
324            "session-a".to_string(),
325            Some("agent-a".to_string()),
326            Some("api".to_string()),
327            vec!["src/db/models.rs".to_string()],
328            vec!["Assuming schema compatibility".to_string()],
329            None,
330        );
331
332        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
333        assert_eq!(conflicts.len(), 1);
334        assert_eq!(conflicts[0].session_id, "session-b");
335        assert_eq!(conflicts[0].overlapping_paths, vec!["src/db/**".to_string()]);
336    }
337
338    #[test]
339    fn non_overlapping_signals_are_ignored() {
340        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-test-{}", Uuid::new_v4()));
341        fs::create_dir_all(&temp_root).unwrap();
342        let workspace = Workspace::init(&temp_root).unwrap();
343
344        let existing = PrismSignal::new(
345            "session-b".to_string(),
346            Some("agent-b".to_string()),
347            Some("docs".to_string()),
348            vec!["docs/**/*.md".to_string()],
349            vec![],
350            None,
351        );
352        workspace.write_prism_signal(&existing).unwrap();
353
354        let current = PrismSignal::new(
355            "session-a".to_string(),
356            Some("agent-a".to_string()),
357            Some("src".to_string()),
358            vec!["src/**/*.rs".to_string()],
359            vec![],
360            None,
361        );
362
363        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
364        assert!(conflicts.is_empty());
365    }
366
367    #[test]
368    fn conflicting_decisions_trigger_a_hard_lock() {
369        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-hardlock-{}", Uuid::new_v4()));
370        fs::create_dir_all(&temp_root).unwrap();
371        let workspace = Workspace::init(&temp_root).unwrap();
372
373        let existing = PrismSignal::new(
374            "session-b".to_string(),
375            Some("agent-b".to_string()),
376            Some("db".to_string()),
377            vec!["src/db/**".to_string()],
378            vec![],
379            Some("Use SQLite".to_string()),
380        );
381        workspace.write_prism_signal(&existing).unwrap();
382
383        let atom = LoreAtom {
384            id: Uuid::new_v4().to_string(),
385            kind: super::super::LoreKind::Decision,
386            state: super::super::AtomState::Proposed,
387            title: "Use Postgres".to_string(),
388            body: None,
389            scope: Some("db".to_string()),
390            path: Some(PathBuf::from("src/db/models.rs")),
391            validation_script: None,
392            created_unix_seconds: 0,
393        };
394
395        let violations = workspace.scan_prism_hard_locks(&[atom]).unwrap();
396        assert_eq!(violations.len(), 1);
397        assert_eq!(violations[0].session_id, "session-b");
398    }
399
400    #[test]
401    fn stale_signals_are_ignored_for_conflicts_and_hard_locks() {
402        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-stale-{}", Uuid::new_v4()));
403        fs::create_dir_all(&temp_root).unwrap();
404        let workspace = Workspace::init(&temp_root).unwrap();
405
406        let mut stale = PrismSignal::new(
407            "stale-session".to_string(),
408            Some("agent-stale".to_string()),
409            Some("db".to_string()),
410            vec!["src/db/**".to_string()],
411            vec![],
412            Some("Use SQLite".to_string()),
413        );
414        stale.created_unix_seconds = now_unix_seconds().saturating_sub(PRISM_STALE_TTL_SECONDS + 10);
415        workspace.write_prism_signal(&stale).unwrap();
416
417        let current = PrismSignal::new(
418            "current-session".to_string(),
419            Some("agent-current".to_string()),
420            Some("db".to_string()),
421            vec!["src/db/models.rs".to_string()],
422            vec![],
423            None,
424        );
425
426        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
427        assert!(conflicts.is_empty());
428
429        let atom = LoreAtom {
430            id: Uuid::new_v4().to_string(),
431            kind: super::super::LoreKind::Decision,
432            state: super::super::AtomState::Proposed,
433            title: "Use Postgres".to_string(),
434            body: None,
435            scope: Some("db".to_string()),
436            path: Some(PathBuf::from("src/db/models.rs")),
437            validation_script: None,
438            created_unix_seconds: 0,
439        };
440        let hard_locks = workspace.scan_prism_hard_locks(&[atom]).unwrap();
441        assert!(hard_locks.is_empty());
442    }
443
444    #[test]
445    fn stale_signal_pruning_removes_expired_entries() {
446        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-prune-{}", Uuid::new_v4()));
447        fs::create_dir_all(&temp_root).unwrap();
448        let workspace = Workspace::init(&temp_root).unwrap();
449
450        let mut stale = PrismSignal::new(
451            "stale-session".to_string(),
452            Some("agent-stale".to_string()),
453            None,
454            vec!["src/**".to_string()],
455            vec![],
456            None,
457        );
458        stale.created_unix_seconds = now_unix_seconds().saturating_sub(PRISM_STALE_TTL_SECONDS + 10);
459        workspace.write_prism_signal(&stale).unwrap();
460
461        let fresh = PrismSignal::new(
462            "fresh-session".to_string(),
463            Some("agent-fresh".to_string()),
464            None,
465            vec!["src/**".to_string()],
466            vec![],
467            None,
468        );
469        workspace.write_prism_signal(&fresh).unwrap();
470
471        let stale_before = workspace.count_stale_prism_signals(PRISM_STALE_TTL_SECONDS).unwrap();
472        assert_eq!(stale_before, 1);
473
474        let removed = workspace.prune_stale_prism_signals(PRISM_STALE_TTL_SECONDS).unwrap();
475        assert_eq!(removed, 1);
476
477        let remaining = workspace.load_prism_signals().unwrap();
478        assert_eq!(remaining.len(), 1);
479        assert_eq!(remaining[0].session_id, "fresh-session");
480    }
481
482    #[test]
483    fn removing_prism_signal_deletes_specific_session_file() {
484        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-remove-{}", Uuid::new_v4()));
485        fs::create_dir_all(&temp_root).unwrap();
486        let workspace = Workspace::init(&temp_root).unwrap();
487
488        let signal = PrismSignal::new(
489            "session-a".to_string(),
490            Some("agent-a".to_string()),
491            None,
492            vec!["src/**".to_string()],
493            vec![],
494            Some("ship ui".to_string()),
495        );
496        workspace.write_prism_signal(&signal).unwrap();
497
498        assert!(workspace.remove_prism_signal("session-a").unwrap());
499        assert!(!workspace.remove_prism_signal("session-a").unwrap());
500    }
501}