use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredProject {
pub path: PathBuf,
pub last_session: Option<SystemTime>,
pub session_count: usize,
}
pub struct ProjectDiscovery;
impl ProjectDiscovery {
pub fn discover() -> Vec<DiscoveredProject> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
Self::discover_in(&home.join(".claude").join("projects"))
}
pub fn discover_in(projects_dir: &Path) -> Vec<DiscoveredProject> {
let Ok(entries) = fs::read_dir(projects_dir) else {
return Vec::new();
};
let mut projects: Vec<DiscoveredProject> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|entry| {
let dir_name = entry.file_name();
let dir_name = dir_name.to_str()?;
let path = decode_project_path(dir_name)?;
let (session_count, last_session) = scan_sessions(&entry.path());
Some(DiscoveredProject {
path,
last_session,
session_count,
})
})
.collect();
projects.sort_by_key(|p| std::cmp::Reverse(p.last_session));
projects
}
}
fn scan_sessions(dir: &Path) -> (usize, Option<SystemTime>) {
let Ok(entries) = fs::read_dir(dir) else {
return (0, None);
};
let mut count = 0;
let mut newest: Option<SystemTime> = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
count += 1;
if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
newest = Some(match newest {
Some(current) if current >= mtime => current,
_ => mtime,
});
}
}
(count, newest)
}
pub fn decode_project_path(dir_name: &str) -> Option<PathBuf> {
let body = dir_name.strip_prefix('-')?;
if body.is_empty() {
return None;
}
let simple = PathBuf::from(format!("/{}", body.replace('-', "/")));
if simple.exists() {
return Some(simple);
}
let segments: Vec<&str> = body.split('-').collect();
let mut path = PathBuf::from("/");
let mut pending = String::new();
let mut any_resolved = false;
for (idx, segment) in segments.iter().enumerate() {
let candidate_name = if pending.is_empty() {
(*segment).to_string()
} else {
format!("{pending}-{segment}")
};
let candidate = path.join(&candidate_name);
let is_last = idx + 1 == segments.len();
if candidate.exists() {
path = candidate;
pending.clear();
any_resolved = true;
} else if is_last {
path = candidate;
pending.clear();
} else {
pending = candidate_name;
}
}
if any_resolved {
Some(path)
} else {
Some(simple)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn decodes_simple_path() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::create_dir_all(base.join("Projects").join("app")).unwrap();
let encoded = format!("{}", base.join("Projects").join("app").display()).replace('/', "-");
let decoded = decode_project_path(&encoded).expect("decodes");
assert_eq!(decoded, base.join("Projects").join("app"));
}
#[test]
fn decodes_path_with_hyphens() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let project = base.join("Projects").join("trusty-mpm");
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let decoded = decode_project_path(&encoded).expect("decodes");
assert_eq!(decoded, project);
}
#[test]
fn rejects_bad_name() {
assert!(decode_project_path("Users-masa").is_none());
assert!(decode_project_path("-").is_none());
}
#[test]
fn unresolved_path_still_decoded_best_effort() {
let decoded = decode_project_path("-nonexistent-deep-path").expect("decodes");
assert_eq!(decoded, PathBuf::from("/nonexistent/deep/path"));
}
#[test]
fn discover_on_missing_dir_is_empty() {
let missing = PathBuf::from("/no/such/projects/dir");
assert!(ProjectDiscovery::discover_in(&missing).is_empty());
}
#[test]
fn discovers_and_counts_sessions() {
let home = tempfile::tempdir().unwrap();
let projects_dir = home.path().join(".claude").join("projects");
std::fs::create_dir_all(&projects_dir).unwrap();
let project = home.path().join("work").join("demo");
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let claude_dir = projects_dir.join(&encoded);
std::fs::create_dir_all(&claude_dir).unwrap();
for name in ["a.jsonl", "b.jsonl"] {
let mut f = std::fs::File::create(claude_dir.join(name)).unwrap();
writeln!(f, "{{}}").unwrap();
}
std::fs::File::create(claude_dir.join("notes.txt")).unwrap();
let found = ProjectDiscovery::discover_in(&projects_dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, project);
assert_eq!(found[0].session_count, 2);
assert!(found[0].last_session.is_some());
}
#[test]
fn discover_sorts_newest_session_first() {
let home = tempfile::tempdir().unwrap();
let projects_dir = home.path().join(".claude").join("projects");
std::fs::create_dir_all(&projects_dir).unwrap();
let make = |name: &str| -> PathBuf {
let project = home.path().join(name);
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let claude_dir = projects_dir.join(encoded);
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::File::create(claude_dir.join("s.jsonl")).unwrap();
project
};
let older = make("older");
std::thread::sleep(std::time::Duration::from_millis(20));
let newer = make("newer");
let found = ProjectDiscovery::discover_in(&projects_dir);
assert_eq!(found.len(), 2);
assert_eq!(found[0].path, newer);
assert_eq!(found[1].path, older);
}
}