use std::path::{Path, PathBuf};
pub fn claude_config_dir() -> anyhow::Result<PathBuf> {
if let Ok(custom) = std::env::var("CLAUDE_CONFIG_DIR") {
if !custom.is_empty() {
return Ok(PathBuf::from(custom));
}
}
let home = dirs_home()?;
Ok(home.join(".claude"))
}
pub fn projects_dir() -> anyhow::Result<PathBuf> {
Ok(claude_config_dir()?.join("projects"))
}
pub fn encode_project_path(path: &str) -> String {
path.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect()
}
pub fn find_project_dir(project_path: &Path) -> anyhow::Result<Option<PathBuf>> {
let projects = projects_dir()?;
if !projects.exists() {
return Ok(None);
}
let encoded = encode_project_path(&project_path.to_string_lossy());
let exact = projects.join(&encoded);
if exact.is_dir() {
return Ok(Some(exact));
}
let encoded_lower = encoded.to_lowercase();
if let Ok(entries) = std::fs::read_dir(&projects) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.to_lowercase() == encoded_lower && entry.path().is_dir() {
return Ok(Some(entry.path()));
}
}
}
Ok(None)
}
pub fn list_sessions(project_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut sessions: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
for entry in std::fs::read_dir(project_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".jsonl") {
continue;
}
if name.starts_with("agent-") {
continue;
}
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
sessions.push((path, mtime));
}
sessions.sort_by(|a, b| b.1.cmp(&a.1));
Ok(sessions.into_iter().map(|(p, _)| p).collect())
}
pub fn list_all_projects() -> anyhow::Result<Vec<(String, PathBuf)>> {
let projects = projects_dir()?;
if !projects.exists() {
return Ok(vec![]);
}
let mut result = Vec::new();
for entry in std::fs::read_dir(&projects)? {
let entry = entry?;
if entry.path().is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
let decoded = decode_project_path(&name);
result.push((decoded, entry.path()));
}
}
result.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
fn decode_project_path(encoded: &str) -> String {
encoded.to_string()
}
fn dirs_home() -> anyhow::Result<PathBuf> {
directories::BaseDirs::new()
.map(|d| d.home_dir().to_path_buf())
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_path_replaces_separators() {
let encoded = encode_project_path("/home/user/project");
assert_eq!(encoded, "-home-user-project");
}
#[test]
fn encode_preserves_dashes() {
let encoded = encode_project_path("/home/my-project");
assert_eq!(encoded, "-home-my-project");
}
#[test]
fn encode_wsl_path() {
let encoded = encode_project_path("\\\\wsl.localhost\\ubuntu\\home\\user\\project");
assert_eq!(encoded, "--wsl-localhost-ubuntu-home-user-project");
}
#[test]
fn list_sessions_returns_jsonl_files_skipping_agent_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("sess-001.jsonl"), "{}").unwrap();
std::fs::write(dir.path().join("sess-002.jsonl"), "{}").unwrap();
std::fs::write(dir.path().join("agent-abc.jsonl"), "{}").unwrap();
std::fs::write(dir.path().join("agent-def.jsonl"), "{}").unwrap();
std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
std::fs::write(dir.path().join("data.json"), "{}").unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 2);
let names: Vec<String> = sessions
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"sess-001.jsonl".to_string()));
assert!(names.contains(&"sess-002.jsonl".to_string()));
assert!(!names.iter().any(|n| n.starts_with("agent-")));
}
#[test]
fn list_sessions_sorted_by_mtime_newest_first() {
let dir = tempfile::tempdir().unwrap();
let older = dir.path().join("older.jsonl");
std::fs::write(&older, "{}").unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let newer = dir.path().join("newer.jsonl");
std::fs::write(&newer, "{}").unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert_eq!(sessions.len(), 2);
let first_name = sessions[0].file_name().unwrap().to_string_lossy().to_string();
assert_eq!(first_name, "newer.jsonl");
}
#[test]
fn list_sessions_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let sessions = list_sessions(dir.path()).unwrap();
assert!(sessions.is_empty());
}
#[test]
fn list_sessions_nonexistent_directory() {
let result = list_sessions(Path::new("/nonexistent/path/xyz"));
assert!(result.is_err());
}
#[test]
fn list_all_projects_with_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path();
let projects = config_dir.join("projects");
std::fs::create_dir_all(&projects).unwrap();
std::fs::create_dir(projects.join("-home-user-project-alpha")).unwrap();
std::fs::create_dir(projects.join("-home-user-project-beta")).unwrap();
std::fs::write(projects.join("not-a-dir.txt"), "").unwrap();
let mut result = Vec::new();
for entry in std::fs::read_dir(&projects).unwrap() {
let entry = entry.unwrap();
if entry.path().is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
let decoded = decode_project_path(&name);
result.push((decoded, entry.path()));
}
}
result.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(result.len(), 2);
assert!(result[0].0.contains("alpha"));
assert!(result[1].0.contains("beta"));
}
#[test]
fn find_project_dir_with_env_override() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(&projects).unwrap();
let encoded = encode_project_path("/home/user/myproject");
std::fs::create_dir(projects.join(&encoded)).unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
let result = find_project_dir(Path::new("/home/user/myproject"));
std::env::remove_var("CLAUDE_CONFIG_DIR");
let found = result.unwrap();
assert!(found.is_some());
let found_path = found.unwrap();
assert!(found_path.ends_with(&encoded));
}
#[test]
fn find_project_dir_returns_none_when_no_match() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path().join("projects");
std::fs::create_dir_all(&projects).unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
let result = find_project_dir(Path::new("/nonexistent/project"));
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result.unwrap().is_none());
}
#[test]
fn find_project_dir_returns_none_when_projects_dir_missing() {
let dir = tempfile::tempdir().unwrap();
std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().to_str().unwrap());
let result = find_project_dir(Path::new("/home/user/myproject"));
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(result.unwrap().is_none());
}
#[test]
fn decode_project_path_returns_same_string() {
let decoded = decode_project_path("-home-user-project");
assert_eq!(decoded, "-home-user-project");
}
#[test]
fn claude_config_dir_respects_env_var() {
std::env::set_var("CLAUDE_CONFIG_DIR", "/tmp/custom-claude-config");
let dir = claude_config_dir().unwrap();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert_eq!(dir, PathBuf::from("/tmp/custom-claude-config"));
}
#[test]
fn claude_config_dir_ignores_empty_env_var() {
std::env::set_var("CLAUDE_CONFIG_DIR", "");
let dir = claude_config_dir().unwrap();
std::env::remove_var("CLAUDE_CONFIG_DIR");
assert!(dir.to_string_lossy().ends_with(".claude"));
}
}