Skip to main content

smc/util/
discover.rs

1/// Session file discovery — finds all JSONL conversation logs under ~/.claude/projects.
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5
6// ── SessionFile ────────────────────────────────────────────────────────────
7
8#[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
31// ── Discovery ──────────────────────────────────────────────────────────────
32
33/// Resolve the Claude projects directory.
34pub 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
45/// Discover all JSONL session files, sorted largest-first.
46pub 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
88/// Find a session by exact ID or unique prefix.
89pub 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
111// ── Helpers ────────────────────────────────────────────────────────────────
112
113fn 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}