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