use crate::error::{HindsightError, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SessionFile {
pub path: PathBuf,
pub session_id: String,
pub project_name: String,
pub file_size: u64,
pub created_at: i64,
pub modified_at: i64,
pub has_subagents: bool,
pub model: Option<String>,
pub error_count: usize,
pub first_message: Option<String>,
pub source_dir: String,
pub subagent_models: Option<String>,
}
pub fn discover_sessions() -> Result<Vec<SessionFile>> {
let home = dirs::home_dir()
.ok_or_else(|| HindsightError::Config("Could not determine home directory".to_string()))?;
let config = match crate::config::Config::load() {
Ok(c) => c,
Err(e) => {
eprintln!("Warning: failed to load config, using defaults: {}", e);
Default::default()
}
};
let claude_dirs: Vec<(PathBuf, String)> = config
.paths
.claude_dirs
.iter()
.map(|d| {
let expanded = if let Some(stripped) = d.path.strip_prefix("~/") {
home.join(stripped)
} else {
PathBuf::from(&d.path)
};
(expanded, d.path.clone())
})
.filter(|(p, _)| p.exists())
.collect();
let mut sessions = Vec::new();
for (claude_dir, source_dir) in &claude_dirs {
if !claude_dir.exists() {
continue;
}
for project_entry in fs::read_dir(claude_dir)? {
let project_entry = project_entry?;
let project_path = project_entry.path();
if !project_path.is_dir() {
continue;
}
let project_name = decode_project_name(&project_path);
for file_entry in fs::read_dir(&project_path)? {
let file_entry = file_entry?;
let file_path = file_entry.path();
if file_path.is_file()
&& file_path.extension().and_then(|s| s.to_str()) == Some("jsonl")
{
let metadata = fs::metadata(&file_path)?;
let session_id = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let modified_at = metadata
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let subagents_dir = project_path.join(&session_id).join("subagents");
let has_subagents = subagents_dir.exists() && subagents_dir.is_dir();
sessions.push(SessionFile {
path: file_path,
session_id,
project_name: project_name.clone(),
file_size: metadata.len(),
created_at: modified_at, modified_at,
has_subagents,
model: None,
error_count: 0,
first_message: None,
source_dir: source_dir.clone(),
subagent_models: None,
});
}
}
}
}
if sessions.is_empty() {
return Err(HindsightError::NoSessionsFound);
}
sessions.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(sessions)
}
fn decode_project_name(path: &Path) -> String {
let dir_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if dir_name.is_empty() || dir_name == "-" {
return String::new();
}
if !dir_name.starts_with('-') {
return dir_name.to_string();
}
let known_parents = [
"PersonalProjects-",
"Projects-",
"workspace-",
"Workspace-",
"repos-",
"Repos-",
"src-",
"dev-",
"code-",
"Code-",
"github-",
"GitHub-",
"git-",
];
for parent in &known_parents {
if let Some(pos) = dir_name.rfind(parent) {
let after = &dir_name[pos + parent.len()..];
if !after.is_empty() {
return after.to_string();
}
}
}
let segments: Vec<&str> = dir_name.split('-').filter(|s| !s.is_empty()).collect();
let skip_words: std::collections::HashSet<&str> = [
"Users", "home", "var", "tmp", "opt", "Documents", "Desktop",
"Downloads", "Library",
].iter().copied().collect();
let mut project_start = 0;
for (i, seg) in segments.iter().enumerate() {
if skip_words.contains(seg) {
project_start = i + 1;
} else if i > 0 && i == project_start {
project_start = i + 1;
} else {
break;
}
}
if project_start < segments.len() {
segments[project_start..].join("-")
} else if !segments.is_empty() {
segments.last().unwrap().to_string()
} else {
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_project_name() {
assert_eq!(
decode_project_name(Path::new("-Users-codestz-Documents-PersonalProjects-claude-hindsight")),
"claude-hindsight"
);
assert_eq!(
decode_project_name(Path::new("-Users-codestz-Documents-PersonalProjects-prompt-evaluator")),
"prompt-evaluator"
);
assert_eq!(
decode_project_name(Path::new("-Users-codestz-Documents-PersonalProjects-mcpx")),
"mcpx"
);
assert_eq!(
decode_project_name(Path::new("-Users-codestz-Documents-Projects-dev-container-poc")),
"dev-container-poc"
);
assert_eq!(
decode_project_name(Path::new("-Users-codestz")),
"codestz"
);
assert_eq!(decode_project_name(Path::new("-")), "");
assert_eq!(decode_project_name(Path::new("my-project")), "my-project");
}
}