use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: Uuid,
pub tool: String,
pub tool_version: Option<String>,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub model: Option<String>,
pub working_directory: String,
pub git_branch: Option<String>,
pub source_path: Option<String>,
pub message_count: i32,
pub machine_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Uuid,
pub session_id: Uuid,
pub parent_id: Option<Uuid>,
pub index: i32,
pub timestamp: DateTime<Utc>,
pub role: MessageRole,
pub content: MessageContent,
pub model: Option<String>,
pub git_branch: Option<String>,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
User,
Assistant,
System,
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageRole::User => write!(f, "user"),
MessageRole::Assistant => write!(f, "assistant"),
MessageRole::System => write!(f, "system"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
impl MessageContent {
#[allow(dead_code)]
pub fn summary(&self, max_len: usize) -> String {
let text = match self {
MessageContent::Text(s) => s.clone(),
MessageContent::Blocks(blocks) => {
blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
ContentBlock::ToolUse { name, .. } => Some(format!("[tool: {name}]")),
ContentBlock::ToolResult { content, .. } => Some(format!(
"[result: {}...]",
&content.chars().take(50).collect::<String>()
)),
ContentBlock::Thinking { .. } => None, })
.collect::<Vec<_>>()
.join(" ")
}
};
if text.len() <= max_len {
text
} else {
format!("{}...", &text.chars().take(max_len - 3).collect::<String>())
}
}
pub fn text(&self) -> String {
match self {
MessageContent::Text(s) => s.clone(),
MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text { text: String },
Thinking { thinking: String },
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionLink {
pub id: Uuid,
pub session_id: Uuid,
pub link_type: LinkType,
pub commit_sha: Option<String>,
pub branch: Option<String>,
pub remote: Option<String>,
pub created_at: DateTime<Utc>,
pub created_by: LinkCreator,
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LinkType {
Commit,
Branch,
Pr,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LinkCreator {
Auto,
User,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub session_id: Uuid,
pub message_id: Uuid,
pub role: MessageRole,
pub snippet: String,
pub timestamp: DateTime<Utc>,
pub working_directory: String,
#[serde(default)]
pub tool: String,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub session_message_count: i32,
#[serde(default)]
pub session_started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub message_index: i32,
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct SearchOptions {
pub query: String,
pub limit: usize,
pub tool: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub project: Option<String>,
pub branch: Option<String>,
pub role: Option<String>,
pub repo: Option<String>,
pub context: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResultWithContext {
pub session_id: Uuid,
pub tool: String,
pub project: String,
pub working_directory: String,
pub git_branch: Option<String>,
pub session_started_at: DateTime<Utc>,
pub session_message_count: i32,
pub matches: Vec<MatchWithContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchWithContext {
pub message: ContextMessage,
pub before: Vec<ContextMessage>,
pub after: Vec<ContextMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextMessage {
pub id: Uuid,
pub role: MessageRole,
pub content: String,
pub index: i32,
#[serde(default)]
pub is_match: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Annotation {
pub id: Uuid,
pub session_id: Uuid,
pub content: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: Uuid,
pub session_id: Uuid,
pub label: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Summary {
pub id: Uuid,
pub session_id: Uuid,
pub content: String,
pub generated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Machine {
pub id: String,
pub name: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Repository {
pub id: Uuid,
pub path: String,
pub name: String,
pub remote_url: Option<String>,
pub created_at: DateTime<Utc>,
pub last_session_at: Option<DateTime<Utc>>,
}
pub fn extract_session_files(messages: &[Message], working_directory: &str) -> Vec<String> {
use std::collections::HashSet;
let mut files = HashSet::new();
for message in messages {
if let MessageContent::Blocks(blocks) = &message.content {
for block in blocks {
if let ContentBlock::ToolUse { name, input, .. } = block {
extract_files_from_tool_use(name, input, working_directory, &mut files);
}
}
}
}
files.into_iter().collect()
}
fn extract_files_from_tool_use(
tool_name: &str,
input: &serde_json::Value,
working_directory: &str,
files: &mut std::collections::HashSet<String>,
) {
match tool_name {
"Read" | "Write" | "Edit" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
if let Some(rel_path) = make_relative(path, working_directory) {
files.insert(rel_path);
}
}
}
"Glob" => {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
if let Some(rel_path) = make_relative(path, working_directory) {
files.insert(rel_path);
}
}
}
"Grep" => {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
if let Some(rel_path) = make_relative(path, working_directory) {
files.insert(rel_path);
}
}
}
"Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
extract_files_from_bash_command(cmd, working_directory, files);
}
}
"NotebookEdit" => {
if let Some(path) = input.get("notebook_path").and_then(|v| v.as_str()) {
if let Some(rel_path) = make_relative(path, working_directory) {
files.insert(rel_path);
}
}
}
_ => {}
}
}
fn extract_files_from_bash_command(
cmd: &str,
working_directory: &str,
files: &mut std::collections::HashSet<String>,
) {
let file_commands = [
"cat", "less", "more", "head", "tail", "vim", "nano", "code", "cp", "mv", "rm", "touch",
"mkdir", "chmod", "chown",
];
for part in cmd.split(&['|', ';', '&', '\n', ' '][..]) {
let part = part.trim();
if part.starts_with('/') || part.starts_with("./") || part.starts_with("../") {
if !part.starts_with('-') {
if let Some(rel_path) = make_relative(part, working_directory) {
if !rel_path.is_empty() && !rel_path.contains('$') {
files.insert(rel_path);
}
}
}
}
for file_cmd in &file_commands {
if part.starts_with(file_cmd) {
let args = part.strip_prefix(file_cmd).unwrap_or("").trim();
for arg in args.split_whitespace() {
if arg.starts_with('-') {
continue;
}
if let Some(rel_path) = make_relative(arg, working_directory) {
if !rel_path.is_empty() && !rel_path.contains('$') {
files.insert(rel_path);
}
}
}
}
}
}
}
fn make_relative(path: &str, working_directory: &str) -> Option<String> {
if !path.starts_with('/') {
let cleaned = path.strip_prefix("./").unwrap_or(path);
if !cleaned.is_empty() {
return Some(cleaned.to_string());
}
return None;
}
let wd = working_directory.trim_end_matches('/');
if let Some(rel) = path.strip_prefix(wd) {
let rel = rel.trim_start_matches('/');
if !rel.is_empty() {
return Some(rel.to_string());
}
}
Some(path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_session_files_read_tool() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "/home/user/project/src/main.rs"}),
}]),
model: None,
git_branch: None,
cwd: None,
}];
let files = extract_session_files(&messages, "/home/user/project");
assert!(files.contains(&"src/main.rs".to_string()));
}
#[test]
fn test_extract_session_files_edit_tool() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Edit".to_string(),
input: serde_json::json!({
"file_path": "/home/user/project/src/lib.rs",
"old_string": "old",
"new_string": "new"
}),
}]),
model: None,
git_branch: None,
cwd: None,
}];
let files = extract_session_files(&messages, "/home/user/project");
assert!(files.contains(&"src/lib.rs".to_string()));
}
#[test]
fn test_extract_session_files_multiple_tools() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "/project/a.rs"}),
},
ContentBlock::ToolUse {
id: "tool_2".to_string(),
name: "Write".to_string(),
input: serde_json::json!({"file_path": "/project/b.rs", "content": "..."}),
},
ContentBlock::ToolUse {
id: "tool_3".to_string(),
name: "Edit".to_string(),
input: serde_json::json!({
"file_path": "/project/c.rs",
"old_string": "x",
"new_string": "y"
}),
},
]),
model: None,
git_branch: None,
cwd: None,
}];
let files = extract_session_files(&messages, "/project");
assert_eq!(files.len(), 3);
assert!(files.contains(&"a.rs".to_string()));
assert!(files.contains(&"b.rs".to_string()));
assert!(files.contains(&"c.rs".to_string()));
}
#[test]
fn test_extract_session_files_deduplicates() {
let messages = vec![
Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "/project/src/main.rs"}),
}]),
model: None,
git_branch: None,
cwd: None,
},
Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 1,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tool_2".to_string(),
name: "Edit".to_string(),
input: serde_json::json!({
"file_path": "/project/src/main.rs",
"old_string": "a",
"new_string": "b"
}),
}]),
model: None,
git_branch: None,
cwd: None,
},
];
let files = extract_session_files(&messages, "/project");
assert_eq!(files.len(), 1);
assert!(files.contains(&"src/main.rs".to_string()));
}
#[test]
fn test_extract_session_files_relative_paths() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "./src/main.rs"}),
}]),
model: None,
git_branch: None,
cwd: None,
}];
let files = extract_session_files(&messages, "/project");
assert!(files.contains(&"src/main.rs".to_string()));
}
#[test]
fn test_extract_session_files_empty_messages() {
let messages: Vec<Message> = vec![];
let files = extract_session_files(&messages, "/project");
assert!(files.is_empty());
}
#[test]
fn test_extract_session_files_text_only_messages() {
let messages = vec![Message {
id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::User,
content: MessageContent::Text("Please fix the bug".to_string()),
model: None,
git_branch: None,
cwd: None,
}];
let files = extract_session_files(&messages, "/project");
assert!(files.is_empty());
}
#[test]
fn test_make_relative_absolute_path() {
let result = make_relative("/home/user/project/src/main.rs", "/home/user/project");
assert_eq!(result, Some("src/main.rs".to_string()));
}
#[test]
fn test_make_relative_with_trailing_slash() {
let result = make_relative("/home/user/project/src/main.rs", "/home/user/project/");
assert_eq!(result, Some("src/main.rs".to_string()));
}
#[test]
fn test_make_relative_already_relative() {
let result = make_relative("src/main.rs", "/home/user/project");
assert_eq!(result, Some("src/main.rs".to_string()));
}
#[test]
fn test_make_relative_dotslash_prefix() {
let result = make_relative("./src/main.rs", "/home/user/project");
assert_eq!(result, Some("src/main.rs".to_string()));
}
#[test]
fn test_make_relative_outside_working_dir() {
let result = make_relative("/other/path/file.rs", "/home/user/project");
assert_eq!(result, Some("/other/path/file.rs".to_string()));
}
}