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)]
459pub struct Machine {
460 pub id: String,
462
463 pub name: String,
465
466 pub created_at: String,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
479#[allow(dead_code)]
480pub struct Repository {
481 pub id: Uuid,
483
484 pub path: String,
486
487 pub name: String,
489
490 pub remote_url: Option<String>,
492
493 pub created_at: DateTime<Utc>,
495
496 pub last_session_at: Option<DateTime<Utc>>,
498}
499
500pub 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
532fn 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 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 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 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 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 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
582fn extract_files_from_bash_command(
586 cmd: &str,
587 working_directory: &str,
588 files: &mut std::collections::HashSet<String>,
589) {
590 let file_commands = [
592 "cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
593 "mkdir", "chmod", "chown",
594 ];
595
596 for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
598 let part = part.trim();
599
600 if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
602 if !part.starts_with('-') {
604 if let Some(rel_path) = make_relative(part, working_directory) {
605 if !rel_path.is_empty() && !rel_path.contains('$') {
607 files.insert(rel_path);
608 }
609 }
610 }
611 }
612
613 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 if arg.starts_with('-') {
620 continue;
621 }
622 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
634fn make_relative(path: &str, working_directory: &str) -> Option<String> {
638 if !path.starts_with('/') {
640 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 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 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 assert_eq!(result, Some("/other/path/file.rs".to_string()));
883 }
884}