lore_cli/storage/
models.rs

1//! Core data models for Lore
2//!
3//! These represent the internal representation of reasoning history,
4//! independent of any specific AI tool's format.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// A Session represents a complete human-AI collaboration.
11/// This is the primary unit of reasoning history.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Session {
14    /// Unique identifier for this session
15    pub id: Uuid,
16
17    /// Which tool created this session (e.g., "claude-code", "cursor")
18    pub tool: String,
19
20    /// Tool version (e.g., "2.0.72")
21    pub tool_version: Option<String>,
22
23    /// When the session started
24    pub started_at: DateTime<Utc>,
25
26    /// When the session ended (None if ongoing)
27    pub ended_at: Option<DateTime<Utc>>,
28
29    /// The AI model used (may change during session, this is the primary one)
30    pub model: Option<String>,
31
32    /// Working directory when session started
33    pub working_directory: String,
34
35    /// Git branch when session started (if in a git repo)
36    pub git_branch: Option<String>,
37
38    /// Original source file path (for re-import detection)
39    pub source_path: Option<String>,
40
41    /// Number of messages in this session
42    pub message_count: i32,
43}
44
45/// A single message in a session
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Message {
48    /// Unique identifier for this message
49    pub id: Uuid,
50
51    /// Session this message belongs to
52    pub session_id: Uuid,
53
54    /// Parent message ID (for threading)
55    pub parent_id: Option<Uuid>,
56
57    /// Position in the conversation (0-indexed)
58    pub index: i32,
59
60    /// When this message was sent
61    pub timestamp: DateTime<Utc>,
62
63    /// Who sent this message
64    pub role: MessageRole,
65
66    /// The message content (may be complex for assistant messages)
67    pub content: MessageContent,
68
69    /// Model used (for assistant messages)
70    pub model: Option<String>,
71
72    /// Git branch at time of message
73    pub git_branch: Option<String>,
74
75    /// Working directory at time of message
76    pub cwd: Option<String>,
77}
78
79/// The role of a message sender in a conversation.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81#[serde(rename_all = "lowercase")]
82pub enum MessageRole {
83    /// A human user message.
84    User,
85    /// An AI assistant response.
86    Assistant,
87    /// A system prompt or instruction.
88    System,
89}
90
91impl std::fmt::Display for MessageRole {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            MessageRole::User => write!(f, "user"),
95            MessageRole::Assistant => write!(f, "assistant"),
96            MessageRole::System => write!(f, "system"),
97        }
98    }
99}
100
101/// Message content - can be simple text or complex with tool calls
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum MessageContent {
105    /// Simple text content
106    Text(String),
107    /// Complex content with multiple blocks
108    Blocks(Vec<ContentBlock>),
109}
110
111impl MessageContent {
112    /// Get a text summary of the content
113    #[allow(dead_code)]
114    pub fn summary(&self, max_len: usize) -> String {
115        let text = match self {
116            MessageContent::Text(s) => s.clone(),
117            MessageContent::Blocks(blocks) => {
118                blocks
119                    .iter()
120                    .filter_map(|b| match b {
121                        ContentBlock::Text { text } => Some(text.clone()),
122                        ContentBlock::ToolUse { name, .. } => Some(format!("[tool: {name}]")),
123                        ContentBlock::ToolResult { content, .. } => Some(format!(
124                            "[result: {}...]",
125                            &content.chars().take(50).collect::<String>()
126                        )),
127                        ContentBlock::Thinking { .. } => None, // Skip thinking in summaries
128                    })
129                    .collect::<Vec<_>>()
130                    .join(" ")
131            }
132        };
133
134        if text.len() <= max_len {
135            text
136        } else {
137            format!("{}...", &text.chars().take(max_len - 3).collect::<String>())
138        }
139    }
140
141    /// Get the full text content (excluding tool calls and thinking).
142    ///
143    /// For simple text messages, returns the text directly. For block content,
144    /// extracts and concatenates all text blocks, ignoring tool calls and
145    /// thinking blocks.
146    pub fn text(&self) -> String {
147        match self {
148            MessageContent::Text(s) => s.clone(),
149            MessageContent::Blocks(blocks) => blocks
150                .iter()
151                .filter_map(|b| match b {
152                    ContentBlock::Text { text } => Some(text.clone()),
153                    _ => None,
154                })
155                .collect::<Vec<_>>()
156                .join("\n"),
157        }
158    }
159}
160
161/// A block of content within a message
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(tag = "type", rename_all = "snake_case")]
164pub enum ContentBlock {
165    /// Plain text
166    Text { text: String },
167
168    /// AI thinking/reasoning (may be redacted in display)
169    Thinking { thinking: String },
170
171    /// Tool/function call
172    ToolUse {
173        id: String,
174        name: String,
175        input: serde_json::Value,
176    },
177
178    /// Result from a tool call
179    ToolResult {
180        tool_use_id: String,
181        content: String,
182        is_error: bool,
183    },
184}
185
186/// Links a session to a git commit
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SessionLink {
189    /// Unique identifier
190    pub id: Uuid,
191
192    /// Session being linked
193    pub session_id: Uuid,
194
195    /// Type of link
196    pub link_type: LinkType,
197
198    /// Git commit SHA (full)
199    pub commit_sha: Option<String>,
200
201    /// Branch name
202    pub branch: Option<String>,
203
204    /// Remote name (e.g., "origin")
205    pub remote: Option<String>,
206
207    /// When the link was created
208    pub created_at: DateTime<Utc>,
209
210    /// How the link was created
211    pub created_by: LinkCreator,
212
213    /// Confidence score for auto-links (0.0 - 1.0)
214    pub confidence: Option<f64>,
215}
216
217/// The type of link between a session and git history.
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[serde(rename_all = "lowercase")]
220pub enum LinkType {
221    /// Link to a specific commit.
222    Commit,
223    /// Link to a branch (session spans multiple commits).
224    Branch,
225    /// Link to a pull request.
226    Pr,
227    /// Manual link created by user without specific target.
228    Manual,
229}
230
231/// Indicates how a session link was created.
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233#[serde(rename_all = "lowercase")]
234pub enum LinkCreator {
235    /// Automatically created by time and file overlap heuristics.
236    Auto,
237    /// Manually created by a user via CLI command.
238    User,
239}
240
241/// A search result from full-text search of message content.
242///
243/// Contains the matching message metadata along with a snippet of the
244/// matching content for display in search results.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct SearchResult {
247    /// The session containing the matching message.
248    pub session_id: Uuid,
249
250    /// The matching message ID.
251    pub message_id: Uuid,
252
253    /// Role of the message sender (user, assistant, system).
254    pub role: MessageRole,
255
256    /// Snippet of matching content with search terms highlighted.
257    pub snippet: String,
258
259    /// Timestamp of the matching message.
260    pub timestamp: DateTime<Utc>,
261
262    /// Working directory of the session containing this message.
263    pub working_directory: String,
264
265    /// AI tool that captured this session (e.g., "claude-code", "aider").
266    #[serde(default)]
267    pub tool: String,
268
269    /// Git branch name if available.
270    #[serde(default)]
271    pub git_branch: Option<String>,
272
273    /// Total message count in the session.
274    #[serde(default)]
275    pub session_message_count: i32,
276
277    /// When the session started.
278    #[serde(default)]
279    pub session_started_at: Option<DateTime<Utc>>,
280
281    /// Index of the matching message within its session.
282    #[serde(default)]
283    pub message_index: i32,
284}
285
286/// Options for filtering search results.
287///
288/// Used by the search command to narrow down results by tool, date range,
289/// project, branch, and other criteria. The context field is used by the
290/// CLI layer, not the database layer.
291#[derive(Debug, Clone, Default)]
292#[allow(dead_code)]
293pub struct SearchOptions {
294    /// The search query text.
295    pub query: String,
296
297    /// Maximum number of results to return.
298    pub limit: usize,
299
300    /// Filter by AI tool name (e.g., "claude-code", "aider").
301    pub tool: Option<String>,
302
303    /// Only include sessions after this date.
304    pub since: Option<DateTime<Utc>>,
305
306    /// Only include sessions before this date.
307    pub until: Option<DateTime<Utc>>,
308
309    /// Filter by project/directory name (partial match).
310    pub project: Option<String>,
311
312    /// Filter by git branch name (partial match).
313    pub branch: Option<String>,
314
315    /// Filter by message role (user, assistant, system).
316    pub role: Option<String>,
317
318    /// Filter by repository path prefix.
319    pub repo: Option<String>,
320
321    /// Number of context messages to include before and after matches.
322    pub context: usize,
323}
324
325/// A search result with surrounding context messages.
326///
327/// Groups matches by session and includes neighboring messages for context.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SearchResultWithContext {
330    /// The session containing the matches.
331    pub session_id: Uuid,
332
333    /// AI tool that captured this session.
334    pub tool: String,
335
336    /// Project name (extracted from working directory).
337    pub project: String,
338
339    /// Full working directory path.
340    pub working_directory: String,
341
342    /// Git branch if available.
343    pub git_branch: Option<String>,
344
345    /// When the session started.
346    pub session_started_at: DateTime<Utc>,
347
348    /// Total messages in the session.
349    pub session_message_count: i32,
350
351    /// The matching messages with their context.
352    pub matches: Vec<MatchWithContext>,
353}
354
355/// A single match with its surrounding context messages.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct MatchWithContext {
358    /// The matching message.
359    pub message: ContextMessage,
360
361    /// Messages before the match (for context).
362    pub before: Vec<ContextMessage>,
363
364    /// Messages after the match (for context).
365    pub after: Vec<ContextMessage>,
366}
367
368/// A simplified message representation for context display.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct ContextMessage {
371    /// Message ID.
372    pub id: Uuid,
373
374    /// Role of the sender.
375    pub role: MessageRole,
376
377    /// Text content (truncated for display).
378    pub content: String,
379
380    /// Message index in the session.
381    pub index: i32,
382
383    /// Whether this is the matching message.
384    #[serde(default)]
385    pub is_match: bool,
386}
387
388/// A tracked git repository.
389///
390/// Repositories are discovered when sessions reference working directories
391/// that are inside git repositories.
392///
393/// Note: This struct is defined for future use when repository tracking
394/// is implemented. Currently, sessions link directly to commits without
395/// explicit repository records.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[allow(dead_code)]
398pub struct Repository {
399    /// Unique identifier
400    pub id: Uuid,
401
402    /// Absolute path on disk
403    pub path: String,
404
405    /// Repository name (derived from path or remote)
406    pub name: String,
407
408    /// Remote URL if available
409    pub remote_url: Option<String>,
410
411    /// When first seen
412    pub created_at: DateTime<Utc>,
413
414    /// When last session was recorded
415    pub last_session_at: Option<DateTime<Utc>>,
416}
417
418/// Extracts file paths mentioned in a list of messages.
419///
420/// Parses tool_use blocks to find file paths from tools like Read, Edit, Write,
421/// Glob, and Bash commands. Returns unique file paths that were referenced.
422///
423/// # Arguments
424///
425/// * `messages` - The messages to extract file paths from
426/// * `working_directory` - The session's working directory, used to convert
427///   absolute paths to relative paths for comparison with git files
428///
429/// # Returns
430///
431/// A vector of unique file paths (relative to the working directory when possible).
432pub fn extract_session_files(messages: &[Message], working_directory: &str) -> Vec<String> {
433    use std::collections::HashSet;
434
435    let mut files = HashSet::new();
436
437    for message in messages {
438        if let MessageContent::Blocks(blocks) = &message.content {
439            for block in blocks {
440                if let ContentBlock::ToolUse { name, input, .. } = block {
441                    extract_files_from_tool_use(name, input, working_directory, &mut files);
442                }
443            }
444        }
445    }
446
447    files.into_iter().collect()
448}
449
450/// Extracts file paths from a single tool_use block.
451fn extract_files_from_tool_use(
452    tool_name: &str,
453    input: &serde_json::Value,
454    working_directory: &str,
455    files: &mut std::collections::HashSet<String>,
456) {
457    match tool_name {
458        "Read" | "Write" | "Edit" => {
459            // These tools have a file_path parameter
460            if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
461                if let Some(rel_path) = make_relative(path, working_directory) {
462                    files.insert(rel_path);
463                }
464            }
465        }
466        "Glob" => {
467            // Glob has a path parameter for the directory to search
468            if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
469                if let Some(rel_path) = make_relative(path, working_directory) {
470                    files.insert(rel_path);
471                }
472            }
473        }
474        "Grep" => {
475            // Grep has a path parameter
476            if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
477                if let Some(rel_path) = make_relative(path, working_directory) {
478                    files.insert(rel_path);
479                }
480            }
481        }
482        "Bash" => {
483            // Try to extract file paths from bash commands
484            if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
485                extract_files_from_bash_command(cmd, working_directory, files);
486            }
487        }
488        "NotebookEdit" => {
489            // NotebookEdit has a notebook_path parameter
490            if let Some(path) = input.get("notebook_path").and_then(|v| v.as_str()) {
491                if let Some(rel_path) = make_relative(path, working_directory) {
492                    files.insert(rel_path);
493                }
494            }
495        }
496        _ => {}
497    }
498}
499
500/// Extracts file paths from a bash command string.
501///
502/// This is a best-effort extraction that looks for common patterns.
503fn extract_files_from_bash_command(
504    cmd: &str,
505    working_directory: &str,
506    files: &mut std::collections::HashSet<String>,
507) {
508    // Common file-related commands
509    let file_commands = [
510        "cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
511        "mkdir", "chmod", "chown",
512    ];
513
514    // Split by common separators
515    for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
516        let part = part.trim();
517
518        // Check if this looks like a file path
519        if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
520            // Skip if it's a command flag
521            if !part.starts_with('-') {
522                if let Some(rel_path) = make_relative(part, working_directory) {
523                    // Only add if it looks like a reasonable file path
524                    if !rel_path.is_empty() && !rel_path.contains('$') {
525                        files.insert(rel_path);
526                    }
527                }
528            }
529        }
530
531        // Check for file command patterns like "cat file.txt"
532        for file_cmd in &file_commands {
533            if part.starts_with(file_cmd) {
534                let args = part.strip_prefix(file_cmd).unwrap_or("").trim();
535                for arg in args.split_whitespace() {
536                    // Skip flags
537                    if arg.starts_with('-') {
538                        continue;
539                    }
540                    // This might be a file path
541                    if let Some(rel_path) = make_relative(arg, working_directory) {
542                        if !rel_path.is_empty() && !rel_path.contains('$') {
543                            files.insert(rel_path);
544                        }
545                    }
546                }
547            }
548        }
549    }
550}
551
552/// Converts an absolute path to a path relative to the working directory.
553///
554/// Returns None if the path cannot be made relative (e.g., not under working dir).
555fn make_relative(path: &str, working_directory: &str) -> Option<String> {
556    // Handle relative paths - they're already relative
557    if !path.starts_with('/') {
558        // Clean up "./" prefix if present
559        let cleaned = path.strip_prefix("./").unwrap_or(path);
560        if !cleaned.is_empty() {
561            return Some(cleaned.to_string());
562        }
563        return None;
564    }
565
566    // For absolute paths, try to make them relative to working_directory
567    let wd = working_directory.trim_end_matches('/');
568
569    if let Some(rel) = path.strip_prefix(wd) {
570        let rel = rel.trim_start_matches('/');
571        if !rel.is_empty() {
572            return Some(rel.to_string());
573        }
574    }
575
576    // If we can't make it relative, still include it as-is
577    // (git may use absolute paths in some cases)
578    Some(path.to_string())
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_extract_session_files_read_tool() {
587        let messages = vec![Message {
588            id: Uuid::new_v4(),
589            session_id: Uuid::new_v4(),
590            parent_id: None,
591            index: 0,
592            timestamp: Utc::now(),
593            role: MessageRole::Assistant,
594            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
595                id: "tool_1".to_string(),
596                name: "Read".to_string(),
597                input: serde_json::json!({"file_path": "/home/user/project/src/main.rs"}),
598            }]),
599            model: None,
600            git_branch: None,
601            cwd: None,
602        }];
603
604        let files = extract_session_files(&messages, "/home/user/project");
605        assert!(files.contains(&"src/main.rs".to_string()));
606    }
607
608    #[test]
609    fn test_extract_session_files_edit_tool() {
610        let messages = vec![Message {
611            id: Uuid::new_v4(),
612            session_id: Uuid::new_v4(),
613            parent_id: None,
614            index: 0,
615            timestamp: Utc::now(),
616            role: MessageRole::Assistant,
617            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
618                id: "tool_1".to_string(),
619                name: "Edit".to_string(),
620                input: serde_json::json!({
621                    "file_path": "/home/user/project/src/lib.rs",
622                    "old_string": "old",
623                    "new_string": "new"
624                }),
625            }]),
626            model: None,
627            git_branch: None,
628            cwd: None,
629        }];
630
631        let files = extract_session_files(&messages, "/home/user/project");
632        assert!(files.contains(&"src/lib.rs".to_string()));
633    }
634
635    #[test]
636    fn test_extract_session_files_multiple_tools() {
637        let messages = vec![Message {
638            id: Uuid::new_v4(),
639            session_id: Uuid::new_v4(),
640            parent_id: None,
641            index: 0,
642            timestamp: Utc::now(),
643            role: MessageRole::Assistant,
644            content: MessageContent::Blocks(vec![
645                ContentBlock::ToolUse {
646                    id: "tool_1".to_string(),
647                    name: "Read".to_string(),
648                    input: serde_json::json!({"file_path": "/project/a.rs"}),
649                },
650                ContentBlock::ToolUse {
651                    id: "tool_2".to_string(),
652                    name: "Write".to_string(),
653                    input: serde_json::json!({"file_path": "/project/b.rs", "content": "..."}),
654                },
655                ContentBlock::ToolUse {
656                    id: "tool_3".to_string(),
657                    name: "Edit".to_string(),
658                    input: serde_json::json!({
659                        "file_path": "/project/c.rs",
660                        "old_string": "x",
661                        "new_string": "y"
662                    }),
663                },
664            ]),
665            model: None,
666            git_branch: None,
667            cwd: None,
668        }];
669
670        let files = extract_session_files(&messages, "/project");
671        assert_eq!(files.len(), 3);
672        assert!(files.contains(&"a.rs".to_string()));
673        assert!(files.contains(&"b.rs".to_string()));
674        assert!(files.contains(&"c.rs".to_string()));
675    }
676
677    #[test]
678    fn test_extract_session_files_deduplicates() {
679        let messages = vec![
680            Message {
681                id: Uuid::new_v4(),
682                session_id: Uuid::new_v4(),
683                parent_id: None,
684                index: 0,
685                timestamp: Utc::now(),
686                role: MessageRole::Assistant,
687                content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
688                    id: "tool_1".to_string(),
689                    name: "Read".to_string(),
690                    input: serde_json::json!({"file_path": "/project/src/main.rs"}),
691                }]),
692                model: None,
693                git_branch: None,
694                cwd: None,
695            },
696            Message {
697                id: Uuid::new_v4(),
698                session_id: Uuid::new_v4(),
699                parent_id: None,
700                index: 1,
701                timestamp: Utc::now(),
702                role: MessageRole::Assistant,
703                content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
704                    id: "tool_2".to_string(),
705                    name: "Edit".to_string(),
706                    input: serde_json::json!({
707                        "file_path": "/project/src/main.rs",
708                        "old_string": "a",
709                        "new_string": "b"
710                    }),
711                }]),
712                model: None,
713                git_branch: None,
714                cwd: None,
715            },
716        ];
717
718        let files = extract_session_files(&messages, "/project");
719        assert_eq!(files.len(), 1);
720        assert!(files.contains(&"src/main.rs".to_string()));
721    }
722
723    #[test]
724    fn test_extract_session_files_relative_paths() {
725        let messages = vec![Message {
726            id: Uuid::new_v4(),
727            session_id: Uuid::new_v4(),
728            parent_id: None,
729            index: 0,
730            timestamp: Utc::now(),
731            role: MessageRole::Assistant,
732            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
733                id: "tool_1".to_string(),
734                name: "Read".to_string(),
735                input: serde_json::json!({"file_path": "./src/main.rs"}),
736            }]),
737            model: None,
738            git_branch: None,
739            cwd: None,
740        }];
741
742        let files = extract_session_files(&messages, "/project");
743        assert!(files.contains(&"src/main.rs".to_string()));
744    }
745
746    #[test]
747    fn test_extract_session_files_empty_messages() {
748        let messages: Vec<Message> = vec![];
749        let files = extract_session_files(&messages, "/project");
750        assert!(files.is_empty());
751    }
752
753    #[test]
754    fn test_extract_session_files_text_only_messages() {
755        let messages = vec![Message {
756            id: Uuid::new_v4(),
757            session_id: Uuid::new_v4(),
758            parent_id: None,
759            index: 0,
760            timestamp: Utc::now(),
761            role: MessageRole::User,
762            content: MessageContent::Text("Please fix the bug".to_string()),
763            model: None,
764            git_branch: None,
765            cwd: None,
766        }];
767
768        let files = extract_session_files(&messages, "/project");
769        assert!(files.is_empty());
770    }
771
772    #[test]
773    fn test_make_relative_absolute_path() {
774        let result = make_relative("/home/user/project/src/main.rs", "/home/user/project");
775        assert_eq!(result, Some("src/main.rs".to_string()));
776    }
777
778    #[test]
779    fn test_make_relative_with_trailing_slash() {
780        let result = make_relative("/home/user/project/src/main.rs", "/home/user/project/");
781        assert_eq!(result, Some("src/main.rs".to_string()));
782    }
783
784    #[test]
785    fn test_make_relative_already_relative() {
786        let result = make_relative("src/main.rs", "/home/user/project");
787        assert_eq!(result, Some("src/main.rs".to_string()));
788    }
789
790    #[test]
791    fn test_make_relative_dotslash_prefix() {
792        let result = make_relative("./src/main.rs", "/home/user/project");
793        assert_eq!(result, Some("src/main.rs".to_string()));
794    }
795
796    #[test]
797    fn test_make_relative_outside_working_dir() {
798        let result = make_relative("/other/path/file.rs", "/home/user/project");
799        // Should return the absolute path as-is
800        assert_eq!(result, Some("/other/path/file.rs".to_string()));
801    }
802}