use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::phrase;
use super::LookupResult;
const MAX_FILES_TO_SEARCH: usize = 10;
const TAIL_READ_BYTES: u64 = 50 * 1024;
const MAX_PHRASES: usize = 5;
fn cwd_to_project_hash(cwd: &str) -> String {
cwd.replace('/', "-")
}
fn claude_projects_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".claude").join("projects"))
}
fn list_recent_jsonl_files(project_dir: &PathBuf, max_count: usize) -> Vec<(PathBuf, u64)> {
let entries = match fs::read_dir(project_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut files: Vec<(PathBuf, u64)> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
return None;
}
let metadata = entry.metadata().ok()?;
let mtime = metadata
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_millis() as u64;
Some((path, mtime))
})
.collect();
files.sort_by(|a, b| b.1.cmp(&a.1));
files.truncate(max_count);
files
}
fn read_tail(path: &Path, max_bytes: u64) -> Option<String> {
let mut file = fs::File::open(path).ok()?;
let file_size = file.metadata().ok()?.len();
if file_size > max_bytes {
file.seek(SeekFrom::End(-(max_bytes as i64))).ok()?;
}
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn extract_session_id(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
if stem.is_empty() || !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return None;
}
Some(stem.to_string())
}
pub fn find_session_id(cwd: &str, capture_content: &str) -> LookupResult {
let projects_dir = match claude_projects_dir() {
Some(dir) => dir,
None => return LookupResult::NotFound,
};
let project_hash = cwd_to_project_hash(cwd);
let project_dir = projects_dir.join(&project_hash);
if !project_dir.exists() {
return LookupResult::NotFound;
}
let phrases = phrase::extract_phrases(capture_content, MAX_PHRASES);
if phrases.is_empty() {
return LookupResult::NotFound;
}
let files = list_recent_jsonl_files(&project_dir, MAX_FILES_TO_SEARCH);
for (path, _mtime) in &files {
let content = match read_tail(path, TAIL_READ_BYTES) {
Some(c) => c,
None => continue,
};
for phrase in &phrases {
if content.contains(phrase.as_str()) {
if let Some(session_id) = extract_session_id(path) {
return LookupResult::Found(session_id);
}
}
}
}
LookupResult::NotFound
}
pub fn probe_session_id(cwd: &str, marker: &str) -> LookupResult {
if marker.is_empty() {
return LookupResult::NotFound;
}
let projects_dir = match claude_projects_dir() {
Some(dir) => dir,
None => return LookupResult::NotFound,
};
let project_hash = cwd_to_project_hash(cwd);
let project_dir = projects_dir.join(&project_hash);
if !project_dir.exists() {
return LookupResult::NotFound;
}
let files = list_recent_jsonl_files(&project_dir, MAX_FILES_TO_SEARCH);
for (path, _mtime) in &files {
let content = match read_tail(path, TAIL_READ_BYTES) {
Some(c) => c,
None => continue,
};
if content.contains(marker) {
if let Some(session_id) = extract_session_id(path) {
return LookupResult::Found(session_id);
}
}
}
LookupResult::NotFound
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cwd_to_project_hash() {
assert_eq!(
cwd_to_project_hash("/home/user/works/tmai"),
"-home-user-works-tmai"
);
assert_eq!(cwd_to_project_hash("/"), "-");
assert_eq!(cwd_to_project_hash("/home/user"), "-home-user");
}
#[test]
fn test_extract_session_id() {
let path = PathBuf::from("/some/path/abcd1234-5678-abcd-efgh-ijklmnop.jsonl");
assert_eq!(
extract_session_id(&path),
Some("abcd1234-5678-abcd-efgh-ijklmnop".to_string())
);
}
#[test]
fn test_extract_session_id_no_extension() {
let path = PathBuf::from("/some/path/sessions-index.json");
assert_eq!(
extract_session_id(&path),
Some("sessions-index".to_string())
);
}
#[test]
fn test_find_session_id_nonexistent_dir() {
let result = find_session_id("/nonexistent/path/that/doesnt/exist", "some content");
assert_eq!(result, LookupResult::NotFound);
}
#[test]
fn test_probe_session_id_nonexistent_dir() {
let result = probe_session_id("/nonexistent/path", "tmai-probe:test-uuid");
assert_eq!(result, LookupResult::NotFound);
}
}