Skip to main content

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 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/// A per-project memory mirrored from a coding tool's memory store.
454///
455/// Tools such as Claude Code write per-project "memory" (running notes, next
456/// steps, corrections) into their own private stores that other tools cannot
457/// see. Lore mirrors those files into this read-only representation so any LLM
458/// can read them through Lore's MCP server. Lore never writes back to the
459/// tool's memory folder.
460///
461/// Each memory corresponds to a single markdown file in the tool's memory
462/// folder. The memory is scoped to the project it belongs to (`project_path`)
463/// and the tool that authored it (`source_tool`).
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465pub struct Memory {
466    /// Unique identifier for this memory.
467    pub id: Uuid,
468
469    /// Absolute path of the repository this memory belongs to.
470    pub project_path: String,
471
472    /// The tool that authored this memory (e.g., "claude-code").
473    pub source_tool: String,
474
475    /// Short name of the memory (from frontmatter `name`, or the file stem).
476    pub name: String,
477
478    /// Optional human-readable description (from frontmatter `description`).
479    pub description: Option<String>,
480
481    /// Optional memory type (from frontmatter `metadata.type`, e.g. user,
482    /// feedback, project, reference).
483    pub memory_type: Option<String>,
484
485    /// The memory body (the markdown content following any frontmatter).
486    pub content: String,
487
488    /// Absolute path of the source file this memory was mirrored from.
489    pub file_path: String,
490
491    /// When the source file was last modified, used to detect changes.
492    pub updated_at: DateTime<Utc>,
493}
494
495/// Represents a machine that has captured sessions.
496///
497/// Used for sync to map machine UUIDs to friendly names. Each machine
498/// has a unique identifier (UUID) and a human-readable name that can be
499/// customized by the user.
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
501pub struct Machine {
502    /// Unique machine identifier (UUID).
503    pub id: String,
504
505    /// Human-readable machine name (e.g., hostname or custom name).
506    pub name: String,
507
508    /// When this machine was first registered (RFC3339 format).
509    pub created_at: String,
510}
511
512/// A record of a locally deleted child record (link, tag, annotation, summary).
513///
514/// Child records merge additively across machines, so without a tombstone a
515/// child DELETED on one machine would be resurrected the next time another
516/// machine re-exported the parent session. A tombstone captures the deleted
517/// child's id and kind so the sync merge can suppress re-adding that specific
518/// record on every machine, while leaving concurrent additions of other records
519/// untouched.
520///
521/// Only user-facing deletions (unlink, tag remove, annotation delete, summary
522/// delete) record tombstones. Deleting a whole session is a purely local
523/// operation and must NOT tombstone its children, or a teammate who shares the
524/// store would lose that session's reasoning.
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
526pub struct Tombstone {
527    /// The id of the deleted child record (its UUID rendered as a string).
528    pub child_id: String,
529
530    /// The kind of child record: `link`, `tag`, `annotation`, or `summary`.
531    pub kind: String,
532
533    /// The session the deleted child belonged to, when known. Used to re-export
534    /// the cleaned parent session after a tombstone is applied.
535    pub session_id: Option<String>,
536
537    /// When the child was deleted, used for garbage-collecting stale tombstones.
538    pub deleted_at: DateTime<Utc>,
539}
540
541/// A tracked git repository.
542///
543/// Repositories are discovered when sessions reference working directories
544/// that are inside git repositories.
545///
546/// Note: This struct is defined for future use when repository tracking
547/// is implemented. Currently, sessions link directly to commits without
548/// explicit repository records.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[allow(dead_code)]
551pub struct Repository {
552    /// Unique identifier
553    pub id: Uuid,
554
555    /// Absolute path on disk
556    pub path: String,
557
558    /// Repository name (derived from path or remote)
559    pub name: String,
560
561    /// Remote URL if available
562    pub remote_url: Option<String>,
563
564    /// When first seen
565    pub created_at: DateTime<Utc>,
566
567    /// When last session was recorded
568    pub last_session_at: Option<DateTime<Utc>>,
569}
570
571/// Extracts file paths mentioned in a list of messages.
572///
573/// Parses tool_use blocks to find file paths from tools like Read, Edit, Write,
574/// Glob, and Bash commands. Returns unique file paths that were referenced.
575///
576/// # Arguments
577///
578/// * `messages` - The messages to extract file paths from
579/// * `working_directory` - The session's working directory, used to convert
580///   absolute paths to relative paths for comparison with git files
581///
582/// # Returns
583///
584/// A vector of unique file paths (relative to the working directory when possible).
585pub fn extract_session_files(messages: &[Message], working_directory: &str) -> Vec<String> {
586    use std::collections::HashSet;
587
588    let mut files = HashSet::new();
589
590    for message in messages {
591        if let MessageContent::Blocks(blocks) = &message.content {
592            for block in blocks {
593                if let ContentBlock::ToolUse { name, input, .. } = block {
594                    extract_files_from_tool_use(name, input, working_directory, &mut files);
595                }
596            }
597        }
598    }
599
600    files.into_iter().collect()
601}
602
603/// Extracts file paths from a single tool_use block.
604fn extract_files_from_tool_use(
605    tool_name: &str,
606    input: &serde_json::Value,
607    working_directory: &str,
608    files: &mut std::collections::HashSet<String>,
609) {
610    match tool_name {
611        "Read" | "Write" | "Edit" => {
612            // These tools have a file_path parameter
613            if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
614                if let Some(rel_path) = make_relative(path, working_directory) {
615                    files.insert(rel_path);
616                }
617            }
618        }
619        "Glob" => {
620            // Glob has a path parameter for the directory to search
621            if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
622                if let Some(rel_path) = make_relative(path, working_directory) {
623                    files.insert(rel_path);
624                }
625            }
626        }
627        "Grep" => {
628            // Grep has a path parameter
629            if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
630                if let Some(rel_path) = make_relative(path, working_directory) {
631                    files.insert(rel_path);
632                }
633            }
634        }
635        "Bash" => {
636            // Try to extract file paths from bash commands
637            if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
638                extract_files_from_bash_command(cmd, working_directory, files);
639            }
640        }
641        "NotebookEdit" => {
642            // NotebookEdit has a notebook_path parameter
643            if let Some(path) = input.get("notebook_path").and_then(|v| v.as_str()) {
644                if let Some(rel_path) = make_relative(path, working_directory) {
645                    files.insert(rel_path);
646                }
647            }
648        }
649        _ => {}
650    }
651}
652
653/// Extracts file paths from a bash command string.
654///
655/// This is a best-effort extraction that looks for common patterns.
656fn extract_files_from_bash_command(
657    cmd: &str,
658    working_directory: &str,
659    files: &mut std::collections::HashSet<String>,
660) {
661    // Common file-related commands
662    let file_commands = [
663        "cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
664        "mkdir", "chmod", "chown",
665    ];
666
667    // Split by common separators
668    for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
669        let part = part.trim();
670
671        // Check if this looks like a file path
672        if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
673            // Skip if it's a command flag
674            if !part.starts_with('-') {
675                if let Some(rel_path) = make_relative(part, working_directory) {
676                    // Only add if it looks like a reasonable file path
677                    if !rel_path.is_empty() && !rel_path.contains('$') {
678                        files.insert(rel_path);
679                    }
680                }
681            }
682        }
683
684        // Check for file command patterns like "cat file.txt"
685        for file_cmd in &file_commands {
686            if part.starts_with(file_cmd) {
687                let args = part.strip_prefix(file_cmd).unwrap_or("").trim();
688                for arg in args.split_whitespace() {
689                    // Skip flags
690                    if arg.starts_with('-') {
691                        continue;
692                    }
693                    // This might be a file path
694                    if let Some(rel_path) = make_relative(arg, working_directory) {
695                        if !rel_path.is_empty() && !rel_path.contains('$') {
696                            files.insert(rel_path);
697                        }
698                    }
699                }
700            }
701        }
702    }
703}
704
705/// Converts an absolute path to a path relative to the working directory.
706///
707/// Returns None if the path cannot be made relative (e.g., not under working dir).
708fn make_relative(path: &str, working_directory: &str) -> Option<String> {
709    // Handle relative paths - they're already relative
710    if !path.starts_with('/') {
711        // Clean up "./" prefix if present
712        let cleaned = path.strip_prefix("./").unwrap_or(path);
713        if !cleaned.is_empty() {
714            return Some(cleaned.to_string());
715        }
716        return None;
717    }
718
719    // For absolute paths, try to make them relative to working_directory
720    let wd = working_directory.trim_end_matches('/');
721
722    if let Some(rel) = path.strip_prefix(wd) {
723        let rel = rel.trim_start_matches('/');
724        if !rel.is_empty() {
725            return Some(rel.to_string());
726        }
727    }
728
729    // If we can't make it relative, still include it as-is
730    // (git may use absolute paths in some cases)
731    Some(path.to_string())
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_extract_session_files_read_tool() {
740        let messages = vec![Message {
741            id: Uuid::new_v4(),
742            session_id: Uuid::new_v4(),
743            parent_id: None,
744            index: 0,
745            timestamp: Utc::now(),
746            role: MessageRole::Assistant,
747            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
748                id: "tool_1".to_string(),
749                name: "Read".to_string(),
750                input: serde_json::json!({"file_path": "/home/user/project/src/main.rs"}),
751            }]),
752            model: None,
753            git_branch: None,
754            cwd: None,
755        }];
756
757        let files = extract_session_files(&messages, "/home/user/project");
758        assert!(files.contains(&"src/main.rs".to_string()));
759    }
760
761    #[test]
762    fn test_extract_session_files_edit_tool() {
763        let messages = vec![Message {
764            id: Uuid::new_v4(),
765            session_id: Uuid::new_v4(),
766            parent_id: None,
767            index: 0,
768            timestamp: Utc::now(),
769            role: MessageRole::Assistant,
770            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
771                id: "tool_1".to_string(),
772                name: "Edit".to_string(),
773                input: serde_json::json!({
774                    "file_path": "/home/user/project/src/lib.rs",
775                    "old_string": "old",
776                    "new_string": "new"
777                }),
778            }]),
779            model: None,
780            git_branch: None,
781            cwd: None,
782        }];
783
784        let files = extract_session_files(&messages, "/home/user/project");
785        assert!(files.contains(&"src/lib.rs".to_string()));
786    }
787
788    #[test]
789    fn test_extract_session_files_multiple_tools() {
790        let messages = vec![Message {
791            id: Uuid::new_v4(),
792            session_id: Uuid::new_v4(),
793            parent_id: None,
794            index: 0,
795            timestamp: Utc::now(),
796            role: MessageRole::Assistant,
797            content: MessageContent::Blocks(vec![
798                ContentBlock::ToolUse {
799                    id: "tool_1".to_string(),
800                    name: "Read".to_string(),
801                    input: serde_json::json!({"file_path": "/project/a.rs"}),
802                },
803                ContentBlock::ToolUse {
804                    id: "tool_2".to_string(),
805                    name: "Write".to_string(),
806                    input: serde_json::json!({"file_path": "/project/b.rs", "content": "..."}),
807                },
808                ContentBlock::ToolUse {
809                    id: "tool_3".to_string(),
810                    name: "Edit".to_string(),
811                    input: serde_json::json!({
812                        "file_path": "/project/c.rs",
813                        "old_string": "x",
814                        "new_string": "y"
815                    }),
816                },
817            ]),
818            model: None,
819            git_branch: None,
820            cwd: None,
821        }];
822
823        let files = extract_session_files(&messages, "/project");
824        assert_eq!(files.len(), 3);
825        assert!(files.contains(&"a.rs".to_string()));
826        assert!(files.contains(&"b.rs".to_string()));
827        assert!(files.contains(&"c.rs".to_string()));
828    }
829
830    #[test]
831    fn test_extract_session_files_deduplicates() {
832        let messages = vec![
833            Message {
834                id: Uuid::new_v4(),
835                session_id: Uuid::new_v4(),
836                parent_id: None,
837                index: 0,
838                timestamp: Utc::now(),
839                role: MessageRole::Assistant,
840                content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
841                    id: "tool_1".to_string(),
842                    name: "Read".to_string(),
843                    input: serde_json::json!({"file_path": "/project/src/main.rs"}),
844                }]),
845                model: None,
846                git_branch: None,
847                cwd: None,
848            },
849            Message {
850                id: Uuid::new_v4(),
851                session_id: Uuid::new_v4(),
852                parent_id: None,
853                index: 1,
854                timestamp: Utc::now(),
855                role: MessageRole::Assistant,
856                content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
857                    id: "tool_2".to_string(),
858                    name: "Edit".to_string(),
859                    input: serde_json::json!({
860                        "file_path": "/project/src/main.rs",
861                        "old_string": "a",
862                        "new_string": "b"
863                    }),
864                }]),
865                model: None,
866                git_branch: None,
867                cwd: None,
868            },
869        ];
870
871        let files = extract_session_files(&messages, "/project");
872        assert_eq!(files.len(), 1);
873        assert!(files.contains(&"src/main.rs".to_string()));
874    }
875
876    #[test]
877    fn test_extract_session_files_relative_paths() {
878        let messages = vec![Message {
879            id: Uuid::new_v4(),
880            session_id: Uuid::new_v4(),
881            parent_id: None,
882            index: 0,
883            timestamp: Utc::now(),
884            role: MessageRole::Assistant,
885            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
886                id: "tool_1".to_string(),
887                name: "Read".to_string(),
888                input: serde_json::json!({"file_path": "./src/main.rs"}),
889            }]),
890            model: None,
891            git_branch: None,
892            cwd: None,
893        }];
894
895        let files = extract_session_files(&messages, "/project");
896        assert!(files.contains(&"src/main.rs".to_string()));
897    }
898
899    #[test]
900    fn test_extract_session_files_empty_messages() {
901        let messages: Vec<Message> = vec![];
902        let files = extract_session_files(&messages, "/project");
903        assert!(files.is_empty());
904    }
905
906    #[test]
907    fn test_extract_session_files_text_only_messages() {
908        let messages = vec![Message {
909            id: Uuid::new_v4(),
910            session_id: Uuid::new_v4(),
911            parent_id: None,
912            index: 0,
913            timestamp: Utc::now(),
914            role: MessageRole::User,
915            content: MessageContent::Text("Please fix the bug".to_string()),
916            model: None,
917            git_branch: None,
918            cwd: None,
919        }];
920
921        let files = extract_session_files(&messages, "/project");
922        assert!(files.is_empty());
923    }
924
925    #[test]
926    fn test_make_relative_absolute_path() {
927        let result = make_relative("/home/user/project/src/main.rs", "/home/user/project");
928        assert_eq!(result, Some("src/main.rs".to_string()));
929    }
930
931    #[test]
932    fn test_make_relative_with_trailing_slash() {
933        let result = make_relative("/home/user/project/src/main.rs", "/home/user/project/");
934        assert_eq!(result, Some("src/main.rs".to_string()));
935    }
936
937    #[test]
938    fn test_make_relative_already_relative() {
939        let result = make_relative("src/main.rs", "/home/user/project");
940        assert_eq!(result, Some("src/main.rs".to_string()));
941    }
942
943    #[test]
944    fn test_make_relative_dotslash_prefix() {
945        let result = make_relative("./src/main.rs", "/home/user/project");
946        assert_eq!(result, Some("src/main.rs".to_string()));
947    }
948
949    #[test]
950    fn test_make_relative_outside_working_dir() {
951        let result = make_relative("/other/path/file.rs", "/home/user/project");
952        // Should return the absolute path as-is
953        assert_eq!(result, Some("/other/path/file.rs".to_string()));
954    }
955}