1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8use crate::git;
9use crate::lore::prism::PRISM_STALE_TTL_SECONDS;
10use crate::lore::{AtomState, LoreAtom, LoreKind, StateTransitionPreview, Workspace};
11use crate::parser::{detect_scope, ScopeContext};
12
13pub mod transport;
14
15#[cfg(feature = "semantic-search")]
16pub mod semantic;
17
18pub use transport::McpServer;
19
20#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct ContextSnapshot {
22 pub workspace_root: PathBuf,
23 pub file_path: PathBuf,
24 pub cursor_line: Option<usize>,
25 pub scope: Option<ScopeContext>,
26 pub historical_decisions: Vec<HistoricalDecision>,
27 pub relevant_atoms: Vec<LoreAtom>,
28 pub constraints: Vec<String>,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct HistoricalDecision {
33 pub commit_hash: String,
34 pub subject: String,
35 pub trailer_value: String,
36 pub file_path: PathBuf,
37}
38
39#[derive(Clone, Debug)]
40pub struct ProposalRequest {
41 pub file_path: PathBuf,
42 pub cursor_line: Option<usize>,
43 pub kind: LoreKind,
44 pub title: String,
45 pub body: Option<String>,
46 pub scope: Option<String>,
47 pub validation_script: Option<String>,
48}
49
50#[derive(Clone, Debug, Serialize, Deserialize)]
51pub struct ProposalResult {
52 pub atom: LoreAtom,
53 pub scope: Option<ScopeContext>,
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize)]
57pub struct ProposalAutofill {
58 pub title: String,
59 pub body: Option<String>,
60 pub scope: Option<String>,
61 pub filled_fields: Vec<String>,
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct MemorySearchHit {
66 pub atom: LoreAtom,
67 pub source: String,
68 pub score: f64,
69 pub reasons: Vec<String>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct MemorySearchReport {
74 pub workspace_root: PathBuf,
75 pub query: String,
76 pub file_path: Option<PathBuf>,
77 pub cursor_line: Option<usize>,
78 pub results: Vec<MemorySearchHit>,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize)]
82pub struct StateSnapshot {
83 pub workspace_root: PathBuf,
84 pub generated_unix_seconds: u64,
85 pub state_checksum: String,
86 pub total_atoms: usize,
87 pub draft_atoms: usize,
88 pub proposed_atoms: usize,
89 pub accepted_atoms: usize,
90 pub deprecated_atoms: usize,
91 pub accepted_records: usize,
92 pub lore_refs: usize,
93}
94
95#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case")]
97pub enum PreflightSeverity {
98 Block,
99 Warn,
100 Info,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize)]
104pub struct PreflightIssue {
105 pub severity: PreflightSeverity,
106 pub code: String,
107 pub message: String,
108 pub atom_ids: Vec<String>,
109}
110
111#[derive(Clone, Debug, Serialize, Deserialize)]
112pub struct MemoryPreflightReport {
113 pub workspace_root: PathBuf,
114 pub operation: String,
115 pub generated_unix_seconds: u64,
116 pub state_checksum: String,
117 pub can_proceed: bool,
118 pub issues: Vec<PreflightIssue>,
119}
120
121#[derive(Clone, Debug)]
122pub struct McpService {
123 workspace_hint: PathBuf,
124}
125
126impl McpService {
127 pub fn new(workspace_hint: impl AsRef<Path>) -> Self {
128 Self {
129 workspace_hint: workspace_hint.as_ref().to_path_buf(),
130 }
131 }
132
133 pub fn context(&self, file_path: impl AsRef<Path>, cursor_line: Option<usize>) -> Result<ContextSnapshot> {
134 let workspace = Workspace::discover(&self.workspace_hint)?;
135 let file_path = file_path.as_ref().to_path_buf();
136 let scope = detect_scope(&file_path, cursor_line).ok();
137 let repository_root = git::discover_repository(&workspace.root()).ok();
138 let state = workspace.load_state()?;
139 let relevant_atoms = relevant_atoms(&state.atoms, &file_path, scope.as_ref());
140 let historical_decisions = repository_root
141 .as_ref()
142 .map(|root| git::collect_recent_decisions_for_path(root, &file_path, 5))
143 .transpose()?
144 .unwrap_or_default()
145 .into_iter()
146 .map(|decision| HistoricalDecision {
147 commit_hash: decision.commit_hash,
148 subject: decision.subject,
149 trailer_value: decision.trailer.value,
150 file_path: decision.file_path,
151 })
152 .collect::<Vec<_>>();
153
154 let mut constraints = historical_decisions
155 .iter()
156 .map(|decision| format!("Decision [{}]: {}", decision.commit_hash, decision.trailer_value))
157 .collect::<Vec<_>>();
158 constraints.extend(relevant_atoms.iter().map(render_constraint));
159
160 Ok(ContextSnapshot {
161 workspace_root: workspace.root().to_path_buf(),
162 file_path,
163 cursor_line,
164 scope,
165 historical_decisions,
166 relevant_atoms,
167 constraints,
168 })
169 }
170
171 pub fn propose(&self, request: ProposalRequest) -> Result<ProposalResult> {
172 let workspace = Workspace::discover(&self.workspace_hint)?;
173 let scope = detect_scope(&request.file_path, request.cursor_line).ok();
174 let scope_name = request
175 .scope
176 .or_else(|| scope.as_ref().map(|value| value.name.clone()));
177
178 let atom = LoreAtom::new(
179 request.kind,
180 AtomState::Proposed,
181 request.title,
182 request.body,
183 scope_name,
184 Some(request.file_path.clone()),
185 )
186 .with_validation_script(request.validation_script);
187
188 workspace.record_atom(atom.clone())?;
189
190 Ok(ProposalResult { atom, scope })
191 }
192
193 pub fn autofill_proposal(
194 &self,
195 file_path: impl AsRef<Path>,
196 cursor_line: Option<usize>,
197 kind: LoreKind,
198 title: Option<String>,
199 body: Option<String>,
200 scope: Option<String>,
201 ) -> Result<ProposalAutofill> {
202 let file_path = file_path.as_ref().to_path_buf();
203 let detected_scope = detect_scope(&file_path, cursor_line).ok();
204 let has_explicit_scope = scope
205 .as_deref()
206 .map(str::trim)
207 .map(|value| !value.is_empty())
208 .unwrap_or(false);
209 let scope_name = scope
210 .filter(|value| !value.trim().is_empty())
211 .or_else(|| detected_scope.as_ref().map(|value| value.name.clone()));
212
213 let mut filled_fields = Vec::new();
214
215 let resolved_title = match title
216 .as_deref()
217 .map(str::trim)
218 .filter(|value| !value.is_empty())
219 {
220 Some(existing) => existing.to_string(),
221 None => {
222 filled_fields.push("title".to_string());
223 let anchor = scope_name
224 .clone()
225 .unwrap_or_else(|| file_path.display().to_string());
226 format!("{} for {}", kind_label(&kind), anchor)
227 }
228 };
229
230 let resolved_body = match body
231 .as_deref()
232 .map(str::trim)
233 .filter(|value| !value.is_empty())
234 {
235 Some(existing) => Some(existing.to_string()),
236 None => {
237 filled_fields.push("body".to_string());
238 let location = scope_name
239 .clone()
240 .unwrap_or_else(|| file_path.display().to_string());
241 Some(format!(
242 "Autofilled rationale for {} at {}. Update with implementation-specific details before commit.",
243 kind_label(&kind).to_lowercase(),
244 location
245 ))
246 }
247 };
248
249 if scope_name.is_some() && !has_explicit_scope {
250 filled_fields.push("scope".to_string());
251 }
252
253 Ok(ProposalAutofill {
254 title: resolved_title,
255 body: resolved_body,
256 scope: scope_name,
257 filled_fields,
258 })
259 }
260
261 pub fn memory_search(
262 &self,
263 query: impl AsRef<str>,
264 file_path: Option<PathBuf>,
265 cursor_line: Option<usize>,
266 limit: usize,
267 ) -> Result<MemorySearchReport> {
268 let query = query.as_ref().trim().to_string();
269 if query.is_empty() {
270 return Err(anyhow::anyhow!("query must not be empty"));
271 }
272
273 let workspace = Workspace::discover(&self.workspace_hint)?;
274 let state = workspace.load_state()?;
275 let accepted = workspace.load_accepted_atoms()?;
276 let scope = file_path
277 .as_ref()
278 .and_then(|path| detect_scope(path, cursor_line).ok());
279
280 let query_tokens = tokenize(&query);
281 let query_lower = query.to_ascii_lowercase();
282 let newest_timestamp = state
283 .atoms
284 .iter()
285 .map(|atom| atom.created_unix_seconds)
286 .chain(accepted.iter().map(|record| record.atom.created_unix_seconds))
287 .max()
288 .unwrap_or(0);
289
290 let mut candidates = BTreeMap::<String, (LoreAtom, String)>::new();
291 for atom in state.atoms {
292 candidates.insert(atom.id.clone(), (atom, "active".to_string()));
293 }
294 for record in accepted {
295 candidates
296 .entry(record.atom.id.clone())
297 .or_insert((record.atom, "accepted_archive".to_string()));
298 }
299
300 let mut hits = Vec::new();
301
302 #[cfg(feature = "semantic-search")]
303 {
304 if semantic::index_exists(workspace.root()) {
305 if let Ok(semantic_results) = semantic::search(workspace.root(), &query, limit.max(5) * 2) {
306 for (id, base_score, source) in semantic_results {
307 if let Some((atom, _)) = candidates.get(&id) {
308 let mut hit = MemorySearchHit {
309 atom: atom.clone(),
310 source,
311 score: base_score * 12.0, reasons: vec![format!("semantic:{:.2}", base_score)],
313 };
314
315 let state_bonus = match atom.state {
316 AtomState::Accepted => 12.0,
317 AtomState::Proposed => 8.0,
318 AtomState::Draft => 4.0,
319 AtomState::Deprecated => -3.0,
320 };
321 hit.score += state_bonus;
322 hit.reasons.push(format!("state:{:?}", atom.state));
323
324 if newest_timestamp > 0 && atom.created_unix_seconds > 0 {
325 let normalized = (atom.created_unix_seconds as f64 / newest_timestamp as f64).min(1.0);
326 let recency_bonus = normalized * 6.0;
327 hit.score += recency_bonus;
328 hit.reasons.push(format!("recency:{:.2}", recency_bonus));
329 }
330
331 if let Some(target_path) = file_path.as_ref() {
332 if atom.path.as_ref() == Some(target_path) {
333 hit.score += 10.0;
334 hit.reasons.push("path:exact".to_string());
335 } else if let (Some(atom_path), Some(parent)) = (atom.path.as_ref(), target_path.parent()) {
336 if atom_path.starts_with(parent) {
337 hit.score += 4.0;
338 hit.reasons.push("path:near".to_string());
339 }
340 }
341 }
342
343 if let Some(scope_hint) = scope.as_ref() {
344 if atom.scope.as_deref() == Some(scope_hint.name.as_str()) {
345 hit.score += 6.0;
346 hit.reasons.push("scope:exact".to_string());
347 }
348 }
349
350 hits.push(hit);
351 }
352 }
353 }
354 }
355 }
356
357 if hits.is_empty() {
358 hits = candidates
359 .into_values()
360 .filter_map(|(atom, source)| {
361 score_memory_hit(
362 &atom,
363 &source,
364 &query_lower,
365 &query_tokens,
366 file_path.as_ref(),
367 scope.as_ref(),
368 newest_timestamp,
369 )
370 })
371 .collect::<Vec<_>>();
372 }
373
374 hits.sort_by(|left, right| {
375 right
376 .score
377 .partial_cmp(&left.score)
378 .unwrap_or(std::cmp::Ordering::Equal)
379 .then(left.atom.id.cmp(&right.atom.id))
380 });
381 hits.truncate(limit.max(1));
382
383 Ok(MemorySearchReport {
384 workspace_root: workspace.root().to_path_buf(),
385 query,
386 file_path,
387 cursor_line,
388 results: hits,
389 })
390 }
391
392 pub fn state_transition_preview(
393 &self,
394 atom_id: impl AsRef<str>,
395 target_state: AtomState,
396 ) -> Result<StateTransitionPreview> {
397 let workspace = Workspace::discover(&self.workspace_hint)?;
398 workspace.preview_state_transition(atom_id.as_ref(), target_state)
399 }
400
401 pub fn state_snapshot(&self) -> Result<StateSnapshot> {
402 let workspace = Workspace::discover(&self.workspace_hint)?;
403 let state = workspace.load_state()?;
404 let encoded_state = serde_json::to_vec(&state)?;
405 let state_checksum = fnv1a_hex(&encoded_state);
406
407 let mut draft_atoms = 0usize;
408 let mut proposed_atoms = 0usize;
409 let mut accepted_atoms = 0usize;
410 let mut deprecated_atoms = 0usize;
411
412 for atom in &state.atoms {
413 match atom.state {
414 AtomState::Draft => draft_atoms += 1,
415 AtomState::Proposed => proposed_atoms += 1,
416 AtomState::Accepted => accepted_atoms += 1,
417 AtomState::Deprecated => deprecated_atoms += 1,
418 }
419 }
420
421 let accepted_records = workspace.load_accepted_atoms()?.len();
422 let lore_refs = git::discover_repository(workspace.root())
423 .ok()
424 .and_then(|root| git::list_lore_refs(&root).ok().map(|refs| refs.len()))
425 .unwrap_or(0);
426
427 Ok(StateSnapshot {
428 workspace_root: workspace.root().to_path_buf(),
429 generated_unix_seconds: now_unix_seconds(),
430 state_checksum,
431 total_atoms: state.atoms.len(),
432 draft_atoms,
433 proposed_atoms,
434 accepted_atoms,
435 deprecated_atoms,
436 accepted_records,
437 lore_refs,
438 })
439 }
440
441 pub fn memory_preflight(&self, operation: impl AsRef<str>) -> Result<MemoryPreflightReport> {
442 let operation = operation.as_ref().to_string();
443 let workspace = Workspace::discover(&self.workspace_hint)?;
444 let state = workspace.load_state()?;
445 let snapshot = self.state_snapshot()?;
446 let mut issues = Vec::new();
447
448 let mut duplicate_counter = BTreeMap::<String, usize>::new();
449 for atom in &state.atoms {
450 *duplicate_counter.entry(atom.id.clone()).or_insert(0) += 1;
451 }
452
453 let duplicate_ids = duplicate_counter
454 .into_iter()
455 .filter_map(|(atom_id, count)| (count > 1).then_some(atom_id))
456 .collect::<Vec<_>>();
457
458 if !duplicate_ids.is_empty() {
459 issues.push(PreflightIssue {
460 severity: PreflightSeverity::Block,
461 code: "duplicate_atom_ids".to_string(),
462 message: "duplicate atom ids detected in active state; run reconciliation before continuing"
463 .to_string(),
464 atom_ids: duplicate_ids,
465 });
466 }
467
468 for issue in workspace.sanitize_report()? {
469 issues.push(PreflightIssue {
470 severity: PreflightSeverity::Block,
471 code: "sanitize_sensitive_content".to_string(),
472 message: format!(
473 "sensitive content in {}.{}: {}",
474 issue.atom_id, issue.field, issue.reason
475 ),
476 atom_ids: vec![issue.atom_id],
477 });
478 }
479
480 for violation in workspace.scan_prism_hard_locks(&state.atoms)? {
481 issues.push(PreflightIssue {
482 severity: PreflightSeverity::Block,
483 code: "prism_hard_lock".to_string(),
484 message: violation.message,
485 atom_ids: violation.atom_ids,
486 });
487 }
488
489 let stale_signal_count = workspace.count_stale_prism_signals(PRISM_STALE_TTL_SECONDS)?;
490 if stale_signal_count > 0 {
491 issues.push(PreflightIssue {
492 severity: PreflightSeverity::Warn,
493 code: "prism_stale_signals".to_string(),
494 message: format!(
495 "{} stale PRISM signal(s) detected; consider cleanup",
496 stale_signal_count
497 ),
498 atom_ids: Vec::new(),
499 });
500 }
501
502 for issue in workspace.validation_report()? {
503 issues.push(PreflightIssue {
504 severity: PreflightSeverity::Block,
505 code: "validation_script_failed".to_string(),
506 message: format!("{} ({})", issue.reason, issue.command),
507 atom_ids: vec![issue.atom_id],
508 });
509 }
510
511 let entropy = workspace.entropy_report()?;
512 if !entropy.contradictions.is_empty() {
513 issues.push(PreflightIssue {
514 severity: PreflightSeverity::Warn,
515 code: "entropy_contradictions".to_string(),
516 message: format!(
517 "{} contradiction(s) detected in active lore",
518 entropy.contradictions.len()
519 ),
520 atom_ids: Vec::new(),
521 });
522 }
523
524 if entropy.score >= 70 {
525 issues.push(PreflightIssue {
526 severity: PreflightSeverity::Warn,
527 code: "entropy_high".to_string(),
528 message: format!("entropy score is high ({}/100)", entropy.score),
529 atom_ids: Vec::new(),
530 });
531 } else if entropy.score >= 35 {
532 issues.push(PreflightIssue {
533 severity: PreflightSeverity::Info,
534 code: "entropy_moderate".to_string(),
535 message: format!("entropy score is moderate ({}/100)", entropy.score),
536 atom_ids: Vec::new(),
537 });
538 }
539
540 if operation == "commit" && state.atoms.is_empty() {
541 issues.push(PreflightIssue {
542 severity: PreflightSeverity::Warn,
543 code: "empty_lore_state".to_string(),
544 message: "no active lore atoms recorded for this commit".to_string(),
545 atom_ids: Vec::new(),
546 });
547 }
548
549 if issues.is_empty() {
550 issues.push(PreflightIssue {
551 severity: PreflightSeverity::Info,
552 code: "preflight_clean".to_string(),
553 message: "no blocking issues detected".to_string(),
554 atom_ids: Vec::new(),
555 });
556 }
557
558 let can_proceed = !issues
559 .iter()
560 .any(|issue| issue.severity == PreflightSeverity::Block);
561
562 Ok(MemoryPreflightReport {
563 workspace_root: workspace.root().to_path_buf(),
564 operation,
565 generated_unix_seconds: now_unix_seconds(),
566 state_checksum: snapshot.state_checksum,
567 can_proceed,
568 issues,
569 })
570 }
571}
572
573fn relevant_atoms<'a>(atoms: &'a [LoreAtom], file_path: &Path, scope: Option<&ScopeContext>) -> Vec<LoreAtom> {
574 let scope_name = scope.map(|value| value.name.as_str());
575 atoms
576 .iter()
577 .rev()
578 .filter(|atom| {
579 let path_matches = atom.path.as_ref().map(|path| path == file_path).unwrap_or(false);
580 let scope_matches = atom
581 .scope
582 .as_deref()
583 .map(|value| scope_name.map(|scope_name| value == scope_name || value.contains(scope_name)).unwrap_or(false))
584 .unwrap_or(false);
585
586 path_matches || scope_matches
587 })
588 .take(5)
589 .cloned()
590 .collect()
591}
592
593fn render_constraint(atom: &LoreAtom) -> String {
594 match atom.kind {
595 LoreKind::Decision => format!("Decision [{}]: {}", atom.id, atom.title),
596 LoreKind::Assumption => format!("Assumption [{}]: {}", atom.id, atom.title),
597 LoreKind::OpenQuestion => format!("Open question [{}]: {}", atom.id, atom.title),
598 LoreKind::Signal => format!("Signal [{}]: {}", atom.id, atom.title),
599 }
600}
601
602fn kind_label(kind: &LoreKind) -> &'static str {
603 match kind {
604 LoreKind::Decision => "Decision",
605 LoreKind::Assumption => "Assumption",
606 LoreKind::OpenQuestion => "Open question",
607 LoreKind::Signal => "Signal",
608 }
609}
610
611fn score_memory_hit(
612 atom: &LoreAtom,
613 source: &str,
614 query_lower: &str,
615 query_tokens: &[String],
616 target_file_path: Option<&PathBuf>,
617 target_scope: Option<&ScopeContext>,
618 newest_timestamp: u64,
619) -> Option<MemorySearchHit> {
620 let title = atom.title.to_ascii_lowercase();
621 let body = atom
622 .body
623 .as_deref()
624 .unwrap_or_default()
625 .to_ascii_lowercase();
626 let scope = atom
627 .scope
628 .as_deref()
629 .unwrap_or_default()
630 .to_ascii_lowercase();
631 let path = atom
632 .path
633 .as_ref()
634 .map(|value| value.to_string_lossy().to_string())
635 .unwrap_or_default()
636 .to_ascii_lowercase();
637
638 let mut score = 0.0f64;
639 let mut reasons = Vec::new();
640
641 let lexical_hits = query_tokens
642 .iter()
643 .filter(|token| {
644 title.contains(token.as_str())
645 || body.contains(token.as_str())
646 || scope.contains(token.as_str())
647 || path.contains(token.as_str())
648 })
649 .count();
650
651 if lexical_hits == 0 {
652 let joined = format!("{} {} {} {}", title, body, scope, path);
653 if !joined.contains(query_lower) {
654 return None;
655 }
656 }
657
658 if lexical_hits > 0 {
659 let lexical_score = lexical_hits as f64 * 8.0;
660 score += lexical_score;
661 reasons.push(format!("lexical:{lexical_hits}"));
662 } else {
663 score += 6.0;
664 reasons.push("lexical:fallback-substring".to_string());
665 }
666
667 let state_bonus = match atom.state {
668 AtomState::Accepted => 12.0,
669 AtomState::Proposed => 8.0,
670 AtomState::Draft => 4.0,
671 AtomState::Deprecated => -3.0,
672 };
673 score += state_bonus;
674 reasons.push(format!("state:{:?}", atom.state));
675
676 if newest_timestamp > 0 && atom.created_unix_seconds > 0 {
677 let normalized = (atom.created_unix_seconds as f64 / newest_timestamp as f64).min(1.0);
678 let recency_bonus = normalized * 6.0;
679 score += recency_bonus;
680 reasons.push(format!("recency:{:.2}", recency_bonus));
681 }
682
683 if let Some(target_path) = target_file_path {
684 if atom.path.as_ref() == Some(target_path) {
685 score += 10.0;
686 reasons.push("path:exact".to_string());
687 } else if let (Some(atom_path), Some(parent)) = (atom.path.as_ref(), target_path.parent()) {
688 if atom_path.starts_with(parent) {
689 score += 4.0;
690 reasons.push("path:near".to_string());
691 }
692 }
693 }
694
695 if let Some(scope_hint) = target_scope {
696 if atom.scope.as_deref() == Some(scope_hint.name.as_str()) {
697 score += 6.0;
698 reasons.push("scope:exact".to_string());
699 }
700 }
701
702 Some(MemorySearchHit {
703 atom: atom.clone(),
704 source: source.to_string(),
705 score,
706 reasons,
707 })
708}
709
710fn tokenize(input: &str) -> Vec<String> {
711 input
712 .to_ascii_lowercase()
713 .split(|character: char| !character.is_alphanumeric())
714 .filter(|token| !token.trim().is_empty())
715 .map(|token| token.to_string())
716 .collect()
717}
718
719fn fnv1a_hex(bytes: &[u8]) -> String {
720 const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
721 const PRIME: u64 = 0x00000100000001b3;
722
723 let mut hash = OFFSET_BASIS;
724 for byte in bytes {
725 hash ^= u64::from(*byte);
726 hash = hash.wrapping_mul(PRIME);
727 }
728
729 format!("{hash:016x}")
730}
731
732fn now_unix_seconds() -> u64 {
733 SystemTime::now()
734 .duration_since(UNIX_EPOCH)
735 .map(|duration| duration.as_secs())
736 .unwrap_or(0)
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::lore::WorkspaceState;
743 use crate::parser::ScopeKind;
744 use std::fs;
745 use uuid::Uuid;
746
747 #[test]
748 fn context_snapshot_includes_relevant_atoms() {
749 let root = std::env::temp_dir().join(format!("git-lore-mcp-test-{}", Uuid::new_v4()));
750 fs::create_dir_all(&root).unwrap();
751 let workspace = Workspace::init(&root).unwrap();
752 let source = root.join("src.rs");
753 fs::write(
754 &source,
755 r#"
756pub fn compute() {
757 let value = 1;
758}
759"#,
760 )
761 .unwrap();
762
763 let atom = LoreAtom::new(
764 LoreKind::Decision,
765 AtomState::Accepted,
766 "Keep compute synchronous".to_string(),
767 None,
768 Some("compute".to_string()),
769 Some(source.clone()),
770 );
771 workspace.record_atom(atom).unwrap();
772
773 let service = McpService::new(&root);
774 let snapshot = service.context(&source, Some(2)).unwrap();
775
776 assert_eq!(snapshot.relevant_atoms.len(), 1);
777 assert_eq!(snapshot.constraints[0], "Decision [".to_string() + &snapshot.relevant_atoms[0].id + "]: Keep compute synchronous");
778 assert!(snapshot.scope.is_some());
779 }
780
781 #[test]
782 fn propose_records_a_proposed_atom() {
783 let root = std::env::temp_dir().join(format!("git-lore-mcp-test-{}", Uuid::new_v4()));
784 fs::create_dir_all(&root).unwrap();
785 let workspace = Workspace::init(&root).unwrap();
786 let source = root.join("src.rs");
787 fs::write(&source, "pub fn compute() {}\n").unwrap();
788
789 let service = McpService::new(&root);
790 let result = service
791 .propose(ProposalRequest {
792 file_path: source.clone(),
793 cursor_line: Some(1),
794 kind: LoreKind::Decision,
795 title: "Use tree-sitter scope context".to_string(),
796 body: Some("Capture active function context before edits".to_string()),
797 scope: None,
798 validation_script: None,
799 })
800 .unwrap();
801
802 assert_eq!(result.atom.state, AtomState::Proposed);
803 assert_eq!(result.atom.path.as_ref(), Some(&source));
804
805 let state = workspace.load_state().unwrap();
806 assert_eq!(state.atoms.len(), 1);
807 }
808
809 #[test]
810 fn state_snapshot_reports_atom_counts() {
811 let root = std::env::temp_dir().join(format!("git-lore-mcp-snapshot-test-{}", Uuid::new_v4()));
812 fs::create_dir_all(&root).unwrap();
813 Workspace::init(&root).unwrap();
814
815 let service = McpService::new(&root);
816 let source = root.join("lib.rs");
817 fs::write(&source, "pub fn run() {}\n").unwrap();
818
819 service
820 .propose(ProposalRequest {
821 file_path: source,
822 cursor_line: Some(1),
823 kind: LoreKind::Decision,
824 title: "Capture snapshot state".to_string(),
825 body: None,
826 scope: None,
827 validation_script: None,
828 })
829 .unwrap();
830
831 let snapshot = service.state_snapshot().unwrap();
832 assert_eq!(snapshot.total_atoms, 1);
833 assert_eq!(snapshot.proposed_atoms, 1);
834 assert!(!snapshot.state_checksum.is_empty());
835 }
836
837 #[test]
838 fn memory_preflight_blocks_duplicate_atom_ids() {
839 let root = std::env::temp_dir().join(format!("git-lore-mcp-preflight-test-{}", Uuid::new_v4()));
840 fs::create_dir_all(&root).unwrap();
841 let workspace = Workspace::init(&root).unwrap();
842
843 let atom = LoreAtom::new(
844 LoreKind::Decision,
845 AtomState::Proposed,
846 "One decision".to_string(),
847 None,
848 Some("scope".to_string()),
849 Some(PathBuf::from("src/lib.rs")),
850 );
851
852 let duplicated = LoreAtom {
853 title: "Duplicated id decision".to_string(),
854 ..atom.clone()
855 };
856
857 workspace
858 .set_state(&WorkspaceState {
859 version: 1,
860 atoms: vec![atom, duplicated],
861 })
862 .unwrap();
863
864 let service = McpService::new(&root);
865 let report = service.memory_preflight("edit").unwrap();
866 assert!(!report.can_proceed);
867 assert!(report
868 .issues
869 .iter()
870 .any(|issue| issue.code == "duplicate_atom_ids"));
871 }
872
873 #[test]
874 fn memory_preflight_flags_sanitization_issues() {
875 let root = std::env::temp_dir().join(format!("git-lore-mcp-preflight-sanitize-test-{}", Uuid::new_v4()));
876 fs::create_dir_all(&root).unwrap();
877 let workspace = Workspace::init(&root).unwrap();
878
879 let sensitive_atom = LoreAtom {
880 id: Uuid::new_v4().to_string(),
881 kind: LoreKind::Decision,
882 state: AtomState::Proposed,
883 title: "Rotate API token for service".to_string(),
884 body: None,
885 scope: Some("auth".to_string()),
886 path: Some(PathBuf::from("src/auth.rs")),
887 validation_script: None,
888 created_unix_seconds: 1,
889 };
890
891 workspace
892 .set_state(&WorkspaceState {
893 version: 1,
894 atoms: vec![sensitive_atom],
895 })
896 .unwrap();
897
898 let service = McpService::new(&root);
899 let report = service.memory_preflight("edit").unwrap();
900 assert!(!report.can_proceed);
901 assert!(report
902 .issues
903 .iter()
904 .any(|issue| issue.code == "sanitize_sensitive_content"));
905 }
906
907 #[test]
908 fn memory_search_returns_ranked_results() {
909 let root = std::env::temp_dir().join(format!("git-lore-mcp-search-test-{}", Uuid::new_v4()));
910 fs::create_dir_all(&root).unwrap();
911 let workspace = Workspace::init(&root).unwrap();
912
913 let strong_match = LoreAtom {
914 id: Uuid::new_v4().to_string(),
915 kind: LoreKind::Decision,
916 state: AtomState::Accepted,
917 title: "Use sqlite cache for local mode".to_string(),
918 body: Some("Cache hit latency target".to_string()),
919 scope: Some("cache".to_string()),
920 path: Some(PathBuf::from("src/cache.rs")),
921 validation_script: None,
922 created_unix_seconds: 100,
923 };
924 let weak_match = LoreAtom {
925 id: Uuid::new_v4().to_string(),
926 kind: LoreKind::Decision,
927 state: AtomState::Draft,
928 title: "Investigate distributed storage".to_string(),
929 body: Some("Potential future work".to_string()),
930 scope: Some("storage".to_string()),
931 path: Some(PathBuf::from("src/storage.rs")),
932 validation_script: None,
933 created_unix_seconds: 10,
934 };
935
936 workspace
937 .set_state(&crate::lore::WorkspaceState {
938 version: 1,
939 atoms: vec![weak_match, strong_match],
940 })
941 .unwrap();
942
943 let service = McpService::new(&root);
944 let report = service
945 .memory_search("sqlite cache", Some(PathBuf::from("src/cache.rs")), None, 5)
946 .unwrap();
947
948 assert!(!report.results.is_empty());
949 assert!(report.results[0].atom.title.contains("sqlite cache"));
950 }
951
952 #[test]
953 fn state_transition_preview_is_exposed_from_service() {
954 let root = std::env::temp_dir().join(format!("git-lore-mcp-transition-preview-test-{}", Uuid::new_v4()));
955 fs::create_dir_all(&root).unwrap();
956 let workspace = Workspace::init(&root).unwrap();
957
958 let atom = LoreAtom::new(
959 LoreKind::Decision,
960 AtomState::Proposed,
961 "Keep parser deterministic".to_string(),
962 None,
963 Some("parser".to_string()),
964 Some(PathBuf::from("src/parser/mod.rs")),
965 );
966 let atom_id = atom.id.clone();
967 workspace.record_atom(atom).unwrap();
968
969 let service = McpService::new(&root);
970 let preview = service
971 .state_transition_preview(&atom_id, AtomState::Accepted)
972 .unwrap();
973
974 assert!(preview.allowed);
975 assert_eq!(preview.code, "state_transition_allowed");
976 }
977
978 #[test]
979 fn autofill_proposal_fills_missing_fields() {
980 let root = std::env::temp_dir().join(format!("git-lore-mcp-autofill-test-{}", Uuid::new_v4()));
981 fs::create_dir_all(&root).unwrap();
982 Workspace::init(&root).unwrap();
983 let source = root.join("src.rs");
984 fs::write(&source, "pub fn compute() {}\n").unwrap();
985
986 let service = McpService::new(&root);
987 let autofilled = service
988 .autofill_proposal(
989 &source,
990 Some(1),
991 LoreKind::Decision,
992 None,
993 None,
994 None,
995 )
996 .unwrap();
997
998 assert!(!autofilled.title.trim().is_empty());
999 assert!(autofilled.body.is_some());
1000 assert!(autofilled.filled_fields.contains(&"title".to_string()));
1001 assert!(autofilled.filled_fields.contains(&"body".to_string()));
1002 }
1003
1004 #[test]
1005 fn context_snapshot_uses_javascript_scope_detection() {
1006 let root = std::env::temp_dir().join(format!("git-lore-mcp-js-test-{}", Uuid::new_v4()));
1007 fs::create_dir_all(&root).unwrap();
1008 let workspace = Workspace::init(&root).unwrap();
1009 let source = root.join("index.js");
1010 fs::write(
1011 &source,
1012 r#"
1013function outer() {
1014 function inner() {
1015 return 1;
1016 }
1017}
1018"#,
1019 )
1020 .unwrap();
1021
1022 let atom = LoreAtom::new(
1023 LoreKind::Decision,
1024 AtomState::Accepted,
1025 "Keep inner synchronous".to_string(),
1026 None,
1027 Some("inner".to_string()),
1028 Some(source.clone()),
1029 );
1030 workspace.record_atom(atom).unwrap();
1031
1032 let service = McpService::new(&root);
1033 let snapshot = service.context(&source, Some(3)).unwrap();
1034
1035 let scope = snapshot.scope.expect("expected javascript scope");
1036 assert_eq!(scope.language, "javascript");
1037 assert_eq!(scope.kind, ScopeKind::Function);
1038 assert_eq!(scope.name, "inner");
1039 assert_eq!(snapshot.relevant_atoms.len(), 1);
1040 }
1041
1042 #[test]
1043 fn propose_records_a_typescript_atom_with_detected_scope() {
1044 let root = std::env::temp_dir().join(format!("git-lore-mcp-ts-test-{}", Uuid::new_v4()));
1045 fs::create_dir_all(&root).unwrap();
1046 let workspace = Workspace::init(&root).unwrap();
1047 let source = root.join("service.ts");
1048 fs::write(
1049 &source,
1050 r#"
1051class Service {
1052 run(): void {
1053 return;
1054 }
1055}
1056"#,
1057 )
1058 .unwrap();
1059
1060 let service = McpService::new(&root);
1061 let result = service
1062 .propose(ProposalRequest {
1063 file_path: source.clone(),
1064 cursor_line: Some(3),
1065 kind: LoreKind::Decision,
1066 title: "Keep the class method synchronous".to_string(),
1067 body: Some("The runtime depends on this method staying blocking".to_string()),
1068 scope: None,
1069 validation_script: None,
1070 })
1071 .unwrap();
1072
1073 let scope = result.scope.expect("expected typescript scope");
1074 assert_eq!(scope.language, "typescript");
1075 assert_eq!(scope.kind, ScopeKind::Method);
1076 assert_eq!(scope.name, "run");
1077 assert_eq!(result.atom.state, AtomState::Proposed);
1078 assert_eq!(result.atom.scope.as_deref(), Some("run"));
1079
1080 let state = workspace.load_state().unwrap();
1081 assert_eq!(state.atoms.len(), 1);
1082 }
1083}