use super::*;
pub fn resolve_target(target: &Path) -> Result<ProjectDir> {
let root = projects_root()?;
if let Some(token) = strip_projects_root_prefix(target, &root) {
let dir = root.join(&token);
if dir.is_dir() {
return Ok(ProjectDir {
dir,
target_cwd: None,
});
}
if token.starts_with('-') {
bail!(
"no Claude Code project dir named {:?} under {}",
token,
root.display()
);
}
}
let abs = absolutize(target)?;
let encoded = encode_cwd(&abs);
let dir = root.join(&encoded);
if dir.is_dir() {
return Ok(ProjectDir {
dir,
target_cwd: Some(abs),
});
}
if encoded.len() > MAX_SANITIZED_LENGTH {
let prefix = format!("{}-", &encoded[..MAX_SANITIZED_LENGTH]);
if let Some(found) = find_dir_by_prefix(&root, &prefix, &abs)? {
return Ok(ProjectDir {
dir: found,
target_cwd: Some(abs.clone()),
});
}
}
bail!(
"no Claude Code project dir for {} (looked for {})",
abs.display(),
dir.display()
)
}
pub(crate) const MAX_SANITIZED_LENGTH: usize = 200;
pub(crate) fn find_dir_by_prefix(root: &Path, prefix: &str, abs: &Path) -> Result<Option<PathBuf>> {
let read = match std::fs::read_dir(root) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let mut matches: Vec<PathBuf> = Vec::new();
for entry in read.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(prefix) && entry.path().is_dir() {
matches.push(entry.path());
}
}
if matches.len() > 1 {
let want = abs.to_string_lossy();
if let Some(exact) = matches
.iter()
.find(|d| dir_first_cwd(d).as_deref() == Some(want.as_ref()))
{
return Ok(Some(exact.clone()));
}
}
matches.sort();
Ok(matches.into_iter().next())
}
pub(crate) fn read_first_cwd(path: &Path) -> Option<String> {
use std::io::Read;
let mut buf = Vec::new();
std::fs::File::open(path)
.ok()?
.take(64 * 1024)
.read_to_end(&mut buf)
.ok()?;
let head = String::from_utf8_lossy(&buf);
let first_line = head.split('\n').next().unwrap_or(&head);
extract_json_string_field(first_line, "cwd")
}
pub(crate) fn dir_first_cwd(dir: &Path) -> Option<String> {
let read = std::fs::read_dir(dir).ok()?;
for entry in read.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "jsonl") {
if let Some(cwd) = read_first_cwd(&p) {
return Some(cwd);
}
}
}
None
}
pub(crate) fn cwd_equivalent(stored: &str, want: &Path) -> bool {
let norm = |s: &str| s.trim_end_matches('/').to_string();
norm(stored) == norm(&want.to_string_lossy())
}
pub(crate) fn extract_json_string_field(text: &str, key: &str) -> Option<String> {
for pat in [format!("\"{key}\":\""), format!("\"{key}\": \"")] {
let Some(idx) = text.find(&pat) else { continue };
let bytes = text.as_bytes();
let start = idx + pat.len();
let mut i = start;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => return Some(text[start..i].replace("\\\\", "\\").replace("\\\"", "\"")),
_ => i += 1,
}
}
}
None
}
pub fn all_project_dirs() -> Result<Vec<ProjectDir>> {
let root = projects_root()?;
let read = std::fs::read_dir(&root)
.with_context(|| format!("cannot read projects root {}", root.display()))?;
let mut dirs = Vec::new();
for entry in read {
let entry =
entry.with_context(|| format!("error reading an entry in {}", root.display()))?;
let path = entry.path();
let is_dir = match entry.file_type() {
Ok(ft) if ft.is_symlink() => path.is_dir(),
Ok(ft) => ft.is_dir(),
Err(_) => path.is_dir(),
};
if is_dir {
dirs.push(ProjectDir {
dir: path,
target_cwd: None,
});
}
}
dirs.sort_by(|a, b| a.dir.cmp(&b.dir));
Ok(dirs)
}