use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
pub fn build_remote_map(claude_projects_dir: &Path) -> HashMap<String, String> {
build_remote_map_with(claude_projects_dir, &git_remote_url)
}
pub fn build_remote_map_with(
claude_projects_dir: &Path,
get_remote: &dyn Fn(&str) -> Option<String>,
) -> HashMap<String, String> {
let mut map = HashMap::new();
let entries = match std::fs::read_dir(claude_projects_dir) {
Ok(e) => e,
Err(_) => return map,
};
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
let real_path = decode_project_dir(&dir_name);
if let Some(remote) = get_remote(&real_path) {
let normalized = normalize_remote(&remote);
map.insert(normalized, dir_name);
}
}
map
}
pub fn resolve_project_dir(
normalized_path: &str,
remote_map: &HashMap<String, String>,
claude_projects_dir: &Path,
) -> Option<String> {
let candidate = crate::manifest::denormalize_project_path(normalized_path);
let candidate_dir = claude_projects_dir.join(&candidate);
if candidate_dir.exists() {
return Some(candidate);
}
let candidate_real = decode_project_dir(&candidate);
if let Some(remote) = git_remote_url(&candidate_real) {
let normalized = normalize_remote(&remote);
if let Some(local_dir) = remote_map.get(&normalized) {
return Some(local_dir.clone());
}
}
if let Ok(entries) = std::fs::read_dir(claude_projects_dir) {
let suffix = format!("-{normalized_path}");
let mut matches: Vec<String> = Vec::new();
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if dir_name.ends_with(&suffix) || dir_name == normalized_path {
matches.push(dir_name);
}
}
if matches.len() == 1 {
return Some(matches.into_iter().next().unwrap());
}
}
let decoded_remote_path = decode_project_dir(&format!("-placeholder-{}", normalized_path));
let repo_name = Path::new(&decoded_remote_path)
.file_name()
.map(|n| n.to_string_lossy().to_string());
if let Some(name) = repo_name {
for local_dir in remote_map.values() {
if local_dir.ends_with(&format!("-{name}")) {
return Some(local_dir.clone());
}
}
}
Some(candidate)
}
pub fn decode_project_dir(encoded: &str) -> String {
let path = encoded.replace('-', "/");
format!("/{}", path.trim_start_matches('/'))
}
pub fn git_remote_url(path: &str) -> Option<String> {
let path = Path::new(path);
if !path.exists() {
return None;
}
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
pub fn normalize_remote(url: &str) -> String {
let s = url.trim_end_matches('/').trim_end_matches(".git");
let s = s
.strip_prefix("ssh://")
.or_else(|| s.strip_prefix("https://"))
.or_else(|| s.strip_prefix("http://"))
.unwrap_or(s);
if let Some(rest) = s.strip_prefix("git@") {
return rest.replace(':', "/").to_lowercase();
}
s.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_project_dir_basic() {
assert_eq!(
decode_project_dir("-Users-akj-code-myapp"),
"/Users/akj/code/myapp"
);
}
#[test]
fn decode_project_dir_leading_dash() {
assert_eq!(decode_project_dir("code-myapp"), "/code/myapp");
}
#[test]
fn normalize_remote_ssh() {
assert_eq!(
normalize_remote("git@github.com:User/Repo.git"),
"github.com/user/repo"
);
}
#[test]
fn normalize_remote_https() {
assert_eq!(
normalize_remote("https://github.com/User/Repo.git"),
"github.com/user/repo"
);
}
#[test]
fn normalize_remote_http() {
assert_eq!(
normalize_remote("http://gitlab.com/Group/Project/"),
"gitlab.com/group/project"
);
}
#[test]
fn normalize_remote_ssh_prefix() {
assert_eq!(
normalize_remote("ssh://git@github.com/User/Repo.git"),
"github.com/user/repo"
);
}
#[test]
fn normalize_ssh_variants_match() {
let a = normalize_remote("git@github.com:User/Repo.git");
let b = normalize_remote("ssh://git@github.com/User/Repo.git");
assert_eq!(a, b);
}
#[test]
fn normalize_remote_plain() {
assert_eq!(normalize_remote("example.com/repo"), "example.com/repo");
}
#[test]
fn build_remote_map_with_mock() {
let dir = std::env::temp_dir().join(format!("clync-resolver-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("project-alpha")).unwrap();
std::fs::create_dir_all(dir.join("project-beta")).unwrap();
let remotes: HashMap<String, String> = [(
"/project/alpha".to_string(),
"git@github.com:user/alpha.git".to_string(),
)]
.into_iter()
.collect();
let map = build_remote_map_with(&dir, &|path: &str| remotes.get(path).cloned());
assert_eq!(map.len(), 1);
assert!(map.contains_key("github.com/user/alpha"));
assert_eq!(map.get("github.com/user/alpha").unwrap(), "project-alpha");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_remote_map_with_nonexistent_dir() {
let map = build_remote_map_with(Path::new("/nonexistent"), &|_| None);
assert!(map.is_empty());
}
#[test]
fn resolve_project_dir_by_suffix() {
let dir = std::env::temp_dir().join(format!("clync-resolve-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("Users-someone-code-myproject")).unwrap();
let map = HashMap::new();
let result = resolve_project_dir("code-myproject", &map, &dir);
assert_eq!(result, Some("Users-someone-code-myproject".to_string()));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_project_dir_no_match_returns_denormalized() {
let dir =
std::env::temp_dir().join(format!("clync-resolve-nomatch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let map = HashMap::new();
let result = resolve_project_dir("some-project", &map, &dir);
assert!(result.is_some());
std::fs::remove_dir_all(&dir).ok();
}
}