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 pub machine_id: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Message {
53 pub id: Uuid,
55
56 pub session_id: Uuid,
58
59 pub parent_id: Option<Uuid>,
61
62 pub index: i32,
64
65 pub timestamp: DateTime<Utc>,
67
68 pub role: MessageRole,
70
71 pub content: MessageContent,
73
74 pub model: Option<String>,
76
77 pub git_branch: Option<String>,
79
80 pub cwd: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86#[serde(rename_all = "lowercase")]
87pub enum MessageRole {
88 User,
90 Assistant,
92 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#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum MessageContent {
110 Text(String),
112 Blocks(Vec<ContentBlock>),
114}
115
116impl MessageContent {
117 #[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, })
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 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#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(tag = "type", rename_all = "snake_case")]
169pub enum ContentBlock {
170 Text { text: String },
172
173 Thinking { thinking: String },
175
176 ToolUse {
178 id: String,
179 name: String,
180 input: serde_json::Value,
181 },
182
183 ToolResult {
185 tool_use_id: String,
186 content: String,
187 is_error: bool,
188 },
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct SessionLink {
194 pub id: Uuid,
196
197 pub session_id: Uuid,
199
200 pub link_type: LinkType,
202
203 pub commit_sha: Option<String>,
205
206 pub branch: Option<String>,
208
209 pub remote: Option<String>,
211
212 pub created_at: DateTime<Utc>,
214
215 pub created_by: LinkCreator,
217
218 pub confidence: Option<f64>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
224#[serde(rename_all = "lowercase")]
225pub enum LinkType {
226 Commit,
228 Branch,
230 Pr,
232 Manual,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238#[serde(rename_all = "lowercase")]
239pub enum LinkCreator {
240 Auto,
242 User,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct SearchResult {
252 pub session_id: Uuid,
254
255 pub message_id: Uuid,
257
258 pub role: MessageRole,
260
261 pub snippet: String,
263
264 pub timestamp: DateTime<Utc>,
266
267 pub working_directory: String,
269
270 #[serde(default)]
272 pub tool: String,
273
274 #[serde(default)]
276 pub git_branch: Option<String>,
277
278 #[serde(default)]
280 pub session_message_count: i32,
281
282 #[serde(default)]
284 pub session_started_at: Option<DateTime<Utc>>,
285
286 #[serde(default)]
288 pub message_index: i32,
289}
290
291#[derive(Debug, Clone, Default)]
297#[allow(dead_code)]
298pub struct SearchOptions {
299 pub query: String,
301
302 pub limit: usize,
304
305 pub tool: Option<String>,
307
308 pub since: Option<DateTime<Utc>>,
310
311 pub until: Option<DateTime<Utc>>,
313
314 pub project: Option<String>,
316
317 pub branch: Option<String>,
319
320 pub role: Option<String>,
322
323 pub repo: Option<String>,
325
326 pub context: usize,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct SearchResultWithContext {
335 pub session_id: Uuid,
337
338 pub tool: String,
340
341 pub project: String,
343
344 pub working_directory: String,
346
347 pub git_branch: Option<String>,
349
350 pub session_started_at: DateTime<Utc>,
352
353 pub session_message_count: i32,
355
356 pub matches: Vec<MatchWithContext>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct MatchWithContext {
363 pub message: ContextMessage,
365
366 pub before: Vec<ContextMessage>,
368
369 pub after: Vec<ContextMessage>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ContextMessage {
376 pub id: Uuid,
378
379 pub role: MessageRole,
381
382 pub content: String,
384
385 pub index: i32,
387
388 #[serde(default)]
390 pub is_match: bool,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct Annotation {
400 pub id: Uuid,
402
403 pub session_id: Uuid,
405
406 pub content: String,
408
409 pub created_at: DateTime<Utc>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct Tag {
420 pub id: Uuid,
422
423 pub session_id: Uuid,
425
426 pub label: String,
428
429 pub created_at: DateTime<Utc>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct Summary {
440 pub id: Uuid,
442
443 pub session_id: Uuid,
445
446 pub content: String,
448
449 pub generated_at: DateTime<Utc>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465pub struct Memory {
466 pub id: Uuid,
468
469 pub project_path: String,
471
472 pub source_tool: String,
474
475 pub name: String,
477
478 pub description: Option<String>,
480
481 pub memory_type: Option<String>,
484
485 pub content: String,
487
488 pub file_path: String,
490
491 pub updated_at: DateTime<Utc>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
501pub struct Machine {
502 pub id: String,
504
505 pub name: String,
507
508 pub created_at: String,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
526pub struct Tombstone {
527 pub child_id: String,
529
530 pub kind: String,
532
533 pub session_id: Option<String>,
536
537 pub deleted_at: DateTime<Utc>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
550#[allow(dead_code)]
551pub struct Repository {
552 pub id: Uuid,
554
555 pub path: String,
557
558 pub name: String,
560
561 pub remote_url: Option<String>,
563
564 pub created_at: DateTime<Utc>,
566
567 pub last_session_at: Option<DateTime<Utc>>,
569}
570
571pub 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
603fn 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 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 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 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 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 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
653fn extract_files_from_bash_command(
657 cmd: &str,
658 working_directory: &str,
659 files: &mut std::collections::HashSet<String>,
660) {
661 let file_commands = [
663 "cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
664 "mkdir", "chmod", "chown",
665 ];
666
667 for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
669 let part = part.trim();
670
671 if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
673 if !part.starts_with('-') {
675 if let Some(rel_path) = make_relative(part, working_directory) {
676 if !rel_path.is_empty() && !rel_path.contains('$') {
678 files.insert(rel_path);
679 }
680 }
681 }
682 }
683
684 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 if arg.starts_with('-') {
691 continue;
692 }
693 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
705fn make_relative(path: &str, working_directory: &str) -> Option<String> {
709 if !path.starts_with('/') {
711 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 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 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 assert_eq!(result, Some("/other/path/file.rs".to_string()));
954 }
955}