Skip to main content

lore_cli/mcp/
server.rs

1//! MCP server implementation for Lore.
2//!
3//! Runs an MCP server on stdio transport, exposing Lore tools to
4//! AI coding assistants like Claude Code.
5
6use anyhow::Result;
7use rmcp::{
8    handler::server::wrapper::Parameters,
9    model::{
10        CallToolResult, Content, ErrorCode, ErrorData as McpError, ProtocolVersion,
11        ServerCapabilities, ServerInfo,
12    },
13    tool, tool_handler, tool_router,
14    transport::stdio,
15    ServerHandler, ServiceExt,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20
21use crate::capture::memory::{resolve_project_path, MemoryMirror, CLAUDE_CODE_TOOL};
22use crate::storage::models::{Memory, Message, SearchOptions, Session};
23use crate::storage::Database;
24
25// ============== Tool Parameter Types ==============
26
27/// Parameters for the lore_search tool.
28#[derive(Debug, Deserialize, JsonSchema)]
29pub struct SearchParams {
30    /// The search query text.
31    #[schemars(description = "Text to search for in session messages")]
32    pub query: String,
33
34    /// Maximum number of results to return.
35    #[schemars(description = "Maximum number of results (default: 10)")]
36    pub limit: Option<usize>,
37
38    /// Filter by repository path prefix.
39    #[schemars(description = "Filter by repository path prefix")]
40    pub repo: Option<String>,
41
42    /// Filter by AI tool name (e.g., claude-code, aider).
43    #[schemars(description = "Filter by AI tool name (e.g., claude-code, aider)")]
44    pub tool: Option<String>,
45
46    /// Filter to sessions after this date (ISO 8601 or relative like 7d, 2w, 1m).
47    #[schemars(description = "Filter to sessions after this date (ISO 8601 or 7d, 2w, 1m)")]
48    pub since: Option<String>,
49}
50
51/// Parameters for the lore_get_session tool.
52#[derive(Debug, Deserialize, JsonSchema)]
53pub struct GetSessionParams {
54    /// Session ID (full UUID or prefix).
55    #[schemars(description = "Session ID (full UUID or short prefix like abc123)")]
56    pub session_id: String,
57
58    /// Whether to include full message content.
59    #[schemars(description = "Include full message content (default: true)")]
60    pub include_messages: Option<bool>,
61}
62
63/// Parameters for the lore_list_sessions tool.
64#[derive(Debug, Deserialize, JsonSchema)]
65pub struct ListSessionsParams {
66    /// Maximum number of sessions to return.
67    #[schemars(description = "Maximum number of sessions (default: 10)")]
68    pub limit: Option<usize>,
69
70    /// Filter by repository path prefix.
71    #[schemars(description = "Filter by repository path prefix")]
72    pub repo: Option<String>,
73}
74
75/// Parameters for the lore_get_context tool.
76#[derive(Debug, Deserialize, JsonSchema)]
77pub struct GetContextParams {
78    /// Repository path to get context for.
79    #[schemars(description = "Repository path (defaults to current directory)")]
80    pub repo: Option<String>,
81
82    /// Whether to show detailed info for the most recent session only.
83    #[schemars(description = "Show detailed info for the most recent session only")]
84    pub last: Option<bool>,
85}
86
87/// Parameters for the lore_get_linked_sessions tool.
88#[derive(Debug, Deserialize, JsonSchema)]
89pub struct GetLinkedSessionsParams {
90    /// Git commit SHA (full or prefix).
91    #[schemars(description = "Git commit SHA (full or short prefix)")]
92    pub commit_sha: String,
93}
94
95/// Parameters for the lore_get_memories tool.
96#[derive(Debug, Deserialize, JsonSchema)]
97pub struct GetMemoriesParams {
98    /// Project path to read memories for.
99    #[schemars(
100        description = "Repository path (defaults to the current directory's git top-level)"
101    )]
102    pub project_path: Option<String>,
103}
104
105/// Parameters for the lore_search_memories tool.
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct SearchMemoriesParams {
108    /// The search query text.
109    #[schemars(description = "Text to search for in the project's mirrored memories")]
110    pub query: String,
111
112    /// Project path to search memories within.
113    #[schemars(
114        description = "Repository path (defaults to the current directory's git top-level)"
115    )]
116    pub project_path: Option<String>,
117
118    /// Maximum number of results to return.
119    #[schemars(description = "Maximum number of results (default: 20)")]
120    pub limit: Option<usize>,
121}
122
123// ============== Result Types ==============
124
125/// A session in search results.
126#[derive(Debug, Serialize)]
127pub struct SessionInfo {
128    pub id: String,
129    pub id_short: String,
130    pub tool: String,
131    pub started_at: String,
132    pub message_count: i32,
133    pub working_directory: String,
134    pub git_branch: Option<String>,
135}
136
137/// A search match result.
138#[derive(Debug, Serialize)]
139pub struct SearchMatch {
140    pub session: SessionInfo,
141    pub message_id: String,
142    pub role: String,
143    pub snippet: String,
144    pub timestamp: String,
145}
146
147/// Search results response.
148#[derive(Debug, Serialize)]
149pub struct SearchResponse {
150    pub query: String,
151    pub total_matches: usize,
152    pub matches: Vec<SearchMatch>,
153}
154
155/// A message for session transcript.
156#[derive(Debug, Serialize)]
157pub struct MessageInfo {
158    pub index: i32,
159    pub role: String,
160    pub content: String,
161    pub timestamp: String,
162}
163
164/// Full session details response.
165#[derive(Debug, Serialize)]
166pub struct SessionDetailsResponse {
167    pub session: SessionInfo,
168    pub linked_commits: Vec<String>,
169    pub messages: Option<Vec<MessageInfo>>,
170    pub summary: Option<String>,
171    pub tags: Vec<String>,
172}
173
174/// Context response for a repository.
175#[derive(Debug, Serialize)]
176pub struct ContextResponse {
177    pub working_directory: String,
178    pub sessions: Vec<SessionInfo>,
179    pub recent_messages: Option<Vec<MessageInfo>>,
180}
181
182/// Linked sessions response.
183#[derive(Debug, Serialize)]
184pub struct LinkedSessionsResponse {
185    pub commit_sha: String,
186    pub sessions: Vec<SessionInfo>,
187}
188
189/// A single mirrored memory in a response.
190#[derive(Debug, Serialize)]
191pub struct MemoryInfo {
192    pub name: String,
193    pub description: Option<String>,
194    pub memory_type: Option<String>,
195    pub content: String,
196    pub file_path: String,
197    pub updated_at: String,
198}
199
200/// Response for the lore_get_memories tool.
201#[derive(Debug, Serialize)]
202pub struct MemoriesResponse {
203    pub project_path: String,
204    pub source_tool: String,
205    pub total: usize,
206    pub memories: Vec<MemoryInfo>,
207}
208
209/// Response for the lore_search_memories tool.
210#[derive(Debug, Serialize)]
211pub struct SearchMemoriesResponse {
212    pub query: String,
213    pub project_path: String,
214    pub source_tool: String,
215    pub total: usize,
216    pub memories: Vec<MemoryInfo>,
217}
218
219// ============== Server Implementation ==============
220
221/// The Lore MCP server.
222///
223/// Provides tools for querying Lore session data via MCP.
224///
225/// In rmcp 1.x the `#[tool_handler]` macro resolves the tool router via
226/// `Self::tool_router()` (generated by `#[tool_router]`), so the router no
227/// longer needs to be stored on the struct.
228#[derive(Debug, Clone)]
229pub struct LoreServer;
230
231impl LoreServer {
232    /// Creates a new LoreServer.
233    pub fn new() -> Self {
234        Self
235    }
236}
237
238impl Default for LoreServer {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244/// Creates an McpError from an error message.
245fn mcp_error(message: &str) -> McpError {
246    McpError {
247        code: ErrorCode(-32603),
248        message: Cow::from(message.to_string()),
249        data: None,
250    }
251}
252
253#[tool_router]
254impl LoreServer {
255    /// Search Lore sessions by query text with optional filters.
256    ///
257    /// Searches message content using full-text search. Supports filtering
258    /// by repository, tool, and date range.
259    #[tool(description = "Search Lore session messages for text content")]
260    async fn lore_search(
261        &self,
262        params: Parameters<SearchParams>,
263    ) -> Result<CallToolResult, McpError> {
264        let params = params.0;
265        let result = search_impl(params);
266        match result {
267            Ok(response) => {
268                let json = serde_json::to_string_pretty(&response)
269                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
270                Ok(CallToolResult::success(vec![Content::text(json)]))
271            }
272            Err(e) => Err(mcp_error(&format!("Search failed: {e}"))),
273        }
274    }
275
276    /// Get full details of a Lore session by ID.
277    ///
278    /// Returns session metadata, linked commits, and optionally the full
279    /// message transcript.
280    #[tool(description = "Get full details of a Lore session by ID")]
281    async fn lore_get_session(
282        &self,
283        params: Parameters<GetSessionParams>,
284    ) -> Result<CallToolResult, McpError> {
285        let params = params.0;
286        let result = get_session_impl(params);
287        match result {
288            Ok(response) => {
289                let json = serde_json::to_string_pretty(&response)
290                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
291                Ok(CallToolResult::success(vec![Content::text(json)]))
292            }
293            Err(e) => Err(mcp_error(&format!("Get session failed: {e}"))),
294        }
295    }
296
297    /// List recent Lore sessions.
298    ///
299    /// Returns a list of recent sessions, optionally filtered by repository.
300    #[tool(description = "List recent Lore sessions")]
301    async fn lore_list_sessions(
302        &self,
303        params: Parameters<ListSessionsParams>,
304    ) -> Result<CallToolResult, McpError> {
305        let params = params.0;
306        let result = list_sessions_impl(params);
307        match result {
308            Ok(sessions) => {
309                let json = serde_json::to_string_pretty(&sessions)
310                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
311                Ok(CallToolResult::success(vec![Content::text(json)]))
312            }
313            Err(e) => Err(mcp_error(&format!("List sessions failed: {e}"))),
314        }
315    }
316
317    /// Get recent session context for a repository.
318    ///
319    /// Provides a summary of recent sessions for quick orientation.
320    #[tool(description = "Get recent session context for a repository")]
321    async fn lore_get_context(
322        &self,
323        params: Parameters<GetContextParams>,
324    ) -> Result<CallToolResult, McpError> {
325        let params = params.0;
326        let result = get_context_impl(params);
327        match result {
328            Ok(response) => {
329                let json = serde_json::to_string_pretty(&response)
330                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
331                Ok(CallToolResult::success(vec![Content::text(json)]))
332            }
333            Err(e) => Err(mcp_error(&format!("Get context failed: {e}"))),
334        }
335    }
336
337    /// Get sessions linked to a git commit.
338    ///
339    /// Returns all sessions that have been linked to the specified commit.
340    #[tool(description = "Get Lore sessions linked to a git commit")]
341    async fn lore_get_linked_sessions(
342        &self,
343        params: Parameters<GetLinkedSessionsParams>,
344    ) -> Result<CallToolResult, McpError> {
345        let params = params.0;
346        let result = get_linked_sessions_impl(params);
347        match result {
348            Ok(response) => {
349                let json = serde_json::to_string_pretty(&response)
350                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
351                Ok(CallToolResult::success(vec![Content::text(json)]))
352            }
353            Err(e) => Err(mcp_error(&format!("Get linked sessions failed: {e}"))),
354        }
355    }
356
357    /// Get the mirrored memories for a project.
358    ///
359    /// Reflects the coding tool's per-project memory store (currently Claude
360    /// Code) and returns the current memories. The mirror is refreshed from the
361    /// tool's memory folder before returning so results are always current.
362    #[tool(description = "Get a project's memories mirrored from a coding tool's memory store")]
363    async fn lore_get_memories(
364        &self,
365        params: Parameters<GetMemoriesParams>,
366    ) -> Result<CallToolResult, McpError> {
367        let params = params.0;
368        let result = get_memories_impl(params);
369        match result {
370            Ok(response) => {
371                let json = serde_json::to_string_pretty(&response)
372                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
373                Ok(CallToolResult::success(vec![Content::text(json)]))
374            }
375            Err(e) => Err(mcp_error(&format!("Get memories failed: {e}"))),
376        }
377    }
378
379    /// Search a project's mirrored memories by text.
380    ///
381    /// Refreshes the mirror from the tool's memory folder, then full-text
382    /// searches the project's memories.
383    #[tool(description = "Full-text search a project's mirrored memories")]
384    async fn lore_search_memories(
385        &self,
386        params: Parameters<SearchMemoriesParams>,
387    ) -> Result<CallToolResult, McpError> {
388        let params = params.0;
389        let result = search_memories_impl(params);
390        match result {
391            Ok(response) => {
392                let json = serde_json::to_string_pretty(&response)
393                    .unwrap_or_else(|e| format!("Error serializing response: {e}"));
394                Ok(CallToolResult::success(vec![Content::text(json)]))
395            }
396            Err(e) => Err(mcp_error(&format!("Search memories failed: {e}"))),
397        }
398    }
399}
400
401#[tool_handler]
402impl ServerHandler for LoreServer {
403    fn get_info(&self) -> ServerInfo {
404        // ServerInfo (InitializeResult) is #[non_exhaustive] in rmcp 1.x, so it
405        // cannot be built with a struct literal. Use the builder methods instead.
406        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
407            .with_protocol_version(ProtocolVersion::V_2024_11_05)
408            .with_instructions(
409                "Lore is a reasoning history system for code. It captures AI coding sessions \
410                 and links them to git commits. Use these tools to search session history, \
411                 view session transcripts, and find sessions linked to commits.",
412            )
413    }
414}
415
416// ============== Implementation Functions ==============
417
418/// Parses a date string (ISO 8601 or relative like 7d, 2w, 1m) into a DateTime.
419fn parse_date(date_str: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
420    use chrono::{Duration, Utc};
421
422    let date_str = date_str.trim().to_lowercase();
423
424    // Try relative format first (e.g., "7d", "2w", "1m")
425    if date_str.ends_with('d') {
426        let days: i64 = date_str[..date_str.len() - 1].parse()?;
427        return Ok(Utc::now() - Duration::days(days));
428    }
429
430    if date_str.ends_with('w') {
431        let weeks: i64 = date_str[..date_str.len() - 1].parse()?;
432        return Ok(Utc::now() - Duration::weeks(weeks));
433    }
434
435    if date_str.ends_with('m') {
436        let months: i64 = date_str[..date_str.len() - 1].parse()?;
437        return Ok(Utc::now() - Duration::days(months * 30));
438    }
439
440    // Try ISO 8601 format
441    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
442        return Ok(dt.with_timezone(&Utc));
443    }
444
445    // Try date-only format
446    if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
447        let datetime = date
448            .and_hms_opt(0, 0, 0)
449            .ok_or_else(|| anyhow::anyhow!("Invalid date"))?;
450        return Ok(datetime.and_utc());
451    }
452
453    anyhow::bail!("Invalid date format: {date_str}")
454}
455
456/// Converts a Session to SessionInfo.
457fn session_to_info(session: &Session) -> SessionInfo {
458    SessionInfo {
459        id: session.id.to_string(),
460        id_short: session.id.to_string()[..8].to_string(),
461        tool: session.tool.clone(),
462        started_at: session.started_at.to_rfc3339(),
463        message_count: session.message_count,
464        working_directory: session.working_directory.clone(),
465        git_branch: session.git_branch.clone(),
466    }
467}
468
469/// Converts a Message to MessageInfo.
470fn message_to_info(message: &Message) -> MessageInfo {
471    MessageInfo {
472        index: message.index,
473        role: message.role.to_string(),
474        content: message.content.text(),
475        timestamp: message.timestamp.to_rfc3339(),
476    }
477}
478
479/// Implementation of the search tool.
480fn search_impl(params: SearchParams) -> anyhow::Result<SearchResponse> {
481    let db = Database::open_default()?;
482
483    // Build search index if needed
484    if db.search_index_needs_rebuild()? {
485        db.rebuild_search_index()?;
486    }
487
488    let since = params.since.as_ref().map(|s| parse_date(s)).transpose()?;
489
490    let options = SearchOptions {
491        query: params.query.clone(),
492        limit: params.limit.unwrap_or(10),
493        repo: params.repo,
494        tool: params.tool,
495        since,
496        ..Default::default()
497    };
498
499    let results = db.search_with_options(&options)?;
500
501    let matches: Vec<SearchMatch> = results
502        .into_iter()
503        .map(|r| SearchMatch {
504            session: SessionInfo {
505                id: r.session_id.to_string(),
506                id_short: r.session_id.to_string()[..8].to_string(),
507                tool: r.tool,
508                started_at: r
509                    .session_started_at
510                    .map(|dt| dt.to_rfc3339())
511                    .unwrap_or_default(),
512                message_count: r.session_message_count,
513                working_directory: r.working_directory,
514                git_branch: r.git_branch,
515            },
516            message_id: r.message_id.to_string(),
517            role: r.role.to_string(),
518            snippet: r.snippet,
519            timestamp: r.timestamp.to_rfc3339(),
520        })
521        .collect();
522
523    let total = matches.len();
524
525    Ok(SearchResponse {
526        query: params.query,
527        total_matches: total,
528        matches,
529    })
530}
531
532/// Implementation of the get_session tool.
533fn get_session_impl(params: GetSessionParams) -> anyhow::Result<SessionDetailsResponse> {
534    let db = Database::open_default()?;
535
536    // Try to find session by ID prefix
537    let session_id = resolve_session_id(&db, &params.session_id)?;
538    let session = db
539        .get_session(&session_id)?
540        .ok_or_else(|| anyhow::anyhow!("Session not found: {}", params.session_id))?;
541
542    // Get linked commits
543    let links = db.get_links_by_session(&session_id)?;
544    let linked_commits: Vec<String> = links.iter().filter_map(|l| l.commit_sha.clone()).collect();
545
546    // Get messages if requested
547    let messages = if params.include_messages.unwrap_or(true) {
548        let msgs = db.get_messages(&session_id)?;
549        Some(msgs.iter().map(message_to_info).collect())
550    } else {
551        None
552    };
553
554    // Get summary and tags
555    let summary = db.get_summary(&session_id)?.map(|s| s.content);
556    let tags: Vec<String> = db
557        .get_tags(&session_id)?
558        .into_iter()
559        .map(|t| t.label)
560        .collect();
561
562    Ok(SessionDetailsResponse {
563        session: session_to_info(&session),
564        linked_commits,
565        messages,
566        summary,
567        tags,
568    })
569}
570
571/// Implementation of the list_sessions tool.
572fn list_sessions_impl(params: ListSessionsParams) -> anyhow::Result<Vec<SessionInfo>> {
573    let db = Database::open_default()?;
574
575    let limit = params.limit.unwrap_or(10);
576    let sessions = db.list_sessions(limit, params.repo.as_deref())?;
577
578    Ok(sessions.iter().map(session_to_info).collect())
579}
580
581/// Implementation of the get_context tool.
582fn get_context_impl(params: GetContextParams) -> anyhow::Result<ContextResponse> {
583    let db = Database::open_default()?;
584
585    let working_dir = params.repo.unwrap_or_else(|| {
586        std::env::current_dir()
587            .map(|p| p.to_string_lossy().to_string())
588            .unwrap_or_default()
589    });
590
591    let limit = if params.last.unwrap_or(false) { 1 } else { 5 };
592    let sessions = db.list_sessions(limit, Some(&working_dir))?;
593
594    let session_infos: Vec<SessionInfo> = sessions.iter().map(session_to_info).collect();
595
596    // Get recent messages for --last mode
597    let recent_messages = if params.last.unwrap_or(false) && !sessions.is_empty() {
598        let messages = db.get_messages(&sessions[0].id)?;
599        let start = messages.len().saturating_sub(3);
600        Some(messages[start..].iter().map(message_to_info).collect())
601    } else {
602        None
603    };
604
605    Ok(ContextResponse {
606        working_directory: working_dir,
607        sessions: session_infos,
608        recent_messages,
609    })
610}
611
612/// Implementation of the get_linked_sessions tool.
613fn get_linked_sessions_impl(
614    params: GetLinkedSessionsParams,
615) -> anyhow::Result<LinkedSessionsResponse> {
616    let db = Database::open_default()?;
617
618    let links = db.get_links_by_commit(&params.commit_sha)?;
619
620    let mut sessions = Vec::new();
621    for link in links {
622        if let Some(session) = db.get_session(&link.session_id)? {
623            sessions.push(session_to_info(&session));
624        }
625    }
626
627    Ok(LinkedSessionsResponse {
628        commit_sha: params.commit_sha,
629        sessions,
630    })
631}
632
633/// Converts a Memory to MemoryInfo.
634fn memory_to_info(memory: &Memory) -> MemoryInfo {
635    MemoryInfo {
636        name: memory.name.clone(),
637        description: memory.description.clone(),
638        memory_type: memory.memory_type.clone(),
639        content: memory.content.clone(),
640        file_path: memory.file_path.clone(),
641        updated_at: memory.updated_at.to_rfc3339(),
642    }
643}
644
645/// Implementation of the get_memories tool.
646///
647/// Refreshes the read-only mirror of the tool's memory folder for the resolved
648/// project, then returns the current memories scoped to that project.
649fn get_memories_impl(params: GetMemoriesParams) -> anyhow::Result<MemoriesResponse> {
650    let db = Database::open_default()?;
651    let project = resolve_project_path(params.project_path.as_deref())?;
652
653    // Refresh-on-read so results are current without needing the daemon.
654    let mirror = MemoryMirror::claude();
655    mirror.refresh(&db, &project)?;
656
657    let project_key = project.to_string_lossy().to_string();
658    let memories = db.get_memories(&project_key, CLAUDE_CODE_TOOL)?;
659    let infos: Vec<MemoryInfo> = memories.iter().map(memory_to_info).collect();
660
661    Ok(MemoriesResponse {
662        project_path: project_key,
663        source_tool: CLAUDE_CODE_TOOL.to_string(),
664        total: infos.len(),
665        memories: infos,
666    })
667}
668
669/// Implementation of the search_memories tool.
670fn search_memories_impl(params: SearchMemoriesParams) -> anyhow::Result<SearchMemoriesResponse> {
671    let db = Database::open_default()?;
672    let project = resolve_project_path(params.project_path.as_deref())?;
673
674    // Refresh-on-read so results are current without needing the daemon.
675    let mirror = MemoryMirror::claude();
676    mirror.refresh(&db, &project)?;
677
678    let project_key = project.to_string_lossy().to_string();
679    let limit = params.limit.unwrap_or(20);
680    let memories = db.search_memories(&project_key, CLAUDE_CODE_TOOL, &params.query, limit)?;
681    let infos: Vec<MemoryInfo> = memories.iter().map(memory_to_info).collect();
682
683    Ok(SearchMemoriesResponse {
684        query: params.query,
685        project_path: project_key,
686        source_tool: CLAUDE_CODE_TOOL.to_string(),
687        total: infos.len(),
688        memories: infos,
689    })
690}
691
692/// Resolves a session ID prefix to a full UUID.
693fn resolve_session_id(db: &Database, id_prefix: &str) -> anyhow::Result<uuid::Uuid> {
694    // Use the efficient database method that searches all sessions
695    match db.find_session_by_id_prefix(id_prefix)? {
696        Some(session) => Ok(session.id),
697        None => anyhow::bail!("No session found with ID prefix: {id_prefix}"),
698    }
699}
700
701/// Runs the MCP server on stdio transport.
702///
703/// This is a blocking call that processes MCP requests until the client
704/// disconnects or an error occurs.
705pub async fn run_server() -> Result<()> {
706    let service = LoreServer::new().serve(stdio()).await?;
707    service.waiting().await?;
708    Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_parse_date_days() {
717        let result = parse_date("7d").expect("Should parse 7d");
718        let expected = chrono::Utc::now() - chrono::Duration::days(7);
719        assert!((result - expected).num_seconds().abs() < 2);
720    }
721
722    #[test]
723    fn test_parse_date_weeks() {
724        let result = parse_date("2w").expect("Should parse 2w");
725        let expected = chrono::Utc::now() - chrono::Duration::weeks(2);
726        assert!((result - expected).num_seconds().abs() < 2);
727    }
728
729    #[test]
730    fn test_parse_date_months() {
731        let result = parse_date("1m").expect("Should parse 1m");
732        let expected = chrono::Utc::now() - chrono::Duration::days(30);
733        assert!((result - expected).num_seconds().abs() < 2);
734    }
735
736    #[test]
737    fn test_parse_date_iso() {
738        let result = parse_date("2024-01-15").expect("Should parse date");
739        assert_eq!(result.format("%Y-%m-%d").to_string(), "2024-01-15");
740    }
741
742    #[test]
743    fn test_parse_date_invalid() {
744        assert!(parse_date("invalid").is_err());
745        assert!(parse_date("abc123").is_err());
746    }
747
748    #[test]
749    fn test_session_to_info() {
750        use chrono::Utc;
751        use uuid::Uuid;
752
753        let session = Session {
754            id: Uuid::new_v4(),
755            tool: "claude-code".to_string(),
756            tool_version: Some("2.0.0".to_string()),
757            started_at: Utc::now(),
758            ended_at: None,
759            model: Some("claude-3-opus".to_string()),
760            working_directory: "/home/user/project".to_string(),
761            git_branch: Some("main".to_string()),
762            source_path: None,
763            message_count: 10,
764            machine_id: None,
765        };
766
767        let info = session_to_info(&session);
768        assert_eq!(info.tool, "claude-code");
769        assert_eq!(info.message_count, 10);
770        assert_eq!(info.working_directory, "/home/user/project");
771        assert_eq!(info.git_branch, Some("main".to_string()));
772        assert_eq!(info.id_short.len(), 8);
773    }
774
775    #[test]
776    fn test_message_to_info() {
777        use crate::storage::models::{MessageContent, MessageRole};
778        use chrono::Utc;
779        use uuid::Uuid;
780
781        let message = Message {
782            id: Uuid::new_v4(),
783            session_id: Uuid::new_v4(),
784            parent_id: None,
785            index: 0,
786            timestamp: Utc::now(),
787            role: MessageRole::User,
788            content: MessageContent::Text("Hello, world!".to_string()),
789            model: None,
790            git_branch: None,
791            cwd: None,
792        };
793
794        let info = message_to_info(&message);
795        assert_eq!(info.index, 0);
796        assert_eq!(info.role, "user");
797        assert_eq!(info.content, "Hello, world!");
798    }
799
800    #[test]
801    fn test_memory_to_info() {
802        use chrono::Utc;
803        use uuid::Uuid;
804
805        let memory = Memory {
806            id: Uuid::new_v4(),
807            project_path: "/home/user/project".to_string(),
808            source_tool: "claude-code".to_string(),
809            name: "API base URL".to_string(),
810            description: Some("Where the API lives".to_string()),
811            memory_type: Some("reference".to_string()),
812            content: "The API base URL is https://example.com.".to_string(),
813            file_path: "/home/user/.claude/projects/slug/memory/fact-1.md".to_string(),
814            updated_at: Utc::now(),
815        };
816
817        let info = memory_to_info(&memory);
818        assert_eq!(info.name, "API base URL");
819        assert_eq!(info.description.as_deref(), Some("Where the API lives"));
820        assert_eq!(info.memory_type.as_deref(), Some("reference"));
821        assert!(info.content.contains("https://example.com"));
822        assert!(info.file_path.ends_with("fact-1.md"));
823    }
824}