Skip to main content

git_lore/mcp/
mod.rs

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, // Scale base semantic score
312                                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}