1use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5
6#[derive(Debug, Clone)]
9pub struct SessionFile {
10 pub path: PathBuf,
11 pub session_id: String,
12 pub project_name: String,
13 pub size_bytes: u64,
14}
15
16impl SessionFile {
17 pub fn size_human(&self) -> String {
18 let b = self.size_bytes;
19 if b < 1024 {
20 format!("{}B", b)
21 } else if b < 1024 * 1024 {
22 format!("{:.1}KB", b as f64 / 1024.0)
23 } else if b < 1024 * 1024 * 1024 {
24 format!("{:.1}MB", b as f64 / (1024.0 * 1024.0))
25 } else {
26 format!("{:.2}GB", b as f64 / (1024.0 * 1024.0 * 1024.0))
27 }
28 }
29}
30
31pub fn claude_dir(path_override: Option<&str>) -> Result<PathBuf> {
35 let dir = if let Some(p) = path_override {
36 PathBuf::from(p)
37 } else {
38 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
39 Path::new(&home).join(".claude").join("projects")
40 };
41 anyhow::ensure!(dir.exists(), "Claude projects directory not found at {}", dir.display());
42 Ok(dir)
43}
44
45pub fn discover_jsonl_files(base: &Path) -> Result<Vec<SessionFile>> {
47 let mut files = Vec::new();
48
49 if !base.is_dir() {
50 return Ok(files);
51 }
52
53 for entry in std::fs::read_dir(base)? {
54 let entry = entry?;
55 let project_dir = entry.path();
56 if !project_dir.is_dir() {
57 continue;
58 }
59
60 let project_name = extract_project_name(entry.file_name().to_str().unwrap_or(""));
61
62 for file_entry in std::fs::read_dir(&project_dir)? {
63 let file_entry = file_entry?;
64 let path = file_entry.path();
65 if path.extension().is_some_and(|e| e == "jsonl") && path.is_file() {
66 let session_id = path
67 .file_stem()
68 .and_then(|s| s.to_str())
69 .unwrap_or("")
70 .to_string();
71
72 let metadata = std::fs::metadata(&path)?;
73
74 files.push(SessionFile {
75 path,
76 session_id,
77 project_name: project_name.clone(),
78 size_bytes: metadata.len(),
79 });
80 }
81 }
82 }
83
84 files.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
85 Ok(files)
86}
87
88pub fn find_session<'a>(
90 files: &'a [SessionFile],
91 query: &str,
92) -> Result<&'a SessionFile> {
93 if let Some(f) = files.iter().find(|f| f.session_id == query) {
94 return Ok(f);
95 }
96 let matches: Vec<_> = files
97 .iter()
98 .filter(|f| f.session_id.starts_with(query))
99 .collect();
100 match matches.len() {
101 0 => anyhow::bail!("no session found matching '{}'", query),
102 1 => Ok(matches[0]),
103 n => anyhow::bail!(
104 "ambiguous session ID '{}' ({} matches) — provide more characters",
105 query,
106 n
107 ),
108 }
109}
110
111fn extract_project_name(dir_name: &str) -> String {
114 let parts: Vec<&str> = dir_name.split('-').collect();
115
116 if let Some(pos) = parts.iter().position(|&p| p == "GitHub") {
117 let project_parts = &parts[pos + 1..];
118 if project_parts.is_empty() {
119 dir_name.to_string()
120 } else {
121 project_parts.join("/")
122 }
123 } else {
124 parts
125 .iter()
126 .rfind(|p| !p.is_empty() && **p != "Users")
127 .unwrap_or(&dir_name)
128 .to_string()
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn extracts_github_project() {
138 assert_eq!(extract_project_name("-Users-travis-GitHub-myapp"), "myapp");
139 }
140
141 #[test]
142 fn extracts_nested_project() {
143 assert_eq!(
144 extract_project_name("-Users-travis-GitHub-misc-smc_cli"),
145 "misc/smc_cli"
146 );
147 }
148
149 #[test]
150 fn fallback_last_segment() {
151 assert_eq!(extract_project_name("-Users-travis-something"), "something");
152 }
153}