1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Session {
14 pub id: Uuid,
16
17 pub tool: String,
19
20 pub tool_version: Option<String>,
22
23 pub started_at: DateTime<Utc>,
25
26 pub ended_at: Option<DateTime<Utc>>,
28
29 pub model: Option<String>,
31
32 pub working_directory: String,
34
35 pub git_branch: Option<String>,
37
38 pub source_path: Option<String>,
40
41 pub message_count: i32,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Message {
48 pub id: Uuid,
50
51 pub session_id: Uuid,
53
54 pub parent_id: Option<Uuid>,
56
57 pub index: i32,
59
60 pub timestamp: DateTime<Utc>,
62
63 pub role: MessageRole,
65
66 pub content: MessageContent,
68
69 pub model: Option<String>,
71
72 pub git_branch: Option<String>,
74
75 pub cwd: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81#[serde(rename_all = "lowercase")]
82pub enum MessageRole {
83 User,
85 Assistant,
87 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#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum MessageContent {
105 Text(String),
107 Blocks(Vec<ContentBlock>),
109}
110
111impl MessageContent {
112 #[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, })
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 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#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(tag = "type", rename_all = "snake_case")]
164pub enum ContentBlock {
165 Text { text: String },
167
168 Thinking { thinking: String },
170
171 ToolUse {
173 id: String,
174 name: String,
175 input: serde_json::Value,
176 },
177
178 ToolResult {
180 tool_use_id: String,
181 content: String,
182 is_error: bool,
183 },
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SessionLink {
189 pub id: Uuid,
191
192 pub session_id: Uuid,
194
195 pub link_type: LinkType,
197
198 pub commit_sha: Option<String>,
200
201 pub branch: Option<String>,
203
204 pub remote: Option<String>,
206
207 pub created_at: DateTime<Utc>,
209
210 pub created_by: LinkCreator,
212
213 pub confidence: Option<f64>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[serde(rename_all = "lowercase")]
220pub enum LinkType {
221 Commit,
223 Branch,
225 Pr,
227 Manual,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233#[serde(rename_all = "lowercase")]
234pub enum LinkCreator {
235 Auto,
237 User,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct SearchResult {
247 pub session_id: Uuid,
249
250 pub message_id: Uuid,
252
253 pub role: MessageRole,
255
256 pub snippet: String,
258
259 pub timestamp: DateTime<Utc>,
261
262 pub working_directory: String,
264
265 #[serde(default)]
267 pub tool: String,
268
269 #[serde(default)]
271 pub git_branch: Option<String>,
272
273 #[serde(default)]
275 pub session_message_count: i32,
276
277 #[serde(default)]
279 pub session_started_at: Option<DateTime<Utc>>,
280
281 #[serde(default)]
283 pub message_index: i32,
284}
285
286#[derive(Debug, Clone, Default)]
292#[allow(dead_code)]
293pub struct SearchOptions {
294 pub query: String,
296
297 pub limit: usize,
299
300 pub tool: Option<String>,
302
303 pub since: Option<DateTime<Utc>>,
305
306 pub until: Option<DateTime<Utc>>,
308
309 pub project: Option<String>,
311
312 pub branch: Option<String>,
314
315 pub role: Option<String>,
317
318 pub repo: Option<String>,
320
321 pub context: usize,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SearchResultWithContext {
330 pub session_id: Uuid,
332
333 pub tool: String,
335
336 pub project: String,
338
339 pub working_directory: String,
341
342 pub git_branch: Option<String>,
344
345 pub session_started_at: DateTime<Utc>,
347
348 pub session_message_count: i32,
350
351 pub matches: Vec<MatchWithContext>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct MatchWithContext {
358 pub message: ContextMessage,
360
361 pub before: Vec<ContextMessage>,
363
364 pub after: Vec<ContextMessage>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct ContextMessage {
371 pub id: Uuid,
373
374 pub role: MessageRole,
376
377 pub content: String,
379
380 pub index: i32,
382
383 #[serde(default)]
385 pub is_match: bool,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
397#[allow(dead_code)]
398pub struct Repository {
399 pub id: Uuid,
401
402 pub path: String,
404
405 pub name: String,
407
408 pub remote_url: Option<String>,
410
411 pub created_at: DateTime<Utc>,
413
414 pub last_session_at: Option<DateTime<Utc>>,
416}
417
418pub 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
450fn 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 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 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 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 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 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
500fn extract_files_from_bash_command(
504 cmd: &str,
505 working_directory: &str,
506 files: &mut std::collections::HashSet<String>,
507) {
508 let file_commands = [
510 "cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
511 "mkdir", "chmod", "chown",
512 ];
513
514 for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
516 let part = part.trim();
517
518 if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
520 if !part.starts_with('-') {
522 if let Some(rel_path) = make_relative(part, working_directory) {
523 if !rel_path.is_empty() && !rel_path.contains('$') {
525 files.insert(rel_path);
526 }
527 }
528 }
529 }
530
531 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 if arg.starts_with('-') {
538 continue;
539 }
540 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
552fn make_relative(path: &str, working_directory: &str) -> Option<String> {
556 if !path.starts_with('/') {
558 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 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 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 assert_eq!(result, Some("/other/path/file.rs".to_string()));
801 }
802}