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    /// A literal shell command that validates the atom when preflight runs.
48    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, // Scale base semantic score
313                                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}