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