use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub session_id: String,
pub transcript_path: PathBuf,
pub modified_at: DateTime<Utc>,
pub size_bytes: u64,
}
pub fn compute_project_id(project_path: &Path) -> String {
let abs_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let path_str = abs_path.to_string_lossy();
path_str.replace('/', "-")
}
pub fn claude_projects_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join("projects"))
}
pub fn get_project_dir(project_path: &Path) -> Option<PathBuf> {
let projects_dir = claude_projects_dir()?;
let project_id = compute_project_id(project_path);
let project_dir = projects_dir.join(&project_id);
if project_dir.exists() {
Some(project_dir)
} else {
None
}
}
pub fn discover_sessions(project_path: &Path) -> Result<Vec<SessionInfo>, String> {
let project_dir = get_project_dir(project_path)
.ok_or_else(|| format!("No Claude project directory found for {:?}", project_path))?;
discover_sessions_in_dir(&project_dir)
}
pub fn discover_sessions_in_dir(project_dir: &Path) -> Result<Vec<SessionInfo>, String> {
let entries = std::fs::read_dir(project_dir)
.map_err(|e| format!("Failed to read project directory: {}", e))?;
let mut sessions: Vec<SessionInfo> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
return None;
}
let session_id = path.file_stem()?.to_str()?.to_string();
if session_id.starts_with("agent-") {
return None;
}
let metadata = std::fs::metadata(&path).ok()?;
let modified = metadata.modified().ok()?;
let size_bytes = metadata.len();
let modified_at = system_time_to_datetime(modified)?;
Some(SessionInfo {
session_id,
transcript_path: path,
modified_at,
size_bytes,
})
})
.collect();
sessions.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(sessions)
}
fn system_time_to_datetime(st: SystemTime) -> Option<DateTime<Utc>> {
let duration = st.duration_since(std::time::UNIX_EPOCH).ok()?;
DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos())
}
pub fn current_project_path() -> PathBuf {
if let Ok(project_dir) = std::env::var("CLAUDE_PROJECT_DIR") {
PathBuf::from(project_dir)
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
#[derive(Debug, Clone)]
pub struct ProjectInfo {
pub project_id: String,
pub project_dir: PathBuf,
pub session_count: usize,
}
pub fn list_all_projects() -> Result<Vec<ProjectInfo>, String> {
let projects_dir = claude_projects_dir()
.ok_or_else(|| "Could not determine Claude projects directory".to_string())?;
if !projects_dir.exists() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(&projects_dir)
.map_err(|e| format!("Failed to read projects directory: {}", e))?;
let mut projects: Vec<ProjectInfo> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
if !path.is_dir() {
return None;
}
let project_id = path.file_name()?.to_str()?.to_string();
let session_count = std::fs::read_dir(&path)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
== Some("jsonl")
})
.count();
Some(ProjectInfo {
project_id,
project_dir: path,
session_count,
})
})
.collect();
projects.sort_by(|a, b| a.project_id.cmp(&b.project_id));
Ok(projects)
}
pub fn find_projects_by_filter(filter: &str) -> Result<Vec<ProjectInfo>, String> {
let all_projects = list_all_projects()?;
let filter_lower = filter.to_lowercase();
Ok(all_projects
.into_iter()
.filter(|p| p.project_id.to_lowercase().contains(&filter_lower))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_project_id() {
let path = Path::new("/Users/drazen/playground/ai-omnibus/wm");
let id = compute_project_id(path);
assert!(id.starts_with("-"));
assert!(id.contains("-wm"));
assert!(!id.contains("/"));
}
#[test]
fn test_claude_projects_dir() {
let dir = claude_projects_dir();
assert!(dir.is_some());
let path = dir.unwrap();
assert!(path.ends_with(".claude/projects") || path.to_string_lossy().contains(".claude"));
}
}